diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py deleted file mode 100644 index 3af49007b1e..00000000000 --- a/.github/e2e-stack/assert_tests_ran.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import re -import sys -import xml.etree.ElementTree as ET -from pathlib import Path -from typing import Final - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests/e2e")) -from coverage_registry.management_cases import MANAGEMENT_CASES - - -def main() -> int: - selected: Final = tuple(sys.argv[2:]) - try: - report: Final = ET.parse(Path(sys.argv[1])).getroot() - except (ET.ParseError, OSError): - _ = sys.stdout.write("::error::could not read the test execution report\n") - return 1 - cases: Final = tuple(report.iter("testcase")) - expected_count: Final = os.environ.get("E2E_REQUIRED_TEST_COUNT") - passed: Final = frozenset( - case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) - ) - missing: Final = tuple(path for path in selected if path not in passed) - required_nodes: Final = frozenset(case.node for case in MANAGEMENT_CASES if case.node.split("::", 1)[0] in selected) - passed_nodes: Final = frozenset( - prop.get("value") - for case in cases - if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) - for prop in case.findall("./properties/property") - if prop.get("name") == "management_node" - ) - missing_nodes: Final = required_nodes - passed_nodes - for node in sorted(missing_nodes): - _ = sys.stdout.write(f"::error::required management case did not pass: {node}\n") - for path in selected: - collected: Final = sum(case.get("file") == path for case in cases) - skipped: Final = sum(case.get("file") == path and case.find("skipped") is not None for case in cases) - _ = sys.stdout.write(f"{path}: {collected} collected, {skipped} skipped\n") - for case in cases: - if case.get("file") != path or all(case.find(tag) is None for tag in ("failure", "error", "skipped")): - continue - outcome = "skipped" if case.find("skipped") is not None else "failed" - _ = sys.stdout.write(f" {outcome}: {case.get('classname', '')}::{case.get('name', '')}\n") - for prop in case.findall("./properties/property"): - name = prop.get("name", "") - value = prop.get("value", "") - if name in ("oauth_failure_phase", "oauth_exception_type", "oauth_frame") and re.fullmatch( - r"[A-Za-z0-9_.:<>-]{1,240}", value - ): - _ = sys.stdout.write(f" {name}: {value}\n") - if expected_count is not None and ( - len(cases) != int(expected_count) or any(case.find("skipped") is not None for case in cases) - ): - _ = sys.stdout.write("::error::required test count was not met or a required case was skipped\n") - return 1 - if ( - selected - and not missing - and not missing_nodes - and not any(case.find(tag) is not None for case in cases for tag in ("failure", "error")) - ): - return 0 - _ = sys.stdout.write("::error::every selected file must execute a passing test, with no failures or errors\n") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index 4c8aeb64e85..3b9a2614725 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -539,8 +539,8 @@ jobs: - name: test_unit_passed_gate run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_unit_passed_gate.py - - name: test_e2e_changed_gate - run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py tests/code_coverage_tests/test_e2e_idp_stack.py + - name: test_e2e_idp_stack + run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_idp_stack.py - name: test_e2e_metadata env: diff --git a/.github/workflows/test-mcp-oauth-e2e.yml b/.github/workflows/test-mcp-oauth-e2e.yml deleted file mode 100644 index 5c07a68714e..00000000000 --- a/.github/workflows/test-mcp-oauth-e2e.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: MCP OAuth happy path - -on: - pull_request: - paths: - - '.github/workflows/test-mcp-oauth-e2e.yml' - - '.github/e2e-stack/**' - - 'tests/e2e/*.py' - - 'tests/e2e/pytest.ini' - - 'tests/e2e/idp_realm.json' - - 'tests/e2e/mcp/**' - - 'litellm/experimental_mcp_client/**' - - 'litellm/proxy/_experimental/mcp_server/**' - - 'litellm/proxy/auth/**' - - 'litellm/proxy/management_endpoints/mcp_management_endpoints.py' - - 'litellm/proxy/_types.py' - - 'litellm/types/mcp_server/mcp_server_manager.py' - - 'litellm/proxy/management_endpoints/*sso*.py' - - 'litellm/proxy/management_endpoints/sso/**' - - 'litellm/proxy/common_utils/encrypt_decrypt_utils.py' - - 'litellm/proxy/proxy_server.py' - - 'litellm/proxy/schema.prisma' - - 'ui/litellm-dashboard/src/app/connect/**' - - 'ui/litellm-dashboard/src/app/mcp/oauth/**' - - 'pyproject.toml' - - 'uv.lock' - workflow_dispatch: - -permissions: {} - -concurrency: - group: mcp-oauth-${{ github.ref }} - cancel-in-progress: true - -jobs: - oauth: - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - environment: e2e-changed - timeout-minutes: 45 - permissions: - contents: read - id-token: write - services: - postgres: - image: postgres:16.6 - env: - POSTGRES_USER: litellm - POSTGRES_PASSWORD: dbpassword9090 - POSTGRES_DB: litellm - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U litellm" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - env: - DATABASE_HOST: 127.0.0.1 - DATABASE_PORT: '5432' - DATABASE_USER: litellm - DATABASE_PASSWORD: dbpassword9090 - DATABASE_NAME: litellm - DATABASE_URL: postgresql://litellm:dbpassword9090@127.0.0.1:5432/litellm - E2E_KEYCLOAK_URL: http://127.0.0.1:8081 - E2E_KEYCLOAK_ADMIN_USER: admin - E2E_KEYCLOAK_ADMIN_PASSWORD: e2e-ephemeral-idp-not-a-secret - E2E_FIXTURE_MODE: live - E2E_PROVIDER_CACHE: '0' - E2E_MCP_OAUTH_LIVE: '1' - E2E_REQUIRED_TEST_COUNT: '4' - steps: - - name: Checkout the tested source - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - - name: Require and materialize the upstream login - env: - STORAGE_STATE: ${{ secrets.E2E_LINEAR_STORAGE_STATE_B64 }} - run: | - umask 077 - python3 - <<'PY' - import base64 - import json - import os - import secrets - from pathlib import Path - encoded = os.environ.get("STORAGE_STATE", "") - if not encoded: - raise SystemExit("E2E_LINEAR_STORAGE_STATE_B64 is required; capture and provision a test-account login") - state = json.loads(base64.b64decode(encoded, validate=True)) - if not isinstance(state, dict) or not state.get("cookies"): - raise SystemExit("The captured login must contain browser cookies") - directory = Path(os.environ["RUNNER_TEMP"]) / "mcp-oauth-private" - directory.mkdir(mode=0o700) - path = directory / "linear-state.json" - path.write_text(json.dumps(state)) - with open(os.environ["GITHUB_ENV"], "a") as output: - output.write(f"E2E_LINEAR_STORAGE_STATE={path}\n") - for name in ("LITELLM_MASTER_KEY", "LITELLM_SALT_KEY"): - value = "sk-e2e-" + secrets.token_hex(24) - print(f"::add-mask::{value}") - output.write(f"{name}={value}\n") - PY - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.13' - - uses: ./.github/actions/setup-uv-with-retries - with: - version: '0.10.9' - - uses: ./.github/actions/cache-cargo-build - - name: Install the frozen E2E environment - run: | - .github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra proxy-runtime --extra extra_proxy --group ci --group proxy-dev --group e2e-dev - uv run --no-sync python scripts/prisma_generate_if_needed.py - uv run --no-sync playwright install --with-deps chromium - - - name: Configure license access - id: aws - uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 - with: - role-to-assume: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }} - aws-region: us-east-1 - role-session-name: mcp-oauth-${{ github.run_id }} - role-duration-seconds: 900 - output-env-credentials: false - output-credentials: true - - name: Load the E2E license - env: - AWS_ACCESS_KEY_ID: ${{ steps.aws.outputs.aws-access-key-id }} - AWS_SECRET_ACCESS_KEY: ${{ steps.aws.outputs.aws-secret-access-key }} - AWS_SESSION_TOKEN: ${{ steps.aws.outputs.aws-session-token }} - AWS_DEFAULT_REGION: us-east-1 - run: | - license="$(aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-license --query SecretString --output text)" - test -n "${license}" - echo "::add-mask::${license}" - echo "LITELLM_LICENSE=${license}" >> "${GITHUB_ENV}" - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: ui/litellm-dashboard/.nvmrc - - name: Build the gateway consent UI at the tested commit - run: | - cd ui/litellm-dashboard - ../../scripts/with_dashboard_node.sh npm ci - ../../scripts/with_dashboard_node.sh npm run build - mkdir -p ../../litellm/proxy/_experimental/out - cp -r out/. ../../litellm/proxy/_experimental/out/ - find ../../litellm/proxy/_experimental/out -name '*.html' ! -name index.html | while read -r page; do - mkdir -p "${page%.html}" - mv "${page}" "${page%.html}/index.html" - done - - - name: Prepare the isolated database and IdP - run: | - umask 077 - bash .github/e2e-stack/start-idp.sh - uv run --no-sync python migrations/run.py > "${RUNNER_TEMP}/mcp-oauth-private/migrations.log" 2>&1 - - - name: Run every required OAuth variant without retries - run: | - umask 077 - uv run --no-sync pytest -c tests/e2e/pytest.ini tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py \ - --rootdir=. --reruns 0 --tb=short -o junit_family=xunit1 \ - --junitxml="${RUNNER_TEMP}/mcp-oauth-private/results.xml" \ - > "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" 2>&1 - - name: Report JUnit results and reject skipped or missing cases - if: always() - run: | - uv run --no-sync python .github/e2e-stack/assert_tests_ran.py \ - "${RUNNER_TEMP}/mcp-oauth-private/results.xml" tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py - - name: Remove private login and logs - if: always() - run: | - docker rm -f e2e-keycloak >/dev/null 2>&1 || true - rm -rf "${RUNNER_TEMP}/mcp-oauth-private" diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 1b486d78f04..cb1b595f3f0 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -658,6 +658,7 @@ jobs: - shard: proxy-extras test-path: >- tests/unit/litellm_proxy_extras + tests/unit/db_scripts python-version: "3.12" workers: 2 reruns: 0 diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 60d695653b5..2a7522cf34e 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -165,6 +165,7 @@ BACKEND_EXACT_PATHS: frozenset[str] = frozenset( BACKEND_MOUNT_PATHS: frozenset[str] = frozenset( { "/admin", + "/metrics", "/swagger", # API documentation static assets belong to the backend "/mcp", # lazily-mounted MCP sub-app serves on the backend component } diff --git a/db_scripts/strip_vector_store_internal_metadata.py b/db_scripts/strip_vector_store_internal_metadata.py new file mode 100644 index 00000000000..34ffa85dcd1 --- /dev/null +++ b/db_scripts/strip_vector_store_internal_metadata.py @@ -0,0 +1,201 @@ +import argparse +import os +import sys +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from itertools import accumulate, repeat, takewhile +from typing import Final, Literal, TextIO + +import httpx +from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError + +from litellm.vector_stores.utils import is_litellm_written_metadata_key + +DEFAULT_API_BASE: Final = "https://api.openai.com/v1" +LIST_PAGE_SIZE: Final = "100" + + +class VectorStore(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + id: str + metadata: dict[str, JsonValue] | None = None + + +class VectorStorePage(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + data: tuple[VectorStore, ...] + has_more: bool = False + + +class OpenAIErrorBody(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + message: str = "" + + +class OpenAIErrorResponse(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + error: OpenAIErrorBody + + +@dataclass(frozen=True, slots=True) +class Options: + api_key: str + api_base: str + vector_store_ids: tuple[str, ...] + extra_keys: frozenset[str] + apply: bool + + +@dataclass(frozen=True, slots=True) +class StoreResult: + store_id: str + matched_keys: tuple[str, ...] + status: Literal["dry_run", "updated", "failed"] + error: str = "" + + +def is_proxy_written_key(key: str, extra_keys: frozenset[str]) -> bool: + return is_litellm_written_metadata_key(key) or key in extra_keys + + +def matching_keys(store: VectorStore, extra_keys: frozenset[str]) -> tuple[str, ...]: + return tuple(sorted(key for key in (store.metadata or {}) if is_proxy_written_key(key, extra_keys))) + + +def fetch_page(client: httpx.Client, after: str | None) -> VectorStorePage: + params: Final = {"limit": LIST_PAGE_SIZE} if after is None else {"limit": LIST_PAGE_SIZE, "after": after} + return VectorStorePage.model_validate_json(client.get("/vector_stores", params=params).raise_for_status().content) + + +def fetch_next_page(client: httpx.Client, page: VectorStorePage | None) -> VectorStorePage | None: + if page is None or not page.has_more or not page.data: + return None + return fetch_page(client, after=page.data[-1].id) + + +def list_all_stores(client: httpx.Client) -> Iterator[VectorStore]: + pages: Final = accumulate( + repeat(None), lambda page, _: fetch_next_page(client, page), initial=fetch_page(client, after=None) + ) + for page in takewhile(lambda page: page is not None, pages): + yield from page.data + + +def retrieve_stores(client: httpx.Client, store_ids: tuple[str, ...]) -> Iterator[VectorStore]: + for store_id in store_ids: + yield VectorStore.model_validate_json(client.get(f"/vector_stores/{store_id}").raise_for_status().content) + + +def describe_http_error(response: httpx.Response) -> str: + try: + message: Final = OpenAIErrorResponse.model_validate_json(response.content).error.message + except ValidationError: + return f"HTTP {response.status_code}" + return f"HTTP {response.status_code}: {message}" + + +def delete_keys(client: httpx.Client, store_id: str, keys: tuple[str, ...]) -> StoreResult: + try: + response: Final = client.post(f"/vector_stores/{store_id}", json={"metadata": dict.fromkeys(keys)}) + except httpx.TransportError as error: + return StoreResult(store_id=store_id, matched_keys=keys, status="failed", error=type(error).__name__) + if response.is_success: + return StoreResult(store_id=store_id, matched_keys=keys, status="updated") + return StoreResult(store_id=store_id, matched_keys=keys, status="failed", error=describe_http_error(response)) + + +def clean_store(client: httpx.Client, store: VectorStore, options: Options) -> StoreResult | None: + keys: Final = matching_keys(store, options.extra_keys) + if not keys: + return None + if not options.apply: + return StoreResult(store_id=store.id, matched_keys=keys, status="dry_run") + return delete_keys(client, store.id, keys) + + +def format_result(result: StoreResult) -> str: + key_list: Final = ", ".join(result.matched_keys) + count: Final = len(result.matched_keys) + match result.status: + case "dry_run": + return f"{result.store_id}: would delete {count} keys: {key_list}" + case "updated": + return f"{result.store_id}: deleted {count} keys: {key_list}" + case "failed": + return f"{result.store_id}: failed to delete {count} keys ({result.error}): {key_list}" + + +def clean_and_report( + client: httpx.Client, stores: Iterator[VectorStore], options: Options, out: TextIO +) -> Iterator[StoreResult | None]: + for store in stores: + result: Final = clean_store(client, store, options) + if result is not None: + print(format_result(result), file=out) + yield result + + +def run(options: Options, client: httpx.Client, out: TextIO) -> int: + stores: Final = ( + retrieve_stores(client, options.vector_store_ids) if options.vector_store_ids else list_all_stores(client) + ) + scanned: Final = tuple(clean_and_report(client, stores, options, out)) + results: Final = tuple(result for result in scanned if result is not None) + updated: Final = sum(1 for result in results if result.status == "updated") + failed: Final = sum(1 for result in results if result.status == "failed") + mode: Final = "applied" if options.apply else "dry run, pass --apply to delete" + print( + f"scanned={len(scanned)} matched={len(results)} updated={updated} failed={failed} ({mode})", + file=out, + ) + return 1 if failed else 0 + + +def parse_options(argv: Sequence[str] | None) -> Options: + parser: Final = argparse.ArgumentParser( + description="Remove LiteLLM proxy-written keys from OpenAI vector store metadata. Dry run by default." + ) + parser.add_argument("--api-key", default=os.environ.get("OPENAI_API_KEY"), help="defaults to $OPENAI_API_KEY") + parser.add_argument("--api-base", default=DEFAULT_API_BASE) + parser.add_argument("--vector-store-id", action="append", default=[], help="repeatable, defaults to every store") + parser.add_argument("--extra-key", action="append", default=[], help="repeatable, another key name to delete") + parser.add_argument("--apply", action="store_true", help="delete the matching keys instead of listing them") + args: Final = parser.parse_args(argv) + if not args.api_key: + parser.error("--api-key or OPENAI_API_KEY is required") + return Options( + api_key=args.api_key, + api_base=args.api_base.rstrip("/"), + vector_store_ids=tuple(args.vector_store_id), + extra_keys=frozenset(args.extra_key), + apply=args.apply, + ) + + +def main(argv: Sequence[str] | None = None, transport: httpx.BaseTransport | None = None) -> int: + options: Final = parse_options(argv) + with httpx.Client( + base_url=options.api_base, + headers={"Authorization": f"Bearer {options.api_key}"}, + timeout=60.0, + transport=transport, + ) as client: + try: + return run(options, client, sys.stdout) + except httpx.HTTPStatusError as error: + sys.stderr.write(f"could not read vector stores: {describe_http_error(error.response)}\n") + return 2 + except httpx.TransportError as error: + sys.stderr.write(f"could not reach {options.api_base}: {type(error).__name__}\n") + return 2 + except ValidationError: + sys.stderr.write(f"unexpected vector store response shape from {options.api_base}\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 734092c4feb..2d2bc92dbd2 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -3,6 +3,7 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t """ import asyncio +import json from collections.abc import Mapping, Sequence from dataclasses import replace as dataclasses_replace from datetime import datetime, timedelta, timezone @@ -227,7 +228,7 @@ class CheckBatchCost: "so it will no longer be polled" ) - async def _claim_job_for_costing(self, job: "ManagedObjectRow") -> bool: + async def _claim_job_for_costing(self, job: "ManagedObjectRow", response: "LiteLLMBatch") -> bool: """ Atomically flip batch_processed from false to true, returning whether this pod won the row. Every pod and uvicorn worker schedules its own poller against the shared @@ -241,10 +242,16 @@ class CheckBatchCost: """ if not self._has_batch_processed_column: return True + from litellm_enterprise.proxy.hooks.managed_files import postgres_storable_json + try: claimed: Final = await _managed_object_table(self.prisma_client).update_many( where={"id": job.id, "batch_processed": False}, - data={"batch_processed": True}, + data={ + "batch_processed": True, + "status": response.status if response.status != "completed" else "complete", + "file_object": postgres_storable_json(response), + }, ) except Exception as db_err: verbose_proxy_logger.error( @@ -253,7 +260,9 @@ class CheckBatchCost: return False return claimed > 0 - async def _release_job_claim(self, job: "ManagedObjectRow") -> None: + async def _release_job_claim( + self, job: "ManagedObjectRow", original_status: str | None, original_file_object: object + ) -> None: """Give a claimed row back once billing it failed, so a later poll cycle retries it. Safe to match on batch_processed=True: while this poller is active the retrieve @@ -265,7 +274,11 @@ class CheckBatchCost: try: await _managed_object_table(self.prisma_client).update_many( where={"id": job.id, "batch_processed": True}, - data={"batch_processed": False}, + data={ + "batch_processed": False, + "status": original_status, + "file_object": json.dumps(original_file_object), + }, ) except Exception as db_err: verbose_proxy_logger.error( @@ -564,8 +577,6 @@ class CheckBatchCost: @staticmethod def _get_input_file_id(job: "ManagedObjectRow") -> Optional[str]: - import json - from litellm.types.utils import LiteLLMBatch file_object: Final = job.file_object @@ -718,8 +729,7 @@ class CheckBatchCost: ) except Exception as _e: verbose_proxy_logger.warning( - f"CheckBatchCost: failed to create managed file ID for " - f"{_file_attr}={_raw_file_id!r}: {_e}" + f"CheckBatchCost: failed to create managed file ID for {_file_attr}={_raw_file_id!r}: {_e}" ) # Pass the deployment's router-registered pricing (litellm_params custom @@ -755,8 +765,9 @@ class CheckBatchCost: failed_requests=output_file_result.failed_requests + error_file_failed_requests, ) ) + logging_model: Final = batch_result.models[0] if batch_result.models else litellm_model_name or model_id logging_obj = LiteLLMLogging( - model=batch_result.models[0], + model=logging_model, messages=[{"role": "user", "content": ""}], stream=False, call_type="aretrieve_batch", @@ -787,7 +798,9 @@ class CheckBatchCost: custom_llm_provider=str(llm_provider) if llm_provider else None, ) - if not await self._claim_job_for_costing(job): + original_status: Final = job.status + original_file_object: Final = job.file_object + if not await self._claim_job_for_costing(job, response.model_copy(update={"usage": batch_result.usage})): verbose_proxy_logger.info( f"CheckBatchCost: batch {batch_id} (job {job.id}) was claimed by another pod " "in this window, so its cost is already being tracked there" @@ -806,7 +819,7 @@ class CheckBatchCost: batch_completion_cost=batch_result.completion_cost, ) except Exception: - await self._release_job_claim(job) + await self._release_job_claim(job, original_status, original_file_object) raise # Record batch duration (completed_at - created_at) @@ -1099,10 +1112,7 @@ class CheckBatchCost: continue ## RETRIEVE THE BATCH JOB OUTPUT FILE - if ( - response.status in PROVIDER_TERMINAL_BATCH_STATUSES - and response.output_file_id is not None - ): + if response.status in PROVIDER_TERMINAL_BATCH_STATUSES and response.output_file_id is not None: try: tracked = await self._track_completed_batch_cost( job=job, @@ -1133,24 +1143,19 @@ class CheckBatchCost: # Track this job for the final metrics summary processed_models.append(tracked) - # mark the job as complete - try: - update_data: dict = { - "status": response.status if response.status != "completed" else "complete", - "file_object": response.model_dump_json(), - } - if self._has_batch_processed_column: - update_data["batch_processed"] = True - await _managed_object_table(self.prisma_client).update( - where={"id": job.id}, - data=update_data, - ) - except Exception as db_err: - verbose_proxy_logger.error( - f"CheckBatchCost: failed to mark job {job.id} complete in DB: {db_err}" - ) - if not self._has_batch_processed_column: - # nothing claimed the row, so it stays in the poll page and its lines wait for that retry + if not self._has_batch_processed_column: + try: + await _managed_object_table(self.prisma_client).update( + where={"id": job.id}, + data={ + "status": response.status if response.status != "completed" else "complete", + "file_object": response.model_dump_json(), + }, + ) + except Exception as db_err: + verbose_proxy_logger.error( + f"CheckBatchCost: failed to mark job {job.id} complete in DB: {db_err}" + ) continue self._store_batch_line_items_in_background(job, response, model_id, batch_id, billed=True) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index a33b33662ab..439770390f8 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -416,7 +416,7 @@ def _storable_json_value(value: JsonValue) -> JsonValue: return value -def _postgres_storable_json(file_object: BaseModel) -> str: +def postgres_storable_json(file_object: BaseModel) -> str: dumped: Final = file_object.model_dump_json() if "\\u0000" not in dumped: return dumped @@ -650,7 +650,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): ) # FIX: Update status and file_object on every operation to keep state in sync update_columns: Final = { - "file_object": _postgres_storable_json(stored_object), + "file_object": postgres_storable_json(stored_object), "status": file_object.status, "updated_by": user_api_key_dict.user_id, } @@ -666,7 +666,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): data={ "create": { "unified_object_id": unified_object_id, - "file_object": _postgres_storable_json(stored_object), + "file_object": postgres_storable_json(stored_object), "model_object_id": model_object_id, "file_purpose": file_purpose, "created_by": resolve_resource_owner_id(user_api_key_dict), @@ -702,7 +702,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): "unified_object_id": response_id, "model_object_id": decrypted_response_id(response_id), "file_purpose": "response", - "file_object": _postgres_storable_json(_without_model_output(response)), + "file_object": postgres_storable_json(_without_model_output(response)), "status": PENDING_RESPONSE_STATUSES[0], "created_by": resolve_resource_owner_id(user_api_key_dict), "team_id": user_api_key_dict.team_id, diff --git a/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs b/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs index 82392cbd7ee..4de67e6e7b5 100644 --- a/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs +++ b/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs @@ -94,6 +94,7 @@ const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[ "docker_model_runner", "ragflow", "pinstripes", + "openinfer", "darkbloom", "meta", "cognition", diff --git a/litellm-rust/crates/inference-messages/tests/messages/request.rs b/litellm-rust/crates/inference-messages/tests/messages/request.rs index aa8cb05b170..93bbbb071ba 100644 --- a/litellm-rust/crates/inference-messages/tests/messages/request.rs +++ b/litellm-rust/crates/inference-messages/tests/messages/request.rs @@ -343,6 +343,77 @@ async fn feature_betas_join_the_callers_betas_in_one_sorted_header( assert_eq!(sent, expected); } +#[rstest] +#[case::anthropic("anthropic", &[AnthropicBeta::DangerousToolUse20260903])] +#[case::deepseek("deepseek", &[])] +#[case::minimax("minimax", &[])] +#[tokio::test] +async fn safeguards_add_the_dangerous_tool_use_beta_only_where_the_beta_map_has_a_column( + call: MessagesCall, + #[case] provider: &str, + #[case] derived: &[AnthropicBeta], +) { + let upstream = upstream([message_response()]).await; + let safeguards = json!([{"type": "dangerous_tool_use", "classifier_context": {"v": 1}}]); + + run_message(with_fields( + MessagesCall { + custom_llm_provider: Some(provider.into()), + api_key: Some("sk".into()), + api_base: Some(upstream.uri()), + extra_headers: headers([("Anthropic-Beta", "caller-beta-2025-01-01")]), + ..call + }, + json!({"safeguards": safeguards}), + )) + .await; + + let request = only_request(&upstream).await; + let expected: BetaSet = derived + .iter() + .cloned() + .chain([AnthropicBeta::Other("caller-beta-2025-01-01".to_string())]) + .collect(); + assert_eq!(sent_betas(&request), expected); + assert_eq!( + request.body_json::().unwrap()["safeguards"], + safeguards + ); +} + +#[rstest] +#[tokio::test] +async fn null_safeguards_are_dropped_instead_of_sent_without_their_beta( + call: MessagesCall, + #[values("anthropic", "azure_ai", "bedrock_mantle", "deepseek", "minimax")] provider: &str, +) { + let upstream = upstream([message_response()]).await; + + run_message(with_fields( + MessagesCall { + custom_llm_provider: Some(provider.into()), + api_key: Some("sk".into()), + api_base: Some(upstream.uri()), + extra_headers: headers([("Anthropic-Beta", "caller-beta-2025-01-01")]), + ..call + }, + json!({"safeguards": null}), + )) + .await; + + let request = only_request(&upstream).await; + assert_eq!( + sent_betas(&request), + BetaSet::from_iter([AnthropicBeta::Other("caller-beta-2025-01-01".to_string())]) + ); + assert!( + !request + .body_json::>() + .unwrap() + .contains_key("safeguards") + ); +} + #[rstest] #[tokio::test] async fn an_oauth_key_sends_the_browser_access_header_and_the_oauth_beta(call: MessagesCall) { diff --git a/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs b/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs index aa926662b7a..bcd1e08ad86 100644 --- a/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs @@ -109,7 +109,10 @@ impl BaseMessagesConfig for AnthropicMessagesConfig { } fn request_headers(&self, headers: Headers, request: &MessagesRequest) -> Headers { - update_headers_with_anthropic_beta(headers, request) + merge_beta_headers( + headers, + provider_feature_betas(request, BetaProvider::Anthropic), + ) } } @@ -136,12 +139,20 @@ pub(crate) fn transform_messages_request( messages: strip_encrypted_reasoning_blocks(messages), params: MessagesOptionalParams { context_management, + extra: without_null_safeguards(request.params.extra), ..request.params }, ..request }) } +fn without_null_safeguards(extra: Map) -> Map { + extra + .into_iter() + .filter(|(key, value)| key != "safeguards" || !value.is_null()) + .collect() +} + pub(crate) fn update_headers_with_anthropic_beta( headers: Headers, request: &MessagesRequest, @@ -149,12 +160,34 @@ pub(crate) fn update_headers_with_anthropic_beta( merge_beta_headers(headers, feature_betas(request)) } -/// The betas a request needs on a host other than the first-party API: the Anthropic set -/// filtered and renamed through the host's `BetaProvider` policy. +pub(crate) fn update_headers_with_safeguards_beta( + headers: Headers, + request: &MessagesRequest, + provider: BetaProvider, +) -> Headers { + merge_beta_headers( + headers, + feature_betas(request).union(on_provider(mapped_host_betas(&request.params), provider)), + ) +} + +/// The betas a request needs on a host with a column in the beta map, filtered and renamed +/// through that column. pub(crate) fn provider_feature_betas(request: &MessagesRequest, provider: BetaProvider) -> BetaSet { - feature_betas(request) - .iter() - .filter_map(|beta| beta.on(provider)) + on_provider( + feature_betas(request).union(mapped_host_betas(&request.params)), + provider, + ) +} + +fn on_provider(betas: BetaSet, provider: BetaProvider) -> BetaSet { + betas.iter().filter_map(|beta| beta.on(provider)).collect() +} + +fn mapped_host_betas(params: &MessagesOptionalParams) -> BetaSet { + carries_safeguards(params) + .then_some(AnthropicBeta::DangerousToolUse20260903) + .into_iter() .collect() } @@ -206,6 +239,13 @@ fn uses_structured_output(params: &MessagesOptionalParams) -> bool { .is_some_and(|config| config.format.is_some()) } +fn carries_safeguards(params: &MessagesOptionalParams) -> bool { + params + .extra + .get("safeguards") + .is_some_and(|value| !value.is_null()) +} + fn messages_carry_output_config(messages: &[Message]) -> bool { messages .iter() @@ -1025,10 +1065,44 @@ mod tests { json!({"messages": [{"role": "user", "content": "hi", "output_config": null}]}), &["per-turn-control-2026-07-01"] )] + #[case::safeguards( + json!({"safeguards": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]}), + &[] + )] fn feature_betas_follow_the_request(#[case] fields: Value, #[case] expected: &[&str]) { assert_eq!(feature_betas(&request(fields)), betas(expected)); } + #[rstest] + #[case::anthropic(BetaProvider::Anthropic, json!([{"type": "dangerous_tool_use"}]), &["dangerous-tool-use-2026-09-03"])] + #[case::vertex_ai(BetaProvider::VertexAi, json!([{"type": "dangerous_tool_use"}]), &["dangerous-tool-use-2026-09-03"])] + #[case::empty_list(BetaProvider::Anthropic, json!([]), &["dangerous-tool-use-2026-09-03"])] + #[case::not_a_list(BetaProvider::Anthropic, json!(1), &["dangerous-tool-use-2026-09-03"])] + #[case::null(BetaProvider::Anthropic, Value::Null, &[])] + #[case::bedrock_converse_has_a_null_cell(BetaProvider::BedrockConverse, json!([{"type": "dangerous_tool_use"}]), &[])] + fn safeguards_add_the_dangerous_tool_use_beta_where_the_beta_map_has_a_cell( + #[case] provider: BetaProvider, + #[case] safeguards: Value, + #[case] expected: &[&str], + ) { + assert_eq!( + provider_feature_betas(&request(json!({"safeguards": safeguards})), provider), + betas(expected) + ); + } + + #[rstest] + fn hosts_without_a_beta_map_column_get_safeguards_without_a_derived_beta() { + let client = headers(&[("anthropic-beta", "claude-code-20250219")]); + assert_eq!( + update_headers_with_anthropic_beta( + client.clone(), + &request(json!({"safeguards": [{"type": "dangerous_tool_use"}]})) + ), + client + ); + } + #[rstest] #[case::no_betas(&[("x-api-key", "k"), ("anthropic-version", "2023-06-01")], json!({}), &[("x-api-key", "k"), ("anthropic-version", "2023-06-01")])] #[case::blank_beta_header(&[("Anthropic-Beta", " , "), ("x-api-key", "k")], json!({}), &[("Anthropic-Beta", " , "), ("x-api-key", "k")])] @@ -1067,6 +1141,11 @@ mod tests { json!({"messages": [{"role": "user", "content": "hi", "output_config": {"effort": "low"}}]}), &[("anthropic-beta", "claude-code-20250219,context-management-2025-06-27,effort-2025-11-24,interleaved-thinking-2025-05-14,per-turn-control-2026-07-01")], )] + #[case::safeguards_add_the_dangerous_tool_use_beta_to_the_client_betas( + &[("anthropic-beta", "claude-code-20250219,interleaved-thinking-2025-05-14")], + json!({"safeguards": [{"type": "dangerous_tool_use"}]}), + &[("anthropic-beta", "claude-code-20250219,dangerous-tool-use-2026-09-03,interleaved-thinking-2025-05-14")], + )] fn request_headers_merge_the_feature_betas( #[case] input: &[(&str, &str)], #[case] fields: Value, diff --git a/litellm-rust/crates/llms/src/azure_ai/messages/transformation.rs b/litellm-rust/crates/llms/src/azure_ai/messages/transformation.rs index 9eaaa657f6f..3bec142ecf8 100644 --- a/litellm-rust/crates/llms/src/azure_ai/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/azure_ai/messages/transformation.rs @@ -2,7 +2,7 @@ use litellm_auth::SecretValue; use litellm_http::request::{has_bearer_auth, has_header}; use litellm_llms_types::{ formats::messages::MessagesRequest, - providers::anthropic::{DEFAULT_HEADERS, MESSAGES_PATH}, + providers::anthropic::{BetaProvider, DEFAULT_HEADERS, MESSAGES_PATH}, }; use litellm_router_types::LitellmParams; @@ -12,7 +12,7 @@ use crate::{ common_utils::API_KEY_PLACEMENT, messages::{ handler::shape_anthropic_messages_request, - transformation::{transform_messages_request, update_headers_with_anthropic_beta}, + transformation::{transform_messages_request, update_headers_with_safeguards_beta}, }, }, azure_ai::common_utils::{ @@ -104,7 +104,7 @@ impl BaseMessagesConfig for AzureAnthropicMessagesConfig { } fn request_headers(&self, headers: Headers, request: &MessagesRequest) -> Headers { - update_headers_with_anthropic_beta(headers, request) + update_headers_with_safeguards_beta(headers, request, BetaProvider::AzureAi) } } @@ -544,6 +544,12 @@ mod tests { &[("x-api-key", "k"), ("anthropic-beta", "structured-outputs-2025-11-13,web-search-2025-03-05")] )] #[case::no_feature_needs_a_beta(json!({}), &[], &[("x-api-key", "k")])] + #[case::safeguards_need_the_dangerous_tool_use_beta( + json!({"safeguards": [{"type": "dangerous_tool_use"}]}), + &[("anthropic-beta", "claude-code-20250219")], + &[("x-api-key", "k"), ("anthropic-beta", "claude-code-20250219,dangerous-tool-use-2026-09-03")] + )] + #[case::null_safeguards_need_no_beta(json!({"safeguards": null}), &[], &[("x-api-key", "k")])] fn request_headers_carry_the_anthropic_feature_betas( #[case] fields: serde_json::Value, #[case] forwarded: &[(&str, &str)], diff --git a/litellm-rust/crates/llms/src/bedrock/claude_platform/messages/transformation.rs b/litellm-rust/crates/llms/src/bedrock/claude_platform/messages/transformation.rs index 1b99eca975f..e2e64e54664 100644 --- a/litellm-rust/crates/llms/src/bedrock/claude_platform/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/bedrock/claude_platform/messages/transformation.rs @@ -6,7 +6,7 @@ use litellm_core_utils::settings::resolve_non_empty; use litellm_http::request::{header_value, with_header, without_headers}; use litellm_llms_types::{ formats::messages::MessagesRequest, - providers::anthropic::{DEFAULT_HEADERS, MESSAGES_PATH}, + providers::anthropic::{BetaProvider, DEFAULT_HEADERS, MESSAGES_PATH}, }; use litellm_router_types::LitellmParams; use serde_json::Value; @@ -15,7 +15,7 @@ use crate::{ Error, ErrorDetail, anthropic::messages::{ handler::shape_anthropic_messages_request, - transformation::{transform_messages_request, update_headers_with_anthropic_beta}, + transformation::{transform_messages_request, update_headers_with_safeguards_beta}, }, base_llm::{ auth::{AuthScheme, Headers, ValidatedEnvironment}, @@ -174,7 +174,7 @@ impl BaseMessagesConfig for BedrockClaudePlatformMessagesConfig { Some(workspace) => with_header(headers, WORKSPACE_HEADER, workspace.into()), None => headers, }; - update_headers_with_anthropic_beta(headers, request) + update_headers_with_safeguards_beta(headers, request, BetaProvider::Anthropic) } fn wire_body(&self, body: Value) -> Value { @@ -356,6 +356,21 @@ mod tests { ); } + #[rstest] + #[case::safeguards(json!([{"type": "dangerous_tool_use"}]), Some("dangerous-tool-use-2026-09-03"))] + #[case::null_safeguards(Value::Null, None)] + fn safeguards_need_the_dangerous_tool_use_beta( + #[case] safeguards: Value, + #[case] expected: Option<&str>, + ) { + let request = serde_json::from_value(json!({"model": "claude-test", "max_tokens": 16, "messages": [], "safeguards": safeguards})).unwrap(); + let headers = BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG.request_headers(vec![], &request); + assert_eq!( + header_value(&headers, "anthropic-beta").map(|value| value.to_string()), + expected.map(str::to_string) + ); + } + #[rstest] #[case::default(json!({}), false)] #[case::override_empty(json!({"claude_platform_unsupported_params": []}), true)] diff --git a/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs b/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs index 9359184f40e..fefedbbbf5e 100644 --- a/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs @@ -451,4 +451,27 @@ mod tests { json!({"type": "enabled", "budget_tokens": 1024}) ); } + + #[rstest] + #[case::without_client_betas(&[])] + #[case::with_client_betas(&[("anthropic-beta", "claude-code-20250219,interleaved-thinking-2025-05-14")])] + fn safeguards_and_client_betas_reach_deepseek_as_sent(#[case] client: &[(&str, &str)]) { + let safeguards = json!([{"type": "dangerous_tool_use", "classifier_context": {"v": 1}}]); + let body = json!({ + "model": "deepseek-v4-pro", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hi"}], + "safeguards": safeguards + }); + let headers: Headers = client + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + assert_eq!( + DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG + .request_headers(headers.clone(), &request_from(body.clone())), + headers + ); + assert_eq!(transformed(body)["safeguards"], safeguards); + } } diff --git a/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs b/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs index e6a3661a81f..0e665304661 100644 --- a/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs @@ -213,23 +213,14 @@ fn has_web_search_tool(tools: Option<&[Recognized]>) -> bool { } /// Python's Vertex `validate_anthropic_messages_environment` beta set: the shared feature -/// betas under Vertex's policy, plus the web search and safeguards betas Vertex needs spelled -/// out. +/// betas under Vertex's policy, plus the web search beta Vertex needs spelled out. fn vertex_feature_betas(request: &MessagesRequest) -> BetaSet { let params = &request.params; provider_feature_betas(request, BetaProvider::VertexAi).union( - [ - has_web_search_tool(params.tools.as_deref()) - .then_some(AnthropicBeta::WebSearch20250305), - params - .extra - .get("safeguards") - .is_some_and(|value| !value.is_null()) - .then_some(AnthropicBeta::DangerousToolUse20260903), - ] - .into_iter() - .flatten() - .collect(), + has_web_search_tool(params.tools.as_deref()) + .then_some(AnthropicBeta::WebSearch20250305) + .into_iter() + .collect(), ) } diff --git a/litellm/__init__.py b/litellm/__init__.py index 959c9fb406d..cc5fbc1e72e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -676,6 +676,7 @@ qwen_ai_platform_models: Set = set() moonshot_models: Set = set() publicai_models: Set = set() darkbloom_models: Set = set() +openinfer_models: Final[Set[str]] = set() v0_models: Set = set() morph_models: Set = set() lambda_ai_models: Set = set() @@ -742,7 +743,7 @@ def is_openai_finetune_model(key: str) -> bool: def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, object]]) -> None: - for key, value in model_cost_map.items(): + for key, value in tuple(model_cost_map.items()): if value.get("litellm_provider") == "openai" and not is_openai_finetune_model(key): open_ai_chat_completion_models.add(key) elif value.get("litellm_provider") == "text-completion-openai": @@ -945,6 +946,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje publicai_models.add(key) elif value.get("litellm_provider") == "darkbloom": darkbloom_models.add(key) + elif value.get("litellm_provider") == "openinfer": + openinfer_models.add(key) elif value.get("litellm_provider") == "v0": v0_models.add(key) elif value.get("litellm_provider") == "morph": @@ -1113,6 +1116,7 @@ model_list = list( | moonshot_models | publicai_models | darkbloom_models + | openinfer_models | v0_models | morph_models | lambda_ai_models @@ -1229,6 +1233,7 @@ def _build_models_by_provider() -> dict: "moonshot": moonshot_models, "publicai": publicai_models, "darkbloom": darkbloom_models, + "openinfer": openinfer_models, "v0": v0_models, "morph": morph_models, "lambda_ai": lambda_ai_models, diff --git a/litellm/_logging.py b/litellm/_logging.py index 913e9a20101..d08254db3fd 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -1000,6 +1000,8 @@ def _initialize_loggers_with_handler(handler: logging.Handler): lg.handlers.clear() # remove any existing handlers lg.addHandler(handler) # add JSON formatter handler lg.propagate = False # prevent bubbling to parent/root + if (configured_level := os.getenv("LITELLM_LOG")) is not None: + logging.getLogger().setLevel(resolve_log_level(configured_level)) def _get_uvicorn_json_log_config(): diff --git a/litellm/anthropic_beta_headers_manager.py b/litellm/anthropic_beta_headers_manager.py index 8b7fa1c9347..96c38b69104 100644 --- a/litellm/anthropic_beta_headers_manager.py +++ b/litellm/anthropic_beta_headers_manager.py @@ -22,17 +22,28 @@ Environment Variables: - LITELLM_ANTHROPIC_BETA_HEADERS_URL: Custom URL for remote config (optional) """ +import asyncio import json import os +import threading +from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor +from functools import cache from importlib.resources import files -from typing import Final +from typing import Final, TypeAlias import httpx +from pydantic import TypeAdapter from litellm._logging import verbose_logger # Cache for the loaded configuration _BETA_HEADERS_CONFIG: dict | None = None +_BETA_HEADERS_CONFIG_LOCK: Final = threading.Lock() +_BETA_HEADERS_CONFIG_LOADER: Final = ThreadPoolExecutor(max_workers=1, thread_name_prefix="litellm-beta-headers-config") + +BetaHeadersConfig: TypeAlias = Mapping[str, str | Mapping[str, str | None]] +_BETA_HEADERS_CONFIG_ADAPTER: Final = TypeAdapter(BetaHeadersConfig) class GetAnthropicBetaHeadersConfig: @@ -174,13 +185,27 @@ def _load_beta_headers_config() -> dict: if _BETA_HEADERS_CONFIG is not None: return _BETA_HEADERS_CONFIG - # Get the URL from environment or use default - from litellm import anthropic_beta_headers_url + with _BETA_HEADERS_CONFIG_LOCK: + if _BETA_HEADERS_CONFIG is not None: + return _BETA_HEADERS_CONFIG - _BETA_HEADERS_CONFIG = get_beta_headers_config(url=anthropic_beta_headers_url) - verbose_logger.debug("Loaded and cached beta headers config") + # Get the URL from environment or use default + from litellm import anthropic_beta_headers_url - return _BETA_HEADERS_CONFIG + _BETA_HEADERS_CONFIG = get_beta_headers_config(url=anthropic_beta_headers_url) + verbose_logger.debug("Loaded and cached beta headers config") + + return _BETA_HEADERS_CONFIG + + +async def aload_beta_headers_config() -> None: + if _BETA_HEADERS_CONFIG is None: + await asyncio.get_running_loop().run_in_executor(_BETA_HEADERS_CONFIG_LOADER, _load_beta_headers_config) + + +@cache +def _bundled_beta_headers_config() -> BetaHeadersConfig: + return _BETA_HEADERS_CONFIG_ADAPTER.validate_python(GetAnthropicBetaHeadersConfig.load_local_beta_headers_config()) def reload_beta_headers_config() -> dict: @@ -288,6 +313,14 @@ def is_beta_header_supported( return beta_header in provider_mapping and provider_mapping[beta_header] is not None +def is_beta_header_supported_by_bundled_config(beta_header: str, provider: str) -> bool: + config: Final = _bundled_beta_headers_config() + aliases: Final = config.get("provider_aliases", {}) + canonical_provider: Final = (aliases.get(provider) if isinstance(aliases, Mapping) else None) or provider + provider_mapping: Final = config.get(canonical_provider, {}) + return isinstance(provider_mapping, Mapping) and provider_mapping.get(beta_header) is not None + + def get_provider_beta_header( anthropic_beta_header: str, provider: str, diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index a50cdf75d33..45b0eaa0b57 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -794,11 +794,17 @@ def _batch_response_was_successful( message batch results lines report ``result.type == "succeeded"``; Bedrock batch output lines report ``modelOutput`` (and no ``error``). """ - if custom_llm_provider == "anthropic": - return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("type") == "succeeded" if custom_llm_provider == "bedrock": return batch_job_output_file.get("modelOutput") is not None and batch_job_output_file.get("error") is None - _response: Final[dict[str, object]] = cast( # cast-ok: batch response bodies come from provider JSON - dict[str, object], batch_job_output_file.get("response", None) or {} + envelope_key: Final = "result" if custom_llm_provider == "anthropic" else "response" + envelope: Final = batch_job_output_file.get(envelope_key) + if not isinstance(envelope, Mapping): + if envelope is not None: + verbose_logger.warning("batch output %s must be an object, treating the line as unsuccessful", envelope_key) + return False + response: Final = cast(Mapping[object, object], envelope) # cast-ok: mapping shape validated above + return ( + response.get("type") == "succeeded" + if custom_llm_provider == "anthropic" + else response.get("status_code") == 200 ) - return _response.get("status_code", None) == 200 diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index 063faa70f2f..7089ff78835 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -346,6 +346,7 @@ class LLMCachingHandler: if litellm.cache is not None and self._is_call_type_supported_by_cache(original_function=original_function): verbose_logger.debug("Checking Async Cache") + logging_obj.reset_caching_details() cached_result = await self._retrieve_from_cache( call_type=call_type, kwargs=kwargs, @@ -357,7 +358,7 @@ class LLMCachingHandler: verbose_logger.debug("Cache Hit!") cache_hit: Final = True end_time: Final = datetime.datetime.now() - model, custom_llm_provider, _, _ = litellm.get_llm_provider( + provider_model, custom_llm_provider, _, _ = litellm.get_llm_provider( model=model, custom_llm_provider=kwargs.get("custom_llm_provider", None), api_base=kwargs.get("api_base", None), @@ -366,7 +367,7 @@ class LLMCachingHandler: cache_duration_ms: Final = (cache_check_end_time - cache_check_start_time) * 1000 self._update_litellm_logging_obj_environment( logging_obj=logging_obj, - model=model, + model=provider_model, kwargs=kwargs, cached_result=cached_result, is_async=True, @@ -381,7 +382,7 @@ class LLMCachingHandler: call_type=call_type, kwargs=kwargs, logging_obj=logging_obj, - model=model, + model=provider_model, custom_llm_provider=kwargs.get("custom_llm_provider", None), args=args, ) @@ -467,6 +468,7 @@ class LLMCachingHandler: new_kwargs["cache_key"] = litellm.cache.get_cache_key(**new_kwargs) self.request_kwargs = _drop_logging_obj_from_kwargs(new_kwargs) print_verbose("Checking Sync Cache") + logging_obj.reset_caching_details() with response_cache_phase("get"): cached_result = litellm.cache.get_cache(**new_kwargs) if is_response_without_output(cached_result): diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 8d23526b41a..98322aecf6a 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -41,7 +41,11 @@ from litellm.responses.sse_output_recovery import ( record_output_item_chunk, record_output_text_chunk, ) -from litellm.responses.utils import ResponsesAPIRequestUtils, normalize_responses_api_stream_options +from litellm.responses.utils import ( + ResponsesAPIRequestUtils, + auto_reasoning_summary, + normalize_responses_api_stream_options, +) from litellm.types.llms.openai import ( ChatCompletionAnnotation, ChatCompletionReasoningItem, @@ -176,6 +180,16 @@ def _without_audio_input_parts(input_items: list[object]) -> list[object]: return [_without_audio_input_parts_in_item(item) for item in input_items] +def _effort_with_requested_summary(nested_effort: object, top_level_effort: object) -> object: + match top_level_effort, nested_effort: + case {"summary": str() as summary}, str() as effort: + return {"effort": effort, "summary": summary} + case {"summary": str() as summary}, Mapping() as reasoning if "summary" not in reasoning: + return {**reasoning, "summary": summary} + case _: + return nested_effort + + def _served_provider(litellm_params: Mapping[str, object]) -> str | None: custom_llm_provider: Final = litellm_params.get("custom_llm_provider") return custom_llm_provider if isinstance(custom_llm_provider, str) else None @@ -953,6 +967,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): optional_params: dict, responses_api_request: "ResponsesAPIOptionalRequestParams", drop_params: bool = False, + model: str | None = None, + custom_llm_provider: str | None = None, ) -> None: """Map optional_params into responses_api_request (mutates in place).""" for key, value in optional_params.items(): @@ -984,7 +1000,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): elif key == "previous_response_id": responses_api_request["previous_response_id"] = value elif key == "reasoning_effort": - responses_api_request["reasoning"] = self.map_reasoning_effort(value) + responses_api_request["reasoning"] = self.map_reasoning_effort(value, model, custom_llm_provider) elif key == "web_search_options": self._add_web_search_tool(responses_api_request, value) @@ -1100,6 +1116,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): optional_params, responses_api_request, drop_params=bool(litellm_params.get("drop_params") or litellm.drop_params), + model=model, + custom_llm_provider=_served_provider(litellm_params), ) stream: Final = optional_params.get("stream") or litellm_params.get("stream", False) @@ -1705,14 +1723,22 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Extract supported params from extra_body and merge into optional_params extra_body_copy: Final = extra_body.copy() + top_level_effort: Final = cast(object, optional_params.get("reasoning_effort")) # cast-ok: upcast for key, value in extra_body_copy.items(): if key in supported_responses_api_params: # Prefer extra_body value if it exists (may have more complete info like summary in reasoning_effort) - optional_params[key] = extra_body.pop(key) + extra_body.pop(key) + optional_params[key] = ( + _effort_with_requested_summary(cast(object, value), top_level_effort) # cast-ok: upcast + if key == "reasoning_effort" + else value + ) return optional_params - def map_reasoning_effort(self, reasoning_effort: object) -> Reasoning: + def map_reasoning_effort( + self, reasoning_effort: object, model: str | None = None, custom_llm_provider: str | None = None + ) -> Reasoning: # If dict is passed, convert it directly to Reasoning object if isinstance(reasoning_effort, dict): return Reasoning( @@ -1726,7 +1752,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) return ( - Reasoning(effort=reasoning_effort, summary="detailed") + Reasoning( + effort=reasoning_effort, + summary="detailed" if model is None else auto_reasoning_summary(model, custom_llm_provider), + ) if auto_summary_enabled else Reasoning(effort=reasoning_effort) ) diff --git a/litellm/constants.py b/litellm/constants.py index 77f2961d6a1..da95b97036a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1030,6 +1030,7 @@ openai_compatible_endpoints: Final[list[str]] = [ "https://api.prisminference.com/v1", "https://api.reka.ai/v1", "https://gigachat.devices.sberbank.ru/api/v1", + "https://api.openinfer.ai/v1", ] @@ -1098,6 +1099,7 @@ openai_compatible_providers: Final[list] = [ "docker_model_runner", "ragflow", "pinstripes", # Pinstripes - JSON-configured provider + "openinfer", "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider "cognition", @@ -1631,6 +1633,7 @@ OUTPUT_TOKEN_CEILING_PARAMS: Final = frozenset({"max_tokens", "max_completion_to CLIENT_OUTPUT_CEILING_METADATA_KEY: Final = "_client_output_ceiling" CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags" ROUTING_REQUEST_TAGS_METADATA_KEY: Final = "_routing_request_tags" +VECTOR_STORE_CALLER_METADATA_KWARG: Final = "litellm_vector_store_caller_metadata" ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY: Final = "_litellm_router_usage_counted_tokens" INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin" MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: Final = "_managed_background_response" diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90383ddb60d..fb0723289ee 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -6,9 +6,10 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from datetime import datetime from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, TypedDict, cast +from typing import TYPE_CHECKING, Any, Final, cast from pydantic import ConfigDict, TypeAdapter +from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger @@ -106,6 +107,14 @@ _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_i # Cap on credential-scoped providers held at once; each one owns an exporter thread. _MAX_DYNAMIC_TRACER_PROVIDERS: Final = 256 + +class _CacheHitView(TypedDict, total=False): + cache_hit: ReadOnly[object] + saved_cache_cost: ReadOnly[object] + + +_CACHE_HIT_VIEW: Final = TypeAdapter(_CacheHitView) + # Dedicated so a slow exporter shutdown cannot starve the shared logging executor. _PROVIDER_SHUTDOWN_EXECUTOR: Final = ThreadPoolExecutor(max_workers=4, thread_name_prefix="OtelProviderShutdown") @@ -2434,6 +2443,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): litellm_params=litellm_params, ) # Cost breakdown tracking + cache_view: Final = _CACHE_HIT_VIEW.validate_python(standard_logging_payload) + cache_hit: Final = cache_view.get("cache_hit") is True cost_breakdown: Final[CostBreakdown | None] = standard_logging_payload.get("cost_breakdown") if cost_breakdown: for key, value in cost_breakdown.items(): @@ -2443,6 +2454,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): key=f"gen_ai.cost.{key}", value=value, ) + self.safe_set_attribute(span=span, key=LiteLLM.CACHE_HIT, value=cache_hit) + if cache_hit: + saved_cache_cost: Final = cache_view.get("saved_cache_cost") + if isinstance(saved_cache_cost, (int, float)): + self.safe_set_attribute(span=span, key=LiteLLM.SAVED_CACHE_COST, value=saved_cache_cost) ############################################# ########## LLM Request Attributes ########### ############################################# diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index a4cb0a84541..8779153959f 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -100,23 +100,19 @@ def _carried_keys( return frozenset(key for key, _value in attributes) -def attribute_budget(span: Span, reserved: int, overwrites: frozenset[str] = frozenset()) -> int | None: - """How many mapped attributes fit on ``span`` next to what it already carries and ``reserved`` more. +def carried_keys(span: Span) -> frozenset[str]: + """The attribute keys ``span`` already holds, empty for a span that exposes none.""" + if not isinstance(span, ReadableSpan): + return frozenset() + return _carried_keys(span.attributes or ()) - ``overwrites`` are the mapped keys already present on ``span``: setting one - replaces the value in place and consumes no slot against the limit, so only - the genuinely new pre-existing keys reduce the budget. Counting the - overwritten ones too reserves slots the fit can never spend and sheds - indexed message attributes for nothing. - """ + +def attribute_budget(span: Span, reserved: int) -> int | None: + """How many new attribute keys fit on ``span`` next to what it already carries and ``reserved`` more.""" limit: Final = span_attribute_limit(span) if limit is None: return None - if not isinstance(span, ReadableSpan): - return limit - reserved - existing_keys: Final = _carried_keys(span.attributes or ()) - fresh: Final = len(existing_keys - overwrites) if overwrites else len(existing_keys) - return limit - fresh - reserved + return limit - len(carried_keys(span)) - reserved def stamp_error( @@ -303,8 +299,14 @@ class SpanEmitter: ) stamped_later: Final = error_attributes(error) if error else _NO_ATTRIBUTES reserved: Final = len(stamped_later.keys() - mapped.keys()) - for key, value in fit_indexed_messages(mapped, attribute_budget(span, reserved, frozenset(mapped))).items(): - span.set_attribute(key, value) + overwrites: Final = carried_keys(span) & mapped.keys() + fitted: Final = fit_indexed_messages( + MappingProxyType({key: value for key, value in mapped.items() if key not in overwrites}), + attribute_budget(span, reserved), + ) + for key, value in mapped.items(): + if key in overwrites or key in fitted: + span.set_attribute(key, value) if error: stamped: Final = stamp_error(span, error) if stamped is not None and self._event_recorder is not None and role is SpanRole.LLM_CALL: diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 9eb9cb40caf..73448221acb 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -538,8 +538,12 @@ class OpenTelemetryV2(CustomLogger): # logger is a success/failure callback only, so ``pre_call`` never reaches it # and no carrier exists. The payload plus the request-level provider-handoff # stamp (``upstream_started``) is the affirmative signal of a real call; a - # gate rejection carries ``is_no_upstream_call`` and gets no span. - if carrier is None and (call.is_no_upstream_call or not call.upstream_started or call.payload is None): + # gate rejection carries ``is_no_upstream_call`` and gets no span. A cache + # hit never hands off to a provider, so ``cache_hit`` substitutes for the + # upstream stamp, since the payload proves the call completed. + if carrier is None and ( + call.is_no_upstream_call or call.payload is None or (not call.upstream_started and not call.cache_hit) + ): return None try: return self._finish_carrier(carrier, call, start_time, end_time) diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index 0ac8b47e239..4e07d090edd 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -91,6 +91,8 @@ class GenAIMapper: f"{LiteLLM.COST_PREFIX}margin_fixed_amount": lambda d: d.cost.margin_fixed_amount, f"{LiteLLM.COST_PREFIX}margin_percent": lambda d: d.cost.margin_percent, f"{LiteLLM.COST_PREFIX}margin_total_amount": lambda d: d.cost.margin_total_amount, + LiteLLM.CACHE_HIT: lambda d: d.cache_hit, + LiteLLM.SAVED_CACHE_COST: lambda d: d.saved_cache_cost, LiteLLM.REQUEST_STREAMING: lambda d: d.is_streaming, LiteLLM.REQUEST_ROUTE: lambda d: d.request_route, LiteLLM.REQUEST_PURPOSE: lambda d: d.request_purpose, diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index cbbb1a9f6d4..a3151c24519 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -249,6 +249,10 @@ class LLMCallEvent: # ``InternalCallOrigin`` such as ``autorouter_classifier``), ``None`` for the # caller's own provider attempt. purpose: str | None + # True for a response served from the litellm response cache. A cache hit + # never hands off to a provider, so ``upstream_started`` stays False even + # though the call completed and is worth a span. + cache_hit: bool # A best-effort ``"{operation} {model}"`` name known at ``pre_call`` time. The # span is renamed from the typed payload at close (``finish_span``); this only # needs to be reasonable for a span that never gets closed (a leak). @@ -273,6 +277,7 @@ class LLMCallEvent: upstream_started=kwargs.get("api_call_start_time") is not None, upstream_start_seconds=_epoch_seconds(kwargs.get("api_call_start_time")), purpose=internal_call_origin(payload, kwargs), + cache_hit=bool(payload.get("cache_hit")) if payload else False, # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown provisional_span_name=f"{operation.value} {model}".strip(), time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs), trace=trace, diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 101e6285946..5bca8e01aec 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -441,6 +441,8 @@ class LLMCallSpanData: embedding_output: EmbeddingOutput | None = None promoted_metadata: Mapping[str, str] = field(default_factory=lambda: _EMPTY_METADATA) routing_attributes: Mapping[str, RoutingAttributeValue] = field(default_factory=lambda: MappingProxyType({})) + cache_hit: bool | None = None + saved_cache_cost: float | None = None @classmethod def from_standard_logging_payload( @@ -456,6 +458,7 @@ class LLMCallSpanData: metadata_keys: tuple[str, ...] = (), ) -> LLMCallSpanData: params: Final = cast(Mapping[str, object], payload.get("model_parameters") or {}) + cache_hit: Final = as_bool(payload.get("cache_hit")) is True # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown # The single parse of the request's metadata — the request-vs-provider # model split, the response model, api base, and identity all come from # here rather than being re-derived from the raw payload dicts. @@ -490,7 +493,7 @@ class LLMCallSpanData: error=_parse_error(payload), response_cost=as_float(payload.get("response_cost")), cost=LLMCost.from_breakdown(cast("Mapping[str, object] | None", payload.get("cost_breakdown"))), - server=ServerInfo.from_api_base(context.api_base), + server=None if cache_hit else ServerInfo.from_api_base(context.api_base), identity=context.identity, promoted_metadata=allowlisted_metadata(context.identity.metadata, metadata_keys), is_streaming=as_bool(payload.get("stream")), @@ -507,6 +510,8 @@ class LLMCallSpanData: session_id=session_id or None, embedding_output=embedding_output if capture_content else None, routing_attributes=routing_decision_attributes(decision), + cache_hit=cache_hit, + saved_cache_cost=as_float(payload.get("saved_cache_cost")) if cache_hit else None, # pyright: ignore[reportUnknownMemberType] # TypedDict .get on a str key is partially unknown ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 38cf7266593..b7f955a75ed 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -340,6 +340,8 @@ class LiteLLM: # semconv key for an MCP server's *name* (the convention uses ``server.address`` # for its network location), so it lives under the vendor namespace. MCP_SERVER_NAME: Final = "litellm.mcp.server.name" + CACHE_HIT: Final = "litellm.cache_hit" + SAVED_CACHE_COST: Final = "litellm.saved_cache_cost" class Metric: diff --git a/litellm/litellm_core_utils/completion_timeout.py b/litellm/litellm_core_utils/completion_timeout.py index e6f18257138..a2e7780ded0 100644 --- a/litellm/litellm_core_utils/completion_timeout.py +++ b/litellm/litellm_core_utils/completion_timeout.py @@ -2,15 +2,25 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping +from enum import Enum +from functools import partial +from types import MappingProxyType from typing import Final import httpx from openai import Timeout as SDKTimeout +from typing_extensions import assert_never from litellm.constants import COMPLETION_HTTP_FALLBACK_SECONDS +class StreamKind(Enum): + NOT_STREAMED = "not_streamed" + LIVE = "live" + FAKED = "faked" + + class CompletionTimeout: """Resolves HTTP timeout for ``completion()`` from model vs global settings.""" @@ -36,17 +46,91 @@ class CompletionTimeout: return COMPLETION_HTTP_FALLBACK_SECONDS return float(global_timeout) + @staticmethod + def _longer_limit(first: float | None, second: float | None) -> float | None: + return None if first is None or second is None else max(first, second) + + @staticmethod + def _longer(first: float | httpx.Timeout, second: float | httpx.Timeout) -> float | httpx.Timeout: + if not isinstance(first, httpx.Timeout) and not isinstance(second, httpx.Timeout): + return max(first, second) + first_limits: Final = httpx.Timeout(first) + second_limits: Final = httpx.Timeout(second) + longer: Final = CompletionTimeout._longer_limit + return httpx.Timeout( + connect=longer(first_limits.connect, second_limits.connect), + read=longer(first_limits.read, second_limits.read), + write=longer(first_limits.write, second_limits.write), + pool=longer(first_limits.pool, second_limits.pool), + ) + + @staticmethod + def for_stream( + stream_kind: StreamKind, request_timeout: float | httpx.Timeout, stream_timeout: float | httpx.Timeout | None + ) -> float | httpx.Timeout: + """ + ``stream_timeout`` bounds each read of a live stream, so it replaces the request timeout there. A fake + stream is one non-streaming request whose first byte arrives when generation ends, so it takes the longer + of each limit and never fails sooner than the same call would with either one alone. + """ + if stream_timeout is None: + return request_timeout + match stream_kind: + case StreamKind.NOT_STREAMED: + return request_timeout + case StreamKind.LIVE: + return stream_timeout + case StreamKind.FAKED: + return CompletionTimeout._longer(request_timeout, stream_timeout) + case _: + assert_never(stream_kind) + + @staticmethod + def as_limit(timeout: object) -> float | httpx.Timeout: + if isinstance(timeout, (httpx.Timeout, SDKTimeout)): + return CompletionTimeout.normalize(timeout) + if isinstance(timeout, (float, int, str)): + return float(timeout) + raise TypeError(f"timeout must be a number or an httpx.Timeout, got {type(timeout).__name__}") + + @staticmethod + def stream_request_kwargs(non_stream_timeout: object, stream_timeout: object) -> Mapping[str, object]: + if stream_timeout is None: + return MappingProxyType({"timeout": non_stream_timeout}) + return MappingProxyType( + { + "timeout": None + if non_stream_timeout is None + else CompletionTimeout.for_stream( + StreamKind.FAKED, + CompletionTimeout.as_limit(non_stream_timeout), + CompletionTimeout.as_limit(stream_timeout), + ), + "stream_timeout": stream_timeout, + } + ) + + @staticmethod + def _coerce( + timeout: object, custom_llm_provider: str, supports_httpx_timeout: Callable[[str], bool] + ) -> float | httpx.Timeout: + limit: Final = CompletionTimeout.as_limit(timeout) + if not isinstance(limit, httpx.Timeout) or supports_httpx_timeout(custom_llm_provider): + return limit + return float(limit.read) if limit.read is not None else COMPLETION_HTTP_FALLBACK_SECONDS + @staticmethod def resolve( model_timeout: float | str | httpx.Timeout | SDKTimeout | None, - kwargs: dict, + kwargs: Mapping[str, object], custom_llm_provider: str, *, global_timeout: float | str | None, supports_httpx_timeout: Callable[[str], bool], + stream_kind: StreamKind, ) -> float | httpx.Timeout: """ - Resolution order (first non-None wins): + Request timeout resolution order (first non-None wins): 1. ``model_timeout`` (call argument / merged ``litellm_params``) 2. ``kwargs["timeout"]`` @@ -54,24 +138,20 @@ class CompletionTimeout: 4. ``global_timeout`` (the explicitly-configured ``litellm.request_timeout``), or 600 when nothing was configured. + A stream then applies ``kwargs["stream_timeout"]`` per :meth:`for_stream`. Coerce :class:`httpx.Timeout` when the provider does not support it. """ - resolved: float | str | httpx.Timeout | SDKTimeout - if model_timeout is not None: - resolved = model_timeout - elif kwargs.get("timeout") is not None: - resolved = kwargs["timeout"] - elif kwargs.get("request_timeout") is not None: - resolved = kwargs["request_timeout"] - else: - resolved = CompletionTimeout._fallback_when_no_explicit_timeout(global_timeout) - - if isinstance(resolved, (httpx.Timeout, SDKTimeout)): - normalized: Final = CompletionTimeout.normalize(resolved) - if supports_httpx_timeout(custom_llm_provider): - return normalized - read_timeout: Final = normalized.read - return ( - float(read_timeout) if read_timeout is not None else COMPLETION_HTTP_FALLBACK_SECONDS - ) # default 10 min timeout - return float(resolved) + coerce: Final = partial( + CompletionTimeout._coerce, + custom_llm_provider=custom_llm_provider, + supports_httpx_timeout=supports_httpx_timeout, + ) + non_stream_candidates: Final = (model_timeout, kwargs.get("timeout"), kwargs.get("request_timeout")) + explicit: Final = next((candidate for candidate in non_stream_candidates if candidate is not None), None) + request_timeout: Final = coerce( + explicit if explicit is not None else CompletionTimeout._fallback_when_no_explicit_timeout(global_timeout) + ) + stream_timeout: Final = None if stream_kind is StreamKind.NOT_STREAMED else kwargs.get("stream_timeout") + return CompletionTimeout.for_stream( + stream_kind, request_timeout, None if stream_timeout is None else coerce(stream_timeout) + ) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index f8aadc4258d..c52bc534058 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -1196,6 +1196,9 @@ def _map_sagemaker_exception( ) +_VERTEX_403_STATUS_CODE_PATTERN: Final = re.compile(r"(? None: + status_code: Final[object] = getattr(original_exception, "status_code", None) + body_code: Final[int | None] = _get_body_error_code(error_str) if "Vertex AI API has not been used in project" in error_str or "Unable to find your project" in error_str: raise BadRequestError( message=f"litellm.BadRequestError: {custom_llm_provider}Exception - {error_str}", @@ -1252,7 +1257,11 @@ def _map_vertex_exception( llm_provider=custom_llm_provider, litellm_debug_info=extra_information, ) - elif "403" in error_str: + elif ( + (not isinstance(status_code, int) or status_code in (403, 500)) + and body_code in (None, 403) + and _VERTEX_403_STATUS_CODE_PATTERN.search(error_str) + ): raise BadRequestError( message=f"{custom_llm_provider.capitalize()}Exception BadRequestError - {error_str}", model=model, @@ -1306,7 +1315,7 @@ def _map_vertex_exception( elif ( isinstance(getattr(original_exception, "status_code", None), int) and 500 <= original_exception.status_code < 600 - and _get_body_error_code(error_str) == 429 + and body_code == 429 ): # upstream gateway wraps a 429 inside a 5xx envelope # e.g. HTTP 500/503 with {"error":{"code":429,...}}. @@ -2413,6 +2422,35 @@ def _map_exception_by_status( ) +_NO_LITELLM_PARAMS: Final[Mapping[str, object]] = MappingProxyType({}) +_URL_SCHEME: Final = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*") +_HOST_WITH_OPTIONAL_PORT: Final = re.compile( + r"(?:(?:[A-Za-z0-9-]+\.)+[A-Za-z0-9-]+|localhost|\[[0-9A-Fa-f:.]+\])(?::\d+)?" +) + + +def with_deployment_api_base(litellm_params: object) -> Mapping[str, object]: + params: Final = as_str_mapping(litellm_params) or _NO_LITELLM_PARAMS + metadata: Final = as_str_mapping(params.get("metadata")) + deployment_api_base: Final = None if metadata is None else metadata.get("api_base") + if not isinstance(deployment_api_base, str) or not deployment_api_base: + return params + return MappingProxyType({**params, "api_base": deployment_api_base}) + + +def _api_base_without_credentials(api_base: str) -> str: + scheme, separator, _ = api_base.partition("://") + scheme_prefix: Final = scheme + separator if separator and _URL_SCHEME.fullmatch(scheme) else "" + authority_and_rest: Final = api_base[len(scheme_prefix) :] + authority: Final = re.split(r"[/?#]", authority_and_rest, maxsplit=1)[0] + after_authority: Final = authority_and_rest[len(authority) :] + host: Final = authority.rpartition("@")[2] + if "@" in after_authority and _HOST_WITH_OPTIONAL_PORT.fullmatch(host) is None: + return "" + path: Final = re.split(r"[?#]", after_authority, maxsplit=1)[0] + return f"{scheme_prefix}{host}{path}" + + def _is_guardrail_block(original_exception: Exception) -> bool: from litellm.integrations.custom_guardrail import is_guardrail_intervention @@ -2483,8 +2521,9 @@ def exception_type( if isinstance(custom_llm_provider, str) and len(custom_llm_provider) > 0: exception_provider = custom_llm_provider[0].upper() + custom_llm_provider[1:] + "Exception" - if _api_base: - extra_information += f"\nAPI Base: `{_api_base}`" + shown_api_base: Final = _api_base_without_credentials(_api_base) if _api_base else "" + if shown_api_base: + extra_information += f"\nAPI Base: `{shown_api_base}`" if messages and len(messages) > 0 and litellm.redact_messages_in_exceptions is False: extra_information += f"\nMessages: `{messages}`" diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index 6e3769260cc..073e3a3455e 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -324,10 +324,16 @@ def _classify_fetch_content(content: bytes, url: str, etag: str | None) -> _Fetc return ModelCostMapReloaded(model_cost_map=model_cost_map, revision=git_blob_id(content), etag=etag) -def _attempt_fetch_s3(url: str, timeout: int) -> _FetchAttemptOutcome: +def _split_url(url: str) -> urllib.parse.SplitResult | ModelCostMapReloadUnavailable: + try: + return urllib.parse.urlsplit(url) + except ValueError as error: + return ModelCostMapReloadUnavailable(reason=f"invalid URL {url}: {error}") + + +def _attempt_fetch_s3(parts: urllib.parse.SplitResult, url: str, timeout: int) -> _FetchAttemptOutcome: from botocore.exceptions import BotoCoreError, ClientError - parts: Final = urllib.parse.urlsplit(url) bucket: Final = parts.netloc key: Final = parts.path.lstrip("/") if not bucket or not key: @@ -343,9 +349,11 @@ def _attempt_fetch_s3(url: str, timeout: int) -> _FetchAttemptOutcome: async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: - parts: Final = urllib.parse.urlsplit(url) + parts: Final = _split_url(url) + if isinstance(parts, ModelCostMapReloadUnavailable): + return parts if parts.scheme == "s3": - return await asyncio.to_thread(_attempt_fetch_s3, url, timeout) + return await asyncio.to_thread(_attempt_fetch_s3, parts, url, timeout) try: response: Final = await client.get(url, timeout=timeout) except (httpx.HTTPError, httpx.InvalidURL) as e: @@ -354,9 +362,11 @@ async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _Fe def _attempt_fetch_sync(client: _SyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: - parts: Final = urllib.parse.urlsplit(url) + parts: Final = _split_url(url) + if isinstance(parts, ModelCostMapReloadUnavailable): + return parts if parts.scheme == "s3": - return _attempt_fetch_s3(url, timeout) + return _attempt_fetch_s3(parts, url, timeout) try: response: Final = client.get(url, timeout=timeout) except (httpx.HTTPError, httpx.InvalidURL) as e: @@ -547,22 +557,39 @@ def get_model_cost_map_loaded_at() -> "datetime | None": return _cost_map_source_info.loaded_at -def _expand_model_aliases(model_cost: dict) -> dict: +class AliasedCostEntry(dict[str, object]): + __slots__ = ("names",) + + def __init__(self, entry: Mapping[str, object], names: tuple[str, ...]) -> None: + super().__init__(entry) + self.names: tuple[str, ...] = names + + +def _alias_groups(alias_owners: Mapping[str, str]) -> Mapping[str, tuple[str, ...]]: + return MappingProxyType( + { + owner: (owner, *(alias for alias, alias_owner in alias_owners.items() if alias_owner == owner)) + for owner in dict.fromkeys(alias_owners.values()) + } + ) + + +def _expand_model_aliases(model_cost: dict[str, dict[str, object]]) -> dict[str, dict[str, object]]: """ Expand ``aliases`` lists in model cost entries into top-level entries. - Each alias gets a reference to the **same** dict object as the canonical - entry (zero memory overhead). The ``aliases`` key is removed from the - entry so downstream code never sees it. + Each alias gets a reference to the **same** ``AliasedCostEntry`` as the + canonical entry, which carries every name it is served under. The + ``aliases`` key is removed from the entry so downstream code never sees it. If an alias collides with an existing canonical entry the alias is skipped and a warning is logged. """ - aliases_to_add: Final[dict[str, dict]] = {} + alias_owners: Final[dict[str, str]] = {} keys_with_aliases: Final[list[str]] = [] for model_name, model_info in model_cost.items(): - aliases: list | None = model_info.get("aliases") + aliases: object = model_info.get("aliases") if aliases is None: continue keys_with_aliases.append(model_name) @@ -584,7 +611,7 @@ def _expand_model_aliases(model_cost: dict) -> dict: model_name, ) continue - if alias in aliases_to_add: + if alias in alias_owners: verbose_logger.warning( "LiteLLM model alias conflict: alias '%s' (from '%s') " "was already claimed by another entry — skipping.", @@ -592,13 +619,14 @@ def _expand_model_aliases(model_cost: dict) -> dict: model_name, ) continue - aliases_to_add[alias] = model_info # same dict reference + alias_owners[alias] = model_name # Remove the ``aliases`` key from entries so it doesn't pollute model info for key in keys_with_aliases: model_cost[key].pop("aliases", None) - model_cost.update(aliases_to_add) + for owner, names in _alias_groups(alias_owners).items(): + model_cost.update(dict.fromkeys(names, AliasedCostEntry(model_cost[owner], names))) return model_cost diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 438b38f3f09..b6bdc21bc05 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -13,12 +13,13 @@ import sys import time import traceback from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass from datetime import datetime as dt_object from functools import lru_cache from types import MappingProxyType, TracebackType from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast -from httpx import Response +from httpx import Response, Timeout from pydantic import BaseModel, JsonValue, TypeAdapter import litellm @@ -558,6 +559,12 @@ def mask_api_base_credentials(api_base: str) -> str: return api_base[:key_end] + "*" * 5 + api_base[-4:] +@dataclass(frozen=True, slots=True) +class UncachedPrice: + cost: float | None + cost_breakdown: CostBreakdown | None + + def _timestamp_seconds(moment: object) -> float | None: if isinstance(moment, datetime.datetime): return moment.timestamp() @@ -664,6 +671,7 @@ class Logging(LiteLLMLoggingBaseClass): self.message_copy_duration_ms: float = (time.time() - _copy_start) * 1000 self.callback_duration_ms: float = 0.0 self.stream = stream + self.applied_timeout: float | Timeout | None = None self.start_time = start_time # log the call start time self.call_type = call_type self.relays_llm_passthrough: Final = call_type in ( @@ -811,6 +819,10 @@ class Logging(LiteLLMLoggingBaseClass): new_call.sync_streaming_chunks = [] return new_call + def reset_caching_details(self) -> None: + """Forget an earlier cache lookup's result, so a call that looks up again reports only its latest.""" + self.caching_details = None + def set_response_timing_metrics(self, timing_metrics: Mapping[str, float]) -> None: """Keep ``_response_ms`` / ``litellm_overhead_time_ms`` for a result that has no ``_hidden_params``.""" self.response_timing_metrics = dict(timing_metrics) @@ -1045,6 +1057,11 @@ class Logging(LiteLLMLoggingBaseClass): if "custom_llm_provider" in self.model_call_details: self.custom_llm_provider = self.model_call_details["custom_llm_provider"] + def record_applied_timeout(self, timeout: float | Timeout) -> None: + self.applied_timeout = timeout + self.litellm_params["timeout"] = timeout + self.model_call_details["litellm_params"] = self.litellm_params + def update_from_kwargs( self, kwargs: dict, @@ -1890,6 +1907,7 @@ class Logging(LiteLLMLoggingBaseClass): cache_hit: bool | None = None, litellm_model_name: str | None = None, router_model_id: str | None = None, + ignore_stamped_cost: bool = False, ) -> float | None: """ Calculate response cost using result + logging object variables. @@ -1925,7 +1943,9 @@ class Logging(LiteLLMLoggingBaseClass): ): hidden_params: Final = result_hidden_params if ( - "response_cost" in hidden_params and hidden_params["response_cost"] is not None + not ignore_stamped_cost + and "response_cost" in hidden_params + and hidden_params["response_cost"] is not None ): # use cost if already calculated self._record_zero_cost_diagnostic( priced_result, @@ -2051,6 +2071,20 @@ class Logging(LiteLLMLoggingBaseClass): _response_cost_calculator = response_cost_calculator + def uncached_price(self, result: object) -> UncachedPrice: + previous_cost_breakdown: Final = self.cost_breakdown + previous_billed_token_rates: Final = self.billed_token_rates + debug_key: Final = "response_cost_failure_debug_information" + had_failure_debug_info: Final = debug_key in self.model_call_details + try: + cost: Final = self.response_cost_calculator(result=result, cache_hit=False, ignore_stamped_cost=True) + return UncachedPrice(cost=cost, cost_breakdown=self.cost_breakdown) + finally: + self.cost_breakdown = previous_cost_breakdown + self.billed_token_rates = previous_billed_token_rates + if not had_failure_debug_info: + self.model_call_details.pop(debug_key, None) + def _record_zero_cost_diagnostic( self, result: object, @@ -3031,7 +3065,7 @@ class Logging(LiteLLMLoggingBaseClass): complete_streaming_response: ( ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None ) = None - if "complete_streaming_response" in self.model_call_details: + if "complete_streaming_response" in self.model_call_details and not for_async_request: return # break out of this. complete_streaming_response = self._get_assembled_streaming_response( result=result, @@ -6909,6 +6943,36 @@ def _autorouter_savings_for_payload( return None +_CACHE_HIT_ZEROED_AMOUNTS: Final = MappingProxyType( + { + "cache_read_cost": CostBreakdown(cache_read_cost=0.0), + "cache_creation_cost": CostBreakdown(cache_creation_cost=0.0), + "reasoning_cost": CostBreakdown(reasoning_cost=0.0), + "original_cost": CostBreakdown(original_cost=0.0), + "discount_amount": CostBreakdown(discount_amount=0.0), + "margin_fixed_amount": CostBreakdown(margin_fixed_amount=0.0), + "margin_total_amount": CostBreakdown(margin_total_amount=0.0), + } +) + + +def _cache_hit_cost_breakdown(served: CostBreakdown | None) -> CostBreakdown: + """Every amount at 0, keeping the pricing basis and rates a fresh call would have had, which savings reports read.""" + zero_costs: Final = CostBreakdown(input_cost=0.0, output_cost=0.0, total_cost=0.0, tool_usage_cost=0.0) + if served is None: + return zero_costs + zeroed_additional_costs: Final = ( + CostBreakdown(additional_costs=dict.fromkeys(served["additional_costs"], 0.0)) + if "additional_costs" in served + else CostBreakdown() + ) + return functools.reduce( + lambda breakdown, zeroed: breakdown | zeroed, + (_CACHE_HIT_ZEROED_AMOUNTS[key] for key in served if key in _CACHE_HIT_ZEROED_AMOUNTS), + served | zero_costs | zeroed_additional_costs, + ) + + def get_standard_logging_object_payload( kwargs: dict | None, init_response_obj: object, @@ -6992,17 +7056,10 @@ def get_standard_logging_object_payload( "user", None ) # maintain backwards compatibility with old request body check - saved_cache_cost: float = 0.0 if cache_hit is True or served_from_cache(init_response_obj): id = f"{id}_cache_hit{time.time()}" # do not duplicate the request id - if cache_hit is True: - saved_cache_cost = ( - logging_obj.response_cost_calculator( - result=init_response_obj, - cache_hit=False, - ) - or 0.0 - ) + uncached_price: Final = logging_obj.uncached_price(result=init_response_obj) if cache_hit is True else None + saved_cache_cost: Final = (uncached_price.cost if uncached_price is not None else None) or 0.0 ## Get model cost information ## base_model = get_base_model_from_metadata(model_call_details=kwargs) @@ -7019,7 +7076,7 @@ def get_standard_logging_object_payload( # clean up litellm hidden params clean_hidden_params: Final = StandardLoggingPayloadSetup.get_hidden_params(hidden_params) - if clean_hidden_params["response_cost"] is None and raw_response_cost is not None: + if cache_hit is True or (clean_hidden_params["response_cost"] is None and raw_response_cost is not None): clean_hidden_params["response_cost"] = llm_response_cost if clean_hidden_params["litellm_overhead_time_ms"] is None and status == "success": # /v1/messages dict results and the bridge stream wrappers keep it on the logging object; @@ -7081,7 +7138,14 @@ def get_standard_logging_object_payload( ): model_name = response_model_name - request_cost_breakdown: Final = cost_breakdown_with_guardrail(logging_obj.cost_breakdown, guardrail_cost) + request_cost_breakdown: Final = cost_breakdown_with_guardrail( + ( + _cache_hit_cost_breakdown(uncached_price.cost_breakdown) + if uncached_price is not None + else logging_obj.cost_breakdown + ), + guardrail_cost, + ) captured_baseline: Final = logging_obj.baseline_observation autorouter_savings: Final = ( None diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 8a1a70128e3..e2fb2be4ec2 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -6171,3 +6171,25 @@ def resolve_structured_messages( if structured: return [msg if isinstance(msg, dict) else msg.model_dump() for msg in structured] return None + + +def is_flat_responses_tool(tool: Mapping[str, object]) -> bool: + tool_type: Final = tool.get("type") + return tool_type in ("function", "custom") and tool_type not in tool and "input_schema" not in tool + + +def chat_completion_tools_for(tool: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + if is_flat_responses_tool(tool): + chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + [dict(tool)] # pyright: ignore[reportArgumentType] # a JSON dict in a flat Responses tool shape + ) + return tuple(chat_tools) + anthropic_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai( + [dict(tool)] # pyright: ignore[reportArgumentType] # a JSON dict in an Anthropic or chat tool shape + ) + return tuple(anthropic_tools) diff --git a/litellm/litellm_core_utils/served_output_texts.py b/litellm/litellm_core_utils/served_output_texts.py index ce9a568ea72..e23db5200a3 100644 --- a/litellm/litellm_core_utils/served_output_texts.py +++ b/litellm/litellm_core_utils/served_output_texts.py @@ -126,13 +126,13 @@ def _chat_stream_choice_text(chunks: Sequence[ModelResponseStream], index: int) content for chunk in chunks for choice in chunk.choices - if choice.index == index and isinstance(content := choice.delta.content, str) + if (choice.index or 0) == index and isinstance(content := choice.delta.content, str) ) return "".join(contents) if contents else None def _chat_stream_texts(chunks: Sequence[ModelResponseStream]) -> ServedTexts | None: - choice_count: Final = max((choice.index + 1 for chunk in chunks for choice in chunk.choices), default=0) + choice_count: Final = max(((choice.index or 0) + 1 for chunk in chunks for choice in chunk.choices), default=0) texts: Final = tuple(_chat_stream_choice_text(chunks, index) for index in range(choice_count)) return texts if any(text is not None for text in texts) else None diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 78fb82c98e7..78cfb58bedf 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -47,7 +47,7 @@ from litellm.types.utils import GenericStreamingChunk as GChunk from ..exceptions import OpenAIError from .core_helpers import map_finish_reason, process_response_headers -from .exception_mapping_utils import exception_type +from .exception_mapping_utils import exception_type, with_deployment_api_base from .llm_response_utils.get_api_base import get_api_base from .rules import Rules @@ -1579,11 +1579,7 @@ class CustomStreamWrapper: except Exception as e: traceback.format_exc() setattr(e, "message", str(e)) - raise exception_type( - model=self.model, - custom_llm_provider=self.custom_llm_provider, - original_exception=e, - ) + raise self._map_stream_exception(e) def set_logging_event_loop(self, loop): """ @@ -2370,6 +2366,14 @@ class CustomStreamWrapper: if not self._emitted_disqualifying_content and self._delta_disqualifies_continuation(delta): self._emitted_disqualifying_content = True + def _map_stream_exception(self, original_exception: Exception) -> Exception: + return exception_type( + model=self.model, + custom_llm_provider=self.custom_llm_provider, + original_exception=original_exception, + extra_kwargs=with_deployment_api_base(self.logging_obj.model_call_details.get("litellm_params")), + ) + def _handle_stream_fallback_error(self, e: Exception) -> "NoReturn": """ Common error handling for both __next__ and __anext__. @@ -2414,13 +2418,7 @@ class CustomStreamWrapper: mapped_exception: Exception = e else: try: - mapped_exception = exception_type( - model=self.model, - custom_llm_provider=self.custom_llm_provider, - original_exception=e, - completion_kwargs={}, - extra_kwargs={}, - ) + mapped_exception = self._map_stream_exception(e) except Exception as mapping_error: mapped_exception = mapping_error self._restore_consumer_correlation_context() diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 6b6974becac..7654b56ad9a 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -30,6 +30,7 @@ from litellm.llms.anthropic.pass_through.adapters.transformation import ( LiteLLMAnthropicMessagesAdapter, is_provider_native_tool_dict, ) +from litellm.llms.anthropic.pass_through.messages.utils import filtered_safeguard_results, redacted_stop_details from litellm.llms.anthropic.pass_through.stream_assembly import ( build_complete_streaming_response, build_usage_only_response_from_chunks, @@ -1456,6 +1457,9 @@ class AnthropicMessagesHandler(BaseTranslation): return _SSEFieldRewrite("delta", "text", next(replacements)) cls._rewrite_ended_stream_events(responses_so_far, rewrite_text_delta) + cls._redact_ended_stream_message_delta( + responses_so_far, kept_tool_use_ids=frozenset(cls._streamed_tool_use_ids(responses_so_far).values()) + ) @classmethod def _write_ended_stream_tool_call_rewrites( @@ -1523,6 +1527,64 @@ class AnthropicMessagesHandler(BaseTranslation): return None cls._rewrite_ended_stream_events(responses_so_far, rewrite_tool_use) + streamed_tool_use_ids: Final = cls._streamed_tool_use_ids(responses_so_far) + cls._redact_ended_stream_message_delta( + responses_so_far, + kept_tool_use_ids=frozenset( + tool_use_id for index, tool_use_id in streamed_tool_use_ids.items() if index not in rewrites_by_block + ), + ) + + @classmethod + def _streamed_tool_use_ids(cls, responses_so_far: Sequence[object]) -> Mapping[int, str]: + """The id of each buffered ``tool_use`` block, by its content block index.""" + events: Final = chain.from_iterable(cls._iter_sse_events(item) for item in responses_so_far) + tool_use_blocks: Final = ( + (event.get("index"), block) + for event in events + if event.get("type") == "content_block_start" + and (block := as_str_mapping(event.get("content_block"))) is not None + and block.get("type") == "tool_use" + ) + return MappingProxyType( + { + index: tool_use_id + for index, block in tool_use_blocks + if isinstance(index, int) and isinstance(tool_use_id := block.get("id"), str) + } + ) + + @classmethod + def _redact_ended_stream_message_delta( + cls, + responses_so_far: MutableSequence[object], # mutable-ok: rewrites the caller's buffered chunks in place + *, + kept_tool_use_ids: frozenset[str], + ) -> None: + """Hold the buffered ``message_delta`` to the rules a re-emitted rewrite follows: a verdict survives + only for a tool call delivered unchanged, and no free-text ``explanation`` describes the text the + guardrail rewrote.""" + + def rewrite_stop_details(event: Mapping[str, object]) -> _SSEFieldRewrite | None: + delta: Final = as_str_mapping(event.get("delta")) if event.get("type") == "message_delta" else None + if delta is None or "stop_details" not in delta: + return None + return _SSEFieldRewrite( + "delta", "stop_details", redacted_stop_details(delta["stop_details"], keep_explanations=False) + ) + + def rewrite_safeguard_results(event: Mapping[str, object]) -> _SSEFieldRewrite | None: + delta: Final = as_str_mapping(event.get("delta")) if event.get("type") == "message_delta" else None + if delta is None or "safeguard_results" not in delta: + return None + return _SSEFieldRewrite( + "delta", + "safeguard_results", + filtered_safeguard_results(delta["safeguard_results"], kept_tool_use_ids, keep_explanations=False), + ) + + cls._rewrite_ended_stream_events(responses_so_far, rewrite_stop_details) + cls._rewrite_ended_stream_events(responses_so_far, rewrite_safeguard_results) @staticmethod def _rewrite_ended_stream_events( diff --git a/litellm/llms/anthropic/pass_through/adapters/handler.py b/litellm/llms/anthropic/pass_through/adapters/handler.py index 7b8c2550838..a9aa3de69ad 100644 --- a/litellm/llms/anthropic/pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/pass_through/adapters/handler.py @@ -501,7 +501,11 @@ class LiteLLMMessagesToCompletionTransformationHandler: return None if resolved_provider == "litellm_proxy": return None - from litellm.responses.bridge_check import chat_target_accepts_reasoning_summary, responses_api_bridge_check + from litellm.responses.bridge_check import ( + chat_target_accepts_reasoning_summary, + responses_api_bridge_check, + str_keyed_mapping, + ) if requested_summary is not None and chat_target_accepts_reasoning_summary(local_model, resolved_provider): return _ChatTargetReasoning(effort=effort, summary=requested_summary) @@ -510,6 +514,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: web_search_options: Final = completion_kwargs.get("web_search_options") tools: Final = completion_kwargs.get("tools") + client_summary: Final = thinking.get("summary") model_info, _ = responses_api_bridge_check( model=local_model, custom_llm_provider=resolved_provider, @@ -517,8 +522,9 @@ class LiteLLMMessagesToCompletionTransformationHandler: cast(OpenAIWebSearchOptions, web_search_options) if isinstance(web_search_options, dict) else None ), tools=cast("list[dict[str, object]]", tools) if isinstance(tools, list) else None, - reasoning_effort=reasoning_effort, + reasoning_effort=effort if client_summary is None else {"effort": effort, "summary": client_summary}, api_base=resolved_api_base, + deployment_model_info=str_keyed_mapping(completion_kwargs.get("model_info")), ) return None if model_info.get("mode") == "responses" else _ChatTargetReasoning(effort=effort) diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 3aad0be1913..0d3e48c49fd 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -14,6 +14,7 @@ from litellm.llms.anthropic.pass_through.utils import ( json_copy_or_original, prompt_cache_key_from_user_id, ) +from litellm.responses.utils import auto_reasoning_summary # OpenAI has a 64-character limit for function/tool names # Anthropic does not have this limit, so we need to truncate long names @@ -736,7 +737,7 @@ class LiteLLMAnthropicMessagesAdapter: if reasoning_effort: return { "reasoning_effort": LiteLLMAnthropicMessagesAdapter._apply_reasoning_summary_wrapping( - reasoning_effort, thinking + reasoning_effort, thinking, model, None ) } return {} @@ -745,6 +746,8 @@ class LiteLLMAnthropicMessagesAdapter: def _apply_reasoning_summary_wrapping( reasoning_effort: str, thinking: Mapping[str, object], + model: str, + custom_llm_provider: str | None, ) -> Any: """ Apply the reasoning_effort/summary wrapping rules shared by every @@ -762,7 +765,7 @@ class LiteLLMAnthropicMessagesAdapter: if summary: return {"effort": reasoning_effort, "summary": summary} if is_reasoning_auto_summary_enabled(): - return {"effort": reasoning_effort, "summary": "detailed"} + return {"effort": reasoning_effort, "summary": auto_reasoning_summary(model, custom_llm_provider)} return reasoning_effort def translate_anthropic_tool_choice_to_openai( @@ -1174,7 +1177,9 @@ class LiteLLMAnthropicMessagesAdapter: new_kwargs["reasoning_effort"] = ( reasoning_effort if is_claude_target - else self._apply_reasoning_summary_wrapping(reasoning_effort, cast(dict[str, object], thinking)) + else self._apply_reasoning_summary_wrapping( + reasoning_effort, cast(dict[str, object], thinking), model, custom_llm_provider + ) ) def _translate_output_format_to_openai( diff --git a/litellm/llms/anthropic/pass_through/messages/fake_stream_iterator.py b/litellm/llms/anthropic/pass_through/messages/fake_stream_iterator.py index 5930739d445..8d1b358c8f1 100644 --- a/litellm/llms/anthropic/pass_through/messages/fake_stream_iterator.py +++ b/litellm/llms/anthropic/pass_through/messages/fake_stream_iterator.py @@ -12,6 +12,7 @@ import json from collections.abc import Mapping from typing import Any, Final, cast +from litellm.llms.anthropic.pass_through.messages.utils import anthropic_message_delta from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) @@ -34,8 +35,13 @@ class FakeAnthropicMessagesStreamIterator: - message_stop """ - def __init__(self, response: AnthropicMessagesResponse): + def __init__( + self, + response: AnthropicMessagesResponse, + message_delta_fields: Mapping[str, object] | None = None, + ): self.response = response + self.message_delta_fields = message_delta_fields self.chunks = self._create_streaming_chunks() self.current_index = 0 @@ -175,14 +181,9 @@ class FakeAnthropicMessagesStreamIterator: cache_read_input_tokens: Final = usage.get("cache_read_input_tokens") if cache_read_input_tokens is not None: delta_usage["cache_read_input_tokens"] = cache_read_input_tokens - safeguard_results: Final = self.response.get("safeguard_results") message_delta: Final = { "type": "message_delta", - "delta": { - "stop_reason": self.response.get("stop_reason"), - "stop_sequence": self.response.get("stop_sequence"), - **({"safeguard_results": safeguard_results} if safeguard_results is not None else {}), - }, + "delta": anthropic_message_delta(response_dict, self.message_delta_fields), "usage": delta_usage, } chunks.append(f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode()) diff --git a/litellm/llms/anthropic/pass_through/messages/handler.py b/litellm/llms/anthropic/pass_through/messages/handler.py index 00bf1774dd0..88cfeaad66a 100644 --- a/litellm/llms/anthropic/pass_through/messages/handler.py +++ b/litellm/llms/anthropic/pass_through/messages/handler.py @@ -55,15 +55,19 @@ _SAFEGUARD_KWARGS: Final = TypeAdapter(Mapping[str, object]) _SAFEGUARD_MESSAGES: Final = TypeAdapter(Sequence[Mapping[str, object]]) -def _bridges_to_responses_api(model: str, custom_llm_provider: str) -> bool: - from litellm.responses.bridge_check import responses_api_bridge_check +def _bridges_to_responses_api(model: str, custom_llm_provider: str, deployment_model_info: object) -> bool: + from litellm.responses.bridge_check import responses_api_bridge_check, str_keyed_mapping - model_info, _ = responses_api_bridge_check(model=model, custom_llm_provider=custom_llm_provider) + model_info, _ = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + deployment_model_info=str_keyed_mapping(deployment_model_info), + ) return model_info.get("mode") == "responses" def _responses_mode_is_lost_by_prefix_strip( - requested_model: str, resolved_model: str, custom_llm_provider: str + requested_model: str, resolved_model: str, custom_llm_provider: str, deployment_model_info: object ) -> bool: """Whether a Responses-only deployment stops looking like one once its provider prefix is stripped. @@ -73,15 +77,16 @@ def _responses_mode_is_lost_by_prefix_strip( """ 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 - ) + return _bridges_to_responses_api( + requested_model, custom_llm_provider, deployment_model_info + ) and not _bridges_to_responses_api(resolved_model, custom_llm_provider, deployment_model_info) def _should_route_to_responses_api( custom_llm_provider: str | None, requested_model: str | None = None, resolved_model: str | None = None, + deployment_model_info: object = None, ) -> bool: """Return True when the request should use the Responses API path. @@ -94,7 +99,9 @@ def _should_route_to_responses_api( 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) + return _responses_mode_is_lost_by_prefix_strip( + requested_model, resolved_model, custom_llm_provider, deployment_model_info + ) async def _with_native_safeguard_results( @@ -606,7 +613,7 @@ def anthropic_messages_handler( if anthropic_messages_provider_config is None: # Route to Responses API for OpenAI / Azure, chat/completions for everything else. if kwargs.get("compaction") is None and _should_route_to_responses_api( - custom_llm_provider, original_model, model + custom_llm_provider, original_model, model, kwargs.get("model_info") ): return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler( max_tokens=max_tokens, diff --git a/litellm/llms/anthropic/pass_through/messages/streaming_iterator.py b/litellm/llms/anthropic/pass_through/messages/streaming_iterator.py index e65fe596da7..44db193ea83 100644 --- a/litellm/llms/anthropic/pass_through/messages/streaming_iterator.py +++ b/litellm/llms/anthropic/pass_through/messages/streaming_iterator.py @@ -18,7 +18,11 @@ from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.anthropic.common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP -from litellm.llms.anthropic.pass_through.messages.utils import INCOMPLETE_STREAM_ERROR_MESSAGE +from litellm.llms.anthropic.pass_through.messages.utils import ( + INCOMPLETE_STREAM_ERROR_MESSAGE, + MESSAGE_DELTA_OPTIONAL_KEYS, + anthropic_message_delta, +) from litellm.llms.anthropic.pass_through.safeguards import StreamedSafeguardResults from litellm.llms.anthropic.pass_through.utils import anthropic_sse_frame from litellm.proxy.pass_through_endpoints.success_handler import ( @@ -280,30 +284,32 @@ def anthropic_messages_response_as_sse_events(response: AnthropicMessagesRespons content_events: Final = ( event for index, block in enumerate(content_blocks) for event in _anthropic_content_block_events(index, block) ) - # A real message_start always carries a null stop_reason/stop_sequence and - # a zero output_tokens - those are only known once generation finishes, so - # copying the completed response's final values here would let a client - # treat the message as already finished, or double-count output tokens. + # A real message_start always carries a null stop_reason/stop_sequence/stop_details, + # a zero output_tokens and no safeguard_results - those are only known once + # generation finishes, so copying the completed response's final values here + # would let a client treat the message as already finished, or double-count + # output tokens. message_start_usage: Final = { **(response.get("usage") or {}), "output_tokens": 0, } + message_start_fields: Final = { + key: value for key, value in response.items() if key not in MESSAGE_DELTA_OPTIONAL_KEYS + } message_start_payload: Final = { "type": "message_start", "message": { - **response, + **message_start_fields, "content": [], "stop_reason": None, "stop_sequence": None, + "stop_details": None, "usage": message_start_usage, }, } message_delta_payload: Final = { "type": "message_delta", - "delta": { - "stop_reason": response.get("stop_reason"), - "stop_sequence": response.get("stop_sequence"), - }, + "delta": anthropic_message_delta(response), "usage": response.get("usage") or {}, } return ( diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index 640736d6463..ad45485fa56 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -4,6 +4,7 @@ from typing import ClassVar, Final import httpx from litellm._logging import verbose_logger +from litellm.anthropic_beta_headers_manager import is_beta_header_supported_by_bundled_config from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.anthropic_messages.transformation import ( @@ -708,6 +709,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): optional_params: dict, custom_llm_provider: str = "anthropic", messages: Sequence[object] = (), + beta_map_provider: str | None = None, ) -> dict: """ Auto-inject anthropic-beta headers based on features used. @@ -724,8 +726,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): optional_params: Optional parameters including tools, context_management, output_format, speed custom_llm_provider: Provider name for looking up correct tool search header messages: Request messages, scanned for per-message output_config + beta_map_provider: Provider whose beta map column gates safeguards, defaults to custom_llm_provider """ - beta_values: Final[set] = set() + beta_values: Final[set[str]] = set() existing_beta: Final = tuple( piece.strip() @@ -775,6 +778,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if optional_params.get("speed") == "fast": beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value) + if optional_params.get("safeguards") is not None and is_beta_header_supported_by_bundled_config( + ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value, + beta_map_provider or custom_llm_provider, + ): + beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value) + if messages_carry_output_config(messages): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value) diff --git a/litellm/llms/anthropic/pass_through/messages/utils.py b/litellm/llms/anthropic/pass_through/messages/utils.py index d8b96c1b7a0..d91a5e2fb5c 100644 --- a/litellm/llms/anthropic/pass_through/messages/utils.py +++ b/litellm/llms/anthropic/pass_through/messages/utils.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping, Sequence from functools import lru_cache from typing import TYPE_CHECKING, Final, cast, get_type_hints -from pydantic import JsonValue +from pydantic import JsonValue, TypeAdapter, ValidationError from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook from litellm.litellm_core_utils.core_helpers import as_str_mapping @@ -49,6 +49,95 @@ def refusal_stop_details(explanation: str | None) -> AnthropicStopDetails: return AnthropicStopDetails(type="refusal", category=None, explanation=explanation) +MESSAGE_DELTA_OPTIONAL_KEYS: Final = ("stop_details", "safeguard_results") + + +def anthropic_message_delta( + response: Mapping[str, object], optional_fields: Mapping[str, object] | None = None +) -> Mapping[str, object]: + """ + The ``delta`` of the ``message_delta`` event synthesized from a complete + Anthropic Messages response. A real stream carries ``stop_details`` and + ``safeguard_results`` there, so a client that asked to stream reads them + from this event and nowhere else. ``optional_fields`` replaces the + response's own when it was rebuilt from a stream that carried them. + """ + optional_source: Final = response if optional_fields is None else optional_fields + present_optional: Final = { + key: value for key in MESSAGE_DELTA_OPTIONAL_KEYS if (value := optional_source.get(key)) is not None + } + return { + "stop_reason": response.get("stop_reason"), + "stop_sequence": response.get("stop_sequence"), + **present_optional, + } + + +_SAFEGUARD_RESULT_ITEMS: Final = TypeAdapter(tuple[object, ...]) + + +def redacted_stop_details(raw: object, keep_explanations: bool) -> object: + """``stop_details`` as a rewritten stream may deliver it: its free-text ``explanation`` can quote + the text a guardrail rewrote, so it is nulled unless the rewrite changed no text.""" + stop_details: Final = as_str_mapping(raw) + if keep_explanations or stop_details is None or "explanation" not in stop_details: + return raw + return {**stop_details, "explanation": None} + + +def _without_explanation(raw: object) -> object: + mapping: Final = as_str_mapping(raw) + if mapping is None: + return raw + return {key: value for key, value in mapping.items() if key != "explanation"} + + +def _status_without_explanations(raw: object) -> object: + status: Final = as_str_mapping(_without_explanation(raw)) + if status is None: + return raw + tool_uses: Final = as_str_mapping(status.get("tool_uses")) + if tool_uses is None: + return status + return { + **status, + "tool_uses": {tool_use_id: _without_explanation(verdict) for tool_use_id, verdict in tool_uses.items()}, + } + + +def _result_without_explanations(raw: object) -> object: + result: Final = as_str_mapping(_without_explanation(raw)) + if result is None: + return raw + if "status" not in result: + return result + return {**result, "status": _status_without_explanations(result["status"])} + + +def _kept_tool_use_verdicts(raw: object, kept_tool_use_ids: frozenset[str]) -> object: + result: Final = as_str_mapping(raw) + status: Final = as_str_mapping(result.get("status")) if result is not None else None + tool_uses: Final = as_str_mapping(status.get("tool_uses")) if status is not None else None + if result is None or status is None or tool_uses is None: + return raw + kept_tool_uses: Final = { + tool_use_id: verdict for tool_use_id, verdict in tool_uses.items() if tool_use_id in kept_tool_use_ids + } + return {**result, "status": {**status, "tool_uses": kept_tool_uses}} + + +def filtered_safeguard_results(raw: object, kept_tool_use_ids: frozenset[str], keep_explanations: bool) -> object: + """``safeguard_results`` as a rewritten stream may deliver them: a ``tool_uses`` verdict survives only + for a tool call the client gets with the input the classifier judged, and the free-text ``explanation`` of a + result, its status and each verdict is dropped unless the rewrite changed no text.""" + try: + results: Final = _SAFEGUARD_RESULT_ITEMS.validate_python(raw) + except ValidationError: + return raw if keep_explanations else None + kept: Final = tuple(_kept_tool_use_verdicts(result, kept_tool_use_ids) for result in results) + return kept if keep_explanations else tuple(_result_without_explanations(result) for result in kept) + + def _mapping_field(container: object, key: str) -> object | None: """One key of a raw provider payload, or None when the payload is not a mapping.""" if not isinstance(container, Mapping): diff --git a/litellm/llms/anthropic/pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/pass_through/responses_adapters/handler.py index 692e5d4c175..05ea4e4301d 100644 --- a/litellm/llms/anthropic/pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/pass_through/responses_adapters/handler.py @@ -16,6 +16,7 @@ from litellm.integrations.anthropic_cache_control_hook import ( ) from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint +from litellm.responses.utils import auto_reasoning_summary from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS from litellm.types.llms.anthropic import ( AllAnthropicMessageValues, @@ -188,6 +189,7 @@ def _build_responses_kwargs( include_encrypted_reasoning=_provider_returns_encrypted_reasoning( model, forwarded_kwargs.get("custom_llm_provider") ), + auto_summary_level=auto_reasoning_summary(model, forwarded_kwargs.get("custom_llm_provider")), ) # Normalize reasoning effort based on model capabilities diff --git a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py index 97f2db36a85..fb611b64dda 100644 --- a/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/responses_adapters/transformation.py @@ -544,6 +544,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: def translate_thinking_to_reasoning( thinking: dict[str, object], output_config: dict[str, object] | None = None, + auto_summary_level: str = "detailed", ) -> dict[str, object] | None: """ Convert Anthropic thinking param to Responses API reasoning param. @@ -576,7 +577,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: if summary: result["summary"] = summary elif auto_summary: - result["summary"] = "detailed" + result["summary"] = auto_summary_level return result @classmethod @@ -591,6 +592,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: self, anthropic_request: AnthropicMessagesRequest, include_encrypted_reasoning: bool = True, + auto_summary_level: str = "detailed", ) -> dict[str, Any]: """ Translate a full Anthropic /v1/messages request dict to @@ -599,7 +601,8 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ``include_encrypted_reasoning`` asks the provider for ``reasoning.encrypted_content`` on every call, so a reasoning model's items can be replayed intact next turn even when the client sent no ``thinking`` block; pass False for a provider whose - Responses API rejects ``include``. + Responses API rejects ``include``. ``auto_summary_level`` is the ``reasoning.summary`` the + opt-in auto summary injects when the client sent none. """ model: Final[str] = anthropic_request["model"] messages_list: Final = cast( @@ -670,6 +673,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: reasoning: Final = self.translate_thinking_to_reasoning( thinking, output_config=cast(dict[str, Any] | None, output_config), + auto_summary_level=auto_summary_level, ) if reasoning: responses_kwargs["reasoning"] = reasoning diff --git a/litellm/llms/anthropic/pass_through/stream_assembly.py b/litellm/llms/anthropic/pass_through/stream_assembly.py index 89961bbe255..90cb9f4c335 100644 --- a/litellm/llms/anthropic/pass_through/stream_assembly.py +++ b/litellm/llms/anthropic/pass_through/stream_assembly.py @@ -1,16 +1,18 @@ import codecs import json from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final import litellm from litellm._logging import verbose_proxy_logger -from litellm.litellm_core_utils.core_helpers import map_finish_reason +from litellm.litellm_core_utils.core_helpers import as_str_mapping, map_finish_reason from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.chat.handler import ( ModelResponseIterator as AnthropicModelResponseIterator, ) from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.anthropic.pass_through.messages.utils import MESSAGE_DELTA_OPTIONAL_KEYS from litellm.types.utils import ( Choices, Message, @@ -447,6 +449,27 @@ def _anthropic_message_start(sse_stream: str) -> Mapping[str, object] | None: ) +def anthropic_message_delta_fields(all_chunks: Sequence[object]) -> Mapping[str, object]: + """The ``stop_details`` and ``safeguard_results`` of the stream's ``message_delta``. + + The assembled OpenAI shape has no slot for them, and a client that asked to stream reads them + from that event and nowhere else (a Claude Code auto-mode session takes its classifier verdict + from ``safeguard_results``), so a caller re-emitting a rewritten response merges them back in. + """ + sse_stream: Final = _joined_sse_stream(all_chunks) + if sse_stream is None: + return MappingProxyType({}) + message_delta: Final = next( + (event for event in reversed(_parsed_sse_events(sse_stream)) if event.get("type") == "message_delta"), None + ) + delta: Final = as_str_mapping(message_delta.get("delta")) if message_delta is not None else None + if delta is None: + return MappingProxyType({}) + return MappingProxyType( + {key: value for key in MESSAGE_DELTA_OPTIONAL_KEYS if (value := delta.get(key)) is not None} + ) + + def is_anthropic_sse_stream(all_chunks: Sequence[object]) -> bool: """Whether raw SSE frames are Anthropic Messages events. diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index bd6c53a0453..06717207dc0 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -180,7 +180,7 @@ class BaseAnthropicMessagesConfig(ABC): return 2 def should_retry_anthropic_messages_on_http_error( - self, e: httpx.HTTPStatusError, litellm_params: dict, request_body: object = None + self, e: httpx.HTTPStatusError, litellm_params: Mapping[str, object], request_body: object = None ) -> bool: """ When True, async_anthropic_messages_handler will transform the request body @@ -202,7 +202,9 @@ class BaseAnthropicMessagesConfig(ABC): ) and is_anthropic_thinking_binding_mismatch_error(error_text) return not caller_asked_for_this_error and is_anthropic_invalid_thinking_block_error(error_text) - def transform_anthropic_messages_request_on_http_error(self, e: httpx.HTTPStatusError, request_data: dict) -> dict: + def transform_anthropic_messages_request_on_http_error( + self, e: httpx.HTTPStatusError, request_data: dict[str, object] + ) -> dict[str, object]: """ Mutates request_data in place when retrying after a recoverable HTTP error. """ @@ -214,3 +216,16 @@ class BaseAnthropicMessagesConfig(ABC): if e.response.status_code == 400 and is_anthropic_invalid_thinking_block_error(e.response.text): strip_thinking_blocks_from_anthropic_messages_request_dict(request_data) return request_data + + def native_output_fallback_litellm_params( + self, + model: str, + anthropic_messages_optional_request_params: Mapping[str, object], + litellm_params: GenericLiteLLMParams, + ) -> GenericLiteLLMParams | None: + return None + + def should_retry_anthropic_messages_without_native_output( + self, e: httpx.HTTPStatusError, request_body: Mapping[str, object], model: str + ) -> bool: + return False diff --git a/litellm/llms/base_llm/responses/transformation.py b/litellm/llms/base_llm/responses/transformation.py index ed8bfc8cb96..01baeed9bef 100644 --- a/litellm/llms/base_llm/responses/transformation.py +++ b/litellm/llms/base_llm/responses/transformation.py @@ -1,7 +1,7 @@ import types from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, cast import httpx @@ -26,6 +26,8 @@ else: LiteLLMLoggingObj = Any BaseLLMException = Any +ReasoningSummaryLevel: TypeAlias = Literal["auto", "concise", "detailed"] + class BaseResponsesAPIConfig(ABC): def __init__(self): @@ -91,6 +93,10 @@ class BaseResponsesAPIConfig(ABC): def get_supported_openai_params(self, model: str) -> list: pass + def auto_reasoning_summary(self) -> ReasoningSummaryLevel: + """The ``reasoning.summary`` LiteLLM's opt-in auto summary asks this provider for.""" + return "detailed" + @abstractmethod def map_openai_params( self, diff --git a/litellm/llms/baseten/chat.py b/litellm/llms/baseten/chat.py index 0374646f2f3..e92f47c41c0 100644 --- a/litellm/llms/baseten/chat.py +++ b/litellm/llms/baseten/chat.py @@ -54,6 +54,7 @@ class BasetenConfig(OpenAIGPTConfig): return [ "max_tokens", "max_completion_tokens", + "reasoning_effort", "response_format", "seed", "stop", diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index 5a64e3fb78e..7d02b9ef230 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -42,14 +42,22 @@ def make_sync_call( if client is None: client = get_httpx_client() # Create a new client if none provided - response: Final = client.post( - api_base, - headers=headers, - data=data, - stream=not fake_stream, - logging_obj=logging_obj, - timeout=timeout, - ) + try: + response: Final = client.post( + api_base, + headers=headers, + data=data, + stream=not fake_stream, + logging_obj=logging_obj, + timeout=timeout, + ) + except httpx.HTTPStatusError as err: + raise BedrockError( + status_code=err.response.status_code, + message=error_response_text(err.response), + headers=err.response.headers, + response=err.response, + ) from err if response.status_code != 200: raise BedrockError( diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 66ee920afd0..9deed66d647 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -12,7 +12,7 @@ from itertools import chain from typing import TYPE_CHECKING, Final, Literal, cast, overload import httpx -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError import litellm from litellm._logging import verbose_logger @@ -61,12 +61,16 @@ from litellm.llms.bedrock.common_utils import ( bedrock_cleaned_output_schema, bedrock_model_supports_regex_lookaround, bedrock_native_output_format, + bedrock_native_output_is_opt_in, bedrock_supports_tool_search, deployment_model_info, + requests_output_config_format, ) from litellm.llms.bedrock.request_metadata import ( bedrock_request_metadata_headers, - bedrock_request_metadata_is_owned, + bedrock_request_metadata_is_rewritten, + bedrock_request_metadata_violation, + caller_request_metadata_violations_are_dropped, merge_bedrock_invoke_headers, resolve_bedrock_request_metadata, ) @@ -168,6 +172,18 @@ def _tools_the_model_accepts( return [tool_with_sanitized_parameters(tool, drop_lookaround_regex_patterns) for tool in tools] +_OUTPUT_CONFIG_BLOCK: Final = TypeAdapter(OutputConfigBlock) + + +def _has_native_text_format(output_config: object) -> bool: + if not isinstance(output_config, Mapping): + return False + try: + return _OUTPUT_CONFIG_BLOCK.validate_python(output_config).get("textFormat") is not None + except ValidationError: + return False + + class AmazonConverseConfig(BaseConfig): """ Reference - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html @@ -354,77 +370,15 @@ class AmazonConverseConfig(BaseConfig): and v is not None } - def _validate_request_metadata(self, metadata: dict) -> None: - """ - Validate requestMetadata according to AWS Bedrock Converse API constraints. - - Constraints: - - Maximum of 16 items - - Keys: 1-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{1,256} - - Values: 0-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{0,256} - """ - import re - - if not isinstance(metadata, dict): + def _validate_request_metadata(self, metadata: object) -> None: + violation: Final = bedrock_request_metadata_violation(metadata) + if violation is not None: raise litellm.exceptions.BadRequestError( - message="requestMetadata must be a dictionary", + message=violation.message, model="bedrock", llm_provider="bedrock", ) - if len(metadata) > 16: - raise litellm.exceptions.BadRequestError( - message="requestMetadata can contain a maximum of 16 items", - model="bedrock", - llm_provider="bedrock", - ) - - key_pattern: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$") - value_pattern: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$") - - for key, value in metadata.items(): - if not isinstance(key, str): - raise litellm.exceptions.BadRequestError( - message="requestMetadata keys must be strings", - model="bedrock", - llm_provider="bedrock", - ) - - if not isinstance(value, str): - raise litellm.exceptions.BadRequestError( - message="requestMetadata values must be strings", - model="bedrock", - llm_provider="bedrock", - ) - - if len(key) == 0 or len(key) > 256: - raise litellm.exceptions.BadRequestError( - message="requestMetadata key length must be 1-256 characters", - model="bedrock", - llm_provider="bedrock", - ) - - if len(value) > 256: - raise litellm.exceptions.BadRequestError( - message="requestMetadata value length must be 0-256 characters", - model="bedrock", - llm_provider="bedrock", - ) - - if not key_pattern.match(key): - raise litellm.exceptions.BadRequestError( - message=f"requestMetadata key '{key}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]", - model="bedrock", - llm_provider="bedrock", - ) - - if not value_pattern.match(value): - raise litellm.exceptions.BadRequestError( - message=f"requestMetadata value '{value}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]", - model="bedrock", - llm_provider="bedrock", - ) - @staticmethod def _is_openai_gpt_reasoning_model(model: str) -> bool: return re.search(r"openai\.gpt-\d", model) is not None @@ -1244,7 +1198,7 @@ class AmazonConverseConfig(BaseConfig): elif param == "context_management" and isinstance(value, (dict, list)): self._map_context_management_param(value, optional_params) if param == "requestMetadata": - self._map_request_metadata_param(value, optional_params) + self._map_request_metadata_param(value, optional_params, drop_params) if param == "service_tier" and isinstance(value, str): self._map_service_tier_param(value, optional_params) @@ -1277,9 +1231,10 @@ class AmazonConverseConfig(BaseConfig): return optional_params - def _map_request_metadata_param(self, value: object, optional_params: dict) -> None: + def _map_request_metadata_param(self, value: object, optional_params: dict, drop_params: bool) -> None: if value is not None and isinstance(value, dict): - self._validate_request_metadata(value) + if not caller_request_metadata_violations_are_dropped(drop_params): + self._validate_request_metadata(value) optional_params["requestMetadata"] = value def _map_context_management_param(self, value: dict | list, optional_params: dict) -> None: @@ -1392,6 +1347,22 @@ class AmazonConverseConfig(BaseConfig): optional_params["json_mode"] = True return optional_params + @staticmethod + def sends_native_text_format(optional_params: Mapping[str, object]) -> bool: + return _has_native_text_format(optional_params.get("outputConfig")) + + def uses_opt_in_native_output( + self, model: str, optional_params: Mapping[str, object], litellm_params: Mapping[str, object] + ) -> bool: + native_output_requested: Final = ( + optional_params.get("json_mode") is True and self.sends_native_text_format(optional_params) + ) or requests_output_config_format(optional_params) + return native_output_requested and bedrock_native_output_is_opt_in( + bedrock_capability_model(model, litellm_params.get("base_model")), + self.custom_llm_provider or "bedrock_converse", + deployment_model_info(litellm_params), + ) + def update_optional_params_with_thinking_tokens(self, non_default_params: dict, optional_params: dict): """ Handles scenario where max tokens is not specified. For anthropic models (anthropic api/bedrock/vertex ai), this requires having the max tokens being set and being greater than the thinking token budget. @@ -1737,7 +1708,7 @@ class AmazonConverseConfig(BaseConfig): # Extract requestMetadata before processing other parameters request_metadata: Final = inference_params.pop("requestMetadata", None) - if request_metadata is not None: + if request_metadata is not None and not caller_request_metadata_violations_are_dropped(drop_params): self._validate_request_metadata(request_metadata) output_config: OutputConfigBlock | None = inference_params.pop("outputConfig", None) @@ -2164,7 +2135,7 @@ class AmazonConverseConfig(BaseConfig): request_metadata: Final = resolve_bedrock_request_metadata( litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata") ) - if bedrock_request_metadata_is_owned(): + if bedrock_request_metadata_is_rewritten(litellm_params.get("drop_params") is True): _data.pop("requestMetadata", None) if request_metadata is not None: _data["requestMetadata"] = request_metadata @@ -2244,7 +2215,7 @@ class AmazonConverseConfig(BaseConfig): request_metadata: Final = resolve_bedrock_request_metadata( litellm_params=litellm_params, caller_metadata=_data.get("requestMetadata") ) - if bedrock_request_metadata_is_owned(): + if bedrock_request_metadata_is_rewritten(litellm_params.get("drop_params") is True): _data.pop("requestMetadata", None) if request_metadata is not None: _data["requestMetadata"] = request_metadata diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_nova_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_nova_transformation.py index b94435345d3..ce28e579dcf 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_nova_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_nova_transformation.py @@ -95,7 +95,12 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig): model_info: Mapping[str, object] | None = None, ) -> dict: return AmazonConverseConfig.map_openai_params( - self, non_default_params, optional_params, model, drop_params, model_info + self, + {key: value for key, value in non_default_params.items() if key != "requestMetadata"}, + optional_params, + model, + drop_params, + model_info, ) def transform_request( @@ -110,7 +115,9 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig): self, model=model, messages=messages, - optional_params=_without_tool_config_injection_points(optional_params), + optional_params=_without_tool_config_injection_points( + {key: value for key, value in optional_params.items() if key != "requestMetadata"} + ), litellm_params=litellm_params, headers=headers, ) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index ba0bb607975..240a85ce327 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -31,6 +31,7 @@ from litellm.llms.bedrock.common_utils import ( normalize_bedrock_opus_output_config_effort, normalize_custom_field_on_tools, normalize_tool_input_schema_types_for_bedrock_invoke, + requests_opt_in_native_output, strip_unsupported_bedrock_invoke_output_config_keys, tools_without_eager_input_streaming, ) @@ -102,6 +103,11 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): model, self.is_thinking_requested(request_params), model_info=self._deployment_model_info ) and (json_schema is None or bedrock_cleaned_output_schema(model, json_schema) is not None) + def uses_opt_in_native_output( + self, model: str, optional_params: Mapping[str, object], litellm_params: Mapping[str, object] + ) -> bool: + return requests_opt_in_native_output(model, optional_params, deployment_model_info(litellm_params)) + def map_openai_params( self, non_default_params: dict, diff --git a/litellm/llms/bedrock/chat/native_output_fallback.py b/litellm/llms/bedrock/chat/native_output_fallback.py new file mode 100644 index 00000000000..1b8ce5fcda4 --- /dev/null +++ b/litellm/llms/bedrock/chat/native_output_fallback.py @@ -0,0 +1,62 @@ +from collections.abc import Callable, Coroutine +from typing import Final, TypeAlias + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.bedrock.common_utils import ( + NativeOutputFallback, + is_bedrock_native_output_rejection, + warn_bedrock_native_output_rejected, +) +from litellm.types.utils import ModelResponse +from litellm.utils import CustomStreamWrapper + +NativeOutputResponse: TypeAlias = ModelResponse | CustomStreamWrapper +NativeOutputAttemptResult: TypeAlias = NativeOutputResponse | Coroutine[object, object, NativeOutputResponse] +NativeOutputAttempt: TypeAlias = Callable[[NativeOutputFallback | None], NativeOutputAttemptResult] +NativeOutputFallbackFactory: TypeAlias = Callable[[], NativeOutputFallback | None] + + +def _fallback_for( + error: BaseLLMException, fallback: NativeOutputFallbackFactory, model: str +) -> NativeOutputFallback | None: + if not is_bedrock_native_output_rejection(error.status_code, error.message): + return None + retry: Final = fallback() + if retry is not None: + warn_bedrock_native_output_rejected(model, error.message) + return retry + + +async def _await_with_native_output_fallback( + first_attempt: Coroutine[object, object, NativeOutputResponse], + attempt: NativeOutputAttempt, + fallback: NativeOutputFallbackFactory, + model: str, +) -> NativeOutputResponse: + try: + return await first_attempt + except BaseLLMException as error: + retry: Final = _fallback_for(error, fallback, model) + if retry is None: + raise + second_attempt: Final = attempt(retry) + return await second_attempt if isinstance(second_attempt, Coroutine) else second_attempt + + +def call_with_native_output_fallback( + attempt: NativeOutputAttempt, + fallback: NativeOutputFallbackFactory | None, + model: str, +) -> NativeOutputAttemptResult: + if fallback is None: + return attempt(None) + try: + first_attempt: Final = attempt(None) + except BaseLLMException as error: + retry: Final = _fallback_for(error, fallback, model) + if retry is None: + raise + return attempt(retry) + if isinstance(first_attempt, Coroutine): + return _await_with_native_output_fallback(first_attempt, attempt, fallback, model) + return first_attempt diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 17a8192ca81..bbbc929fad3 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -11,6 +11,7 @@ import os import re import warnings from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias @@ -184,7 +185,7 @@ def _get_local_model_cost_map() -> dict: return GetModelCostMap.load_local_model_cost_map() -def pop_bedrock_invoke_output_config_format(request_body: dict) -> dict | None: +def pop_bedrock_invoke_output_config_format(request_body: dict[str, object]) -> dict[str, object] | None: """ Remove and return Anthropic's nested ``output_config.format`` field. @@ -205,8 +206,8 @@ def pop_bedrock_invoke_output_config_format(request_body: dict) -> dict | None: def convert_bedrock_invoke_output_format_to_inline_schema( - output_format: dict, - request_body: dict, + output_format: Mapping[str, object], + request_body: dict[str, object], ) -> None: """ Embed an Anthropic structured-output schema into the last user message. @@ -292,6 +293,77 @@ def bedrock_chat_uses_native_structured_output( ) +def bedrock_native_output_is_opt_in( + model: str, custom_llm_provider: str = "bedrock", model_info: Mapping[str, object] | None = None +) -> bool: + return not _bedrock_model_supports( + model, "supports_native_structured_output", custom_llm_provider + ) and _bedrock_native_output_opt_in(model, custom_llm_provider, model_info) + + +def without_bedrock_native_output_opt_in(litellm_params: Mapping[str, object]) -> Mapping[str, object]: + return { + **litellm_params, + "model_info": {**deployment_model_info(litellm_params), "supports_bedrock_native_structured_output": False}, + } + + +_BEDROCK_NATIVE_OUTPUT_UNSUPPORTED: Final = "output_config.format: Extra inputs are not permitted" +_BEDROCK_NATIVE_OUTPUT_REJECTIONS: Final = ( + _BEDROCK_NATIVE_OUTPUT_UNSUPPORTED, + "compilation exceeded limits: Schema is too complex", + "output_config.format.schema:", +) + + +def is_bedrock_native_output_rejection(status_code: int, error_text: str) -> bool: + return status_code == 400 and any(rejection in error_text for rejection in _BEDROCK_NATIVE_OUTPUT_REJECTIONS) + + +def warn_bedrock_native_output_rejected(model: str, error_text: str) -> None: + opt_out_advice: Final = ( + " Set `supports_bedrock_native_structured_output: false` in this deployment's model_info to skip the " + "rejected call." + if _BEDROCK_NATIVE_OUTPUT_UNSUPPORTED in error_text + else "" + ) + verbose_logger.warning( + "Bedrock rejected native structured output for model=%s, so litellm sent the request again without it.%s", + model, + opt_out_advice, + ) + + +@dataclass(frozen=True, slots=True) +class NativeOutputFallback: + optional_params: Mapping[str, object] + litellm_params: Mapping[str, object] + + +def requests_output_config_format(optional_params: Mapping[str, object]) -> bool: + match optional_params: + case {"output_config": {"format": object()}}: + return True + case _: + return False + + +def requests_opt_in_native_output( + model: str, anthropic_optional_params: Mapping[str, object], model_info: Mapping[str, object] | None +) -> bool: + native_output_requested: Final = isinstance( + anthropic_optional_params.get("output_format"), Mapping + ) or requests_output_config_format(anthropic_optional_params) + return native_output_requested and bedrock_native_output_is_opt_in(model, model_info=model_info) + + +_JSON_OBJECT: Final = TypeAdapter(dict[str, object]) + + +def json_object_or_none(value: object) -> Mapping[str, object] | None: + return _JSON_OBJECT.validate_python(value) if isinstance(value, Mapping) else None + + BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES: Final = 64 @@ -358,7 +430,8 @@ def apply_bedrock_invoke_structured_output( """ legacy_output_format: Final = request_body.pop("output_format", None) output_config_format: Final = pop_bedrock_invoke_output_config_format(request_body) - schema_format: Final = legacy_output_format if isinstance(legacy_output_format, dict) else output_config_format + legacy_format: Final = json_object_or_none(legacy_output_format) + schema_format: Final = legacy_format if legacy_format is not None else output_config_format if schema_format is None: return diff --git a/litellm/llms/bedrock/count_tokens/handler.py b/litellm/llms/bedrock/count_tokens/handler.py index be8243e48ef..3bae1f22490 100644 --- a/litellm/llms/bedrock/count_tokens/handler.py +++ b/litellm/llms/bedrock/count_tokens/handler.py @@ -48,7 +48,7 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig): verbose_logger.debug("Processing CountTokens request for resolved model: %s", resolved_model) # Get AWS region using existing LiteLLM function - aws_region_name: Final = self._get_aws_region_name( + aws_region_name: Final = self.get_aws_region_name( optional_params=litellm_params, model=resolved_model, model_id=None, diff --git a/litellm/llms/bedrock/count_tokens/kimi_k3.py b/litellm/llms/bedrock/count_tokens/kimi_k3.py new file mode 100644 index 00000000000..5e3fd56e4ba --- /dev/null +++ b/litellm/llms/bedrock/count_tokens/kimi_k3.py @@ -0,0 +1,502 @@ +""" +Exact Bedrock token counts for Kimi K3, which has no CountTokens support. + +Renders the Converse body litellm sends through Moonshot's Kimi K3 chat template and counts it with the +official tokenizer, downloaded once from Hugging Face at a pinned revision and checked against its SHA-256. +""" + +import asyncio +import base64 +import copy +import hashlib +import json +import threading +import time +from collections.abc import Callable, Iterable, Mapping, Sequence +from concurrent.futures import Future +from dataclasses import dataclass +from enum import Enum +from itertools import chain, groupby +from pathlib import Path +from typing import Annotated, Final, Literal, TypeAlias + +import regex +import tiktoken +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, model_validator +from typing_extensions import Self + +import litellm +from litellm._logging import verbose_logger +from litellm.litellm_core_utils.prompt_templates.factory import ( + chat_completion_tools_for, + is_flat_responses_tool, +) +from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam + +KIMI_K3_REPO: Final = "moonshotai/Kimi-K3" +KIMI_K3_REVISION: Final = "f831ab66814297da540d832a5235f8e904f29d06" +KIMI_K3_RANKS_FILE: Final = "tiktoken.model" +KIMI_K3_RANKS_SHA256: Final = "b6c497a7469b33ced9c38afb1ad6e47f03f5e5dc05f15930799210ec050c5103" +_PATTERN: Final = "|".join( + ( + r"[\p{Han}]+", + r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?", + r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?", + r"\p{N}{1,3}", + r" ?[^\s\p{L}\p{N}]+[\r\n]*", + r"\s*[\r\n]+", + r"\s+(?!\S)", + r"\s+", + ) +) +# Ends chunks only where the pattern above always splits, so their counts sum to the whole text's count while BPE +# stays linear. Only a run of over 1024 characters with no such split, like 2000 spaces, is cut mid-piece. +_CHUNK: Final = regex.compile( + r"(?s).{1,1024}(?:(?<=\S)(?= \S)|(?<=\p{N})(?=\P{N})|(?<=[^\s\p{N}])(?=\p{N})|(?<=\p{L})(?=[^\p{L}\p{M}'])" + r"|(?<=[\r\n])(?=\S)|\Z)|.{1024}\p{N}*" +) + + +@dataclass(frozen=True, slots=True) +class KimiK3TokenizerSource: + ranks_path: Callable[[], str] + sha256: str + + +def _hugging_face_ranks_path() -> str: + import huggingface_hub + + return huggingface_hub.hf_hub_download( + repo_id=KIMI_K3_REPO, + filename=KIMI_K3_RANKS_FILE, + revision=KIMI_K3_REVISION, + local_files_only=litellm.disable_hf_tokenizer_download is True, + ) + + +HUGGING_FACE_SOURCE: Final = KimiK3TokenizerSource(ranks_path=_hugging_face_ranks_path, sha256=KIMI_K3_RANKS_SHA256) + + +def _loaded(source: KimiK3TokenizerSource) -> tiktoken.Encoding | str: + try: + data: Final = Path(source.ranks_path()).read_bytes() + except Exception as e: # noqa: BLE001 # a download or read failure of any kind falls back to the estimate + return type(e).__name__ + if hashlib.sha256(data).hexdigest() != source.sha256: + return "SHA-256 mismatch" + rows: Final = (line.split() for line in data.splitlines() if line) + return tiktoken.Encoding( + name="kimi_k3", + pat_str=_PATTERN, + mergeable_ranks={base64.b64decode(token): int(rank) for token, rank in rows}, + special_tokens={}, + ) + + +def load_kimi_k3_encoding(source: KimiK3TokenizerSource) -> tiktoken.Encoding | None: + loaded: Final = _loaded(source) + if isinstance(loaded, tiktoken.Encoding): + return loaded + verbose_logger.warning( + "Kimi K3 tokenizer unavailable (%s), so Kimi K3 token counts are estimated until a later count loads it. " + "Allow downloads of %s from Hugging Face repo %s at revision %s, or put it in the Hugging Face cache", + loaded, + KIMI_K3_RANKS_FILE, + KIMI_K3_REPO, + KIMI_K3_REVISION, + ) + return None + + +def _started_load(source: KimiK3TokenizerSource) -> "Future[tiktoken.Encoding | None]": + encoding: Final = Future[tiktoken.Encoding | None]() + threading.Thread( + target=lambda: encoding.set_result(load_kimi_k3_encoding(source)), name="litellm-kimi-k3-tokenizer", daemon=True + ).start() + return encoding + + +@dataclass(frozen=True, slots=True) +class _EncodingLoad: + encoding: "Future[tiktoken.Encoding | None]" + started_at: float + is_retry: bool + + def retry_due(self, now: float, retry_after_seconds: float) -> bool: + return self.encoding.done() and self.encoding.result() is None and now - self.started_at >= retry_after_seconds + + +@dataclass(slots=True) +class KimiK3Encodings: + source: KimiK3TokenizerSource + retry_after_seconds: float + clock: Callable[[], float] = time.monotonic + current: _EncodingLoad | None = None + + def _load(self) -> _EncodingLoad: + now: Final = self.clock() + current: Final = self.current + if current is not None and not current.retry_due(now, self.retry_after_seconds): + return current + load: Final = _EncodingLoad(encoding=_started_load(self.source), started_at=now, is_retry=current is not None) + self.current = load + return load + + async def encoding(self, wait_seconds: float) -> tiktoken.Encoding | None: + load: Final = self._load() + remaining: Final = 0 if load.is_retry else load.started_at + wait_seconds - self.clock() + if load.encoding.done() or remaining <= 0: + return load.encoding.result() if load.encoding.done() else None + await asyncio.wait((asyncio.wrap_future(load.encoding),), timeout=remaining) + if load.encoding.done(): + return load.encoding.result() + verbose_logger.warning( + "Kimi K3 tokenizer still loading after %s s, so Kimi K3 token counts are estimated until it loads. " + "Check that %s can be downloaded from Hugging Face repo %s at revision %s, or put it in the Hugging Face cache", + wait_seconds, + KIMI_K3_RANKS_FILE, + KIMI_K3_REPO, + KIMI_K3_REVISION, + ) + return None + + +class _Converse(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class _Text(_Converse): + text: str + + +class _CachePoint(_Converse): + cache_point: JsonValue = Field(alias="cachePoint") + + +class _ToolUse(_Converse): + tool_use_id: str = Field(alias="toolUseId") + name: str + arguments: Mapping[str, JsonValue] = Field(alias="input") + + +class _ToolUseBlock(_Converse): + tool_use: _ToolUse = Field(alias="toolUse") + + +class _ToolResult(_Converse): + tool_use_id: str = Field(alias="toolUseId") + content: tuple[_Text, ...] + status: str | None = None + + +class _ToolResultBlock(_Converse): + tool_result: _ToolResult = Field(alias="toolResult") + + +class _ReasoningText(_Converse): + text: str + signature: str | None = None + + +class _Reasoning(_Converse): + reasoning_text: _ReasoningText = Field(alias="reasoningText") + + +class _ReasoningBlock(_Converse): + reasoning_content: _Reasoning = Field(alias="reasoningContent") + + +class _UserMessage(_Converse): + role: Literal["user"] + content: tuple[_Text | _ToolResultBlock | _CachePoint, ...] + + +class _AssistantMessage(_Converse): + role: Literal["assistant"] + content: tuple[_Text | _ToolUseBlock | _ReasoningBlock | _CachePoint, ...] + + def calls(self) -> tuple[_ToolUse, ...]: + return tuple(block.tool_use for block in self.content if isinstance(block, _ToolUseBlock)) + + +class _InputSchema(_Converse): + definition: JsonValue = Field(alias="json") + + +class _ToolSpec(_Converse): + name: str + description: str | None = None + input_schema: _InputSchema = Field(alias="inputSchema") + + def declared(self) -> Mapping[str, JsonValue]: + described: Final[Mapping[str, JsonValue]] = ( + {} if self.description is None else {"description": self.description} + ) + return {"name": self.name, **described, "parameters": self.input_schema.definition} + + +class _ToolSpecBlock(_Converse): + tool_spec: _ToolSpec = Field(alias="toolSpec") + + +class _ToolConfig(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + tools: tuple[_ToolSpecBlock | _CachePoint, ...] + + +_Message: TypeAlias = Annotated[_UserMessage | _AssistantMessage, Field(discriminator="role")] + + +def _result_ids(message: _Message) -> frozenset[str]: + if isinstance(message, _AssistantMessage): + return frozenset() + return frozenset(block.tool_result.tool_use_id for block in message.content if isinstance(block, _ToolResultBlock)) + + +def _call_ids(message: _Message | None) -> frozenset[str]: + if not isinstance(message, _AssistantMessage): + return frozenset() + return frozenset(call.tool_use_id for call in message.calls()) + + +class _ConverseRequest(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + messages: tuple[_Message, ...] + system: tuple[_Text | _CachePoint, ...] = () + tool_config: _ToolConfig | None = Field(default=None, alias="toolConfig") + + @model_validator(mode="after") + def _results_answer_the_previous_calls(self) -> Self: + pairs: Final = zip((None, *self.messages), self.messages, strict=False) + if not all(_result_ids(message) <= _call_ids(previous) for previous, message in pairs): + raise ValueError("a toolResult does not answer a toolUse of the previous assistant message") + return self + + +class _Control(Enum): + OPEN = "<|open|>" + CLOSE = "<|close|>" + SEP = "<|sep|>" + END_OF_MSG = "<|end_of_msg|>" + + +_Segment: TypeAlias = str | _Control +_Segments: TypeAlias = tuple[_Segment, ...] + + +def _open(tag: str, attrs: Iterable[tuple[str, str]] = ()) -> _Segments: + escaped: Final = ( + (f" {key}", '="', value.replace("&", "&").replace('"', """), '"') for key, value in attrs + ) + return (_Control.OPEN, tag, *chain.from_iterable(escaped), _Control.SEP) + + +def _close(tag: str) -> _Segments: + return (_Control.CLOSE, tag, _Control.SEP) + + +def _message(attrs: Iterable[tuple[str, str]], body: Iterable[_Segment]) -> _Segments: + return (*_open("message", attrs), *body, *_close("message"), _Control.END_OF_MSG) + + +def _tool_declare(specs: Sequence[_ToolSpec]) -> _Segments: + tools: Final = json.dumps( + [{"type": "function", "function": spec.declared()} for spec in specs], + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + return _message( + (("role", "system"), ("type", "tool-declare")), + (f"# Tools\nHere are the available tools, described in JSONSchema.\n\n```json\n{tools}\n```",), + ) + + +def _text(blocks: Iterable[_Converse]) -> str: + return "".join(block.text for block in blocks if isinstance(block, _Text)) + + +def _json_type(value: JsonValue) -> str: + match value: + case bool(): + return "boolean" + case None: + return "null" + case int() | float(): + return "number" + case str(): + return "string" + case dict(): + return "object" + case _: + return "array" + + +def _argument(key: str, value: JsonValue) -> _Segments: + text: Final = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + return (*_open("argument", (("key", key), ("type", _json_type(value)))), text, *_close("argument")) + + +def _call(index: int, call: _ToolUse) -> _Segments: + arguments: Final = chain.from_iterable(_argument(key, value) for key, value in call.arguments.items()) + return (*_open("call", (("tool", call.name), ("index", str(index)))), *arguments, *_close("call")) + + +def _tool_calls(calls: Sequence[_ToolUse]) -> _Segments: + if not calls: + return () + rendered: Final = chain.from_iterable(_call(index, call) for index, call in enumerate(calls, start=1)) + return (*_open("tools"), *rendered, *_close("tools")) + + +def _assistant_message(message: _AssistantMessage) -> _Segments: + reasoning: Final = "".join( + block.reasoning_content.reasoning_text.text for block in message.content if isinstance(block, _ReasoningBlock) + ) + return _message( + (("role", "assistant"),), + ( + *_open("think"), + reasoning, + *_close("think"), + *_open("response"), + _text(message.content), + *_close("response"), + *_tool_calls(message.calls()), + ), + ) + + +def _tool_messages(results: Sequence[_ToolResult], previous: _AssistantMessage) -> _Segments: + names: Final = {call.tool_use_id: call.name for call in previous.calls()} + return tuple( + chain.from_iterable( + _message( + (("role", "tool"), ("tool", names[result.tool_use_id]), ("index", str(index))), + (_text(result.content),), + ) + for index, result in enumerate(results, start=1) + ) + ) + + +def _user_run(blocks: Sequence[_Text | _ToolResultBlock], previous: _Message | None) -> _Segments: + results: Final = tuple(block.tool_result for block in blocks if isinstance(block, _ToolResultBlock)) + if results and isinstance(previous, _AssistantMessage): + return _tool_messages(results, previous) + return _message((("role", "user"),), (_text(blocks),)) + + +def _user_messages(message: _UserMessage, previous: _Message | None) -> _Segments: + blocks: Final = tuple(block for block in message.content if not isinstance(block, _CachePoint)) + runs: Final = groupby(blocks, key=lambda block: isinstance(block, _ToolResultBlock)) + return tuple(chain.from_iterable(_user_run(tuple(run), previous) for _, run in runs)) + + +def _turn(previous: _Message | None, message: _Message) -> _Segments: + if isinstance(message, _AssistantMessage): + return _assistant_message(message) + return _user_messages(message, previous) + + +def _rendered(request: _ConverseRequest) -> _Segments: + specs: Final = ( + () + if request.tool_config is None + else tuple(block.tool_spec for block in request.tool_config.tools if isinstance(block, _ToolSpecBlock)) + ) + system: Final = _text(request.system) + turns: Final = chain.from_iterable( + _turn(previous, message) for previous, message in zip((None, *request.messages), request.messages, strict=False) + ) + return ( + *(_tool_declare(specs) if specs else ()), + *(_message((("role", "system"),), (system,)) if system else ()), + *turns, + *_open("message", (("role", "assistant"),)), + *_open("think"), + ) + + +def _text_tokens(encoding: tiktoken.Encoding, text: str) -> int: + spans: Final = (match.span() for match in _CHUNK.finditer(text)) + return sum(len(encoding.encode_ordinary(text[start:end])) for start, end in spans) + + +class _TextPart(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + type: Literal["text"] + + +class _TextOnlyMessage(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + content: str | tuple[_TextPart, ...] | None = None + + +def _text_only(message: AllMessageValues) -> bool: + try: + _TextOnlyMessage.model_validate(message) + except ValidationError: + return False + return True + + +def _as_converse_input(tool: ChatCompletionToolParam) -> tuple[Mapping[str, object], ...]: + return chat_completion_tools_for(tool) if is_flat_responses_tool(tool) else (tool,) + + +def _converse_request( + model: str, messages: Sequence[AllMessageValues], tools: Sequence[ChatCompletionToolParam] | None +) -> _ConverseRequest | None: + if not all(_text_only(message) for message in messages): + return None + try: + chat_tools: Final = tuple( + chat_tool for tool in tools or () for chat_tool in _as_converse_input(tool) + ) # comprehension-ok: flattens each tool's translation + return _ConverseRequest.model_validate( + litellm.AmazonConverseConfig().transform_request( + model=model, + messages=copy.deepcopy(list(messages)), + optional_params={"tools": copy.deepcopy(list(chat_tools))} if chat_tools else {}, + litellm_params={}, + headers={}, + ) + ) + except Exception: # noqa: BLE001 # a body litellm cannot send to Bedrock, or cannot render, falls back to the estimate + return None + + +def kimi_k3_tokens( + encoding: tiktoken.Encoding, + model: str, + messages: Sequence[AllMessageValues], + tools: Sequence[ChatCompletionToolParam] | None, +) -> int | None: + request: Final = _converse_request(model, messages, tools) + if request is None: + return None + return sum( + 1 if isinstance(segment, _Control) else _text_tokens(encoding, segment) for segment in _rendered(request) + ) + + +def kimi_k3_anthropic_tokens(encoding: tiktoken.Encoding, model: str, fields: Mapping[str, object]) -> int | None: + body: Final = {**fields, "model": model, "max_tokens": 1} + try: + translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + copy.deepcopy(body), # pyright: ignore[reportArgumentType] # raw /v1/messages fields + custom_llm_provider="bedrock", + ) + except Exception: # noqa: BLE001 # a request the adapter cannot translate falls back to the estimate + return None + match translated: + case {"tools": tools}: + return kimi_k3_tokens(encoding, model, translated["messages"], tools) + case _: + return kimi_k3_tokens(encoding, model, translated["messages"], None) + + +def kimi_k3_template_tokens(message_count: int, has_tools: bool) -> int: + return 14 + 13 * max(message_count - 1, 0) + (40 if has_tools else 0) diff --git a/litellm/llms/bedrock/count_tokens/mantle_handler.py b/litellm/llms/bedrock/count_tokens/mantle_handler.py index 23c3dc54c95..4ea4440a3c4 100644 --- a/litellm/llms/bedrock/count_tokens/mantle_handler.py +++ b/litellm/llms/bedrock/count_tokens/mantle_handler.py @@ -52,7 +52,7 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM): ) -> dict[str, JsonValue]: try: request: Final = _COUNT_REQUEST.validate_python(request_data) - aws_region_name: Final = self._get_aws_region_name( + aws_region_name: Final = self.get_aws_region_name( optional_params=litellm_params, model=resolved_model, model_id=None ) endpoint_url: Final = self.get_mantle_count_tokens_endpoint(aws_region_name) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 4d40e0dec85..ce2c8f15d66 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -66,8 +66,10 @@ from litellm.utils import get_llm_provider, get_optional_params from ..base_aws_llm import BaseAWSLLM from ..common_utils import ( BedrockError, + deployment_model_info, merge_bedrock_aws_request_params, resolve_s3_encryption_key_id, + without_bedrock_native_output_opt_in, ) S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers" @@ -989,10 +991,12 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) config: Final = AmazonAnthropicClaudeConfig() + litellm_params: Final = without_bedrock_native_output_opt_in({}) mapped_params = get_optional_params( model=_invoke_route_model(model), custom_llm_provider="bedrock", messages=messages, + model_info=deployment_model_info(litellm_params), **MappingProxyType( { k: v @@ -1007,7 +1011,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): optional_params={ k: v for k, v in mapped_params.items() if k not in _MAPPED_PARAMS_THE_REQUEST_HANDLER_STRIPS }, - litellm_params={}, + litellm_params=dict(litellm_params), headers={}, ) @@ -1021,7 +1025,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): converse_config: Final = AmazonConverseConfig() mapped_params = converse_config.map_openai_params( - non_default_params=optional_params, + non_default_params={k: v for k, v in optional_params.items() if k != "requestMetadata"}, optional_params={}, model=model, drop_params=False, diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 96a7186b136..df85c72ecdc 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -40,12 +40,16 @@ from litellm.llms.bedrock.common_utils import ( bedrock_supports_tool_search, ensure_bedrock_anthropic_messages_tool_names, get_anthropic_beta_from_headers, + is_bedrock_native_output_rejection, normalize_bedrock_opus_output_config_effort, normalize_custom_field_on_tools, normalize_tool_input_schema_types_for_bedrock_invoke, + requests_opt_in_native_output, + requests_output_config_format, strip_unsupported_bedrock_invoke_output_config_keys, supports_bedrock_prompt_cache_ttl, tools_without_eager_input_streaming, + warn_bedrock_native_output_rejected, ) from litellm.llms.bedrock.request_metadata import ( bedrock_request_metadata_headers, @@ -111,6 +115,29 @@ class AmazonAnthropicClaudeMessagesConfig( BaseAnthropicMessagesConfig.__init__(self, **kwargs) AmazonInvokeConfig.__init__(self, **kwargs) + def native_output_fallback_litellm_params( + self, + model: str, + anthropic_messages_optional_request_params: Mapping[str, object], + litellm_params: GenericLiteLLMParams, + ) -> GenericLiteLLMParams | None: + model_info: Final = _MODEL_INFO.validate_python(litellm_params.model_info) + if not requests_opt_in_native_output(model, anthropic_messages_optional_request_params, model_info): + return None + return litellm_params.model_copy( + update={"model_info": {**(model_info or {}), "supports_bedrock_native_structured_output": False}} + ) + + def should_retry_anthropic_messages_without_native_output( + self, e: httpx.HTTPStatusError, request_body: Mapping[str, object], model: str + ) -> bool: + if not requests_output_config_format(request_body) or not is_bedrock_native_output_rejection( + e.response.status_code, e.response.text + ): + return False + warn_bedrock_native_output_rejected(model, e.response.text) + return True + def validate_anthropic_messages_environment( self, headers: dict, diff --git a/litellm/llms/bedrock/passthrough/transformation.py b/litellm/llms/bedrock/passthrough/transformation.py index d7d6341fe7a..9d895616336 100644 --- a/litellm/llms/bedrock/passthrough/transformation.py +++ b/litellm/llms/bedrock/passthrough/transformation.py @@ -10,6 +10,7 @@ from httpx import Response from pydantic import TypeAdapter from litellm._logging import verbose_logger +from litellm.constants import AWS_ERROR_TYPE_HEADER from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig, PassthroughStreamCollector from litellm.llms.bedrock.chat.converse_to_openai import as_json_array, as_json_object @@ -18,6 +19,16 @@ from litellm.types.utils import ModelResponseStream from ..base_aws_llm import BaseAWSLLM from ..common_utils import BedrockError, BedrockEventStreamDecoderBase, BedrockModelInfo +from ..request_metadata import ( + BEDROCK_REQUEST_METADATA_HEADER, + BedrockRequestMetadataViolation, + bedrock_request_metadata_headers, + bedrock_request_metadata_is_owned, + bedrock_request_metadata_violation, + caller_request_metadata_violations_are_dropped, + merge_bedrock_invoke_headers, + resolve_bedrock_request_metadata, +) if TYPE_CHECKING: from botocore.eventstream import EventStreamMessage @@ -29,17 +40,87 @@ if TYPE_CHECKING: BEDROCK_STREAMING_ACTIONS: Final = frozenset({"invoke-with-response-stream", "converse-stream"}) +_BEDROCK_CONVERSE_ACTIONS: Final = frozenset({"converse", "converse-stream"}) +_BEDROCK_INVOKE_ACTIONS: Final = frozenset({"invoke", "invoke-with-response-stream"}) + + +def _bedrock_endpoint_action(endpoint: str) -> str: + return endpoint.partition("?")[0].rstrip("/").rsplit("/", 1)[-1] def is_bedrock_streaming_endpoint(endpoint: str) -> bool: - return endpoint.partition("?")[0].rstrip("/").rsplit("/", 1)[-1] in BEDROCK_STREAMING_ACTIONS + return _bedrock_endpoint_action(endpoint) in BEDROCK_STREAMING_ACTIONS -_CONVERSE_ACTIONS: Final = frozenset({"converse", "converse-stream"}) +def _caller_request_metadata_violation( + action: str, body: Mapping[str, object] +) -> BedrockRequestMetadataViolation | None: + if action in _BEDROCK_INVOKE_ACTIONS and "requestMetadata" in body and body["requestMetadata"] is None: + return BedrockRequestMetadataViolation("type", "requestMetadata must be a dictionary") + return bedrock_request_metadata_violation(body.get("requestMetadata")) -def _is_converse_url(url: str) -> bool: - return httpx.URL(url).path.rstrip("/").rsplit("/", 1)[-1] in _CONVERSE_ACTIONS +def _aws_error_type(action: str, violation: BedrockRequestMetadataViolation) -> str: + if action in _BEDROCK_CONVERSE_ACTIONS and violation.kind == "type": + return "SerializationException" + return "ValidationException" + + +def _caller_request_metadata(action: str, body: Mapping[str, object], litellm_params: Mapping[str, object]) -> object: + violation: Final = ( + None + if caller_request_metadata_violations_are_dropped(litellm_params.get("drop_params") is True) + else _caller_request_metadata_violation(action, body) + ) + if violation is not None: + raise BedrockError( + status_code=400, + message=violation.message, + headers={AWS_ERROR_TYPE_HEADER: _aws_error_type(action, violation)}, + ) + return body.get("requestMetadata") + + +def _with_body_request_metadata( + action: str, body: Mapping[str, object], litellm_params: Mapping[str, object] +) -> Mapping[str, object]: + resolved: Final = resolve_bedrock_request_metadata( + litellm_params, caller_metadata=_caller_request_metadata(action, body, litellm_params) + ) + without_caller: Final = {key: value for key, value in body.items() if key != "requestMetadata"} + return without_caller if resolved is None else {**without_caller, "requestMetadata": resolved} + + +def _without_owned_request_metadata_header(headers: Mapping[str, str]) -> Mapping[str, str]: + if not bedrock_request_metadata_is_owned(): + return headers + return {name: value for name, value in headers.items() if name.lower() != BEDROCK_REQUEST_METADATA_HEADER.lower()} + + +def _with_request_metadata( + action: str | None, + headers: Mapping[str, str], + body: Mapping[str, object], + litellm_params: Mapping[str, object], +) -> tuple[Mapping[str, str], Mapping[str, object]]: + if action in _BEDROCK_CONVERSE_ACTIONS: + return headers, _with_body_request_metadata(action, body, litellm_params) + if action in _BEDROCK_INVOKE_ACTIONS and "requestMetadata" in body: + return _without_owned_request_metadata_header(headers), _with_body_request_metadata( + action, body, litellm_params + ) + if action in _BEDROCK_INVOKE_ACTIONS: + return _with_invoke_request_metadata(headers, body, litellm_params) + return headers, body + + +def _with_invoke_request_metadata( + headers: Mapping[str, str], + body: Mapping[str, object], + litellm_params: Mapping[str, object], +) -> tuple[Mapping[str, str], Mapping[str, object]]: + owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params) + return merge_bedrock_invoke_headers(dict(headers), (), metadata_headers, owned_names), body def _is_cache_point(entry: object) -> bool: @@ -357,19 +438,26 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD def sign_request( self, - headers: dict, - litellm_params: dict, + headers: dict[str, str], + litellm_params: dict[str, object], request_data: dict[str, object] | None, api_base: str, model: str | None = None, ) -> tuple[dict, bytes | None]: + action: Final = _bedrock_endpoint_action(api_base) body: Final[Mapping[str, object]] = request_data or {} optional_params: Final = litellm_params.copy() + signed_headers, signed_body = _with_request_metadata( + action=action if bedrock_request_metadata_is_owned() else None, + headers=headers, + body=_converse_body_to_send(body) if action in _BEDROCK_CONVERSE_ACTIONS else body, + litellm_params=litellm_params, + ) return self._sign_request( service_name="bedrock", - headers=headers, + headers=dict(signed_headers), optional_params=optional_params, - request_data=dict(_converse_body_to_send(body) if _is_converse_url(api_base) else body), + request_data=dict(signed_body), api_base=api_base, model=model, api_key=self.get_bedrock_bearer_token(optional_params), diff --git a/litellm/llms/bedrock/request_metadata.py b/litellm/llms/bedrock/request_metadata.py index 1f4e5886508..71a2dc78578 100644 --- a/litellm/llms/bedrock/request_metadata.py +++ b/litellm/llms/bedrock/request_metadata.py @@ -8,17 +8,22 @@ sets ``litellm.bedrock_request_metadata_fields`` (``litellm_settings`` on the pr Two properties are load-bearing for that billing record and are asserted by the tests: proxy identity is resolved first so it can never be evicted by caller-supplied pairs, and the whole ``user_api_key_`` prefix is reserved so a caller cannot write a proxy-authoritative -looking key. Values that break Bedrock's constraints are dropped rather than sanitised or -rejected, because an operator flipping this setting on must not turn a working request into a -400 and a silently rewritten attribution key is worse than an absent one. +looking key. Identity and ``spend_logs_metadata`` values that break Bedrock's constraints are +dropped rather than sanitised or rejected, because an operator flipping this setting on must not +turn a working request into a 400 and a silently rewritten attribution key is worse than an +absent one. For the same reason an Invoke header that would pass Bedrock's 8,500-character limit +keeps only the leading pairs that fit. A caller's own malformed ``requestMetadata`` is rejected with +a 400 unless ``drop_params`` is set, in which case its bad pairs are dropped the same way, with or +without the setting. """ from __future__ import annotations import json import re -from collections.abc import Mapping -from typing import Final +from collections.abc import Mapping, Sized +from dataclasses import dataclass +from typing import Final, Literal import litellm @@ -26,15 +31,27 @@ BEDROCK_REQUEST_METADATA_HEADER: Final = "X-Amzn-Bedrock-Request-Metadata" BEDROCK_REQUEST_METADATA_MAX_PAIRS: Final = 16 BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX: Final = "user_api_key_" BEDROCK_REQUEST_METADATA_CLIENT_FIELD: Final = "spend_logs_metadata" +BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS: Final = 8500 _METADATA_PARAM_NAMES: Final[tuple[str, ...]] = ("metadata", "litellm_metadata") -_KEY_PATTERN: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$") -_VALUE_PATTERN: Final = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$") +_ALLOWED_CHARACTERS: Final = r"[a-zA-Z0-9\s:_@$#=/+,.-]" +_KEY_PATTERN: Final = re.compile(rf"{_ALLOWED_CHARACTERS}{{1,256}}", re.ASCII) +_VALUE_PATTERN: Final = re.compile(rf"{_ALLOWED_CHARACTERS}{{0,256}}", re.ASCII) _OWNED_HEADER_NAMES: Final[frozenset[str]] = frozenset((BEDROCK_REQUEST_METADATA_HEADER.lower(),)) +@dataclass(frozen=True, slots=True) +class BedrockRequestMetadataViolation: + kind: Literal["type", "constraint"] + message: str + + def _is_forwardable(key: str, value: str) -> bool: - return _KEY_PATTERN.match(key) is not None and _VALUE_PATTERN.match(value) is not None + return _KEY_PATTERN.fullmatch(key) is not None and _VALUE_PATTERN.fullmatch(value) is not None + + +def _is_reserved(key: str) -> bool: + return key.strip().lower().startswith(BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX) def _text_pairs(source: object) -> tuple[tuple[str, str], ...]: @@ -99,13 +116,16 @@ def _client_pairs( candidates: Final = tuple( (key, value) for key, value in (*_text_pairs(caller_metadata), *spend_logs_pairs) - if not key.startswith(BEDROCK_REQUEST_METADATA_IDENTITY_PREFIX) and _is_forwardable(key, value) + if not _is_reserved(key) and _is_forwardable(key, value) ) - return tuple( - pair - for index, pair in enumerate(candidates) - if pair[0] not in tuple(earlier for earlier, _ in candidates[:index]) - )[:budget] + first_index: Final = {key: index for index, (key, _) in reversed(tuple(enumerate(candidates)))} + return tuple(pair for index, pair in enumerate(candidates) if first_index[pair[0]] == index)[:budget] + + +def _forwardable_caller_pairs(caller_metadata: object) -> tuple[tuple[str, str], ...]: + return tuple(pair for pair in _text_pairs(caller_metadata) if _is_forwardable(*pair))[ + :BEDROCK_REQUEST_METADATA_MAX_PAIRS + ] def resolve_bedrock_request_metadata( @@ -113,17 +133,18 @@ def resolve_bedrock_request_metadata( caller_metadata: object = None, ) -> dict[str, str] | None: """ - Resolve the ``requestMetadata`` pairs to send to Bedrock, or ``None`` when the feature is - off or nothing survives Bedrock's constraints. The result is a plain dict because it is - written straight onto the Converse body, which Bedrock types as ``dict[str, str]``. + Resolve the ``requestMetadata`` pairs to send to Bedrock, or ``None`` when nothing survives + Bedrock's constraints. The result is a plain dict because it is written straight onto the + Converse body, which Bedrock types as ``dict[str, str]``. - ``caller_metadata`` is any ``requestMetadata`` the caller passed explicitly. It has already - been validated (and rejected with a 400) by the Converse transformation, so it is only - filtered here for the reserved identity prefix and the remaining slot budget. + ``caller_metadata`` is any ``requestMetadata`` the caller passed explicitly. Callers reject a + malformed one with a 400 unless ``drop_params`` is set, so here its pairs are only filtered for + Bedrock's rules, the reserved identity prefix, and the remaining slot budget. With the feature + off only Bedrock's rules apply, since no identity is written for a caller to forge. """ allowed_fields: Final = _allowed_fields() if not allowed_fields: - return None + return dict(_forwardable_caller_pairs(caller_metadata)) or None sources: Final = _metadata_sources(litellm_params) identity: Final = _identity_pairs(sources, allowed_fields) client: Final = _client_pairs( @@ -136,6 +157,36 @@ def resolve_bedrock_request_metadata( return resolved or None +def _pair_violation(key: str, value: str) -> BedrockRequestMetadataViolation | None: + if _KEY_PATTERN.fullmatch(key) is None: + return BedrockRequestMetadataViolation( + "constraint", f"requestMetadata key {key!r} must be 1-256 characters of {_ALLOWED_CHARACTERS}" + ) + if _VALUE_PATTERN.fullmatch(value) is None: + return BedrockRequestMetadataViolation( + "constraint", f"requestMetadata value for key {key!r} must be 0-256 characters of {_ALLOWED_CHARACTERS}" + ) + return None + + +def bedrock_request_metadata_violation(caller_metadata: object) -> BedrockRequestMetadataViolation | None: + """Why a caller's ``requestMetadata`` breaks Bedrock's rules, in the order Bedrock checks them: a + wrong JSON type before the pair count and character rules. ``None`` means absent or valid.""" + text_pairs: Final = _text_pairs(caller_metadata) + entry_count: Final = len(caller_metadata) if isinstance(caller_metadata, Sized) else 0 + if caller_metadata is None: + return None + if not isinstance(caller_metadata, Mapping): + return BedrockRequestMetadataViolation("type", "requestMetadata must be a dictionary") + if len(text_pairs) != entry_count: + return BedrockRequestMetadataViolation("type", "requestMetadata keys and values must be strings") + if len(text_pairs) > BEDROCK_REQUEST_METADATA_MAX_PAIRS: + return BedrockRequestMetadataViolation( + "constraint", f"requestMetadata can contain a maximum of {BEDROCK_REQUEST_METADATA_MAX_PAIRS} items" + ) + return next((violation for key, value in text_pairs if (violation := _pair_violation(key, value))), None) + + def bedrock_request_metadata_is_owned() -> bool: """ Whether the proxy OWNS the request-metadata field and header name for this request. @@ -149,25 +200,51 @@ def bedrock_request_metadata_is_owned() -> bool: return bool(_allowed_fields()) +def caller_request_metadata_violations_are_dropped(drop_params: bool) -> bool: + return drop_params or litellm.drop_params is True + + +def bedrock_request_metadata_is_rewritten(drop_params: bool) -> bool: + return bedrock_request_metadata_is_owned() or caller_request_metadata_violations_are_dropped(drop_params) + + +def _header_value(pairs: Mapping[str, str]) -> str: + return json.dumps(pairs, separators=(",", ":")) + + +def _fits_header(pairs: Mapping[str, str]) -> bool: + return len(_header_value(pairs)) <= BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS + + +def _longest_fitting_prefix(resolved: Mapping[str, str]) -> dict[str, str]: + pairs: Final = tuple(resolved.items()) + return next( + (dict(pairs[:count]) for count in range(len(pairs), 0, -1) if _fits_header(dict(pairs[:count]))), + {}, + ) + + def bedrock_request_metadata_headers( litellm_params: Mapping[str, object] | None, ) -> tuple[frozenset[str], tuple[tuple[str, str], ...]]: """ - The signed ``X-Amzn-Bedrock-Request-Metadata`` header for the Invoke paths, which have no - body field for request metadata. + The signed ``X-Amzn-Bedrock-Request-Metadata`` header for an Invoke request whose body carries + no ``requestMetadata`` of its own. Returns the header names the proxy OWNS and, separately, the pairs to send. Ownership is reported whenever forwarding is enabled, including when nothing resolves, because a caller can suppress the resolver (omit the allow-listed fields, or send values that all fail Bedrock's rules) and an owned-but-empty result must still evict the caller's header rather - than fall back to it. + than fall back to it. Only the leading pairs that fit Bedrock's header limit are sent, so + identity always survives and long ``spend_logs_metadata`` pairs drop off the end. """ if not bedrock_request_metadata_is_owned(): return frozenset(), () resolved: Final = resolve_bedrock_request_metadata(litellm_params) - if resolved is None: + fitting: Final = {} if resolved is None else _longest_fitting_prefix(resolved) + if not fitting: return _OWNED_HEADER_NAMES, () - return _OWNED_HEADER_NAMES, ((BEDROCK_REQUEST_METADATA_HEADER, json.dumps(resolved, separators=(",", ":"))),) + return _OWNED_HEADER_NAMES, ((BEDROCK_REQUEST_METADATA_HEADER, _header_value(fitting)),) def merge_bedrock_invoke_headers( diff --git a/litellm/llms/bedrock_mantle/chat/transformation.py b/litellm/llms/bedrock_mantle/chat/transformation.py index 1c791e994f2..61c7d51a889 100644 --- a/litellm/llms/bedrock_mantle/chat/transformation.py +++ b/litellm/llms/bedrock_mantle/chat/transformation.py @@ -10,19 +10,21 @@ Auth: Bearer token (litellm_params.api_key, BEDROCK_MANTLE_API_KEY, or the BedrockMantleAuthMixin in common_utils. """ -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Iterator, Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Final import httpx import litellm from litellm._logging import verbose_logger +from litellm.exceptions import UnsupportedParamsError from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock_mantle.common_utils import ( BEDROCK_MANTLE_DEFAULT_REGION, BedrockMantleAuthMixin, ) -from litellm.llms.openai.chat.gpt_5_transformation import is_gpt_reasoning_series_name +from litellm.llms.openai.chat.gpt_5_transformation import get_effort_level, is_gpt_reasoning_series_name from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues from litellm.types.router import GenericLiteLLMParams @@ -39,6 +41,51 @@ if TYPE_CHECKING: ) +def _with_chat_reasoning_effort(non_default_params: Mapping[str, object]) -> Mapping[str, object]: + """Chat Completions takes ``reasoning_effort`` as a level, so a Responses-shaped ``{"effort", "summary"}`` + passes only its effort, the way the OpenAI GPT-5 chat config reads it.""" + reasoning_effort: Final = non_default_params.get("reasoning_effort") + if not isinstance(reasoning_effort, dict): + return non_default_params + effort: Final = get_effort_level(reasoning_effort) + rest: Final = {key: value for key, value in non_default_params.items() if key != "reasoning_effort"} + return rest if effort is None else {**rest, "reasoning_effort": effort} + + +def _unsupported_chat_values(non_default_params: Mapping[str, object]) -> tuple[str, ...]: + n: Final = non_default_params.get("n") + reasoning_effort: Final = non_default_params.get("reasoning_effort") + summary: Final = reasoning_effort.get("summary") if isinstance(reasoning_effort, Mapping) else None + return tuple( + value + for value, unsupported in ( + (f"n={n}", isinstance(n, int) and n > 1), + (f"reasoning summary {summary}", summary is not None), + ) + if unsupported + ) + + +def _drops_params(litellm_params: Mapping[str, object]) -> bool: + return litellm.drop_params is True or litellm_params.get("drop_params") is True + + +def _chat_request_params(optional_params: Mapping[str, object], drop_params: bool) -> Mapping[str, object]: + unsupported: Final = _unsupported_chat_values(optional_params) + if unsupported and not drop_params: + raise UnsupportedParamsError( + message=f"Bedrock Mantle Chat Completions does not support {', '.join(unsupported)}. To drop unsupported " + "openai params from the call, set `litellm.drop_params = True`", + llm_provider="bedrock_mantle", + status_code=400, + ) + servable: Final = _with_chat_reasoning_effort(optional_params) + n: Final = servable.get("n") + return MappingProxyType( + {key: value for key, value in servable.items() if not (key == "n" and isinstance(n, int) and n > 1)} + ) + + class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig): """ Transformation config for Amazon Bedrock Mantle OpenAI-compatible API. @@ -158,6 +205,28 @@ class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig): if param != "max_tokens" } + def transform_request( + self, + model: str, + messages: list[AllMessageValues], # mutable-ok: OpenAIGPTConfig.transform_request signature + optional_params: dict[str, object], # mutable-ok: OpenAIGPTConfig.transform_request signature + litellm_params: dict[str, object], # mutable-ok: OpenAIGPTConfig.transform_request signature + headers: dict[str, str], # mutable-ok: OpenAIGPTConfig.transform_request signature + ) -> dict[str, object]: # mutable-ok: OpenAIGPTConfig.transform_request signature + request_params: Final = dict(_chat_request_params(optional_params, _drops_params(litellm_params))) + return super().transform_request(model, messages, request_params, litellm_params, headers) + + def transform_extra_body( + self, + extra_body: Mapping[str, object], + request: Mapping[str, object], + model: str, + litellm_params: Mapping[str, object], + ) -> Mapping[str, object]: + return _chat_request_params( + super().transform_extra_body(extra_body, request, model, litellm_params), _drops_params(litellm_params) + ) + def _supports_reasoning(self, model: str) -> bool: try: return litellm.supports_reasoning(model=model, custom_llm_provider=self.custom_llm_provider) diff --git a/litellm/llms/bedrock_mantle/responses/transformation.py b/litellm/llms/bedrock_mantle/responses/transformation.py index fa0da6a28b4..8a0ce58c8b2 100644 --- a/litellm/llms/bedrock_mantle/responses/transformation.py +++ b/litellm/llms/bedrock_mantle/responses/transformation.py @@ -24,6 +24,7 @@ import litellm from litellm._logging import verbose_logger from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.responses.codex_compat import drop_unsupported_tools, normalize_codex_input_items +from litellm.llms.base_llm.responses.transformation import ReasoningSummaryLevel from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock_mantle.common_utils import ( @@ -73,6 +74,9 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI def custom_llm_provider(self) -> LlmProviders: return LlmProviders.BEDROCK_MANTLE + def auto_reasoning_summary(self) -> ReasoningSummaryLevel: + return "auto" if self.use_openai_path else super().auto_reasoning_summary() + def get_error_class( self, error_message: str, status_code: int, headers: dict[str, object] | httpx.Headers ) -> BaseLLMException: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index fffcdd67150..bbda4f57ec8 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1,10 +1,11 @@ import asyncio +import copy import inspect import json import ssl -from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Coroutine, Iterator, Mapping, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence from contextlib import asynccontextmanager -from functools import lru_cache, partial +from functools import lru_cache, partial, reduce from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -35,7 +36,7 @@ import litellm.litellm_core_utils import litellm.types import litellm.types.utils from litellm._logging import redact_string, verbose_logger -from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta +from litellm.anthropic_beta_headers_manager import aload_beta_headers_config, update_headers_with_filtered_beta from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.files.types import FileContentStreamingResult from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs @@ -49,6 +50,7 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import ( synthesize_subtitle_document, ) from litellm.litellm_core_utils.core_helpers import set_provider_response_headers_in_hidden_params +from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields from litellm.litellm_core_utils.realtime_errors import ( @@ -215,6 +217,7 @@ if TYPE_CHECKING: FakeAnthropicMessagesStreamIterator, ) from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig + from litellm.responses.streaming_iterator import ResponsesWsFrameGuard from litellm.types.google_genai.main import GenerateContentResponse from litellm.types.llms.openai_evals import ( CancelEvalResponse, @@ -467,6 +470,8 @@ async def _aiter_bytes_then_close(response: httpx.Response, *, chunk_size: int) _DECODED_BODY_STALE_HEADERS: Final[frozenset[str]] = frozenset({"content-encoding", "content-length"}) _OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object]) +_JSON_OBJECT: Final = TypeAdapter(dict[str, object]) +_JSON_OBJECTS: Final = TypeAdapter(list[dict[str, object]]) def _decoded_body_headers(response: httpx.Response) -> httpx.Headers: @@ -2180,11 +2185,13 @@ class BaseLLMHTTPHandler: api_key: str | None, model: str, timeout: float | httpx.Timeout | None = None, + rebuild_without_native_output: Callable[[], Mapping[str, object]] | None = None, ) -> httpx.Response: max_attempts: Final = max(provider_config.max_retry_on_anthropic_messages_http_error, 1) litellm_params_dict: Final = dict(litellm_params) optional_params_dict: Final = dict(litellm_params) - for attempt_idx in range(max_attempts): + recovered_errors: tuple[httpx.HTTPStatusError, ...] = () # rebind-ok: a rebuilt body replays every recovery + while True: try: response = await async_httpx_client.post( url=request_url, @@ -2197,64 +2204,79 @@ class BaseLLMHTTPHandler: response.raise_for_status() return response except httpx.HTTPStatusError as e: - hit_max_attempt = attempt_idx + 1 == max_attempts + rebuild_body = ( + rebuild_without_native_output + if rebuild_without_native_output is not None + and provider_config.should_retry_anthropic_messages_without_native_output( + e=e, request_body=request_body, model=model + ) + else None + ) + hit_max_attempt = len(recovered_errors) + 1 == max_attempts should_retry = provider_config.should_retry_anthropic_messages_on_http_error( e=e, litellm_params=litellm_params_dict, request_body=request_body ) - if should_retry and not hit_max_attempt: - if logging_obj.baseline_cache_context is not None: - await logging_obj.invalidate_baseline_cache_estimate("retried_request") + if rebuild_body is None and (hit_max_attempt or not should_retry): + raise self._handle_error(e=e, provider_config=provider_config) + if logging_obj.baseline_cache_context is not None: + await logging_obj.invalidate_baseline_cache_estimate("retried_request") + if rebuild_body is not None: + request_body.clear() + request_body.update(rebuild_body()) + for recovered_error in recovered_errors: + provider_config.transform_anthropic_messages_request_on_http_error( + e=recovered_error, request_data=request_body + ) + else: verbose_logger.debug( "Anthropic /v1/messages: invalid thinking signature; " "stripping thinking blocks and retrying (attempt %s/%s).", - attempt_idx + 2, + len(recovered_errors) + 2, max_attempts, ) provider_config.transform_anthropic_messages_request_on_http_error(e=e, request_data=request_body) - headers, signed_json_body = await sign_request_off_loop_if_aws( - provider_config, - provider_config.sign_request, - headers=headers, - optional_params=optional_params_dict, - request_data=request_body, - api_base=request_url, - api_key=api_key, - stream=stream, - fake_stream=False, - model=model, - ) - logging_obj.model_call_details.update(request_body) - continue - raise self._handle_error(e=e, provider_config=provider_config) + recovered_errors = (*recovered_errors, e) + headers, signed_json_body = await sign_request_off_loop_if_aws( + provider_config, + provider_config.sign_request, + headers=headers, + optional_params=optional_params_dict, + request_data=request_body, + api_base=request_url, + api_key=api_key, + stream=stream, + fake_stream=False, + model=model, + ) + logging_obj.model_call_details.update(request_body) except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - raise RuntimeError("unreachable: anthropic messages HTTP retry loop exited without return") - @staticmethod def _resolve_anthropic_messages_timeout( litellm_params: GenericLiteLLMParams, stream: bool, custom_llm_provider: str, ) -> float | httpx.Timeout | None: - from litellm.litellm_core_utils.completion_timeout import CompletionTimeout + from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) from litellm.utils import supports_httpx_timeout stream_timeout: Final = litellm_params.get("stream_timeout") if stream else None - model_timeout: Final = stream_timeout if stream_timeout is not None else litellm_params.get("timeout") + model_timeout: Final = litellm_params.get("timeout") request_timeout: Final = litellm_params.get("request_timeout") global_timeout: Final = get_configured_request_timeout() - if model_timeout is None and request_timeout is None and global_timeout is None: + if stream_timeout is None and model_timeout is None and request_timeout is None and global_timeout is None: return None return CompletionTimeout.resolve( model_timeout, - {"request_timeout": request_timeout}, + {"stream_timeout": stream_timeout, "request_timeout": request_timeout}, custom_llm_provider, global_timeout=global_timeout, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.LIVE if stream else StreamKind.NOT_STREAMED, ) async def async_anthropic_messages_handler( @@ -2304,6 +2326,16 @@ class BaseLLMHTTPHandler: merged_headers.update(extra_headers_from_kwargs) if provider_specific_headers: merged_headers.update(provider_specific_headers) + request_params: Final = reduce( + delete_nested_value, + litellm_params.get("additional_drop_params") or (), + anthropic_messages_optional_request_params, + ) + if ( + request_params.get("safeguards") is not None + and anthropic_messages_provider_config.should_filter_anthropic_beta_headers() + ): + await aload_beta_headers_config() ( headers, api_base, @@ -2311,7 +2343,7 @@ class BaseLLMHTTPHandler: headers=merged_headers or {}, model=model, messages=messages, - optional_params=anthropic_messages_optional_request_params, + optional_params=request_params, litellm_params=dict(litellm_params), api_key=api_key, api_base=api_base, @@ -2342,20 +2374,29 @@ class BaseLLMHTTPHandler: custom_llm_provider=custom_llm_provider, ) - additional_drop_params: Final[list[str]] = litellm_params.get("additional_drop_params") or [] - if additional_drop_params: - from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value - - for path in additional_drop_params: - anthropic_messages_optional_request_params = delete_nested_value( - anthropic_messages_optional_request_params, path - ) + native_output_fallback_params: Final = anthropic_messages_provider_config.native_output_fallback_litellm_params( + model=model, + anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python(request_params), + litellm_params=litellm_params, + ) + rebuild_without_native_output: Final = ( + None + if native_output_fallback_params is None + else partial[Mapping[str, object]]( + anthropic_messages_provider_config.transform_anthropic_messages_request, + model=model, + messages=copy.deepcopy(_JSON_OBJECTS.validate_python(messages)), + anthropic_messages_optional_request_params=copy.deepcopy(_JSON_OBJECT.validate_python(request_params)), + litellm_params=native_output_fallback_params, + headers=_JSON_OBJECT.validate_python(headers), + ) + ) # Prepare request body request_body: Final = anthropic_messages_provider_config.transform_anthropic_messages_request( model=model, messages=messages, - anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + anthropic_messages_optional_request_params=request_params, litellm_params=litellm_params, headers=headers, ) @@ -2407,6 +2448,13 @@ class BaseLLMHTTPHandler: }, ) + applied_timeout: Final = self._resolve_anthropic_messages_timeout( + litellm_params=litellm_params, + stream=stream or False, + custom_llm_provider=custom_llm_provider, + ) + if applied_timeout is not None: + logging_obj.record_applied_timeout(applied_timeout) response: Final = await self._async_post_anthropic_messages_with_http_error_retry( async_httpx_client=async_httpx_client, request_url=request_url, @@ -2419,11 +2467,8 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, api_key=api_key, model=model, - timeout=self._resolve_anthropic_messages_timeout( - litellm_params=litellm_params, - stream=stream or False, - custom_llm_provider=custom_llm_provider, - ), + timeout=applied_timeout, + rebuild_without_native_output=rebuild_without_native_output, ) # used for logging + cost tracking @@ -2462,7 +2507,7 @@ class BaseLLMHTTPHandler: held_back_tool_names: Final = self._server_fulfilled_tools_in_request( logging_obj=logging_obj, - tools=anthropic_messages_optional_request_params.get("tools"), + tools=request_params.get("tools"), ) initial_response = AgenticAnthropicStreamingIterator( completion_stream=completion_stream, @@ -2470,7 +2515,7 @@ class BaseLLMHTTPHandler: model=model, messages=messages, anthropic_messages_provider_config=anthropic_messages_provider_config, - anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + anthropic_messages_optional_request_params=request_params, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, kwargs=kwargs_for_agentic, @@ -2493,7 +2538,7 @@ class BaseLLMHTTPHandler: model=model, messages=messages, anthropic_messages_provider_config=anthropic_messages_provider_config, - anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + anthropic_messages_optional_request_params=request_params, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, kwargs=kwargs_for_agentic, @@ -6865,6 +6910,7 @@ class BaseLLMHTTPHandler: custom_llm_provider: str | None = None, first_message: str | None = None, request_defaults: ResponsesWebSocketRequestDefaults | None = None, + frame_guard: "ResponsesWsFrameGuard | None" = None, **kwargs: object, ) -> Exception | None: """ @@ -6880,6 +6926,35 @@ class BaseLLMHTTPHandler: """ _ws_quota_callbacks: Final = _collect_ws_project_quota_callbacks() + _ws_guardrail_callbacks: list = [] + _ws_output_guardrail_callbacks: list = [] + try: + import litellm as _litellm + + # Use duck-typing so any guardrail that exposes the PII + # masking interface works, not just _OPTIONAL_PresidioPIIMasking. + # This avoids a layering violation (SDK importing from proxy). + _ws_guardrail_callbacks = [ + cb + for cb in _litellm.callbacks + if callable(getattr(cb, "check_pii", None)) + and callable(getattr(cb, "get_presidio_settings_from_request_data", None)) + and callable(getattr(cb, "_unmask_pii_text", None)) + and getattr(cb, "output_parse_pii", False) + ] + _ws_output_guardrail_callbacks = [ + cb + for cb in _litellm.callbacks + if callable(getattr(cb, "check_pii", None)) + and callable(getattr(cb, "get_presidio_settings_from_request_data", None)) + and getattr(cb, "apply_to_output", False) + ] + except Exception as _guardrail_exc: + verbose_logger.warning( + "Responses WebSocket: failed to collect guardrail callbacks — PII masking will be skipped. Error: %s", + _guardrail_exc, + ) + if responses_api_provider_config is None or not responses_api_provider_config.supports_native_websocket(): from litellm.responses.streaming_iterator import ( ManagedResponsesWebSocketHandler, @@ -6897,6 +6972,8 @@ class BaseLLMHTTPHandler: custom_llm_provider=custom_llm_provider, first_message=first_message, quota_callbacks=_ws_quota_callbacks, + guardrail_callbacks=_ws_guardrail_callbacks, # pyright: ignore[reportUnknownArgumentType] # duck-typed callbacks from litellm.callbacks + frame_guard=frame_guard, **kwargs, ) await handler.run() @@ -6978,36 +7055,6 @@ class BaseLLMHTTPHandler: if litellm_metadata: _request_data["litellm_metadata"] = litellm_metadata - _ws_guardrail_callbacks: list = [] - _ws_output_guardrail_callbacks: list = [] - try: - import litellm as _litellm - - # Use duck-typing so any guardrail that exposes the PII - # masking interface works, not just _OPTIONAL_PresidioPIIMasking. - # This avoids a layering violation (SDK importing from proxy). - _ws_guardrail_callbacks = [ - cb - for cb in _litellm.callbacks - if callable(getattr(cb, "check_pii", None)) - and callable(getattr(cb, "get_presidio_settings_from_request_data", None)) - and callable(getattr(cb, "_unmask_pii_text", None)) - and getattr(cb, "output_parse_pii", False) - ] - _ws_output_guardrail_callbacks = [ - cb - for cb in _litellm.callbacks - if callable(getattr(cb, "check_pii", None)) - and callable(getattr(cb, "get_presidio_settings_from_request_data", None)) - and getattr(cb, "apply_to_output", False) - ] - except Exception as _guardrail_exc: - verbose_logger.warning( - "Responses WebSocket: failed to collect guardrail " - "callbacks — PII masking will be skipped. Error: %s", - _guardrail_exc, - ) - streaming: Final = ResponsesWebSocketStreaming( websocket=websocket, backend_ws=cast(ClientConnection, backend_ws), @@ -7021,6 +7068,7 @@ class BaseLLMHTTPHandler: authorized_model=model, custom_llm_provider=custom_llm_provider, request_defaults=request_defaults, + frame_guard=frame_guard, ) return await streaming.bidirectional_forward() diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index aa3d7942174..2acf1c69594 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -87,7 +87,7 @@ def _keyed_rows(model: str, quality: str) -> tuple[tuple[int, int, float], ...]: suffix: Final = f"/{model}" return tuple( (width, height, float(raw_cost)) - for key in litellm.model_cost + for key in (*litellm.model_cost,) if isinstance(key, str) and key.startswith(prefix) and key.endswith(suffix) for size in (key[len(prefix) : -len(suffix)],) for dimensions in (_parse_keyed_dimensions(size),) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 8b6946ee562..14ae59d4f85 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -544,7 +544,7 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): return cached[2] index: Final[list[tuple[str, dict]]] = [] - for key, model_info in model_cost.items(): + for key, model_info in (*model_cost.items(),): if not key.startswith("fireworks_ai/"): continue if not isinstance(model_info, dict): diff --git a/litellm/llms/microsoft_365_copilot/chat/transformation.py b/litellm/llms/microsoft_365_copilot/chat/transformation.py index 6b7366685fd..0a40790001c 100644 --- a/litellm/llms/microsoft_365_copilot/chat/transformation.py +++ b/litellm/llms/microsoft_365_copilot/chat/transformation.py @@ -109,6 +109,11 @@ class Microsoft365CopilotChatConfig(BaseConfig): def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseConfig requires a list return return ["stream", "max_tokens", "max_completion_tokens"] + def should_fake_stream( + self, model: str | None, stream: bool | None, custom_llm_provider: str | None = None + ) -> bool: + return True + def transform_request( self, model: str, diff --git a/litellm/llms/ollama/common_utils.py b/litellm/llms/ollama/common_utils.py index b5328b1ba4d..284a6ffea62 100644 --- a/litellm/llms/ollama/common_utils.py +++ b/litellm/llms/ollama/common_utils.py @@ -197,7 +197,7 @@ class OllamaModelInfo(BaseLLMModelInfo): "ollama/" + stripped_model, "ollama_chat/" + stripped_model, } - model_cost_keys: Final = {key.lower() for key in model_cost} + model_cost_keys: Final = {key.lower() for key in (*model_cost,)} return any(name.lower() in model_cost_keys for name in potential_model_names) @staticmethod diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 94c5c5d016d..5bf19844287 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -28,7 +28,9 @@ def _catalogue_declares_default_effort() -> bool: the one litellm gave before it existed. Scanning costs ~80us on the largest published map and only on the fallback path, which is noise beside the request it precedes. """ - return any(isinstance(entry, dict) and "default_reasoning_effort" in entry for entry in litellm.model_cost.values()) + return any( + isinstance(entry, dict) and "default_reasoning_effort" in entry for entry in (*litellm.model_cost.values(),) + ) def _normalize_reasoning_effort_for_chat_completion( diff --git a/litellm/llms/openai/responses/count_tokens/token_counter.py b/litellm/llms/openai/responses/count_tokens/token_counter.py index c7c997bc961..633f154c4cd 100644 --- a/litellm/llms/openai/responses/count_tokens/token_counter.py +++ b/litellm/llms/openai/responses/count_tokens/token_counter.py @@ -124,6 +124,7 @@ class OpenAITokenCounter(BaseTokenCounter): error=True, error_message=request_error, status_code=400, + rejected_before_provider_call=True, ) deployment = deployment or {} diff --git a/litellm/llms/openai_like/README.md b/litellm/llms/openai_like/README.md index e1409b81c35..c56f58b24c9 100644 --- a/litellm/llms/openai_like/README.md +++ b/litellm/llms/openai_like/README.md @@ -63,6 +63,12 @@ That's it! The provider will be automatically loaded and available. // Optional: Special handling flags "special_handling": { "convert_content_list_to_string": true + }, + + // Optional: Capabilities every model of this provider has, used when a + // model has no cost map row or its row leaves the field unset + "provider_info": { + "supports_function_calling": true } } } diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 5cdaff90d24..23b63177137 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -6,7 +6,12 @@ import json from pathlib import Path from typing import Final +from pydantic import TypeAdapter + from litellm._logging import verbose_logger +from litellm.types.utils import ProviderSpecificModelInfo + +_PROVIDER_INFO_ADAPTER: Final = TypeAdapter(ProviderSpecificModelInfo) class SimpleProviderConfig: @@ -22,6 +27,9 @@ class SimpleProviderConfig: self.constraints = data.get("constraints", {}) self.special_handling = data.get("special_handling", {}) self.supported_endpoints = data.get("supported_endpoints", []) + self.provider_info: ProviderSpecificModelInfo = ( + _PROVIDER_INFO_ADAPTER.validate_python(data["provider_info"]) if "provider_info" in data else {} + ) class JSONProviderRegistry: diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 2e9a300e2fd..94f9792da35 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -8,6 +8,7 @@ from litellm.llms.anthropic.pass_through.messages.transformation import ( from litellm.llms.openai_like.json_loader import SimpleProviderConfig from litellm.secret_managers.main import get_secret_str from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01" @@ -57,6 +58,7 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): headers=normalized, optional_params=optional_params, messages=messages, + beta_map_provider=LlmProviders.OPENAI_LIKE.value, ) return merged, api_base diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index aa8a17c3824..36337fddbdc 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -230,6 +230,16 @@ "api_key_env": "REKA_API_KEY", "api_base_env": "REKA_API_BASE" }, + "openinfer": { + "base_url": "https://api.openinfer.ai/v1", + "api_key_env": "OPENINFER_API_KEY", + "api_base_env": "OPENINFER_API_BASE", + "base_class": "openai_gpt", + "provider_info": { + "supports_function_calling": true + }, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + }, "inferenceapis": { "base_url": "https://api.inferenceapis.com/v1", "api_key_env": "INFERENCEAPIS_API_KEY", diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 27ca4acf05d..3c9a8429882 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1278,6 +1278,7 @@ class VertexAITokenCounter(BaseTokenCounter): ) -> TokenCountResponse | None: import copy + from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import count_tokens_publisher from litellm.llms.vertex_ai.vertex_ai_partner_models.main import ( VertexAIError as PartnerVertexAIError, ) @@ -1293,6 +1294,23 @@ class VertexAITokenCounter(BaseTokenCounter): ): return None + without_count_route: Final = ( + VertexAIPartnerModels.is_vertex_partner_model(model_to_use) and count_tokens_publisher(model_to_use) is None + ) + model_info: Final[Mapping[str, object]] = deployment.get("model_info") or {} + if without_count_route and messages and model_info.get("custom_tokenizer") is not None: + return None + if without_count_route: + return TokenCountResponse( + total_tokens=0, + request_model=request_model, + model_used=model_to_use, + tokenizer_type="vertex_ai_partner_models", + error=True, + error_message=f"LiteLLM does not support counting tokens for Vertex AI model {model_to_use}.", + status_code=400, + ) + # Check if this is a partner model (Claude, Mistral, etc.) if VertexAIPartnerModels.is_vertex_partner_model(model_to_use): # Use partner models token counter diff --git a/litellm/llms/vertex_ai/count_tokens/context.py b/litellm/llms/vertex_ai/count_tokens/context.py index 871532b7af5..c218b4710fd 100644 --- a/litellm/llms/vertex_ai/count_tokens/context.py +++ b/litellm/llms/vertex_ai/count_tokens/context.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Final, Literal from pydantic import JsonValue, TypeAdapter, ValidationError from typing_extensions import assert_never +from litellm.litellm_core_utils.prompt_templates.factory import chat_completion_tools_for from litellm.types.llms.anthropic import AnthropicMessagesTextParam from litellm.types.llms.openai import AllMessageValues from litellm.types.llms.vertex_ai import ContentType @@ -75,27 +76,6 @@ def _is_function_tool(tool: Mapping[str, JsonValue]) -> bool: return isinstance(tool.get("name"), str) or "input_schema" in tool or "function" in tool -def _is_responses_function_tool(tool: Mapping[str, JsonValue]) -> bool: - return tool.get("type") == "function" and "function" not in tool and "input_schema" not in tool - - -def _chat_tool(tool: Mapping[str, JsonValue]) -> tuple[Mapping[str, object], ...]: - from litellm.llms.anthropic.pass_through.adapters.transformation import LiteLLMAnthropicMessagesAdapter - from litellm.responses.litellm_completion_transformation.transformation import ( - LiteLLMCompletionResponsesConfig, - ) - - if _is_responses_function_tool(tool): - chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - [dict(tool)] # pyright: ignore[reportArgumentType] # validated JSON dict in the Responses function tool shape - ) - return tuple(chat_tools) - anthropic_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai( - [dict(tool)] # pyright: ignore[reportArgumentType] # validated JSON dict in an Anthropic or chat tool shape - ) - return tuple(anthropic_tools) - - def _map_function_tools( tools: object | None, ) -> tuple[Mapping[str, object], ...] | None | VertexGeminiCountContextError: @@ -112,7 +92,7 @@ def _map_function_tools( from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig chat_tools: Final = tuple( - chat_tool for tool in function_tools for chat_tool in _chat_tool(tool) + chat_tool for tool in function_tools for chat_tool in chat_completion_tools_for(tool) ) # comprehension-ok: flattens each tool's translation try: mapped_tools: Final = VertexGeminiConfig().map_function( @@ -163,6 +143,7 @@ def count_context_error_response( error=True, error_message=error.error_message, status_code=error.status_code, + rejected_before_provider_call=True, ) diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py index 285380f5fc7..7100c9efde3 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py @@ -1,5 +1,5 @@ """ -Token counter for Vertex AI Partner Models (Anthropic Claude, Mistral, etc.) +Token counter for Vertex AI Partner Models (Anthropic Claude, Meta Llama) This handler provides token counting for partner models hosted on Vertex AI. Unlike Gemini models which use Google's token counting API, partner models use @@ -12,36 +12,30 @@ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +_COUNT_TOKENS_PUBLISHERS: Final = ( + ("claude", "anthropic"), + ("llama", "meta"), + ("meta/", "meta"), +) + + +def count_tokens_publisher(model: str) -> str | None: + return next((publisher for marker, publisher in _COUNT_TOKENS_PUBLISHERS if marker in model), None) + class VertexAIPartnerModelsTokenCounter(VertexBase): """ Token counter for Vertex AI Partner Models. - Handles token counting for models like Claude (Anthropic), Mistral, etc. + Handles token counting for models like Claude (Anthropic) and Llama (Meta) that are available through Vertex AI's partner model program. """ def _get_publisher_for_model(self, model: str) -> str: - """ - Determine the publisher name for the given model. - - Args: - model: The model name (e.g., "claude-3-5-sonnet-20241022") - - Returns: - Publisher name to use in the Vertex AI endpoint URL - - Raises: - ValueError: If the model is not a recognized partner model - """ - if "claude" in model: - return "anthropic" - elif "mistral" in model or "codestral" in model: - return "mistralai" - elif "llama" in model or "meta/" in model: - return "meta" - else: + publisher: Final = count_tokens_publisher(model) + if publisher is None: raise ValueError(f"Unknown partner model: {model}") + return publisher def _build_count_tokens_endpoint( self, diff --git a/litellm/main.py b/litellm/main.py index 3eae64d828e..2ac65b738a4 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -77,7 +77,7 @@ from litellm.litellm_core_utils.audio_utils.utils import ( from litellm.litellm_core_utils.chat_completion_agentic_loop import ( maybe_run_chat_completion_agentic_loop, ) -from litellm.litellm_core_utils.completion_timeout import CompletionTimeout +from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_litellm_params import ( AWS_CREDENTIAL_KWARGS_KEYS, @@ -119,7 +119,10 @@ from litellm.llms.base_llm.base_model_iterator import ( from litellm.llms.base_llm.chat.transformation import with_attribution_headers from litellm.llms.bedrock.common_utils import ( BedrockModelInfo, + NativeOutputFallback, bedrock_route_for_request, + deployment_model_info, + without_bedrock_native_output_opt_in, without_bedrock_route_prefix, ) from litellm.llms.bedrock_mantle.chat.claude_transformation import bedrock_mantle_chat_config @@ -223,6 +226,9 @@ from .llms.azure.completion.handler import AzureTextCompletion from .llms.azure_ai.anthropic.handler import AzureAnthropicChatCompletion from .llms.azure_ai.embed import AzureAIEmbedding from .llms.bedrock.chat import BedrockConverseLLM +from .llms.bedrock.chat.converse_transformation import AmazonConverseConfig +from .llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import AmazonAnthropicClaudeConfig +from .llms.bedrock.chat.native_output_fallback import NativeOutputAttemptResult, call_with_native_output_fallback from .llms.bedrock.embed.embedding import BedrockEmbedding from .llms.bedrock.image_edit.handler import BedrockImageEdit from .llms.bedrock.image_generation.image_handler import BedrockImageGeneration @@ -530,7 +536,14 @@ async def acompletion( mock_timeout = kwargs.get("mock_timeout", None) if mock_timeout is True: - await _handle_mock_timeout_async(mock_timeout, timeout, model) + stream_timeout: Final = kwargs.get("stream_timeout") + await _handle_mock_timeout_async( + mock_timeout, + stream_timeout + if stream is True and isinstance(stream_timeout, int | float | str | httpx.Timeout) + else timeout, + model, + ) loop: Final = asyncio.get_event_loop() custom_llm_provider = kwargs.get("custom_llm_provider", None) @@ -1078,6 +1091,11 @@ def mock_completion( from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the old definition site _resolve_openai_api_base as _resolve_openai_api_base, # pyright: ignore[reportPrivateUsage] # noqa: PLC0414 # re-export of moved helper ) +from litellm.responses.bridge_check import ( + bridge_reasoning_effort, + keep_registered_mode, + str_keyed_mapping, +) from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the old definition site responses_api_bridge_check as responses_api_bridge_check, # pyright: ignore[reportUnknownVariableType] # noqa: PLC0414 # re-export of moved function ) @@ -1130,6 +1148,52 @@ def _drop_anthropic_only_tool_keys(tools: list[dict[str, object]] | None) -> lis return [_without_anthropic_only_tool_keys(tool) if isinstance(tool, dict) else tool for tool in tools] +def _azure_completion_fakes_stream(model: str, base_model: str | None) -> bool: + if not litellm.AzureOpenAIO1Config().is_o_series_model(model=base_model or model): + return False + o_series_handler_config: Final = ProviderConfigManager.get_provider_chat_config( + model=model, provider=LlmProviders.AZURE + ) + return o_series_handler_config is not None and o_series_handler_config.should_fake_stream( + model=model, stream=True, custom_llm_provider=LlmProviders.AZURE.value + ) + + +def _completion_fakes_stream( + model: str, + custom_llm_provider: str | None, + provider_config: BaseConfig | None, + optional_params: Mapping[str, object], + *, + base_model: str | None, + bridges_to_responses_api: bool, +) -> bool: + if optional_params.get("fake_stream") is True: + return True + if custom_llm_provider is None: + return False + if bridges_to_responses_api: + responses_config: Final = ProviderConfigManager.get_provider_responses_api_config( + provider=custom_llm_provider, model=model + ) + return responses_config is not None and responses_config.should_fake_stream( + model=model, stream=True, custom_llm_provider=custom_llm_provider + ) + if ( + custom_llm_provider == LlmProviders.VERTEX_AI.value + and get_vertex_ai_model_route(model=model, litellm_params={"base_model": base_model}) + == VertexAIModelRoute.GEMMA + ): + return True + if isinstance(provider_config, litellm.AmazonAI21Config): + return True + if custom_llm_provider == LlmProviders.AZURE.value: + return _azure_completion_fakes_stream(model, base_model) + return provider_config is not None and provider_config.should_fake_stream( + model=model, stream=True, custom_llm_provider=custom_llm_provider + ) + + class _ProxyAuthHeadersProvider(Protocol): def get_auth_headers(self) -> Mapping[str, str]: ... @@ -1201,6 +1265,8 @@ def _register_custom_pricing_for_request( pricing overrides (e.g. a zero-cost wildcard) from clobbering built-in pricing used by sibling deployments of the same backend model. Direct SDK calls keep the legacy behavior of registering the shared key with pricing. + Either way the shared key's ``mode`` follows ``keep_registered_mode``, as on + deployment registration, since a call routes by its own ``model_info``. """ entry: Final = _build_custom_pricing_entry( custom_llm_provider=custom_llm_provider, @@ -1210,8 +1276,11 @@ def _register_custom_pricing_for_request( shared_key: Final = f"{custom_llm_provider}/{model}" deployment_id: Final = _get_router_deployment_id(kwargs) if deployment_id is None: - litellm.register_model({shared_key: entry}, persist_across_reloads=False) + litellm.register_model({shared_key: keep_registered_mode(shared_key, entry)}, persist_across_reloads=False) return + shared_entry: Final = keep_registered_mode( + shared_key, CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry) + ) litellm.register_model( {deployment_id: entry}, persist_across_reloads=False, @@ -1220,7 +1289,7 @@ def _register_custom_pricing_for_request( add_to_provider_model_sets=False, ) litellm.register_model( - {shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry)}, + {shared_key: shared_entry}, persist_across_reloads=False, warning_display_name=shared_key, custom_llm_provider=custom_llm_provider, @@ -4137,6 +4206,53 @@ _PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints) _BASE_URL_HINT: Final = TypeAdapter(BaseUrlRoutingHint) +_AWS_BEDROCK_CLIENT_CREDENTIAL_KEYS: Final = ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_region_name", +) + + +def _native_output_fallback( + ctx: CompletionDispatchContext, + first_attempt_params: Mapping[str, object], + first_attempt_litellm_params: Mapping[str, object], +) -> NativeOutputFallback: + litellm_params: Final = without_bedrock_native_output_opt_in(first_attempt_litellm_params) + remapped_params: Final = ctx.optional_params_for_model_info(deployment_model_info(litellm_params)) + return NativeOutputFallback( + optional_params={ + **{key: value for key, value in remapped_params.items() if key != "aws_bedrock_client"}, + **{ + key: first_attempt_params[key] + for key in _AWS_BEDROCK_CLIENT_CREDENTIAL_KEYS + if key in first_attempt_params + }, + }, + litellm_params=litellm_params, + ) + + +def _converse_native_output_fallback( + ctx: CompletionDispatchContext, + first_attempt_params: Mapping[str, object], + first_attempt_litellm_params: Mapping[str, object], +) -> NativeOutputFallback | None: + fallback: Final = _native_output_fallback(ctx, first_attempt_params, first_attempt_litellm_params) + return None if AmazonConverseConfig.sends_native_text_format(fallback.optional_params) else fallback + + +def _native_output_attempt_timeout( + ctx: CompletionDispatchContext, fallback: NativeOutputFallback | None +) -> float | str | httpx.Timeout | None: + if fallback is None: + return ctx.timeout + retry_timeout: Final = ctx.timeout_for_optional_params(fallback.optional_params) + ctx.logging.record_applied_timeout(retry_timeout) + return retry_timeout + + def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResult: acompletion: Final = ctx.acompletion api_base: Final = ctx.api_base @@ -4208,22 +4324,36 @@ def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResu ) elif bedrock_route == "converse": model = without_bedrock_route_prefix(model) - response = bedrock_converse_chat_completion.completion( - model=model, - messages=messages, - custom_prompt_dict=custom_prompt_dict, - model_response=model_response, - optional_params=optional_params, - litellm_params=litellm_params, - logger_fn=logger_fn, - encoding=_get_encoding(), - logging_obj=logging, - extra_headers=headers, # Use merged headers instead of original extra_headers - timeout=timeout, - acompletion=acompletion, - client=client, - api_base=api_base, - api_key=api_key, + converse_model: Final = model + + def converse_attempt(fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult: + return bedrock_converse_chat_completion.completion( + model=converse_model, + messages=messages, + custom_prompt_dict=custom_prompt_dict, + model_response=model_response, + optional_params=optional_params if fallback is None else dict(fallback.optional_params), + litellm_params=litellm_params if fallback is None else dict(fallback.litellm_params), + logger_fn=logger_fn, + encoding=_get_encoding(), + logging_obj=logging, + extra_headers=headers, # Use merged headers instead of original extra_headers + timeout=_native_output_attempt_timeout(ctx, fallback), + acompletion=acompletion, + client=client, + api_base=api_base, + api_key=api_key, + ) + + converse_params: Final = _OPTIONAL_PARAMS_ADAPTER.validate_python(optional_params) + response = call_with_native_output_fallback( + converse_attempt, + ( + partial(_converse_native_output_fallback, ctx, converse_params, dict(litellm_params)) + if AmazonConverseConfig().uses_opt_in_native_output(converse_model, converse_params, litellm_params) + else None + ), + converse_model, ) elif bedrock_route == "converse_like": model = model.replace("converse_like/", "") @@ -4245,22 +4375,36 @@ def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResu client=client, ) else: - response = base_llm_http_handler.completion( - model=model, - stream=stream, - messages=messages, - acompletion=acompletion, - api_base=api_base, - model_response=model_response, - optional_params=optional_params, - litellm_params=litellm_params, - custom_llm_provider="bedrock", - timeout=timeout, - headers=headers, - encoding=_get_encoding(), - api_key=api_key, - logging_obj=logging, - client=client, + + def invoke_attempt(fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult: + return base_llm_http_handler.completion( + model=model, + stream=stream, + messages=messages, + acompletion=acompletion, + api_base=api_base, + model_response=model_response, + optional_params=optional_params if fallback is None else dict(fallback.optional_params), + litellm_params=litellm_params if fallback is None else dict(fallback.litellm_params), + custom_llm_provider="bedrock", + timeout=_native_output_attempt_timeout(ctx, fallback), + headers=headers, + encoding=_get_encoding(), + api_key=api_key, + logging_obj=logging, + client=client, + ) + + invoke_params: Final = _OPTIONAL_PARAMS_ADAPTER.validate_python(optional_params) + response = call_with_native_output_fallback( + invoke_attempt, + ( + partial(_native_output_fallback, ctx, invoke_params, dict(litellm_params)) + if isinstance(provider_config, AmazonAnthropicClaudeConfig) + and provider_config.uses_opt_in_native_output(model, invoke_params, litellm_params) + else None + ), + model, ) return response @@ -5539,6 +5683,9 @@ def completion( custom_llm_provider=custom_llm_provider, web_search_options=web_search_options, api_base=api_base, + deployment_model_info=str_keyed_mapping( + cast(object, model_info) # cast-ok: model_info is the untyped dict kwarg + ), ) if not _is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model): @@ -5561,15 +5708,6 @@ def completion( "aws_region_name", None ) # support region-based pricing for bedrock - ### TIMEOUT LOGIC ### - timeout = CompletionTimeout.resolve( - timeout, - kwargs, - custom_llm_provider, - global_timeout=get_configured_request_timeout(), - supports_httpx_timeout=supports_httpx_timeout, - ) - ### REGISTER CUSTOM MODEL PRICING -- IF GIVEN ### if ( (input_cost_per_token is not None and output_cost_per_token is not None) @@ -5695,11 +5833,42 @@ def completion( accepts_summary=provider_config is not None and provider_config.accepts_reasoning_summary_in_reasoning_effort, ) - optional_params = get_optional_params( - **{**optional_param_args, "reasoning_effort": provider_reasoning_effort}, - model_info=_MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None, - **non_default_params, + + mapping_args: Final = MappingProxyType({**optional_param_args, "reasoning_effort": provider_reasoning_effort}) + mapping_special_params: Final = MappingProxyType(dict(non_default_params)) + + def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]: + return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params) + + optional_params = optional_params_for_model_info( + _MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None ) + fakes_stream: Final = partial( + _completion_fakes_stream, + model, + custom_llm_provider, + provider_config, + base_model=base_model if isinstance(base_model, str) else None, + bridges_to_responses_api=bridges_to_responses_api is True, + ) + resolve_timeout: Final = partial( + CompletionTimeout.resolve, + timeout, + kwargs, + custom_llm_provider, + global_timeout=get_configured_request_timeout(), + supports_httpx_timeout=supports_httpx_timeout, + ) + streams: Final = stream is True + + def timeout_for_optional_params(attempt_params: Mapping[str, object]) -> float | httpx.Timeout: + if not streams: + return resolve_timeout(stream_kind=StreamKind.NOT_STREAMED) + return resolve_timeout(stream_kind=StreamKind.FAKED if fakes_stream(attempt_params) else StreamKind.LIVE) + + ### TIMEOUT LOGIC ### + timeout = timeout_for_optional_params(optional_params) + logging.record_applied_timeout(timeout) processed_non_default_params: Final = pre_process_non_default_params( model=model, passed_params=optional_param_args, @@ -5858,9 +6027,12 @@ def completion( custom_llm_provider=custom_llm_provider, web_search_options=web_search_options, tools=tools, - reasoning_effort=reasoning_effort, + reasoning_effort=bridge_reasoning_effort(reasoning_effort, optional_params.get("extra_body")), reasoning_summary=_reasoning_summary_for_bridge, api_base=api_base, + deployment_model_info=str_keyed_mapping( + cast(object, model_info) # cast-ok: model_info is the untyped dict kwarg + ), ) # Use base_model (the true underlying model) for Azure model-type @@ -5933,6 +6105,7 @@ def completion( model=model, model_response=model_response, optional_params=optional_params, + optional_params_for_model_info=optional_params_for_model_info, organization=organization, provider_config=provider_config, request_params=MappingProxyType( @@ -5943,6 +6116,7 @@ def completion( temperature=temperature, text_completion=text_completion, timeout=timeout, + timeout_for_optional_params=timeout_for_optional_params, top_p=top_p, ) if custom_llm_provider == "azure": diff --git a/litellm/messages/dispatch.py b/litellm/messages/dispatch.py index cbf3ff6a04d..cb6ed8d5c75 100644 --- a/litellm/messages/dispatch.py +++ b/litellm/messages/dispatch.py @@ -50,6 +50,13 @@ _PYTHON_AMESSAGES: Final = _python_amessages() _AMESSAGES: Final = signature(_PYTHON_AMESSAGES) +def _native_kwargs(fields: Mapping[str, object], kwargs: Mapping[str, object]) -> Mapping[str, object]: + stream_timeout: Final = kwargs.get("stream_timeout") + if fields.get("stream") is not True or stream_timeout is None: + return kwargs + return {**kwargs, "timeout": stream_timeout} + + def _public_request( legacy: inspect.Signature, args: tuple[object, ...], kwargs: Mapping[str, object] ) -> NativeCall | None: @@ -61,7 +68,7 @@ def _public_request( max_tokens: Final = fields.get("max_tokens") if not isinstance(model, str) or messages is None or not isinstance(max_tokens, int): return None - return native_call(legacy, args, kwargs) + return native_call(legacy, args, _native_kwargs(fields, kwargs)) def _resolved_provider(request: NativeCall) -> str | None: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 58338f185c7..da625869e9a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -2323,6 +2323,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2362,6 +2363,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2401,6 +2403,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2440,6 +2443,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2479,6 +2483,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2518,6 +2523,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2594,6 +2600,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2633,6 +2640,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2672,6 +2680,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2711,6 +2720,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2750,6 +2760,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2789,6 +2800,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -3016,7 +3028,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -3301,7 +3313,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -10904,7 +10916,6 @@ }, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -10973,7 +10984,6 @@ "output_cost_per_token_batches": 4e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -11005,7 +11015,6 @@ "output_cost_per_token_batches": 4e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -12877,6 +12886,7 @@ "supports_prompt_caching": true }, "azure_ai/ministral-3b": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 4e-08, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -12940,6 +12950,7 @@ "supports_vision": true }, "azure_ai/mistral-medium-2505": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 4e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -12977,6 +12988,7 @@ "supports_tool_choice": true }, "azure_ai/mistral-small-2503": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 1e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -21132,6 +21144,7 @@ "input_cost_per_token": 1.4e-07, "input_dbu_cost_per_token": 2e-06, "litellm_provider": "databricks", + "token_count_framing": 97, "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, @@ -21154,6 +21167,11 @@ "supports_tool_choice": true, "supports_vision": false }, + "databricks/system.ai.deepseek-v4-flash-0731": { + "litellm_provider": "databricks", + "mode": "chat", + "token_count_framing": 97 + }, "databricks/databricks-deepseek-v4-pro-0813": { "cache_creation_input_token_cost": 1.31999e-06, "cache_read_input_token_cost": 1.3202e-07, @@ -22122,6 +22140,7 @@ "input_cost_per_token": 2.99999e-06, "input_dbu_cost_per_token": 4.2857e-05, "litellm_provider": "databricks", + "token_count_framing": 100, "max_input_tokens": 1000000, "max_output_tokens": 1048576, "max_tokens": 1048576, @@ -22145,6 +22164,11 @@ "supports_tool_choice": true, "supports_vision": true }, + "databricks/system.ai.kimi-k3": { + "litellm_provider": "databricks", + "mode": "chat", + "token_count_framing": 100 + }, "databricks/databricks-llama-4-maverick": { "cache_creation_input_token_cost": 5.0001e-07, "cache_read_input_token_cost": 5.0001e-07, @@ -24683,7 +24707,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -24746,6 +24770,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "eu.meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.3e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -24753,10 +24778,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.3e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "eu.meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -24764,6 +24791,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.9e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, @@ -29699,6 +29727,7 @@ }, "vertex_ai/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -29806,6 +29835,7 @@ "supports_native_streaming": true }, "vertex_ai/gemini-3.8-live": { + "deprecation_date": "2027-09-25", "input_cost_per_audio_token": 3e-06, "input_cost_per_image_token": 1e-06, "input_cost_per_token": 7.5e-07, @@ -31960,6 +31990,7 @@ }, "gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -33882,7 +33913,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -40514,6 +40545,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "meta.llama3-1-405b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 2.4e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40556,6 +40588,7 @@ "supports_tool_choice": false }, "meta.llama3-2-11b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40569,6 +40602,7 @@ "supports_vision": true }, "meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40576,10 +40610,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40587,10 +40623,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-90b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -45425,6 +45463,38 @@ "output_cost_per_token": 0.0, "source": "https://docs.perplexity.ai/docs/getting-started/pricing" }, + "perplexity/pplx-decider-v1.2": { + "input_cost_per_token": 2e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-latest": { + "input_cost_per_token": 2e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-flash-v1.2": { + "input_cost_per_token": 1e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-flash-latest": { + "input_cost_per_token": 1e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, "perplexity/sonar": { "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", @@ -48433,6 +48503,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -48467,6 +48538,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -48868,7 +48940,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -48947,6 +49019,7 @@ "supports_vision": false }, "us.meta.llama3-1-405b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 2.4e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -48989,6 +49062,7 @@ "supports_tool_choice": false }, "us.meta.llama3-2-11b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49002,6 +49076,7 @@ "supports_vision": true }, "us.meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49009,10 +49084,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49020,10 +49097,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-90b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -65561,6 +65640,40 @@ "supports_vision": false, "source": "https://deepinfra.com/pricing" }, + "deepinfra/deepseek-ai/DeepSeek-V4.1-Flash": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 4.2e-07, + "cache_read_input_token_cost": 4.2e-09, + "supports_prompt_caching": true, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://deepinfra.com/deepseek-ai/DeepSeek-V4.1-Flash" + }, + "deepinfra/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 4.2e-07, + "cache_read_input_token_cost": 4.2e-09, + "supports_prompt_caching": true, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://deepinfra.com/deepseek-ai/DeepSeek-V4.1-Flash-1017" + }, "deepinfra/openai/gpt-oss-120b-Ultra": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -67074,6 +67187,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67107,6 +67221,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67344,6 +67459,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67377,6 +67493,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -71435,6 +71552,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -71465,6 +71584,8 @@ "input_cost_per_token_flex": 1.5e-06, "input_cost_per_token_priority": 5.25e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -71495,6 +71616,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -72729,6 +72852,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -72861,6 +72985,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -72993,6 +73118,10 @@ "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -73066,6 +73195,10 @@ "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "au.anthropic.claude-sonnet-5-5": { + "token_count_model": { + "model": "au.anthropic.claude-opus-5-5", + "region": "ap-southeast-4" + }, "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 2.75e-06, "cache_creation_input_token_cost_above_1hr": 4.4e-06, @@ -73266,6 +73399,10 @@ "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "global.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -73475,6 +73612,10 @@ "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 2.2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "us.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74275,6 +74416,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -74448,6 +74591,10 @@ "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74526,6 +74673,10 @@ "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "au.anthropic.claude-haiku-5-5": { + "token_count_model": { + "model": "au.anthropic.claude-opus-5-5", + "region": "ap-southeast-4" + }, "input_cost_per_token_above_100k_tokens": 5.5e-07, "output_cost_per_token_above_100k_tokens": 2.75e-06, "cache_creation_input_token_cost_above_100k_tokens": 6.875e-07, @@ -74756,6 +74907,10 @@ "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "global.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74903,6 +75058,10 @@ "cache_read_input_token_cost": 1.1e-08, "input_cost_per_token": 1.1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "us.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -75323,6 +75482,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-4-argon": { + "deprecation_date": "2027-10-13", "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, @@ -75456,6 +75616,42 @@ "supports_reasoning": true, "supports_vision": true }, + "openinfer/@oi/GLM-5.3-Flash": { + "max_tokens": 393216, + "max_input_tokens": 393216, + "max_output_tokens": 393216, + "input_cost_per_token": 1.95e-07, + "output_cost_per_token": 1.56e-06, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, + "openinfer/@oi/Qwen3.5-27B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1.95e-07, + "output_cost_per_token": 1.56e-06, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, + "openinfer/@oi/Qwen3.5-9B": { + "max_tokens": 100000, + "max_input_tokens": 100000, + "max_output_tokens": 100000, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 1.5e-07, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, "xai/grok-voice-3.0": { "input_cost_per_second": 0.001333333333333, "input_cost_per_text_message": 0.004, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index a10f2f93d48..f95b553c2b4 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -2731,6 +2731,23 @@ "messages": true, "responses": true } + }, + "openinfer": { + "display_name": "OpenInfer (`openinfer`)", + "url": "https://docs.litellm.ai/docs/providers/openinfer", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } } }, "endpoints": { diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 449adaa73e9..3b2c2e09df5 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -862,12 +862,17 @@ def _raise_unless_oauth2_discovery_server( def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool: """True when a DCR-bridge server relays client registration to the upstream authorization - server instead of short-circuiting to an admin-configured OAuth client. In the relay arm the + server instead of short-circuiting to the server's own OAuth client. In the relay arm the upstream holds each client's own registration, so the authorize and token relays pass the client's ``client_id`` and ``redirect_uri`` through verbatim and the authorization code returns directly to the client's redirect URI without transiting the gateway. Gateway-side redirect trust and the ``/callback`` state relay therefore only apply to the short-circuit - arm, where the upstream only knows the gateway's own callback.""" + arm, where the upstream only knows the gateway's own callback. + + Authorize and token decide per request from the server's current client: once the gateway + holds its shared client (registered at ``/register`` under ``PROXY_BASE_URL``, or entered by an + admin) every caller runs the gateway-callback arm, while a server that still has none keeps + relaying, so clients registered before the shared client existed keep their own binding.""" return bool( mcp_server.is_dcr_bridge and mcp_server.effective_registration_url @@ -880,6 +885,27 @@ def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool: ) +def _register_relays_bridge_client(mcp_server: MCPServer) -> bool: + """``/register`` relays the caller's own registration only when the gateway cannot keep a shared + client: with ``PROXY_BASE_URL`` set it registers one client bound to its own ``/callback`` and + keeps it as the server's client, so an upstream that restricts redirect domains (Atlassian) + never sees a laptop loopback redirect. Without a database the shared client would live only in + this process, so a database-free gateway keeps relaying as well.""" + return _dcr_bridge_relays_client_registration(mcp_server) and ( + configured_proxy_base_url() is None or not _shared_client_store_available() + ) + + +def _shared_client_store_available() -> bool: + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 # avoids circular import + + try: + get_prisma_client_or_throw("Database not connected. Cannot keep the shared MCP OAuth client.") + except HTTPException: + return False + return True + + def _require_s256_pkce( code_challenge: str | None, code_challenge_method: str | None, @@ -1028,6 +1054,17 @@ async def _managed_authorization_scope( return selected_scope, scope_user_id +async def _hydrate_bridge_shared_client(mcp_server: MCPServer, request: Request) -> None: + """Whichever worker served ``/register`` persisted the bridge's shared gateway client; a worker + whose catalog snapshot predates that write still holds no ``client_id``, so the gateway-callback + arms load the persisted client before presenting one upstream. Callers that carry their own + client (a gateway-minted ephemeral client sealed into the state or the code) skip this, since the + upstream bound their code to that client.""" + if not mcp_server.is_dcr_bridge or mcp_server.client_id: + return + await _reuse_persisted_dcr_client_if_available(mcp_server, f"{get_request_base_url(request)}/callback") + + async def authorize_with_server( request: Request, mcp_server: MCPServer, @@ -1069,6 +1106,8 @@ async def authorize_with_server( # calling this for its enforcement side effect, then falls through to the gateway # /callback flow below, which reads the original code_challenge names. bridge_challenge, bridge_method = _require_s256_pkce(code_challenge, code_challenge_method) + if ephemeral_dcr_client is None: + await _hydrate_bridge_shared_client(resolved_server, request) # A gateway-minted ephemeral client is registered against {base}/callback, so its # flow must run the short-circuit arm; the relay arm is only for clients that # registered themselves through the front door and hold their own redirect binding. @@ -1233,6 +1272,7 @@ async def exchange_token_with_server( refresh_token: str | None = None, scope: str | None = None, client_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None, + sealed_client: bool = False, ): raise_if_not_oauth2(mcp_server) if grant_type not in ("authorization_code", "refresh_token"): @@ -1282,11 +1322,14 @@ async def exchange_token_with_server( # recovered from a sealed code) must authenticate the way its own registration was granted, # not the way the server row is configured; callers that carry no method keep the row's method # as before. - resolved_client_id: Final = resolved_server.client_id if resolved_server.client_id else client_id - resolved_client_secret: Final = resolved_server.client_secret if resolved_server.client_id else client_secret + if not sealed_client: + await _hydrate_bridge_shared_client(resolved_server, request) + server_client_wins: Final = bool(resolved_server.client_id) and not sealed_client + resolved_client_id: Final = resolved_server.client_id if server_client_wins else client_id + resolved_client_secret: Final = resolved_server.client_secret if server_client_wins else client_secret resolved_auth_method: Final = ( resolved_server.token_endpoint_auth_method - if resolved_server.client_id + if server_client_wins else (client_token_endpoint_auth_method or resolved_server.token_endpoint_auth_method) ) try: @@ -1830,10 +1873,13 @@ async def _persist_dcr_client_registration( empty and otherwise adopts whatever a concurrent registration persisted first. The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped - unconditionally: the caller holds the upstream token and the gateway must hold no OAuth - client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a - ``client_id`` onto a server whose mode promises the gateway stores nothing, making a - fresh pass-through server read as gateway-authorized. + unless the server is a DCR bridge: the caller holds the upstream token and the gateway holds + no OAuth client identity for these servers, so persisting would stamp ``oauth2_flow`` and a + ``client_id`` onto a server whose mode promises the gateway stores nothing. A DCR bridge is + the exception by design: its gateway-hosted sign-in registers one shared client bound to the + gateway ``/callback`` (the same app an admin may enter by hand), and that client must survive + for every later caller's authorize and token exchange. ``oauth2_flow`` is still never stamped + on a client-forwarded row. ``redirect_uris`` records what the client is bound to so a later origin change can be detected as a positive mismatch and trigger re-registration instead of stranding the @@ -1842,7 +1888,7 @@ async def _persist_dcr_client_registration( ``update_mcp_server`` merges credential blobs: a re-registered public client must not inherit the previous client's secret or auth method. """ - if mcp_server.is_client_forwarded_token: + if mcp_server.is_client_forwarded_token and not mcp_server.is_dcr_bridge: return "skipped" try: @@ -1910,20 +1956,23 @@ async def _persist_dcr_client_registration( ): await _refresh_persisted_dcr_server(stored) return "reused" + stamped_flow: Final[Literal["authorization_code"] | None] = ( + None if mcp_server.is_client_forwarded_token else "authorization_code" + ) updated_row: Final = await update_mcp_server( prisma_client=prisma_client, data=( UpdateMCPServerRequest( server_id=mcp_server.server_id, credentials=credentials, - oauth2_flow="authorization_code", + oauth2_flow=stamped_flow, token_url=mcp_server.token_url, ) if mcp_server.token_url else UpdateMCPServerRequest( server_id=mcp_server.server_id, credentials=credentials, - oauth2_flow="authorization_code", + oauth2_flow=stamped_flow, ) ), touched_by="mcp_oauth_dcr", @@ -2211,7 +2260,7 @@ async def register_client_with_server( ) return dummy_return - bridge_relay: Final = _dcr_bridge_relays_client_registration(resolved_server) + bridge_relay: Final = _register_relays_bridge_client(resolved_server) if bridge_relay and not client_redirect_uris: raise HTTPException( status_code=400, diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py index 90289fd4c20..70e4bac01b5 100644 --- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py +++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py @@ -248,6 +248,7 @@ class SubjectTokenRefusal(LiteLLMBaseModel): "invalid_grant", "invalid_client", "invalid_scope", + "invalid_target", "temporarily_unavailable", ] description: str = Field(min_length=1) @@ -1527,7 +1528,7 @@ def _subject_token_refusal_response(refusal: SubjectTokenRefusal) -> Response: return _oauth_error(503, refusal.error, refusal.description) case "invalid_client": return _oauth_error(401, refusal.error, refusal.description) - case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope": + case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope" | "invalid_target": return _oauth_error(400, refusal.error, refusal.description) case _: assert_never(refusal.error) diff --git a/litellm/proxy/_experimental/mcp_server/id_jag_grant.py b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py index 155d5280f5a..f2177df5a26 100644 --- a/litellm/proxy/_experimental/mcp_server/id_jag_grant.py +++ b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py @@ -23,6 +23,7 @@ from urllib.parse import unquote_plus import jwt from fastapi import Request +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger from litellm.models.user import LiteLLM_UserTable @@ -48,6 +49,7 @@ _INACTIVE_SUBJECT: Final = frozenset(("no_active_key", "unresolvable")) _REPLAY_KEY_PREFIX: Final = "mcp_gateway_dcr:id_jag:" SCOPE_MCP: Final = "litellm:mcp" SCOPE_LLM: Final = "litellm:llm" +_RESOURCE_CLAIM_ADAPTER: Final = TypeAdapter(list[str]) SCOPE_ROUTE_GROUPS: Final = MappingProxyType( {SCOPE_MCP: ("mcp_inference_routes",), SCOPE_LLM: ("llm_api_routes_without_mcp",)} ) @@ -65,9 +67,9 @@ class GrantedScope: def granted_scope(claim: object, *, mcp_resource: bool) -> GrantedScope | SubjectTokenRefusal: """The routes the assertion buys. With ``aud`` the bare origin the ``scope`` claim decides and - no claim is the full credential. With ``aud`` the MCP resource (``{base}/mcp``) the identity - provider named that resource, so the credential is ceilinged to the MCP routes whatever the - claim says: ``litellm:llm`` cannot be granted and is refused.""" + no claim is the full credential. With the MCP resource (``{base}/mcp``) as ``aud`` or as every + ``resource``, the identity provider named that resource, so the credential is ceilinged to the + MCP routes whatever the claim says: ``litellm:llm`` cannot be granted and is refused.""" if claim is not None and not isinstance(claim, str): return SubjectTokenRefusal(error="invalid_scope", description=_SCOPE_DESCRIPTION) requested: Final = frozenset(claim.split()) if claim else frozenset() @@ -90,6 +92,32 @@ def names_mcp_resource(aud: object, base_url: str | None) -> bool: return base_url is not None and f"{base_url}/mcp" in audiences and base_url not in audiences +def _resource_claim_values(claim: object) -> tuple[str, ...] | None: + if isinstance(claim, str): + return (claim,) + if not isinstance(claim, list): + return None + try: + values: Final = _RESOURCE_CLAIM_ADAPTER.validate_python(claim, strict=True) + except ValidationError: + return None + return tuple(values) if values else None + + +def resource_names_mcp(claims: Mapping[str, object], base_url: str | None) -> bool | SubjectTokenRefusal: + """The signed RFC 8707 ``resource`` claim (a URI or an array of URIs) restricts the credential like an + ``aud`` of ``{base}/mcp`` does.""" + if "resource" not in claims: + return False + resources: Final = _resource_claim_values(claims["resource"]) + if resources is None: + return SubjectTokenRefusal(error="invalid_target", description=_RESOURCE_DESCRIPTION) + served_resources: Final = frozenset(gateway_audiences(base_url)) + if any(resource not in served_resources for resource in resources): + return SubjectTokenRefusal(error="invalid_target", description=_RESOURCE_DESCRIPTION) + return frozenset(resources) == frozenset((f"{base_url}/mcp",)) + + def _route_groups(scopes: Sequence[str]) -> Iterator[str]: for scope in scopes: yield from SCOPE_ROUTE_GROUPS[scope] @@ -98,8 +126,10 @@ def _route_groups(scopes: Sequence[str]) -> Iterator[str]: _SCOPE_DESCRIPTION: Final = ( f"scope must be {SCOPE_MCP} (MCP gateway routes), {SCOPE_LLM} (LLM API routes), or both, space separated" ) +_RESOURCE_DESCRIPTION: Final = "the assertion's resource claim must name this gateway's origin or its /mcp resource" _MCP_RESOURCE_DESCRIPTION: Final = ( - f"the /mcp audience only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience for LLM routes" + f"the /mcp audience or resource only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience and " + "resource for LLM routes" ) @@ -450,8 +480,12 @@ async def identity_from_id_jag( if isinstance(marker, SubjectTokenRefusal): return marker replay_key, replay_ttl_seconds = marker + mcp_resource: Final = resource_names_mcp(verified, prerequisites.base_url) + if isinstance(mcp_resource, SubjectTokenRefusal): + return mcp_resource scope: Final = granted_scope( - verified.get("scope"), mcp_resource=names_mcp_resource(verified.get("aud"), prerequisites.base_url) + verified.get("scope"), + mcp_resource=mcp_resource or names_mcp_resource(verified.get("aud"), prerequisites.base_url), ) if isinstance(scope, SubjectTokenRefusal): return scope diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 02799b90566..96cf6ee11aa 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -4531,7 +4531,7 @@ class MCPServerManager: ) else: page: Final = ( - await client.list_tools_page(params) + await self._fetch_tools_page_with_timeout(client, server, params) if params is not None else ListToolsResult(tools=await self._fetch_tools_with_timeout(client, server.name)) ) @@ -5621,6 +5621,19 @@ class MCPServerManager: ) raise_classified_list_failure(e, server_name) + async def _fetch_tools_page_with_timeout( + self, client: MCPClient, server: MCPServer, params: PaginatedRequestParams + ) -> ListToolsResult: + deadline: Final = ( + MCP_TOOL_LISTING_TIMEOUT if server.timeout is None else max(server.timeout, MCP_TOOL_LISTING_TIMEOUT) + ) + try: + with anyio.fail_after(deadline): + return await client.list_tools_page(params) + except TimeoutError as e: + verbose_logger.warning("Timeout while listing tools from %s", server.name) + raise MCPServerListError(ServerListFault(tag="timeout"), server.name) from e + _SHORT_PREFIX_MAX_REHASH_ATTEMPTS = 1024 def assign_unique_short_prefix( diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 961d32ab664..7198d74909c 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -14412,6 +14412,20 @@ "description": "Optional field if guardrail requires a 'model' parameter", "title": "Model" }, + "num_retries": { + "anyOf": [ + { + "maximum": 5.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "How many more times to send a guardrail check whose connection timed out waiting for the answer or was dropped after the request went out. Each retry opens a fresh connection and gets the full timeout. Connect failures, HTTP error statuses and BLOCKED verdicts are not retried. Unset or 0 sends each check once, and the most allowed is 5. Implemented by guardrail='generic_guardrail_api', other guardrails ignore it.", + "title": "Num Retries" + }, "on_sensitive_data": { "anyOf": [ { @@ -17001,6 +17015,20 @@ "description": "If True, logs violations without blocking. Defaults to False if not provided", "title": "Monitor Mode" }, + "num_retries": { + "anyOf": [ + { + "maximum": 5.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "How many more times to send a guardrail check whose connection timed out waiting for the answer or was dropped after the request went out. Each retry opens a fresh connection and gets the full timeout. Connect failures, HTTP error statuses and BLOCKED verdicts are not retried. Unset or 0 sends each check once, and the most allowed is 5. Implemented by guardrail='generic_guardrail_api', other guardrails ignore it.", + "title": "Num Retries" + }, "on_disallowed_action": { "default": "block", "description": "Choose whether disallowed tools block the request or get rewritten out of the payload", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2c98cfeb33d..5970ede5c7e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -563,6 +563,7 @@ class LiteLLMRoutes(enum.Enum): "/team/metadata_schema", "/user/info", "/v2/user/info", + "/management/v1/users/me", "/model/info", "/v1/model/info", "/v2/model/info", @@ -3667,6 +3668,7 @@ class SpendLogsMetadata(TypedDict): user_api_key_project_alias: str | None user_api_key_org_id: str | None user_api_key_user_id: str | None + user_api_key_user_email: ReadOnly[str | None] user_api_key_team_alias: str | None spend_logs_metadata: dict | None # special param to log k,v pairs to spendlogs for a call requester_ip_address: str | None @@ -4827,6 +4829,10 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): "mcp_routes", "/v1/messages", "/v1/messages/count_tokens", + "/rag/ingest", + "/v1/rag/ingest", + "/rag/query", + "/v1/rag/query", ] team_id_default: str | None = Field( default=None, diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 1fa276d9ab2..b12676e08f3 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -48,6 +48,7 @@ from litellm.proxy.common_utils.openai_error_payload import ( openai_error_type, with_litellm_call_id, ) +from litellm.proxy.common_utils.token_count_settings import count_source_fields from litellm.proxy.litellm_pre_call_utils import should_auto_drop_params_for_agentic_cli from litellm.router import Router @@ -420,7 +421,7 @@ async def count_tokens( log_llm_api_exception(e, litellm_call_id) return _anthropic_error_json_response(proxy_exception_from_route_error(e), request) else: - return {"input_tokens": token_response.total_tokens} + return {"input_tokens": token_response.total_tokens, **count_source_fields(token_response)} @router.post( diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index b395a826625..d0708fcda2f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4784,7 +4784,7 @@ def check_model_access_helper( if _model_in_team_aliases(model=model, team_model_aliases=team_model_aliases): return True - if _model_matches_any_wildcard_pattern_in_list(model=model, allowed_model_list=filtered_models): + if model_matches_any_wildcard_pattern_in_list(model=model, allowed_model_list=filtered_models): return True all_model_access: bool = False @@ -7434,7 +7434,7 @@ def is_model_allowed_by_pattern(model: str, allowed_model_pattern: str) -> bool: return False -def _model_matches_any_wildcard_pattern_in_list(model: str, allowed_model_list: list) -> bool: +def model_matches_any_wildcard_pattern_in_list(model: str, allowed_model_list: Sequence[str]) -> bool: """ Returns True if a model matches any wildcard pattern in a list. diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 05b8cca19af..9d800a1fd63 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -1767,6 +1767,7 @@ class JWTAuthManager: from litellm.proxy.proxy_server import llm_router denied_pass_through_team: LiteLLM_TeamTable | None = None + route_denied_team_id: str | None = None # rebind-ok: retain route-denied team while checking all claims if not team_ids: if ( @@ -1780,7 +1781,7 @@ class JWTAuthManager: return None, None any_claim_team_resolved = False - for team_id in team_ids: + for team_id in sorted(team_ids, key=str): try: team_object = await get_team_object( team_id=team_id, @@ -1809,7 +1810,9 @@ class JWTAuthManager: user_route=route, litellm_proxy_roles=jwt_handler.litellm_jwtauth, ) - if is_allowed and not JWTAuthManager._team_has_passthrough_route_access( + if not is_allowed: + route_denied_team_id = team_id + elif not JWTAuthManager._team_has_passthrough_route_access( team_object=team_object, route=route, request_method=request_method, @@ -1832,7 +1835,13 @@ class JWTAuthManager: # Claim resolved but no model access, or fallback disabled — deny. raise HTTPException( status_code=403, - detail=f"No team has access to the requested model: {requested_model}. Checked teams={team_ids}. Check `/models` to see all available models.", + detail=( + f"Team {route_denied_team_id} can access model {requested_model} but route {route} " + "is not in litellm_jwtauth.team_allowed_routes" + if route_denied_team_id is not None + else f"No team has access to the requested model: {requested_model}. " + f"Checked teams={team_ids}. Check `/models` to see all available models." + ), ) # No claim team resolved and fallback enabled — defer to fallback. @@ -2003,7 +2012,7 @@ class JWTAuthManager: status_code=403, detail=( f"x-litellm-team-id '{header_value}' does not resolve to a team id or a unique team alias in your " - f"JWT's allowed teams. Allowed team ids: {sorted(allowed_team_ids)}" + f"JWT's allowed teams. Allowed team ids: {sorted(allowed_team_ids, key=str)}" ), ) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 629b31024e2..9783f44b41f 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -57,7 +57,7 @@ if TYPE_CHECKING: from prisma import types as prisma_types BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24) -PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change", "/session/logout") +PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change", "/session/logout", "/management/v1/users/me") PASSWORD_SESSION_METADATA: Final = MappingProxyType({"login_method": "username_password"}) diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 8dab693cd58..f5e8304c83c 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -72,14 +72,22 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES: Final = ("/regenerate", "/reset_spe _LLM_API_ROUTE_GROUPS: Final = frozenset(("llm_api_routes", "llm_api_routes_without_mcp")) _AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", *_LLM_API_ROUTE_GROUPS)) -# Method-scoped because the generic credential CRUD handlers share these paths: -# the route check cannot admit PATCH/DELETE on a name like "user_connections" -# or "*/user_connection*" without also opening the admin CRUD handlers. -_INTERNAL_USER_CREDENTIAL_CONNECTION_ROUTES: Final = ( +# Writes that only touch the caller's own credential row (BYOK connections and +# per-user MCP credentials). Method-scoped because the generic credential CRUD +# handlers share these paths: the route check cannot admit PATCH/DELETE on a +# name like "user_connections" without also opening the admin CRUD handlers. +_SELF_SCOPED_CREDENTIAL_ROUTES: Final = ( ("GET", "/credentials/user_connections"), ("POST", "/credentials/{credential_name:path}/user_connection/start"), ("POST", "/credentials/{credential_name:path}/user_connection/poll"), ("DELETE", "/credentials/{credential_name:path}/user_connection"), + ("POST", "/v1/mcp/server/oauth/{server_id}/register"), + ("POST", "/v1/mcp/server/{server_id}/oauth-user-credential"), + ("DELETE", "/v1/mcp/server/{server_id}/oauth-user-credential"), + ("POST", "/v1/mcp/server/{server_id}/user-credential"), + ("DELETE", "/v1/mcp/server/{server_id}/user-credential"), + ("POST", "/v1/mcp/server/{server_id}/user-env-vars"), + ("DELETE", "/v1/mcp/server/{server_id}/user-env-vars"), ) @@ -349,7 +357,7 @@ class RouteChecks: _user_role == LitellmUserRoles.INTERNAL_USER.value and ( RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value) - or RouteChecks.is_internal_user_credential_connection_route(route=route, request=request) + or RouteChecks.is_self_scoped_credential_route(route=route, request=request) ) or user_is_org_admin(request_data=request_data, user_object=user_obj) and RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value) @@ -702,13 +710,13 @@ class RouteChecks: _get_request_method = get_request_method @staticmethod - def is_internal_user_credential_connection_route(route: str, request: Request | None) -> bool: + def is_self_scoped_credential_route(route: str, request: Request | None) -> bool: method: Final = RouteChecks.get_request_method(request) if method is None: return False return any( method == expected_method and RouteChecks.check_route_access(route=route, allowed_routes=(pattern,)) - for expected_method, pattern in _INTERNAL_USER_CREDENTIAL_CONNECTION_ROUTES + for expected_method, pattern in _SELF_SCOPED_CREDENTIAL_ROUTES ) @staticmethod @@ -1033,6 +1041,9 @@ class RouteChecks: if route == "/session/logout": return + if RouteChecks.is_self_scoped_credential_route(route=route, request=request): + return + # Hard-block known write routes regardless of HTTP method (defensive # — these are POSTs in practice, but pinning them here protects # against future GET-shaped writes). diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index ead1cd8e2ca..763737191ef 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -149,6 +149,14 @@ async def _raise_when_input_file_must_be_managed(model: str, credentials: Mappin ) +def _raise_when_routing_headers_disagree(request: Request, body_model: object) -> None: + if (isinstance(body_model, str) and body_model) or request.query_params.get("model"): + return + if len(frozenset(request.headers.getlist("x-litellm-model"))) < 2: + return + raise batch_error(400, "Send one x-litellm-model header. This request has several naming different models") + + def _litellm_metadata_of(data: MutableMapping[str, object]) -> MutableMapping[str, object]: """The request's litellm_metadata mapping, created on the request when it carries none. @@ -274,6 +282,7 @@ async def create_batch( data: dict = {} try: data = await read_request_body(request=request) # rebind-ok: pre-existing rebinding on a rename-only line + _raise_when_routing_headers_disagree(request, data.get("model")) verbose_proxy_logger.debug( "Request received by LiteLLM:\n%s", json.dumps(data, indent=4), diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 74ee87c772b..e52f11641b0 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1663,19 +1663,26 @@ def _totals_to_zero(response_cost: float | str | None) -> bool: return False +def _is_cache_hit(litellm_logging_obj: LiteLLMLoggingObj) -> bool: + caching_details: Final = litellm_logging_obj.caching_details + return caching_details is not None and caching_details.get("cache_hit") is True + + def _get_cost_breakdown_from_logging_obj( litellm_logging_obj: LiteLLMLoggingObj | None, response_cost: float | str | None = None, ) -> CostBreakdownHeaderValues: """Extract discount, margin, and per-component cost information from logging object's cost breakdown. - A non-inference call that priced at zero never records a breakdown, so its components are - reported as zero here. Any such call that did price normally (retrieving a background response, - and the cost poller's read of one) reports the breakdown it stored, or nothing at all when the - breakdown has not landed yet. + A cache hit reports every component as zero. A non-inference call that priced at zero never + records a breakdown, so its components are reported as zero too. Any such call that did price + normally (retrieving a background response, and the cost poller's read of one) reports the + breakdown it stored, or nothing at all when the breakdown has not landed yet. """ if not litellm_logging_obj or not hasattr(litellm_logging_obj, "cost_breakdown"): return CostBreakdownHeaderValues() + if _is_cache_hit(litellm_logging_obj): + return _ZERO_COST_BREAKDOWN cost_breakdown: Final = litellm_logging_obj.cost_breakdown if not cost_breakdown: @@ -1705,6 +1712,13 @@ def _header_value(value: float | None) -> str | None: return str(value) if value is not None else None +def _response_cost_header_value(hidden_params: Mapping[str, object]) -> float | str: + if hidden_params.get("cache_hit") is True: + return 0.0 + response_cost: Final = hidden_params.get("response_cost") + return response_cost if isinstance(response_cost, (int, float, str)) and response_cost else "" + + def cost_headers( response_cost: float | str | None, litellm_logging_obj: LiteLLMLoggingObj | None ) -> Mapping[str, str | None]: @@ -2084,18 +2098,12 @@ class ProxyBaseLLMRequestProcessing: Build LiteLLM proxy response headers for routes that call the LLM directly (e.g. Google native :generateContent) instead of base_process_llm_request. """ - if isinstance(response, dict): - hidden_params = get_hidden_params_dict(response) - else: - hidden_params = getattr(response, "_hidden_params", None) or {} - if not isinstance(hidden_params, dict): - hidden_params = {} - + hidden_params: Final = get_hidden_params_dict(response) model_id: Final = ProxyBaseLLMRequestProcessing._get_model_id_from_response(hidden_params, request_data) cache_key: Final = hidden_params.get("cache_key", None) or "" api_base: Final = hidden_params.get("api_base", None) or "" - response_cost: Final = hidden_params.get("response_cost", None) or "" + response_cost: Final = _response_cost_header_value(hidden_params) fastest_response_batch_completion: Final = hidden_params.get("fastest_response_batch_completion", None) additional_headers: Final = hidden_params.get("additional_headers", {}) or {} @@ -2640,7 +2648,7 @@ class ProxyBaseLLMRequestProcessing: cache_key=hidden_params.get("cache_key") or "", api_base=hidden_params.get("api_base") or "", version=version, - response_cost=hidden_params.get("response_cost") or "", + response_cost=_response_cost_header_value(hidden_params), model_region=getattr(user_api_key_dict, "allowed_model_region", ""), fastest_response_batch_completion=hidden_params.get("fastest_response_batch_completion"), request_data=self.data, @@ -2685,13 +2693,17 @@ class ProxyBaseLLMRequestProcessing: hold the attribute at all, and Google :generateContent carries ``_hidden_params`` but no synchronously-populated ``response_cost``. In both cases the cost is read back from the logging object instead, recomputing from - the same calculator only when it has not been stored yet. + the same calculator only when it has not been stored yet. A response served + from the cache costs 0, which the calculator cannot tell from a fresh call, and any + other zero or unknown cost comes back empty so no cost header is sent. """ + if _is_cache_hit(logging_obj): + return 0.0 stored_cost: Final = logging_obj.model_call_details.get("response_cost") if isinstance(stored_cost, (int, float)): - return float(stored_cost) + return float(stored_cost) or "" recomputed_cost: Final = logging_obj.response_cost_calculator(result=response) - return recomputed_cost if isinstance(recomputed_cost, (int, float)) else "" + return recomputed_cost or "" if isinstance(recomputed_cost, (int, float)) else "" def _debug_log_request_payload(self) -> None: """Log request payload at DEBUG level, truncating if too large.""" @@ -2900,7 +2912,7 @@ class ProxyBaseLLMRequestProcessing: cache_key, api_base, response_cost = ( hidden_params.get("cache_key", None) or "", hidden_params.get("api_base", None) or "", - hidden_params.get("response_cost", None) or "", + _response_cost_header_value(hidden_params), ) fastest_response_batch_completion, additional_headers = ( hidden_params.get("fastest_response_batch_completion", None), @@ -3170,7 +3182,7 @@ class ProxyBaseLLMRequestProcessing: recover_response_cost: Final = not response_cost and hidden_params.get("response_cost") is None computed_cost_for_headers: Final = ( - self._response_cost_from_logging_obj(response=response, logging_obj=logging_obj) or "" + self._response_cost_from_logging_obj(response=response, logging_obj=logging_obj) if recover_response_cost else response_cost ) diff --git a/litellm/proxy/common_utils/openai_error_payload.py b/litellm/proxy/common_utils/openai_error_payload.py index f092f9637f8..2f01dbe11f7 100644 --- a/litellm/proxy/common_utils/openai_error_payload.py +++ b/litellm/proxy/common_utils/openai_error_payload.py @@ -42,6 +42,10 @@ def openai_error_type(exc: object, status_code: int) -> str: carried: Final = attribute_of(exc, "type") if isinstance(carried, str) and carried != STRINGIFIED_NONE: return carried + return openai_error_type_for_status(status_code) + + +def openai_error_type_for_status(status_code: int) -> str: mapped: Final = _OPENAI_ERROR_TYPE_BY_STATUS.get(status_code) if mapped is not None: return mapped diff --git a/litellm/proxy/common_utils/token_count_settings.py b/litellm/proxy/common_utils/token_count_settings.py new file mode 100644 index 00000000000..6f805616820 --- /dev/null +++ b/litellm/proxy/common_utils/token_count_settings.py @@ -0,0 +1,164 @@ +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from types import MappingProxyType +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +import litellm +from litellm.anthropic_interface.exceptions.exception_mapping_utils import AnthropicExceptionMapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.proxy._types import ProxyException +from litellm.types.token_count import TokenCountSourceFields, TokenCountTokenizer +from litellm.types.utils import TokenCountResponse + +CANNOT_COUNT_MODEL_STATUSES: Final = frozenset({400, 404}) +TOKENIZER_LOAD_WAIT_SECONDS: Final = 5.0 +TOKENIZER_LOAD_REUSE_SECONDS: Final = 60.0 +TOKENIZER_LOAD_MAX_RUNNING: Final = 4 + + +class _UpstreamErrorDetail(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + message: str + + +class _UpstreamErrorEnvelope(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + type: Literal["error"] + error: _UpstreamErrorDetail + + +def _joined(message: str, hint: str) -> str: + return f"{message.rstrip().rstrip('.')}. {hint}" + + +def _with_hint(message: str, hint: str) -> str: + try: + envelope: Final = _UpstreamErrorEnvelope.model_validate_json(message) + except ValidationError: + return _joined(AnthropicExceptionMapping.extract_error_message(message), hint) + detail: Final = envelope.error.model_copy(update={"message": _joined(envelope.error.message, hint)}) + return envelope.model_copy(update={"error": detail}).model_dump_json() + + +@dataclass(frozen=True, slots=True) +class TokenCountModel: + model: str + region: str | None + + +@dataclass(frozen=True, slots=True) +class TokenCountSettings: + framing: int | None + count_model: TokenCountModel | None + tokenizer: TokenCountTokenizer | None = None + + +@dataclass(frozen=True, slots=True) +class ProviderCountFailure: + status_code: int + message: str + rejected_before_provider_call: bool = False + + @property + def cannot_count_model(self) -> bool: + return not self.rejected_before_provider_call and self.status_code in CANNOT_COUNT_MODEL_STATUSES + + def with_hint(self, hint: str) -> "ProviderCountFailure": + return replace(self, message=_with_hint(self.message, hint)) + + def to_proxy_exception(self) -> ProxyException: + return ProxyException( + message=self.message, + type="token_counting_error", + param="model", + code=self.status_code, + ) + + +def cost_map_entry(model: str, custom_llm_provider: str | None = None) -> Mapping[str, object]: + try: + return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models + return MappingProxyType({}) + + +def _framing(value: object) -> int | None: + match value: + case bool(): + return None + case int() if value >= 0: + return value + case float() if value.is_integer() and value >= 0: + return int(value) + case _: + return None + + +def _non_blank(value: object) -> str | None: + return value if isinstance(value, str) and value.strip() else None + + +def _count_model(value: Mapping[str, object]) -> TokenCountModel | None: + model: Final = _non_blank(value.get("model")) + return None if model is None else TokenCountModel(model=model, region=_non_blank(value.get("region"))) + + +def _geography(region: str) -> str: + return region.rsplit("-", 2)[0] + + +def _within_deployment_geography( + count_model: TokenCountModel | None, deployment_region: Callable[[], str] +) -> TokenCountModel | None: + if count_model is None or count_model.region is None or count_model.model.startswith("global."): + return count_model + if _geography(deployment_region()) == _geography(count_model.region): + return count_model + return replace(count_model, region=None) + + +def _resolved_count_model( + configured: object, default: object, deployment_region: Callable[[], str] +) -> TokenCountModel | None: + configured_mapping: Final = as_str_mapping(configured) + if configured_mapping is not None: + return _count_model(configured_mapping) + default_mapping: Final = as_str_mapping(default) + return ( + None + if default_mapping is None + else _within_deployment_geography(_count_model(default_mapping), deployment_region) + ) + + +def _tokenizer(value: object) -> TokenCountTokenizer | None: + match value: + case "kimi_k3": + return "kimi_k3" + case _: + return None + + +def resolve_token_count_settings( + configured: Mapping[str, object], default: Mapping[str, object], deployment_region: Callable[[], str] +) -> TokenCountSettings: + framing: Final = _framing(configured.get("token_count_framing")) + return TokenCountSettings( + framing=framing if framing is not None else _framing(default.get("token_count_framing")), + count_model=_resolved_count_model( + configured.get("token_count_model"), default.get("token_count_model"), deployment_region + ), + tokenizer=_tokenizer(default.get("token_count_tokenizer")), + ) + + +def count_source_fields(response: TokenCountResponse) -> TokenCountSourceFields: + match response.count_source: + case None: + return {} + case "token_count_model": + return {"litellm_count_source": "token_count_model", "litellm_count_model": response.model_used} + case source: + return {"litellm_count_source": source} diff --git a/litellm/proxy/google_endpoints/endpoints.py b/litellm/proxy/google_endpoints/endpoints.py index d382ff53ffb..3d1a9ab4cfe 100644 --- a/litellm/proxy/google_endpoints/endpoints.py +++ b/litellm/proxy/google_endpoints/endpoints.py @@ -21,6 +21,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg _read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export read_request_body, ) +from litellm.proxy.common_utils.token_count_settings import count_source_fields from litellm.types.llms.vertex_ai import TokenCountDetailsResponse @@ -309,10 +310,12 @@ async def google_count_tokens(request: Request, model_name: str): ProxyException(message="Internal server error", type="internal_server_error", param=None, code=500), ) original_response: Final[dict] = token_response.original_response or {} - return TokenCountDetailsResponse( - totalTokens=original_response.get("totalTokens") or token_response.total_tokens or 0, - promptTokensDetails=original_response.get("promptTokensDetails", []), - ) + counted: Final[TokenCountDetailsResponse] = { + "totalTokens": original_response.get("totalTokens") or token_response.total_tokens or 0, + "promptTokensDetails": original_response.get("promptTokensDetails", []), + **count_source_fields(token_response), + } + return counted # ============================================================ diff --git a/litellm/proxy/guardrails/anthropic_sse.py b/litellm/proxy/guardrails/anthropic_sse.py index c3a6f259f95..2baeabdce7b 100644 --- a/litellm/proxy/guardrails/anthropic_sse.py +++ b/litellm/proxy/guardrails/anthropic_sse.py @@ -8,8 +8,13 @@ hook scan such a stream, and re-emit it when the guardrail rewrote the response. from __future__ import annotations import json +from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final +from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.llms.anthropic.pass_through.messages.utils import filtered_safeguard_results, redacted_stop_details +from litellm.llms.anthropic.pass_through.stream_assembly import anthropic_message_delta_fields from litellm.llms.anthropic.pass_through.stream_assembly import ( assemble_anthropic_sse_stream as assemble_anthropic_sse_stream, # noqa: PLC0414 # public re-export ) @@ -50,7 +55,62 @@ from litellm.llms.anthropic.pass_through.stream_assembly import ( # noqa: E402 ) -def anthropic_sse_chunks_from_response(assembled: ModelResponse) -> tuple[bytes, ...]: +def _tool_use_inputs(content: Sequence[object]) -> Mapping[str, object]: + blocks: Final = (block for raw in content if (block := as_str_mapping(raw)) is not None) + return MappingProxyType( + { + tool_use_id: block.get("input") + for block in blocks + if block.get("type") == "tool_use" and isinstance(tool_use_id := block.get("id"), str) + } + ) + + +def _unchanged_tool_use_ids(source_content: Sequence[object], delivered_content: Sequence[object]) -> frozenset[str]: + source_inputs: Final = _tool_use_inputs(source_content) + return frozenset( + tool_use_id + for tool_use_id, tool_input in _tool_use_inputs(delivered_content).items() + if tool_use_id in source_inputs and source_inputs[tool_use_id] == tool_input + ) + + +def _rewritten_message_delta_fields( + source_chunks: Sequence[object], unchanged_tool_use_ids: frozenset[str], keep_explanations: bool +) -> Mapping[str, object]: + source_fields: Final = anthropic_message_delta_fields(source_chunks) + stop_details: Final = source_fields.get("stop_details") + safeguard_results: Final = source_fields.get("safeguard_results") + return MappingProxyType( + { + **( + {"stop_details": redacted_stop_details(stop_details, keep_explanations)} + if stop_details is not None + else {} + ), + **( + { + "safeguard_results": filtered_safeguard_results( + safeguard_results, unchanged_tool_use_ids, keep_explanations + ) + } + if safeguard_results is not None + else {} + ), + } + ) + + +def anthropic_sse_chunks_from_response( + assembled: ModelResponse, source_chunks: Sequence[object] = (), *, keep_explanations: bool = False +) -> tuple[bytes, ...]: + """Re-emit a rewritten response as the SSE events of the ``source_chunks`` stream it replaces. + + ``message_delta`` carries the source stream's ``stop_details`` and ``safeguard_results`` and + nothing the OpenAI round trip made up, with a verdict kept only for a tool call delivered with + the input the classifier judged. Their free-text ``explanation`` was written about the text the + guardrail rewrote, so it is dropped unless the caller rewrites no text (``keep_explanations``). + """ from litellm.llms.anthropic.pass_through.adapters.transformation import ( LiteLLMAnthropicMessagesAdapter, ) @@ -58,7 +118,22 @@ def anthropic_sse_chunks_from_response(assembled: ModelResponse) -> tuple[bytes, FakeAnthropicMessagesStreamIterator, ) - anthropic_response: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic( - response=assembled + adapter: Final = LiteLLMAnthropicMessagesAdapter() + anthropic_response: Final = adapter.translate_openai_response_to_anthropic(response=assembled) + source_response: Final = assemble_anthropic_sse_stream(source_chunks) + source_content: Final = ( + adapter.translate_openai_response_to_anthropic(response=source_response).get("content") + if source_response is not None + else None + ) + unchanged_tool_use_ids: Final = _unchanged_tool_use_ids( + source_content or (), anthropic_response.get("content") or () + ) + return tuple( + FakeAnthropicMessagesStreamIterator( + response=anthropic_response, + message_delta_fields=_rewritten_message_delta_fields( + source_chunks, unchanged_tool_use_ids, keep_explanations + ), + ).chunks ) - return tuple(FakeAnthropicMessagesStreamIterator(response=anthropic_response).chunks) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 94fab744a48..12b8e728b4e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -3061,7 +3061,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################################### if raw_sse: for sse_chunk in ( - anthropic_sse_chunks_from_response(assembled_model_response) + anthropic_sse_chunks_from_response(assembled_model_response, all_chunks) if model_response_text(assembled_model_response) != pre_guardrail_text else all_chunks ): diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py index 242354ac417..44705c4d166 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py @@ -44,6 +44,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" litellm_params, optional_params, "streaming_buffer_until_moderated" ), timeout=litellm_params.timeout, + num_retries=litellm_params.num_retries, ) litellm.logging_callback_manager.add_litellm_callback(_generic_guardrail_api_callback) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 93b08831d84..199da7df30d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -22,6 +22,7 @@ from litellm.integrations.custom_guardrail import ( log_guardrail_information, ) from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, get_async_httpx_client, httpxSpecialProvider, ) @@ -63,6 +64,21 @@ _HEADER_VALUE_ALLOWLIST: Final = frozenset( # Placeholder for headers that exist but are not on the allowlist (we don't expose their value). _HEADER_PRESENT_PLACEHOLDER: Final = "[present]" +_AFTER_CONNECT_FAILURES: Final = ( + httpx.ReadTimeout, + httpx.WriteTimeout, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, +) + + +def _after_connect_failure(error: Timeout | httpx.TransportError) -> httpx.TransportError | None: + """AsyncHTTPHandler re-raises connect and read timeouts as the same litellm Timeout, so the phase comes from + the httpx error it was raised while handling.""" + origin: Final = error if isinstance(error, httpx.TransportError) else error.__cause__ or error.__context__ + return origin if isinstance(origin, _AFTER_CONNECT_FAILURES) else None + def _header_value_allowed( header_name: str, @@ -81,7 +97,7 @@ def _header_value_allowed( def _sanitize_inbound_headers( - headers: object, + headers: Mapping[object, object] | None, extra_allowlist: set[str] | None = None, ) -> dict[str, str] | None: """ @@ -254,9 +270,18 @@ class GenericGuardrailAPI(CustomGuardrail): streaming_sampling_rate: int | None = None, streaming_transform_mode: Literal["block_only", "incremental_diff"] | None = None, streaming_buffer_until_moderated: bool | None = None, + num_retries: int | None = None, + async_handler: AsyncHTTPHandler | None = None, + fresh_handler_factory: Callable[[], AsyncHTTPHandler] | None = None, **kwargs, ): - self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) + self.async_handler = async_handler or get_async_httpx_client( + llm_provider=httpxSpecialProvider.GuardrailCallback + ) + self.fresh_handler_factory: Callable[[], AsyncHTTPHandler] = fresh_handler_factory or ( + lambda: AsyncHTTPHandler(timeout=self.async_handler.timeout) + ) + self.num_retries: int = num_retries or 0 self.headers = headers or {} self.extra_headers = extra_headers or [] @@ -384,12 +409,9 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs.update(inputs) return return_inputs - def _build_request_headers(self) -> dict: + def _build_request_headers(self) -> Mapping[str, str]: """Build HTTP headers for the guardrail API request.""" - headers: Final = {"Content-Type": "application/json"} - if self.headers: - headers.update(self.headers) - return headers + return {"Content-Type": "application/json", **self.headers} def _request_body( self, @@ -463,6 +485,35 @@ class GenericGuardrailAPI(CustomGuardrail): verbose_proxy_logger.error("Generic Guardrail API: failed to make request: %s", str(error)) raise Exception(f"Generic Guardrail API failed: {error}") + async def _post_check( + self, + payload: Mapping[str, object], + headers: Mapping[str, str], + retries_left: int, + fresh: bool = False, + ) -> httpx.Response: + handler: Final = self.fresh_handler_factory() if fresh else self.async_handler + try: + return await handler.post( + url=self.api_base, json=dict(payload), headers=dict(headers), timeout=self.timeout + ) + except (Timeout, httpx.TransportError) as error: + failure: Final = _after_connect_failure(error) + if retries_left < 1 or failure is None: + raise + verbose_proxy_logger.warning( + "Generic Guardrail API check timed out or lost its connection, retrying on a fresh connection. " + "guardrail_name=%s api_base=%s error_type=%s retries_left=%d", + getattr(self, "guardrail_name", None), + self.api_base, + type(failure).__name__, + retries_left - 1, + ) + finally: + if fresh: + await handler.close() + return await self._post_check(payload, headers, retries_left - 1, fresh=True) + @log_guardrail_information async def apply_guardrail( self, @@ -508,12 +559,8 @@ class GenericGuardrailAPI(CustomGuardrail): request_body: Final = request_data.get("body") or {} # Merge additional provider specific params from config and dynamic params - additional_params: Final = {**self.additional_provider_specific_params} - - # Get dynamic params from request if available - dynamic_params: Final = self.get_guardrail_dynamic_request_body_params(request_body) - if dynamic_params: - additional_params.update(dynamic_params) + dynamic_params: Final[Mapping[str, object]] = self.get_guardrail_dynamic_request_body_params(request_body) or {} + additional_params: Final = {**self.additional_provider_specific_params, **dynamic_params} # Extract user API key metadata user_metadata: Final = self._extract_user_api_key_metadata(request_data) @@ -546,11 +593,10 @@ class GenericGuardrailAPI(CustomGuardrail): # Make the API request # Use mode="json" to ensure all iterables are converted to lists - response: Final = await self.async_handler.post( - url=self.api_base, - json=dict(self._request_body(guardrail_request, structured_messages)), - headers=headers, - timeout=self.timeout, + response: Final = await self._post_check( + self._request_body(guardrail_request, structured_messages), + headers, + self.num_retries, ) response.raise_for_status() diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index e590e5c2e43..b9871b1e276 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1263,7 +1263,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): # Return sanitized stream if surface is _StreamSurface.ANTHROPIC_MESSAGES: - for sse_chunk in anthropic_sse_chunks_from_response(assembled_response): + for sse_chunk in anthropic_sse_chunks_from_response(assembled_response, all_chunks): yield sse_chunk return mock_response: Final = MockResponseIterator(model_response=assembled_response) diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 01a9259203a..cadc9ca689b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -16,6 +16,7 @@ from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Awaita from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime +from itertools import accumulate, count, pairwise from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast import aiohttp @@ -182,6 +183,59 @@ async def _coalesce_first_sse_frame(stream: AsyncIterator[object]) -> AsyncGener yield chunk +@dataclass(frozen=True, slots=True) +class _Detection: + entity_type: str + start: int + end: int + score: float + + +def _strictly_contains(outer: _Detection, inner: _Detection) -> bool: + return ( + outer.start <= inner.start and inner.end <= outer.end and (outer.start, outer.end) != (inner.start, inner.end) + ) + + +def _collapse_run(run: Sequence[_Detection]) -> _Detection: + label: Final = max( + (detection for detection in run if not any(_strictly_contains(other, detection) for other in run)), + key=lambda detection: (detection.score, detection.end - detection.start, detection.entity_type), + ) + return _Detection(label.entity_type, run[0].start, max(detection.end for detection in run), label.score) + + +def _to_detection(item: PresidioAnalyzeResponseItem) -> _Detection: + return _Detection( + str(item.get("entity_type")), + int(item.get("start") or 0), + int(item.get("end") or 0), + float(item.get("score") or 0.0), + ) + + +def _resolve_overlapping_spans(analyze_results: Sequence[PresidioAnalyzeResponseItem]) -> tuple[_Detection, ...]: + """ + Collapse each run of overlapping detections into one detection covering the whole run. + + The run takes its entity type from its highest-scoring detection that no other + detection in the run strictly contains, the same containment rule Presidio's + anonymizer applies, so a phone number with a date-shaped fragment inside it stays + a PHONE_NUMBER. Runs come back sorted by start and never overlap. + """ + detections: Final = sorted( + (detection for detection in map(_to_detection, analyze_results) if detection.start < detection.end), + key=lambda detection: detection.start, + ) + run_ends: Final = tuple(accumulate((detection.end for detection in detections), max)) + boundaries: Final = ( + 0, + *(index for index in range(1, len(detections)) if detections[index].start >= run_ends[index - 1]), + len(detections), + ) + return tuple(_collapse_run(detections[first:last]) for first, last in pairwise(boundaries) if first < last) + + class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): user_api_key_cache = None ad_hoc_recognizers: list[str] | None = None @@ -822,31 +876,25 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): request_data["metadata"] = {} if "pii_tokens" not in request_data["metadata"]: request_data["metadata"]["pii_tokens"] = {} - pii_tokens: Final = request_data["metadata"]["pii_tokens"] + pii_tokens: Final = cast("dict[str, str]", request_data["metadata"]["pii_tokens"]) # cast-ok: this hook's map - # Assign sequence numbers in forward (left-to-right) order so - # that is the first entity in the text, etc. - sorted_forward: Final = sorted(analyze_results, key=lambda x: x["start"]) - seq_map: Final = {} - for idx, ar in enumerate(sorted_forward, start=1): - seq_map[(ar["start"], ar["end"])] = idx + runs: Final = _resolve_overlapping_spans(analyze_results) + # Every message and content part numbers its runs from 1 into this one map, so a + # token another part already holds for a different value takes a spare number. + # No token in the map is numbered above its size, so spares start above both. + spare_numbers: Final = count(max(len(pii_tokens), len(runs)) + 1) - # Apply replacements in reverse order by start position so - # that replacing later spans first does not shift earlier - # coordinates in the original text. - for ar in reversed(sorted_forward): - start = ar["start"] - end = ar["end"] - entity_type = ar["entity_type"] - replacement = f"<{entity_type}>" - seq = seq_map[(start, end)] - if replacement.endswith(">"): - replacement = f"{replacement[:-1]}_{seq}>" - else: - replacement = f"{replacement}_{seq}" - pii_tokens[replacement] = text[start:end] - new_text = new_text[:start] + replacement + new_text[end:] - masked_entity_count[entity_type] = masked_entity_count.get(entity_type, 0) + 1 + for seq, run in reversed(tuple(enumerate(runs, start=1))): + original = text[run.start : run.end] + numbered = f"<{run.entity_type}_{seq}>" + replacement = ( + numbered + if pii_tokens.get(numbered, original) == original + else f"<{run.entity_type}_{next(spare_numbers)}>" + ) + pii_tokens[replacement] = original + new_text = new_text[: run.start] + replacement + new_text[run.end :] + masked_entity_count[run.entity_type] = masked_entity_count.get(run.entity_type, 0) + 1 return new_text async def anonymize_text( @@ -1532,7 +1580,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): await self._process_response_for_pii(response=assembled, request_data=request_data, mode="mask") if model_response_text(assembled) == original_text: return chunks - return anthropic_sse_chunks_from_response(assembled) + return anthropic_sse_chunks_from_response(assembled, chunks) @staticmethod def _unmask_sse_bytes_chunk(chunk: bytes, pii_tokens: dict[str, str]) -> bytes: diff --git a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py index ebd50c8cd39..fa92a0c90e0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py +++ b/litellm/proxy/guardrails/guardrail_hooks/tool_permission.py @@ -971,7 +971,7 @@ class ToolPermissionGuardrail(CustomGuardrail): return self._modify_response_with_permission_errors(anthropic_response, anthropic_denials) - for sse_chunk in anthropic_sse_chunks_from_response(anthropic_response): + for sse_chunk in anthropic_sse_chunks_from_response(anthropic_response, all_chunks, keep_explanations=True): yield sse_chunk def _check_assembled_stream( diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index 14ec69a2059..0d2f8dc9a0c 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -551,10 +551,13 @@ async def _run_model_health_check(model: dict): model_info, litellm_params ) timeout: Final = model_info.get("health_check_timeout") or HEALTH_CHECK_TIMEOUT_SECONDS + probe_params: Final = ( + {**litellm_params, "model_info": {"mode": model_info["mode"]}} if "mode" in model_info else litellm_params + ) return await run_with_timeout( litellm.ahealth_check( - litellm_params, + probe_params, mode=mode, prompt=DEFAULT_HEALTH_CHECK_PROMPT, input=["test from litellm"], diff --git a/litellm/proxy/hooks/batch_rate_limiter.py b/litellm/proxy/hooks/batch_rate_limiter.py index 5f214816578..94762232e0b 100644 --- a/litellm/proxy/hooks/batch_rate_limiter.py +++ b/litellm/proxy/hooks/batch_rate_limiter.py @@ -22,6 +22,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timezone from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, TypeAlias +from urllib.parse import parse_qs, urlsplit from fastapi import HTTPException from pydantic import Field, TypeAdapter, ValidationError @@ -99,6 +100,28 @@ else: _BATCH_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) _WINDOW_START_ADAPTER: Final[TypeAdapter[int | float | str | None]] = TypeAdapter(int | float | str | None) + +def _batch_create_routing_model(data: Mapping[str, object]) -> str | None: + body_model: Final = data.get("model") + if isinstance(body_model, str) and body_model: + return body_model + proxy_server_request: Final = data.get("proxy_server_request") + if not isinstance(proxy_server_request, Mapping): + return None + request_line: Final = _BATCH_BODY_ADAPTER.validate_python(proxy_server_request) + url: Final = request_line.get("url") + query_model: Final = ( + parse_qs(urlsplit(url).query, keep_blank_values=True).get("model", [None])[-1] if isinstance(url, str) else None + ) + if query_model: + return query_model + headers: Final = request_line.get("headers") + header_model: Final = ( + _BATCH_BODY_ADAPTER.validate_python(headers).get("x-litellm-model") if isinstance(headers, Mapping) else None + ) + return header_model if isinstance(header_model, str) and header_model else None + + IncrementAmounts: TypeAlias = dict[Literal["requests", "tokens"], int] @@ -509,6 +532,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): file_id: str, custom_llm_provider: str, data: dict, + user_api_key_dict: UserAPIKeyAuth | None = None, ) -> tuple[str, dict[str, Any]]: """ Map proxy-facing file IDs to provider file IDs and credentials. @@ -519,8 +543,10 @@ class _PROXY_BatchRateLimiter(CustomLogger): """ from litellm.proxy.openai_files_endpoints.common_utils import ( decode_model_from_file_id, + explicit_request_credentials, get_credentials_for_model, get_original_file_id, + get_team_provider_credentials, ) from litellm.proxy.proxy_server import llm_router @@ -546,22 +572,36 @@ class _PROXY_BatchRateLimiter(CustomLogger): pass return get_original_file_id(file_id), fetch_kwargs - request_model: Final = data.get("model") - if isinstance(request_model, str) and request_model and llm_router is not None: - try: - credentials = get_credentials_for_model( - llm_router=llm_router, - model_id=request_model, - operation_context="batch input file read (rate limiting)", - ) - fetch_kwargs.update(extract_file_access_credentials(credentials)) - fetch_kwargs["model"] = request_model - provider = credentials.get("custom_llm_provider") - if provider: - fetch_kwargs["custom_llm_provider"] = provider - except HTTPException: - pass + request_model: Final = _batch_create_routing_model(data) + if request_model is not None: + if llm_router is not None: + try: + credentials = get_credentials_for_model( + llm_router=llm_router, + model_id=request_model, + operation_context="batch input file read (rate limiting)", + ) + fetch_kwargs.update(extract_file_access_credentials(credentials)) + fetch_kwargs["model"] = request_model + provider = credentials.get("custom_llm_provider") + if provider: + fetch_kwargs["custom_llm_provider"] = provider + except HTTPException: + pass + return file_id, fetch_kwargs + team_credentials: Final = ( + get_team_provider_credentials( + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + custom_llm_provider=custom_llm_provider, + ) + if user_api_key_dict is not None + else None + ) + fetch_kwargs.update( + extract_file_access_credentials({**(team_credentials or {}), **explicit_request_credentials(data)}) + ) return file_id, fetch_kwargs async def _reserve_batch_enqueued_tokens( @@ -912,6 +952,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): file_id=file_id, custom_llm_provider=custom_llm_provider, data=data or {}, + user_api_key_dict=user_api_key_dict, ) # For non-managed files, use the standard litellm.afile_content file_content = await litellm.afile_content( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 3ad5caade1b..29fe636f5e4 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -41,6 +41,7 @@ from litellm.constants import ( SESSION_ID_GENERATED_METADATA_KEY, SESSION_ID_OMITTED_METADATA_KEY, TYPESAFE_DANGER_ZONE_HEADER_PREFIX, + VECTOR_STORE_CALLER_METADATA_KWARG, X_LITELLM_DISABLE_CALLBACKS, ) from litellm.integrations.custom_guardrail import without_server_streaming_classification @@ -2160,7 +2161,7 @@ def refresh_proxy_server_request_body_snapshot( if not isinstance(proxy_server_request, dict): return _body_snapshot_exclude: Final = ( - frozenset({"secret_fields", "proxy_server_request", "litellm_logging_obj"}) + frozenset({"secret_fields", "proxy_server_request", "litellm_logging_obj", VECTOR_STORE_CALLER_METADATA_KWARG}) | _TRANSPORT_ONLY_CREDENTIAL_KEYS | _CALLBACK_CREDENTIAL_KEYS ) diff --git a/litellm/proxy/management/users/endpoints.py b/litellm/proxy/management/users/endpoints.py new file mode 100644 index 00000000000..62dc3a2a518 --- /dev/null +++ b/litellm/proxy/management/users/endpoints.py @@ -0,0 +1,20 @@ +from typing import Annotated, Final + +from fastapi import APIRouter, Depends + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management.users.exceptions import current_user_not_found +from litellm.proxy.management.users.schemas import CurrentUserResponse +from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +@router.get("/users/me", include_in_schema=False) +async def get_current_user( + caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> CurrentUserResponse: + if caller.user_id is None: + raise current_user_not_found() + return CurrentUserResponse(user_id=caller.user_id, user_role=caller.user_role, user_email=caller.user_email) diff --git a/litellm/proxy/management/users/exceptions.py b/litellm/proxy/management/users/exceptions.py new file mode 100644 index 00000000000..2c1e58c9283 --- /dev/null +++ b/litellm/proxy/management/users/exceptions.py @@ -0,0 +1,13 @@ +from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + + +def current_user_not_found() -> ManagementProblem: + return ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}user-not-found", + title="User not found", + status=404, + detail="The key used for this request is not bound to a user.", + ) + ) diff --git a/litellm/proxy/management/users/schemas.py b/litellm/proxy/management/users/schemas.py new file mode 100644 index 00000000000..243e970c3a1 --- /dev/null +++ b/litellm/proxy/management/users/schemas.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict + +from litellm.proxy._types import LitellmUserRoles + + +class CurrentUserResponse(BaseModel): + model_config = ConfigDict(frozen=True) + + user_id: str + user_role: LitellmUserRoles | None + user_email: str | None diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index b29b7fe5dd5..a02d989cc28 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -4,6 +4,7 @@ from typing import Final from fastapi import APIRouter +from litellm.proxy.management.users.endpoints import router as current_user_router from litellm.proxy.management_endpoints.management_v1.budgets import ( router as budgets_router, ) @@ -22,5 +23,6 @@ router.include_router(budgets_router) router.include_router(spend_logs_router) router.include_router(teams_router) router.include_router(users_router) +router.include_router(current_user_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index d014ff6cf07..a014b9cf8a5 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -2517,7 +2517,9 @@ if MCP_AVAILABLE: resolved_redirect_uri: Final = f"{get_request_base_url(request)}/callback" if sealed_code else redirect_uri caller_client_id: Final = sealed_code.client_id if sealed_code else client_id caller_client_secret: Final = sealed_code.client_secret if sealed_code else client_secret - resolved_client_id: Final = mcp_server.client_id or caller_client_id or "" + resolved_client_id: Final = ( + caller_client_id if sealed_code else (mcp_server.client_id or caller_client_id or "") + ) if not resolved_client_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -2541,6 +2543,7 @@ if MCP_AVAILABLE: refresh_token=refresh_token, scope=scope, client_token_endpoint_auth_method=sealed_code.token_endpoint_auth_method if sealed_code else None, + sealed_client=sealed_code is not None, ) @router.post( diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 48306cc8d7a..2e07694876b 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -4,6 +4,7 @@ import mimetypes import re from collections.abc import Mapping, Sequence from dataclasses import dataclass, field +from datetime import datetime, timezone from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -17,6 +18,7 @@ from typing import ( ) from pydantic import TypeAdapter +from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.batches.batch_utils import batch_cost_is_final from litellm.constants import MAX_FILE_LIST_LIMIT @@ -43,6 +45,7 @@ FILE_LIST_CONTINUATION_CHUNK_SIZE: Final = 500 BATCH_CREATE_HIDDEN_PARAM: Final = "batch_create" LITELLM_EXECUTED_BATCH_ID_PREFIX: Final = "litellm_batch_" +EXPLICIT_CREDENTIAL_KEYS: Final = frozenset({"api_key", "api_base", "litellm_credential_name"}) def validate_file_list_limit(limit: int | None) -> None: @@ -474,10 +477,47 @@ async def get_authorized_credentials_for_model( ) +def _key_allowlist_covers(public_model_name: str, key_model_allowlist: Sequence[str]) -> bool: + from litellm.proxy.auth.auth_checks import model_matches_any_wildcard_pattern_in_list + + if public_model_name in key_model_allowlist: + return True + if model_matches_any_wildcard_pattern_in_list(model=public_model_name, allowed_model_list=key_model_allowlist): + return True + return any( + model_matches_any_wildcard_pattern_in_list(model=key_model, allowed_model_list=(public_model_name,)) + for key_model in key_model_allowlist + ) + + +_CONFIG_FILE_RANK: Final = datetime.min.replace(tzinfo=timezone.utc) +_UNKNOWN_CREATION_TIME: Final = datetime.max.replace(tzinfo=timezone.utc) + + +class _RouterDeployment(TypedDict): + model_name: ReadOnly[NotRequired[str]] + model_info: ReadOnly[Mapping[str, object]] + + +def _team_deployment_rank(model_info: Mapping[str, object], deployment_id: str) -> tuple[datetime, str]: + if model_info.get("db_model") is not True: + return _CONFIG_FILE_RANK, "" + created_at: Final = model_info.get("created_at") + if not isinstance(created_at, datetime): + return _UNKNOWN_CREATION_TIME, deployment_id + return (created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)), deployment_id + + +def _by_team_deployment_rank(order: tuple[tuple[datetime, str], str]) -> tuple[datetime, str]: + return order[0] + + def get_team_provider_credentials( llm_router: Optional["Router"], user_api_key_dict: "UserAPIKeyAuth", custom_llm_provider: str, + *, + include_accessible_deployments: bool = True, ) -> dict | None: """ Resolve upstream credentials for a provider-scoped file operation @@ -490,12 +530,15 @@ def get_team_provider_credentials( global one. 2. Fallback: any deployment the caller is granted access to for this provider, expanding wildcard routes and the all-proxy-models sentinel. + Skipped when ``include_accessible_deployments`` is False. Credential lookup is scoped to both the team's allowlist and the key's own model allowlist (``user_api_key_dict.models``), so neither a team nor a restricted key within a team can resolve a provider key for a deployment it isn't authorized to use. A key restricted to an explicit model list - only narrows the team scope; sentinel-bearing keys (all-proxy-models / + only narrows the team scope, matching a wildcard on either side the way + request authorization does (a key allowed ``gpt-4o`` may use the team's + ``openai/*`` deployment); sentinel-bearing keys (all-proxy-models / all-team-models) defer to the team scope instead of widening past it. Returns None when the router is unavailable or no authorized deployment matches, so the caller can fall back to default credential resolution. @@ -531,33 +574,52 @@ def get_team_provider_credentials( if key_is_restricted else () ) - key_model_allowlist_set: Final = frozenset(key_model_allowlist) - def _key_may_use(public_model_name: str | None) -> bool: - if not key_model_allowlist_set: + def _key_may_use(public_model_name: object) -> bool: + if not key_model_allowlist: return True - return public_model_name is not None and public_model_name in key_model_allowlist_set + return isinstance(public_model_name, str) and _key_allowlist_covers(public_model_name, key_model_allowlist) - def _provider_credentials(model_id: str) -> dict | None: + def _provider_credentials(model_id: str) -> dict[str, object] | None: credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=model_id, team_id=team_id) if credentials is not None and credentials.get("custom_llm_provider") == custom_llm_provider: return {key: value for key, value in credentials.items() if key != "model"} return None - # 1. Prefer the team's own BYOK deployment, matched by model_info.team_id. + def _usable_team_deployment_order(deployment: _RouterDeployment) -> tuple[tuple[datetime, str], str] | None: + model_info: Final[Mapping[str, object]] = deployment["model_info"] + if model_info.get("team_id") != team_id: + return None + if not _key_may_use(model_info.get("team_public_model_name") or deployment.get("model_name")): + return None + deployment_id: Final = model_info.get("id") + if not isinstance(deployment_id, str): + return None + return _team_deployment_rank(model_info, deployment_id), deployment_id + + # 1. Prefer the team's own BYOK deployment, matched by model_info.team_id. Config file + # deployments win, kept in file order by the stable sort, then DB ones oldest first, then + # by lowest id, so adding a deployment never moves the team off the one it already uses, + # and every worker picks the same one in any DB load or edit order. if team_id is not None: - for deployment in llm_router.model_list or []: - model_info = deployment.get("model_info") or {} - if model_info.get("team_id") != team_id: - continue - deployment_id = model_info.get("id") - if deployment_id is None: - continue - if not _key_may_use(model_info.get("team_public_model_name") or deployment.get("model_name")): - continue - credentials = _provider_credentials(deployment_id) - if credentials is not None: - return credentials + team_deployment_orders: Final = sorted( + ( + order + for deployment in llm_router.model_list or [] + if (order := _usable_team_deployment_order(deployment)) is not None + ), + key=_by_team_deployment_rank, + ) + team_deployment_ids: Final = tuple(deployment_id for _, deployment_id in team_deployment_orders) + team_credentials: Final = next( + (credentials for credentials in map(_provider_credentials, team_deployment_ids) if credentials is not None), + None, + ) + if team_credentials is not None: + return team_credentials + + if not include_accessible_deployments: + return None # 2. Fall back to deployments the caller is allowed to access. The key's # effective allowlist (sentinels and access groups already expanded by @@ -594,6 +656,42 @@ def get_team_provider_credentials( return None +HTTP_HEADER_VALUE: Final = re.compile(r"[\x21-\x7e]+(?:[ \t]+[\x21-\x7e]+)*") + + +def _non_blank_string(value: object) -> str | None: + return value if isinstance(value, str) and value.strip() else None + + +def _request_api_key(data: Mapping[str, object]) -> str | None: + from fastapi import HTTPException + + import litellm + + api_key: Final = _non_blank_string(data.get("api_key")) + if api_key is None or HTTP_HEADER_VALUE.fullmatch(api_key): + return api_key + if data.get("drop_params") is True or litellm.drop_params is True: + return None + raise HTTPException( + status_code=400, + detail={ + "error": "The request's provider api_key is not a valid HTTP header value. Remove surrounding " + "whitespace and non-ASCII characters, or set drop_params to send the request without it." + }, + ) + + +def explicit_request_credentials(data: Mapping[str, object]) -> Mapping[str, str]: + api_key: Final = _request_api_key(data) + if api_key is None: + return MappingProxyType({}) + api_base: Final = _non_blank_string(data.get("api_base")) + if api_base is None: + return MappingProxyType({"api_key": api_key}) + return MappingProxyType({"api_key": api_key, "api_base": api_base}) + + def apply_team_provider_credentials( data: dict, # mutable-ok: credentials are merged into the request payload in place, same contract as prepare_data_with_credentials llm_router: Optional["Router"], @@ -605,6 +703,9 @@ def apply_team_provider_credentials( ``get_team_provider_credentials`` and merge them into ``data`` in-place. Leaves ``data`` untouched when no authorized deployment matches, so the caller falls back to environment-variable credentials exactly as before. + An ``api_key`` the request carries itself (a provider key forwarded with + ``forward_llm_provider_auth_headers``) replaces the deployment's, and the + request's ``api_base`` replaces the deployment's only alongside that key. """ credentials: Final = get_team_provider_credentials( llm_router=llm_router, @@ -613,7 +714,7 @@ def apply_team_provider_credentials( ) if credentials is None: return - prepare_data_with_credentials(data=data, credentials=credentials) + prepare_data_with_credentials(data=data, credentials={**credentials, **explicit_request_credentials(data)}) def add_internal_model_credentials( diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 464cfee270a..3aef5724bdc 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1087,6 +1087,16 @@ def _guardrail_refusal_status(error: Exception) -> int | None: return None +def _aws_error_type(error: Exception, handled: ProxyException) -> str | None: + """The AWS error type to answer the handled error with: ValidationException for a guardrail block, else the type + the error carried, as LiteLLM's own Bedrock request checks set it. None when the shared handler changed the + guardrail block's status.""" + refusal_status: Final = _guardrail_refusal_status(error) + if refusal_status is not None: + return "ValidationException" if str(refusal_status) == handled.code else None + return next((value for name, value in handled.headers.items() if name.lower() == AWS_ERROR_TYPE_HEADER), None) + + def _bedrock_error_response(error: Exception, handled: HTTPException) -> Response | None: """Bedrock's own error response, for an error Bedrock answered the passthrough request with an AWS error type.""" @@ -1111,9 +1121,9 @@ async def bedrock_passthrough_error_response( user_api_key_dict: UserAPIKeyAuth, proxy_logging_obj: ProxyLoggingType, ) -> Response: - """Run the shared error handling for a Bedrock passthrough request, then answer a guardrail block or an error - Bedrock returned the way Bedrock does, so an AWS SDK reads its code and message. Anything else is raised as the - shared handler raises it.""" + """Run the shared error handling for a Bedrock passthrough request, then answer a guardrail block, an error + Bedrock returned, or an error carrying an AWS error type the way Bedrock does, so an AWS SDK reads its code and + message. Anything else is raised as the shared handler raises it.""" try: await processor.handle_llm_api_exception( e=error, @@ -1126,9 +1136,9 @@ async def bedrock_passthrough_error_response( raise return response except ProxyException as handled: - refusal_status: Final = _guardrail_refusal_status(error) - if refusal_status is not None and str(refusal_status) == handled.code: - raise AwsErrorTypeProxyException(handled, "ValidationException") from handled + aws_error_type: Final = _aws_error_type(error, handled) + if aws_error_type is not None: + raise AwsErrorTypeProxyException(handled, aws_error_type) from handled raise diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27bfd03d7f8..0c7840c92fd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1,6 +1,7 @@ import asyncio import copy import enum +import gc import importlib import inspect import io @@ -54,6 +55,7 @@ from typing import ( ) import anyio +import anyio.lowlevel import websockets import websockets.exceptions from pydantic import BaseModel, ConfigDict, Json, JsonValue, TypeAdapter, ValidationError, with_config @@ -98,6 +100,13 @@ from litellm.litellm_core_utils.litellm_logging import ( from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.token_counter import offload_token_count +from litellm.llms.bedrock.count_tokens.kimi_k3 import ( + HUGGING_FACE_SOURCE, + KimiK3Encodings, + kimi_k3_anthropic_tokens, + kimi_k3_template_tokens, + kimi_k3_tokens, +) from litellm.proxy._types import ( UI_TEAM_ID, AwsErrorTypeProxyException, @@ -148,6 +157,15 @@ from litellm.proxy.common_utils.realtime_utils import ( # noqa: F401, RUF100 # _realtime_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export realtime_request_body, ) +from litellm.proxy.common_utils.token_count_settings import ( + TOKENIZER_LOAD_MAX_RUNNING, + TOKENIZER_LOAD_REUSE_SECONDS, + TOKENIZER_LOAD_WAIT_SECONDS, + ProviderCountFailure, + TokenCountSettings, + cost_map_entry, + resolve_token_count_settings, +) from litellm.proxy.management_helpers.auto_router_availability import AutoRouterCatalogEntry, build_auto_router_catalog from litellm.router_utils.access_windows import access_windows_config_error from litellm.router_utils.add_retry_fallback_headers import ( @@ -303,6 +321,7 @@ from litellm.constants import ( CLI_SSO_SESSION_TTL_SECONDS, DAYS_IN_A_MONTH, DEFAULT_HEALTH_CHECK_INTERVAL, + DEFAULT_MAX_LRU_CACHE_SIZE, DEFAULT_MODEL_CREATED_AT_TIME, GLOBAL_PROXY_SPEND_CACHE_KEY, LITELLM_PROXY_ADMIN_NAME, @@ -336,6 +355,7 @@ from litellm.litellm_core_utils.core_helpers import ( drop_params_flag, get_litellm_metadata_from_kwargs, get_parent_otel_span_from_kwargs, + iter_items, ) from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -953,7 +973,14 @@ from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionSystemMessage, ChatCompletionToolParam, + GenericEvent, HttpxBinaryResponseContent, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponsesAPIResponse, ) from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry from litellm.types.proxy.management_endpoints.model_management_endpoints import ( @@ -985,7 +1012,13 @@ from litellm.types.secret_managers.main import ( KeyManagementSettings, KeyManagementSystem, ) -from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer, RawRequestTypedDict, StandardLoggingPayload +from litellm.types.utils import ( + CredentialItem, + CustomHuggingfaceTokenizer, + RawRequestTypedDict, + SelectTokenizerResponse, + StandardLoggingPayload, +) from litellm.types.utils import ModelInfo as ModelMapInfo from litellm.utils import add_custom_logger_callback_to_specific_event @@ -1746,6 +1779,8 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState async with AsyncExitStack() as admin_mcp_stack: try: await admin_mcp_stack.enter_async_context(admin_mcp_lifespan(app)) + gc.freeze() + verbose_proxy_logger.warning("gc.freeze pinned %d startup objects", gc.get_freeze_count()) yield state finally: if model_info_scheduler is not None and model_info_scheduler.running: @@ -6525,10 +6560,13 @@ class ProxyConfig: environment_variables: Final = config.get("environment_variables", None) if environment_variables: for key, value in environment_variables.items(): + if not isinstance(key, str): + verbose_proxy_logger.warning("Skipping an environment_variables key that is not a string") + continue if key in self._BLOCKED_ENV_KEYS: verbose_proxy_logger.warning("Skipping blocked environment variable key: %s", key) continue - if isinstance(key, str) and is_mcp_stdio_flag_key(key): + if is_mcp_stdio_flag_key(key): verbose_proxy_logger.warning( "Ignoring %s set in the config file. Set it in the proxy's environment instead", MCP_STDIO_ENABLED_ENV_VAR, @@ -6544,7 +6582,7 @@ class ProxyConfig: if isinstance(value, str) and value.startswith("os.environ/"): resolved_secret_string: str | None = get_secret_str(secret_name=value) if resolved_secret_string is not None: - os.environ[key] = resolved_secret_string + self._set_config_environment_variable(key=key, value=resolved_secret_string) else: ######################################################### # handles this scenario: @@ -6553,13 +6591,21 @@ class ProxyConfig: # ARIZE_ENDPOINT: https://otlp.arize.com/v1 # ``` ######################################################### - os.environ[key] = str(value) + self._set_config_environment_variable(key=key, value=str(value)) # check if litellm_license in general_settings if "LITELLM_LICENSE" in environment_variables: _license_check.license_str = os.getenv("LITELLM_LICENSE", None) premium_user = _license_check.is_premium() + def _set_config_environment_variable(self, key: str, value: str) -> None: + if key in os.environ and os.environ[key] != value: + verbose_proxy_logger.warning( + "environment_variables.%s from the config file replaces the value already set in the process environment", + key, + ) + os.environ[key] = value + def _warn_on_misplaced_jwt_keys(self, config: dict) -> tuple[str, ...]: misplaced_jwt_keys = tuple(key for key in ("enable_jwt_auth", "litellm_jwtauth") if key in config) if not misplaced_jwt_keys: @@ -9919,7 +9965,7 @@ def _restamp_streaming_chunk_model( # Note: This warning is intentionally verbose. A mismatch is a useful signal that an # internal provider/deployment identifier is leaking into the public API, and helps # maintainers/operators catch regressions while preserving OpenAI-compatible output. - if not target_model or not isinstance(chunk, (BaseModel, dict)): + if not target_model or not isinstance(chunk, (BaseModel, dict, bytes)): return chunk, model_mismatch_logged # For Azure Model Router, preserve the actual model used in each chunk @@ -9931,9 +9977,21 @@ def _restamp_streaming_chunk_model( if not fallback_was_attempted and request_data.get("fastest_response", False): return chunk, model_mismatch_logged - downstream_model: Final = chunk.get("model") if isinstance(chunk, dict) else getattr(chunk, "model", None) + if isinstance(chunk, bytes): + if request_data.get("_litellm_raw_sse_stream"): + return chunk, model_mismatch_logged + return _restamp_nested_response_model_in_sse_frame(chunk, target_model), model_mismatch_logged + + typed_chunk: Final[BaseModel | Mapping[str, object]] = chunk + restamped_chunk: Final = _restamp_nested_response_model(typed_chunk, target_model) + + downstream_model: Final = ( + restamped_chunk.get("model") + if isinstance(restamped_chunk, Mapping) + else getattr(restamped_chunk, "model", None) + ) if downstream_model == target_model: - return chunk, model_mismatch_logged + return restamped_chunk, model_mismatch_logged if not model_mismatch_logged and downstream_model != target_model: verbose_proxy_logger.debug( @@ -9951,22 +10009,92 @@ def _restamp_streaming_chunk_model( logging_obj: Final = request_data.get("litellm_logging_obj") if isinstance(logging_obj, LiteLLMLoggingObj): logging_obj.client_facing_stream_model = target_model - if isinstance(chunk, dict): - return {**chunk, "model": target_model}, model_mismatch_logged + if isinstance(restamped_chunk, Mapping): + return {**restamped_chunk, "model": target_model}, model_mismatch_logged try: - return chunk.model_copy(update={"model": target_model}), model_mismatch_logged + return restamped_chunk.model_copy(update={"model": target_model}), model_mismatch_logged except Exception as e: verbose_proxy_logger.error( "litellm_call_id=%s: failed to override chunk.model=%r on chunk_type=%s. error=%s", request_data.get("litellm_call_id"), target_model, - type(chunk), + type(restamped_chunk), str(e), exc_info=True, ) - return chunk, model_mismatch_logged + return restamped_chunk, model_mismatch_logged + + +def _nested_response_of(chunk: BaseModel) -> object: + if isinstance( + chunk, + ( + ResponseCreatedEvent, + ResponseInProgressEvent, + ResponseCompletedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ), + ): + return chunk.response + if isinstance(chunk, GenericEvent): + extra_fields: Final[Mapping[str, object]] = chunk.model_extra or {} + return extra_fields.get("response") + return None + + +def _restamp_nested_response_model( + chunk: BaseModel | Mapping[str, object], target_model: str +) -> BaseModel | Mapping[str, object]: + if not isinstance(chunk, BaseModel): + restamped_event: Final = _event_with_restamped_nested_model(chunk, target_model) + return chunk if restamped_event is None else restamped_event + nested_response: Final = _nested_response_of(chunk) + if isinstance(nested_response, ResponsesAPIResponse): + if nested_response.model in (None, target_model): + return chunk + return chunk.model_copy(update={"response": nested_response.model_copy(update={"model": target_model})}) + untyped_response: Final = as_str_mapping(nested_response) + if untyped_response is None or untyped_response.get("model") in (None, target_model): + return chunk + return chunk.model_copy(update={"response": {**untyped_response, "model": target_model}}) + + +def _event_with_restamped_nested_model(event: Mapping[str, object], target_model: str) -> Mapping[str, object] | None: + nested_response: Final = as_str_mapping(event.get("response")) + if nested_response is None or nested_response.get("model") in (None, target_model): + return None + return {**event, "response": {**nested_response, "model": target_model}} + + +_SSE_DATA_FIELD: Final = b"data:" +_SSE_NESTED_RESPONSE_MARKER: Final = b'"response"' +_SSE_EVENT_OBJECT: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) + + +def _restamp_nested_response_model_in_sse_frame(frame: bytes, target_model: str) -> bytes: + if _SSE_NESTED_RESPONSE_MARKER not in frame: + return frame + return b"".join( + _restamp_nested_response_model_in_sse_line(line, target_model) for line in frame.splitlines(keepends=True) + ) + + +def _restamp_nested_response_model_in_sse_line(line: bytes, target_model: str) -> bytes: + if not line.startswith(_SSE_DATA_FIELD): + return line + try: + event: Final = _SSE_EVENT_OBJECT.validate_json(line.removeprefix(_SSE_DATA_FIELD).strip()) + except ValidationError: + return line + restamped_event: Final = _event_with_restamped_nested_model(event, target_model) + if restamped_event is None: + return line + serialized_event: Final = json.dumps(restamped_event, separators=(",", ":"), ensure_ascii=False) + line_ending: Final = line[len(line.rstrip(b"\r\n")) :] + return _SSE_DATA_FIELD + b" " + serialized_event.encode() + line_ending def _fast_serialize_simple_model_response_stream( @@ -14570,25 +14698,36 @@ async def _try_provider_token_count( provider_counter: "BaseTokenCounter", custom_llm_provider: str | None, model_to_use: str, + region: str | None, messages: list | None, contents: list | None, - deployment: dict[str, object] | None, + deployment: Mapping[str, object] | None, request_model: str, tools: list | None = None, system: str | None = None, messages_format: TokenCountMessagesFormat = "openai", output_format: JsonValue = None, context_management: CountContextManagement | None = None, -) -> Optional["TokenCountResponse"]: - """Attempt provider-specific token counting. Returns result on success, None to fall through to local counting.""" +) -> Union["TokenCountResponse", ProviderCountFailure, None]: + """Attempt provider-specific token counting. Returns None to fall through to local counting.""" if not provider_counter.should_use_token_counting_api(custom_llm_provider=custom_llm_provider): return None + deployment_in_region: Final = ( + None + if deployment is None + else dict(deployment) + if region is None + else { + **deployment, + "litellm_params": {**(as_str_mapping(deployment.get("litellm_params")) or {}), "aws_region_name": region}, + } + ) try: result: Final = await provider_counter.count_tokens( model_to_use=model_to_use or "", messages=messages, contents=contents, - deployment=deployment, + deployment=deployment_in_region, request_model=request_model, tools=tools, system=system, @@ -14597,24 +14736,325 @@ async def _try_provider_token_count( context_management=context_management, ) except httpx.HTTPStatusError as e: - error_message: Final = getattr(e, "message", None) or str(e) - status_code: Final = getattr(e, "status_code", None) or e.response.status_code - raise ProxyException( - message=error_message, - type="token_counting_error", - param="model", - code=status_code, + return ProviderCountFailure( + status_code=getattr(e, "status_code", None) or e.response.status_code, + message=getattr(e, "message", None) or str(e), ) if result is not None and result.error is True: - raise ProxyException( + return ProviderCountFailure( + status_code=result.status_code or 500, message=result.error_message or "Token counting failed", - type="token_counting_error", - param="model", - code=result.status_code or 500, + rejected_before_provider_call=result.rejected_before_provider_call, ) return result +async def _provider_token_count( + count_with: Callable[[str, str | None], Awaitable[Union["TokenCountResponse", ProviderCountFailure, None]]], + model_to_use: str, + settings: TokenCountSettings, + local_estimate_allowed: bool, +) -> Union["TokenCountResponse", ProviderCountFailure, None]: + """Count with the provider, using the token_count_model in its region when one is set. + + Returns None when the count should be estimated locally, or the provider's failure when the framed local + estimate stands in for it and that failure is the answer if the estimate cannot read the request.""" + count_model: Final = settings.count_model + counted: Final = await ( + count_with(model_to_use, None) if count_model is None else count_with(count_model.model, count_model.region) + ) + if counted is None: + return None + if isinstance(counted, TokenCountResponse): + if count_model is None: + return counted.model_copy(update={"count_source": "provider"}) + return counted.model_copy(update={"count_source": "token_count_model", "model_used": count_model.model}) + failure: Final = ( + counted + if count_model is None or count_model.region is None + else counted.with_hint( + f"This model counts tokens with {count_model.model} in {count_model.region}. " + "If that region is not enabled for the AWS account, enable it or set token_count_model in model_info" + ) + ) + if count_model is None and failure.cannot_count_model and settings.framing is not None and local_estimate_allowed: + return failure + raise failure.to_proxy_exception() + + +@dataclass(frozen=True, slots=True) +class _LocalTokenEstimate: + total_tokens: int + tokenizer_type: str + + +def _is_fork_guard_error(error: BaseException) -> bool: + from litellm.rust_bridge.fork_guard import ForkedAfterNativeRuntimeStarted, ProcessReservedForForking + + return isinstance(error, (ForkedAfterNativeRuntimeStarted, ProcessReservedForForking)) + + +def _is_tokenizer_panic(error: BaseException) -> bool: + return type(error).__module__ == "pyo3_runtime" and type(error).__name__ == "PanicException" + + +def _answers_provider_error(error: BaseException) -> bool: + return (isinstance(error, Exception) or _is_tokenizer_panic(error)) and not _is_fork_guard_error(error) + + +def _provider_error_after_failed_estimate( + provider_failure: ProviderCountFailure, error: BaseException +) -> ProxyException: + verbose_proxy_logger.warning( + "Local token estimate failed after the provider's count error %s, answering that error: %s", + provider_failure.status_code, + type(error).__name__, + ) + return provider_failure.to_proxy_exception() + + +async def _estimate_or_provider_error(estimate: Awaitable[int], provider_failure: ProviderCountFailure) -> int: + try: + return await estimate + except BaseException as e: + if not _answers_provider_error(e): + raise + raise _provider_error_after_failed_estimate(provider_failure, e) from e + + +@dataclass(frozen=True, slots=True) +class _CustomTokenizerKey: + identifier: str + revision: str + auth_token: str | None + + +class _TokenizerLoadsBusy(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class _TokenizerLoadOutcome: + tokenizer: SelectTokenizerResponse | BaseException + finished_at: float + + +@dataclass(frozen=True, slots=True) +class _SharedTokenizerLoad: + outcome: asyncio.Task[_TokenizerLoadOutcome] + started_at: float + + def reusable(self, now: float) -> bool: + return not self.outcome.done() or now - self.outcome.result().finished_at < TOKENIZER_LOAD_REUSE_SECONDS + + +async def _loaded_tokenizer( + model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, threads: anyio.CapacityLimiter +) -> SelectTokenizerResponse | BaseException: + try: + return await asyncify(litellm.utils.select_tokenizer, limiter=threads)( + model=model, custom_tokenizer=custom_tokenizer + ) + except asyncio.CancelledError: + raise + except BaseException as e: # noqa: BLE001 # every count sharing this load decides what it answers, pyo3 panics included + return e + + +async def _tokenizer_load_outcome( + model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, threads: anyio.CapacityLimiter +) -> _TokenizerLoadOutcome: + tokenizer: Final = await _loaded_tokenizer(model, custom_tokenizer, threads) + return _TokenizerLoadOutcome(tokenizer=tokenizer, finished_at=asyncio.get_running_loop().time()) + + +@dataclass(slots=True) +class _TokenizerLoads: + threads: anyio.CapacityLimiter + running: int = 0 + + def start( + self, model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, loop: asyncio.AbstractEventLoop + ) -> _SharedTokenizerLoad | None: + if self.running >= self.threads.total_tokens: + return None + self.running += 1 + outcome: Final = loop.create_task(_tokenizer_load_outcome(model, custom_tokenizer, self.threads)) + outcome.add_done_callback(self._finished) + return _SharedTokenizerLoad(outcome=outcome, started_at=loop.time()) + + def _finished(self, _outcome: asyncio.Task[_TokenizerLoadOutcome]) -> None: + self.running -= 1 + + +_TOKENIZER_LOADS: Final = anyio.lowlevel.RunVar[_TokenizerLoads]("litellm_proxy_tokenizer_loads") + + +def _tokenizer_loads_for_this_loop() -> _TokenizerLoads: + existing: Final = _TOKENIZER_LOADS.get(None) + if existing is not None: + return existing + created: Final = _TokenizerLoads(threads=anyio.CapacityLimiter(TOKENIZER_LOAD_MAX_RUNNING)) + _TOKENIZER_LOADS.set(created) + return created + + +@dataclass(slots=True) +class _TokenizerLoadSlot: + model: str + custom_tokenizer: CustomHuggingfaceTokenizer | None + current: _SharedTokenizerLoad | None = None + + def load(self, loads: _TokenizerLoads, loop: asyncio.AbstractEventLoop) -> _SharedTokenizerLoad | None: + current: Final = self.current + if current is not None and current.reusable(loop.time()): + return current + self.current = loads.start(self.model, self.custom_tokenizer, loop) + return self.current + + +@lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE) +def _tokenizer_load_slot( + model: str, custom_tokenizer: _CustomTokenizerKey | None, loop: asyncio.AbstractEventLoop +) -> _TokenizerLoadSlot: + return _TokenizerLoadSlot( + model=model, + custom_tokenizer=None + if custom_tokenizer is None + else CustomHuggingfaceTokenizer( + identifier=custom_tokenizer.identifier, + revision=custom_tokenizer.revision, + auth_token=custom_tokenizer.auth_token, + ), + ) + + +async def _tokenizer_after_provider_error( + model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None +) -> SelectTokenizerResponse | BaseException: + loop: Final = asyncio.get_running_loop() + try: + load: Final = _tokenizer_load_slot( + model, + None + if custom_tokenizer is None + else _CustomTokenizerKey( + identifier=custom_tokenizer["identifier"], + revision=custom_tokenizer["revision"], + auth_token=custom_tokenizer["auth_token"], + ), + loop, + ).load(_tokenizer_loads_for_this_loop(), loop) + if load is None: + return _TokenizerLoadsBusy() + outcome: Final = await asyncio.wait_for( + asyncio.shield(load.outcome), + timeout=max(load.started_at + TOKENIZER_LOAD_WAIT_SECONDS - loop.time(), 0), + ) + except Exception as e: # noqa: BLE001 # a tokenizer that cannot be read, loaded in time, or loaded at all answers the provider's error + return e + return outcome.tokenizer + + +async def _tokenizer_or_provider_error( + model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None, provider_failure: ProviderCountFailure +) -> SelectTokenizerResponse: + tokenizer: Final = await _tokenizer_after_provider_error(model, custom_tokenizer) + if not isinstance(tokenizer, BaseException): + return tokenizer + if not _answers_provider_error(tokenizer): + raise tokenizer.with_traceback(None) + raise _provider_error_after_failed_estimate(provider_failure, tokenizer) from tokenizer + + +async def _local_token_estimate( + model: str, + prompt: str | None, + messages: Sequence[AllMessageValues] | None, + tools: list[ChatCompletionToolParam] | None, + custom_tokenizer: CustomHuggingfaceTokenizer | None, + provider_failure: ProviderCountFailure | None, +) -> _LocalTokenEstimate: + tokenizer: Final = ( + await asyncify(litellm.utils.select_tokenizer)(model=model, custom_tokenizer=custom_tokenizer) + if provider_failure is None + else await _tokenizer_or_provider_error(model, custom_tokenizer, provider_failure) + ) + count: Final = offload_token_count(litellm.token_counter)( + model=model, + text=prompt, + messages=messages, + tools=tools, + custom_tokenizer=tokenizer, + ) + total_tokens: Final = await ( + count if provider_failure is None else _estimate_or_provider_error(count, provider_failure) + ) + return _LocalTokenEstimate(total_tokens=total_tokens, tokenizer_type=str(tokenizer["type"])) + + +_KIMI_K3_ENCODINGS: Final = KimiK3Encodings( + source=HUGGING_FACE_SOURCE, retry_after_seconds=TOKENIZER_LOAD_REUSE_SECONDS +) + + +async def _kimi_k3_count( + request: TokenCountRequest, + messages_format: TokenCountMessagesFormat, + model: str, + messages: Sequence[AllMessageValues], + tools: Sequence[ChatCompletionToolParam] | None, +) -> int | None: + encoding: Final = await _KIMI_K3_ENCODINGS.encoding(TOKENIZER_LOAD_WAIT_SECONDS) + if encoding is None: + return None + match messages_format: + case "anthropic": + return await offload_token_count(kimi_k3_anthropic_tokens)( + encoding, model, request.model_dump(include={"messages", "system", "tools"}, exclude_none=True) + ) + case "openai": + return await offload_token_count(kimi_k3_tokens)(encoding, model, messages, tools) + + +class _TokenCountDeployment(TypedDict, total=False): + litellm_params: ReadOnly[Mapping[str, object]] + model_info: ReadOnly[Mapping[str, object]] + + +_TOKEN_COUNT_DEPLOYMENT: Final = TypeAdapter(_TokenCountDeployment) + + +def _model_group_deployment(router: Router | None, model_group: str) -> Mapping[str, object] | None: + group: Final = () if router is None else router.get_all_deployments(model_name=model_group) + return group[0] if group else None + + +def _non_empty_str(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _deployment_aws_region(deployment: _TokenCountDeployment) -> str: + from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM + + params: Final = deployment.get("litellm_params", {}) + return BaseAWSLLM().get_aws_region_name( + {"aws_region_name": params.get("aws_region_name")}, model=_non_empty_str(params.get("model")) + ) + + +def _token_count_settings_model(deployment: _TokenCountDeployment, request_model: str) -> tuple[str, str | None]: + model_info: Final = deployment.get("model_info", {}) + params: Final = deployment.get("litellm_params", {}) + base_model: Final = _non_empty_str(model_info.get("base_model")) or _non_empty_str(params.get("base_model")) + if base_model is not None: + return base_model, None + model: Final = _non_empty_str(params.get("model")) + if model is None: + return request_model, None + return model, _non_empty_str(params.get("custom_llm_provider")) + + _PROVIDERS_COUNTING_WITHOUT_MESSAGES: Final = frozenset( {litellm.LlmProviders.GEMINI.value, litellm.LlmProviders.VERTEX_AI.value} ) @@ -14625,6 +15065,14 @@ def _drops_params(deployment: Mapping[str, object] | None) -> bool: return litellm.drop_params is True or (litellm_params is not None and litellm_params.get("drop_params") is True) +_ESTIMABLE_GOOGLE_PART_KEYS: Final = frozenset({"text", "functionCall", "functionResponse", "thoughtSignature"}) + + +def _estimable_contents(contents: Sequence[Mapping[str, object]]) -> bool: + parts: Final = chain.from_iterable(iter_items(content.get("parts") or ()) for content in contents) + return all(set(as_str_mapping(part) or ()) <= _ESTIMABLE_GOOGLE_PART_KEYS for part in parts) + + def _router_default_litellm_params(router: Router) -> Mapping[str, object]: defaults: Final[Mapping[str, object] | None] = getattr(router, "default_litellm_params", None) if defaults is None: @@ -14728,6 +15176,14 @@ async def count_request_tokens( model_to_use: str = ( litellm_model_name if isinstance(litellm_model_name, str) and litellm_model_name else request.model ) + deployment_view: Final = _TOKEN_COUNT_DEPLOYMENT.validate_python( + deployment or _model_group_deployment(llm_router, request.model) or {} + ) + settings: Final = await asyncify(resolve_token_count_settings)( + configured=deployment_view.get("model_info", {}), + default=await asyncify(cost_map_entry)(*_token_count_settings_model(deployment_view, request.model)), + deployment_region=partial(_deployment_aws_region, deployment_view), + ) # Try provider-specific token counting first - only for non-direct requests (from provider endpoints) provider_counter: BaseTokenCounter | None = None @@ -14747,24 +15203,33 @@ async def count_request_tokens( code=400, ) prompt_only: Final = bool(prompt) and conversation_empty - if provider_counter is not None: - prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES - result: Final = await _try_provider_token_count( - provider_counter=provider_counter, - custom_llm_provider=custom_llm_provider, + contents_estimable: Final = not contents or (bool(messages) and _estimable_contents(contents)) + prompt_as_message: Final = prompt_only and custom_llm_provider in _PROVIDERS_COUNTING_WITHOUT_MESSAGES + ladder: Final = ( + None + if provider_counter is None + else await _provider_token_count( + count_with=partial( + _try_provider_token_count, + provider_counter, + custom_llm_provider, + messages=[{"role": "user", "content": prompt}] if prompt_as_message else messages, + contents=None if prompt_as_message else contents, + deployment=deployment, + request_model=request.model, + tools=tools, + system=system, + messages_format=messages_format, + output_format=output_format, + context_management=_count_context_management(context_management, request.model, request_litellm_params), + ), model_to_use=model_to_use, - messages=[{"role": "user", "content": prompt}] if prompt_as_message else messages, - contents=None if prompt_as_message else contents, - deployment=deployment, - request_model=request.model, - tools=tools, - system=system, - messages_format=messages_format, - output_format=output_format, - context_management=_count_context_management(context_management, request.model, request_litellm_params), + settings=settings, + local_estimate_allowed=litellm.disable_token_counter is not True and contents_estimable, ) - if result is not None: - return result + ) + if isinstance(ladder, TokenCountResponse): + return ladder # Check if token counter is disabled before fallback if litellm.disable_token_counter is True: @@ -14782,11 +15247,6 @@ async def count_request_tokens( CustomHuggingfaceTokenizer | None, model_info.get("custom_tokenizer", None), ) - _tokenizer_used: Final = await asyncify(litellm.utils.select_tokenizer)( - model=model_to_use, custom_tokenizer=custom_tokenizer - ) - - tokenizer_used: Final = str(_tokenizer_used["type"]) system_message: Final = _system_message(system) prompt_turn: Final = prompt_only and not (system_message is None and not tools) typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes @@ -14799,18 +15259,38 @@ async def count_request_tokens( counted_tools: Final = cast( # cast-ok: raw OpenAI or Anthropic tool dicts, both of which token_counter formats list[ChatCompletionToolParam] | None, tools if counted_messages is not None else None ) - total_tokens: Final = await offload_token_count(litellm.token_counter)( + framing: Final = settings.framing if counted_messages is not None and settings.framing is not None else 0 + kimi_k3_messages: Final = counted_messages if settings.tokenizer == "kimi_k3" else None + exact: Final = ( + await _kimi_k3_count(request, messages_format, model_to_use, kimi_k3_messages, counted_tools) + if kimi_k3_messages is not None and custom_tokenizer is None + else None + ) + if exact is not None: + return TokenCountResponse( + total_tokens=exact + framing, + request_model=request.model, + model_used=model_to_use, + tokenizer_type="huggingface_tokenizer", + count_source="local_estimate", + ) + estimate: Final = await _local_token_estimate( model=model_to_use, - text=prompt if counted_messages is None else None, + prompt=prompt if counted_messages is None else None, messages=counted_messages, tools=counted_tools, - custom_tokenizer=_tokenizer_used, + custom_tokenizer=custom_tokenizer, + provider_failure=ladder, + ) + template: Final = ( + 0 if kimi_k3_messages is None else kimi_k3_template_tokens(len(kimi_k3_messages), bool(counted_tools)) ) return TokenCountResponse( - total_tokens=total_tokens, + total_tokens=estimate.total_tokens + template + framing, request_model=request.model, model_used=model_to_use, - tokenizer_type=tokenizer_used, + tokenizer_type=estimate.tokenizer_type, + count_source="local_estimate", ) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index cb9e193fa48..a794854ce19 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2735,6 +2735,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "OPENINFER", + "provider_display_name": "OpenInfer", + "litellm_provider": "openinfer", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api.openinfer.ai/v1", + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": null, + "required": true, + "field_type": "password", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "openinfer/@oi/Qwen3.5-27B" + }, { "provider": "OPENAI_LIKE", "provider_display_name": "Openai Like", diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index a1a7d62899f..a7146f13035 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -13,8 +13,9 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final import orjson -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import ORJSONResponse, StreamingResponse +from pydantic import TypeAdapter from starlette.datastructures import UploadFile import litellm @@ -38,6 +39,11 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg read_request_body, safe_get_request_headers, ) +from litellm.proxy.openai_files_endpoints.common_utils import ( + EXPLICIT_CREDENTIAL_KEYS, + explicit_request_credentials, + get_team_provider_credentials, +) from litellm.proxy.rag_endpoints.upload_security import ( MAX_UPLOAD_SIZE_BYTES, EicarTestMalwareScanner, @@ -57,9 +63,13 @@ from litellm.repositories.table_repositories import ManagedVectorStoresRepositor from litellm.types.utils import ModelResponse if TYPE_CHECKING: + from litellm import Router from litellm.proxy.utils import PrismaClient router: Final = APIRouter() +_TEAM_PROVIDER_CREDENTIALS_ADAPTER: Final = TypeAdapter(dict[str, object]) +_EMPTY_TEAM_PROVIDER_CREDENTIALS: Final[Mapping[str, object]] = MappingProxyType({}) +_TEAM_CREDENTIAL_PROVIDER: Final = "openai" def _as_string_keyed_mapping(value: object) -> Mapping[str, object] | None: @@ -186,21 +196,91 @@ def _caller_vector_store_options( managed_store: LiteLLM_ManagedVectorStore | None, ) -> Mapping[str, object]: if managed_store is None: - return request_vector_store_config + return _without_blank_credentials(request_vector_store_config) return MappingProxyType( {key: value for key, value in request_vector_store_config.items() if key in _MANAGED_STORE_CALLER_OPTIONS} ) +def _is_blank(value: object) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) + + +def _without_blank_credentials(config: Mapping[str, object]) -> Mapping[str, object]: + return MappingProxyType( + {key: value for key, value in config.items() if key not in EXPLICIT_CREDENTIAL_KEYS or not _is_blank(value)} + ) + + +def _store_provider(config: Mapping[str, object]) -> object: + provider: Final = config.get("custom_llm_provider") + return _TEAM_CREDENTIAL_PROVIDER if provider is None else provider + + def _managed_store_overrides(managed_store: LiteLLM_ManagedVectorStore | None) -> Mapping[str, object]: if managed_store is None: return MappingProxyType({}) + stored: Final = build_request_data_from_managed_vector_store(managed_store) + return _without_blank_credentials({key: value for key, value in stored.items() if value is not None}) + + +def _forwarded_store_credentials( + vector_store_config: Mapping[str, object], request_data: Mapping[str, object] +) -> Mapping[str, object]: + if _store_provider(vector_store_config) != _TEAM_CREDENTIAL_PROVIDER: + return _EMPTY_TEAM_PROVIDER_CREDENTIALS + return explicit_request_credentials(request_data) + + +def _with_body_drop_params(request_data: Mapping[str, object], body: Mapping[str, object]) -> Mapping[str, object]: + if body.get("drop_params") is not True: + return request_data + return MappingProxyType({**request_data, "drop_params": True}) + + +async def _rag_ingest_body(request: Request) -> Mapping[str, object]: + body: Final = _TEAM_PROVIDER_CREDENTIALS_ADAPTER.validate_python(await read_request_body(request)) + if "multipart/form-data" not in request.headers.get("content-type", ""): + return body + form_request: Final = body.get("request") + parsed: Final[object] = ( + orjson.loads(form_request) if isinstance(form_request, (str, bytes)) and form_request else None + ) + return ( + _TEAM_PROVIDER_CREDENTIALS_ADAPTER.validate_python(parsed) if isinstance(parsed, dict) else MappingProxyType({}) + ) + + +def _with_request_credentials( + team_credentials: Mapping[str, object], request_data: Mapping[str, object] +) -> Mapping[str, object]: + if not team_credentials: + return team_credentials + return MappingProxyType({**team_credentials, **explicit_request_credentials(request_data)}) + + +def _team_provider_credentials( + effective_config: Mapping[str, object], + llm_router: "Router | None", + user_api_key_dict: UserAPIKeyAuth, +) -> Mapping[str, object]: + if not EXPLICIT_CREDENTIAL_KEYS.isdisjoint(effective_config): + return _EMPTY_TEAM_PROVIDER_CREDENTIALS + + if _store_provider(effective_config) != _TEAM_CREDENTIAL_PROVIDER: + return _EMPTY_TEAM_PROVIDER_CREDENTIALS + + validated_credentials: Final = _TEAM_PROVIDER_CREDENTIALS_ADAPTER.validate_python( + get_team_provider_credentials( + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + custom_llm_provider=_TEAM_CREDENTIAL_PROVIDER, + include_accessible_deployments=False, + ) + or {} + ) return MappingProxyType( - { - key: value - for key, value in build_request_data_from_managed_vector_store(managed_store).items() - if value is not None - } + {key: value for key, value in validated_credentials.items() if key not in {"custom_llm_provider", "model"}} ) @@ -591,18 +671,6 @@ async def rag_ingest( request, scanner=EicarTestMalwareScanner() ) - # INTERNAL_USER_VIEW_ONLY can ingest to existing vector stores only - if user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value and not ingest_options.get( - "vector_store", {} - ).get("vector_store_id"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "internal_user_viewer role can only ingest files to an existing vector store. " - "Provide 'vector_store_id' in ingest_options.vector_store." - }, - ) - resolved_stores: Final = await _authorize_nested_vector_store_ids( payload=ingest_options, user_api_key_dict=user_api_key_dict, @@ -620,10 +688,26 @@ async def rag_ingest( raise HTTPException(status_code=400, detail={"error": str(e)}) managed_store: Final = resolved_stores.get(request_vector_store_config.get("vector_store_id")) - merged_vector_store_config: Final = { + base: Final = { **_caller_vector_store_options(request_vector_store_config, managed_store), **_managed_store_overrides(managed_store), } + request_data: Final[dict[str, Any]] = await add_litellm_data_to_request( + data={}, + request=request, + general_settings=general_settings, + user_api_key_dict=user_api_key_dict, + version=version, + proxy_config=proxy_config, + ) + forwarded_credentials: Final = _forwarded_store_credentials( + base, _with_body_drop_params(request_data, await _rag_ingest_body(request)) + ) + merged_vector_store_config: Final = { + **_team_provider_credentials(base, llm_router, user_api_key_dict), + **forwarded_credentials, + **base, + } merged_ingest_options: Final = { **ingest_options, "vector_store": merged_vector_store_config, @@ -636,17 +720,6 @@ async def rag_ingest( detail={"error": provider_error}, ) - # Add litellm data - request_data: dict[str, Any] = {} - request_data = await add_litellm_data_to_request( - data=request_data, - request=request, - general_settings=general_settings, - user_api_key_dict=user_api_key_dict, - version=version, - proxy_config=proxy_config, - ) - verbose_proxy_logger.debug( "RAG Ingest - options: %s, custom_llm_provider: %s", ingest_options, @@ -662,6 +735,11 @@ async def rag_ingest( router=llm_router, **request_data, ) + if response.get("status") == "failed": + raise HTTPException( + status_code=response.get("error_status_code") or 500, + detail={"id": response.get("id"), "status": "failed", "error": response.get("error")}, + ) # Save vector store to database if it was newly created and prisma_client is available verbose_proxy_logger.debug( @@ -777,7 +855,7 @@ async def rag_query( # Extract required fields model: Final = data.get("model") messages: Final = data.get("messages") - retrieval_config: Final = data.get("retrieval_config") + raw_retrieval_config: Final = data.get("retrieval_config") rerank: Final = data.get("rerank") stream: Final = data.get("stream", False) @@ -792,21 +870,28 @@ async def rag_query( status_code=400, detail={"error": "messages is required"}, ) - if not retrieval_config: + if not raw_retrieval_config: raise HTTPException( status_code=400, detail={"error": "retrieval_config is required"}, ) - if not isinstance(retrieval_config, dict): + if not isinstance(raw_retrieval_config, dict): raise HTTPException( status_code=400, detail={"error": "retrieval_config must be an object"}, ) + retrieval_config: Final = _TEAM_PROVIDER_CREDENTIALS_ADAPTER.validate_python(raw_retrieval_config) if "vector_store_id" not in retrieval_config: raise HTTPException( status_code=400, detail={"error": "retrieval_config must contain 'vector_store_id'"}, ) + vector_store_id: Final = retrieval_config.get("vector_store_id") + if not isinstance(vector_store_id, str): + raise HTTPException( + status_code=400, + detail={"error": "vector_store_id must be a non-empty string"}, + ) reject_caller_embedding_selection_params(payload=retrieval_config, source="retrieval_config") resolved_stores: Final = await _authorize_nested_vector_store_ids( payload=retrieval_config, @@ -817,27 +902,39 @@ async def rag_query( # model, credentials, ...) from the registry: the same source the direct # /vector_stores/{id}/search endpoint uses. Store-managed keys win on # conflict so callers cannot override the store's provider or credentials. - managed_store: Final = resolved_stores.get(retrieval_config["vector_store_id"]) - store_data: Final = ( + managed_store: Final = resolved_stores.get(vector_store_id) + store_data: Final[Mapping[str, object]] = ( build_request_data_from_managed_vector_store(managed_store) if managed_store is not None - else MappingProxyType({}) + else _EMPTY_TEAM_PROVIDER_CREDENTIALS ) + managed_store_params: Final = _managed_store_overrides(managed_store) merged_retrieval_config: Final = { **retrieval_config, **store_data, } - - # Add litellm data - request_data: dict[str, object] = {} - request_data = await add_litellm_data_to_request( - data=request_data, + effective_vector_store_config: Final = ( + managed_store_params + if managed_store is not None + else MappingProxyType( + {key: value for key, value in retrieval_config.items() if key not in EXPLICIT_CREDENTIAL_KEYS} + ) + ) + request_data: Final[dict[str, object]] = await add_litellm_data_to_request( + data={}, request=request, general_settings=general_settings, user_api_key_dict=user_api_key_dict, version=version, proxy_config=proxy_config, ) + team_credentials: Final = _with_request_credentials( + _team_provider_credentials(effective_vector_store_config, llm_router, user_api_key_dict), + _with_body_drop_params(request_data, data), + ) + vector_store_params: Final = ( + {**team_credentials, **managed_store_params} if managed_store is not None else team_credentials + ) verbose_proxy_logger.debug( "RAG Query - model: %s, vector_store_id: %s, custom_llm_provider: %s", @@ -851,7 +948,7 @@ async def rag_query( model=model, messages=messages, retrieval_config=merged_retrieval_config, - vector_store_params=store_data, + vector_store_params=vector_store_params, rerank=rerank, stream=stream, router=llm_router, diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 207e966ef28..f032cd79783 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -1,6 +1,7 @@ import asyncio import contextlib import json +import uuid from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from enum import Enum @@ -24,14 +25,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse from openai.types.responses import ResponseItemList from openai.types.responses.response_create_params import ResponseInputParam -from pydantic import ConfigDict, TypeAdapter, ValidationError +from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config from starlette.websockets import WebSocket, WebSocketDisconnect -from typing_extensions import ReadOnly, TypedDict +from typing_extensions import NotRequired, ReadOnly, TypedDict +import litellm from litellm._logging import verbose_proxy_logger +from litellm.anthropic_interface.exceptions.exception_mapping_utils import AnthropicExceptionMapping from litellm.constants import EMPTY_MAPPING, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY, TERMINAL_RESPONSE_STATUSES from litellm.integrations.custom_guardrail import ModifyResponseException -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.core_helpers import as_str_mapping, get_metadata_variable_name_from_kwargs from litellm.litellm_core_utils.internal_call_metadata import is_background_response, requests_background from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup from litellm.litellm_core_utils.tool_fields import first_validation_error @@ -52,17 +55,30 @@ from litellm.proxy.common_request_processing import ( create_response, resolve_counted_tool_fields, ) +from litellm.proxy.common_utils.error_body_call_id import error_body_call_id, with_call_id from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports _read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export _safe_set_request_parsed_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export read_request_body, safe_set_request_parsed_body, ) +from litellm.proxy.common_utils.openai_error_payload import ( + LITELLM_CALL_ID_HEADER, + error_status_code, + openai_error_type_for_status, +) +from litellm.proxy.common_utils.token_count_settings import count_source_fields +from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity, decrypted_response_id +from litellm.proxy.litellm_pre_call_utils import ( + _CLIENT_PRICING_METADATA_FIELDS, # pyright: ignore[reportPrivateUsage] # the HTTP path's own strip list + _UNTRUSTED_METADATA_CONTROL_FIELDS, # pyright: ignore[reportPrivateUsage] # the HTTP path's own strip list +) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import ( request_tags_from_request_data, ) from litellm.proxy.route_llm_request import raise_if_required_body_param_missing +from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ResponsesWsFrameGuard from litellm.router import Router from litellm.types.llms.base import LiteLLMBaseModel from litellm.types.llms.openai import ( @@ -71,6 +87,7 @@ from litellm.types.llms.openai import ( ResponsesAPIResponse, ) from litellm.types.responses.main import DeleteResponseResult +from litellm.types.token_count import TokenCountSourceFields from litellm.types.utils import TokenCountResponse if TYPE_CHECKING: @@ -1358,7 +1375,7 @@ class _ResponsesApiErrorBody(TypedDict): error: ReadOnly[_ResponsesApiErrorDetail] -class _ResponsesInputTokensResult(TypedDict): +class _ResponsesInputTokensResult(TokenCountSourceFields): object: ReadOnly[str] input_tokens: ReadOnly[int] @@ -1403,6 +1420,85 @@ def _responses_invalid_request_response(message: str, param: str | None, code: s return JSONResponse(status_code=400, content=body) +class _ProviderErrorMessage(TypedDict): + message: ReadOnly[str] + + +class _NestedProviderError(TypedDict): + error: ReadOnly[_ProviderErrorMessage] + + +@with_config(ConfigDict(extra="ignore")) +class _ProviderOpenAIErrorDetail(TypedDict): + message: ReadOnly[str] + type: ReadOnly[str] + param: NotRequired[ReadOnly[str | None]] + code: NotRequired[ReadOnly[str | None]] + + +@with_config(ConfigDict(extra="forbid")) +class _ProviderOpenAIError(TypedDict): + error: ReadOnly[_ProviderOpenAIErrorDetail] + + +_PROVIDER_COUNT_ERROR_TYPE: Final = "token_counting_error" +_PROVIDER_OPENAI_ERROR: Final = TypeAdapter(_ProviderOpenAIError) +_NESTED_PROVIDER_ERROR: Final = TypeAdapter(_NestedProviderError) + + +def _provider_error_json(raw_message: str) -> str: + start: Final = raw_message.find("{") + return raw_message if start == -1 else raw_message[start:] + + +def _provider_error_message(raw_message: str) -> str: + error_json: Final = _provider_error_json(raw_message) + try: + return _NESTED_PROVIDER_ERROR.validate_json(error_json)["error"]["message"] + except ValidationError: + flat_message: Final = AnthropicExceptionMapping.extract_error_message(error_json) + return raw_message if flat_message == error_json else flat_message + + +def _provider_count_error_body(raw_message: str, status_code: int) -> _ResponsesApiErrorBody: + try: + provider_error: Final = _PROVIDER_OPENAI_ERROR.validate_json(_provider_error_json(raw_message))["error"] + except ValidationError: + return { + "error": { + "message": _provider_error_message(raw_message), + "type": openai_error_type_for_status(status_code), + "param": None, + "code": None, + } + } + return { + "error": { + "message": provider_error["message"], + "type": provider_error["type"], + "param": provider_error.get("param"), + "code": provider_error.get("code"), + } + } + + +def _provider_count_error_response(exc: ProxyException, request: Request) -> JSONResponse: + from litellm.proxy.proxy_server import ( + _close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; a route that answers an error itself must stamp the OTel server span like the global ProxyException handler does + general_settings_view, + ) + + status_code: Final = error_status_code(exc, 500) + _close_dangling_otel_server_span(request, status_code, exc=exc) + body: Final = _provider_count_error_body(exc.message, status_code) + call_id: Final = error_body_call_id(general_settings_view(), exc.headers.get(LITELLM_CALL_ID_HEADER)) + return JSONResponse( + status_code=status_code, + content={"error": with_call_id({**body["error"]}, call_id)}, + headers=exc.headers, + ) + + def _missing_responses_param_response(param: str) -> JSONResponse: return _responses_invalid_request_response( message=f"Missing required parameter: '{param}'.", @@ -1507,10 +1603,16 @@ async def responses_input_tokens( message=f"Invalid request for token counting: {e}", param=None, code=None ) - token_response: Final = await token_counter(request=token_request, call_endpoint=True) + try: + token_response: Final = await token_counter(request=token_request, call_endpoint=True) + except ProxyException as e: + if e.type != _PROVIDER_COUNT_ERROR_TYPE: + raise + return _provider_count_error_response(e, request) result: Final[_ResponsesInputTokensResult] = { "object": "response.input_tokens", "input_tokens": token_response.total_tokens, + **count_source_fields(token_response), } return result @@ -1810,6 +1912,143 @@ async def _enforce_responses_ws_first_frame_model_auth( ) +_WS_FRAME: Final = TypeAdapter(dict[str, object]) +_GUARDRAIL_INFORMATION: Final = TypeAdapter(list[dict[str, object]]) +_PII_TOKENS: Final = TypeAdapter(dict[str, str]) +_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information" +_GUARDED_FRAME_FIELDS: Final = ("input", "instructions", "tools") +_CLIENT_METADATA_KEYS: Final = ("metadata", "litellm_metadata") +# the guardrail list is merged from key, team and the admission frame; a later frame cannot shrink it +_CLIENT_GUARDRAIL_SELECTORS: Final = ("guardrails", "guardrail_config", "policies") + + +class _PreCallGuardrailWalk(Protocol): + async def pre_call_hook( + self, + *, + user_api_key_dict: UserAPIKeyAuth, + data: dict[str, object], # mutable-ok: ProxyLogging.pre_call_hook hands the guardrails a dict they edit + call_type: Literal["aresponses"], + guardrails_only: bool, + ) -> dict[str, object]: ... # mutable-ok: ProxyLogging.pre_call_hook returns the guardrails' own dict + + +def _responses_ws_frame_guard( + admitted: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth, proxy_logging_obj: _PreCallGuardrailWalk +) -> ResponsesWsFrameGuard: + """Socket admission skips guardrails (the first frame's ``input`` is not part of the admitted request), so every + ``response.create`` frame gets the pre_call guardrail walk the HTTP ``/v1/responses`` path runs on its body.""" + metadata_key: Final = get_metadata_variable_name_from_kwargs(admitted) + admitted_bucket: Final = admitted.get(metadata_key) + bucket: Final = { + key: value + for key, value in ( + _WS_FRAME.validate_python(admitted_bucket) if isinstance(admitted_bucket, dict) else {} + ).items() + if key != _GUARDRAIL_INFORMATION_KEY + } + model: Final = admitted.get("model") + + # a blocked frame is billed as its own failed request; only the first turn settles the admission reservation + rejected_frame_auth: Final = user_api_key_dict.model_copy(update={"budget_reservation": None}) + + async def guard(frame: str) -> GuardedResponsesWsFrame: + frame_obj: Final = _WS_FRAME.validate_json(frame) + nested: Final = frame_obj.get("response") + if isinstance(nested, dict) and any(key in frame_obj for key in _GUARDED_FRAME_FIELDS): + raise ValueError( + "response.create carries input, instructions or tools both at the top level and under response" + ) + body: Final = _WS_FRAME.validate_python(nested) if isinstance(nested, dict) else frame_obj + frame_data: Final = { + **{ + key: _without_untrusted_metadata(key, value) + for key, value in body.items() + if key != "type" and key not in _CLIENT_GUARDRAIL_SELECTORS + }, + "model": model, + "litellm_call_id": str(uuid.uuid4()), + # shallow: the bucket carries proxy-owned objects (the OTEL parent span) that cannot be deep-copied + metadata_key: {**bucket}, + } + try: + guarded_data: Final = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, data=frame_data, call_type="aresponses", guardrails_only=True + ) + except Exception as e: + await _bill_rejected_frame(rejected_frame_auth, e, frame_data) + raise + if guarded_data.get("model") != model: + rerouted: Final = ValueError( + f"a guardrail rerouted this turn to {guarded_data.get('model')!r}; an open Responses WebSocket" + f" is bound to {model!r}, send this turn over HTTP /v1/responses" + ) + await _bill_rejected_frame(rejected_frame_auth, rerouted, frame_data) + raise rerouted + # a guardrail may delete a field (the Responses translation pops ``instructions``), so start without them + guarded_fields: Final = {key: guarded_data[key] for key in _GUARDED_FRAME_FIELDS if key in guarded_data} + kept: Final = {key: value for key, value in body.items() if key not in _GUARDED_FRAME_FIELDS} + rewritten: Final = ( + {**frame_obj, "response": {**kept, **guarded_fields}} + if isinstance(nested, dict) + else {**kept, **guarded_fields} + ) + return GuardedResponsesWsFrame( + frame=json.dumps(rewritten), + guardrail_information=_recorded_guardrail_information(guarded_data.get(metadata_key)), + pii_tokens=_recorded_pii_tokens(guarded_data.get("metadata")), + ) + + return guard + + +def _without_untrusted_metadata(key: str, value: object) -> object: + """Like the HTTP path, drop the proxy-owned ``user_api_key_*`` and guardrail control keys a client may forge.""" + if key not in _CLIENT_METADATA_KEYS or not isinstance(value, dict): + return value + return { + name: item + for name, item in _WS_FRAME.validate_python(value).items() + if not name.startswith("user_api_key_") + and name not in _UNTRUSTED_METADATA_CONTROL_FIELDS + and name not in _CLIENT_PRICING_METADATA_FIELDS + and name not in ("headers", "pii_tokens", *_CLIENT_GUARDRAIL_SELECTORS) + } + + +async def _bill_rejected_frame( + user_api_key_dict: UserAPIKeyAuth, + exception: Exception, + frame_data: dict[str, object], # mutable-ok: the spend logger stamps the failure row on the request dict +) -> None: + """Only the spend logger sees a rejected frame; the other failure hooks (the rate limiter's slot release, + alerting) belong to the live connection, which stays open. Billing never replaces the guardrail's own error.""" + for callback in litellm.callbacks: + if isinstance(callback, ProxyDBLogger): + try: + await callback.async_post_call_failure_hook( # pyright: ignore[reportUnknownMemberType] # bare dict + request_data=frame_data, original_exception=exception, user_api_key_dict=user_api_key_dict + ) + except Exception: # noqa: BLE001 # billing never replaces the guardrail's own error + verbose_proxy_logger.exception("[Non-Blocking] Responses WebSocket: billing a rejected frame failed") + + +def _recorded_pii_tokens(metadata: object) -> Mapping[str, str]: + """Presidio stores the tokens it masked a frame with in ``metadata.pii_tokens``; the socket unmasks with them.""" + recorded: Final = _WS_FRAME.validate_python(metadata).get("pii_tokens") if isinstance(metadata, dict) else None + return MappingProxyType(_PII_TOKENS.validate_python(recorded)) if isinstance(recorded, dict) else _NO_PII_TOKENS + + +_NO_PII_TOKENS: Final[Mapping[str, str]] = MappingProxyType({}) + + +def _recorded_guardrail_information(bucket: object) -> tuple[Mapping[str, object], ...]: + recorded: Final = ( + _WS_FRAME.validate_python(bucket).get(_GUARDRAIL_INFORMATION_KEY) if isinstance(bucket, dict) else None + ) + return tuple(_GUARDRAIL_INFORMATION.validate_python(recorded)) if recorded else () + + async def _responses_websocket_session( websocket: WebSocket, model: str | None, @@ -1885,6 +2124,7 @@ async def _responses_websocket_session( user_api_base=user_api_base, model=resolved_model, route_type="_aresponses_websocket", + skip_guardrails=True, ) except Exception as e: verbose_proxy_logger.exception("Responses WebSocket pre-call error") @@ -1906,7 +2146,10 @@ async def _responses_websocket_session( return routed_data: Final = dict( - data, user_api_key_dict=user_api_key_dict, **_routing_hints_from_first_ws_frame(first_message) + data, + user_api_key_dict=user_api_key_dict, + responses_ws_frame_guard=_responses_ws_frame_guard(data, user_api_key_dict, proxy_logging_obj), + **_routing_hints_from_first_ws_frame(first_message), ) # Phase 2: route to upstream provider try: diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 6f93bcd82dc..8bab7215b17 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -813,7 +813,23 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr route=ROUTE_ENDPOINT_MAPPING.get(route_type, route_type), param="model", ) - team_model_name: Final = llm_router.map_team_model(data["model"], team_id) if team_id is not None else None + custom_llm_provider: Final[object] = ( + JSON_OBJECT_ADAPTER.validate_python(data).get("custom_llm_provider") + if route_type + in ( + "avector_store_search", + "avector_store_retrieve", + "avector_store_update", + "avector_store_delete", + ) + else None + ) + team_model_provider: Final[str | None] = custom_llm_provider if isinstance(custom_llm_provider, str) else None + team_model_name: Final = ( + llm_router.map_team_model(data["model"], team_id, custom_llm_provider=team_model_provider) + if team_id is not None + else None + ) if team_model_name is not None: data["model"] = team_model_name return getattr(llm_router, f"{route_type}")(**data) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index c7ca08fb513..d1303b012c8 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -179,10 +179,16 @@ _STAMPED_METADATA_KEYS: Final = frozenset( "autorouter_baseline_observation", "used_client_oauth_token", "litellm_roi_estimator", + "user_api_key_user_email", ) ) +def _stored_user_email(metadata: Mapping[str, object]) -> str | None: + email: Final = metadata.get("user_api_key_user_email") + return email if isinstance(email, str) and email.strip() else None + + def _get_spend_logs_metadata( metadata: dict | None, applied_guardrails: list[str] | None = None, @@ -214,6 +220,7 @@ def _get_spend_logs_metadata( user_api_key_project_alias=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -251,9 +258,7 @@ def _get_spend_logs_metadata( azure_spillover=azure_spillover, used_client_oauth_token=used_client_oauth_token, ) - verbose_proxy_logger.debug( - "getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys())) - ) + verbose_proxy_logger.debug("getting payload for SpendLogs, available keys in metadata: %s", [*metadata]) # Filter the metadata dictionary to include only the specified keys clean_metadata: Final = SpendLogsMetadata( @@ -267,6 +272,7 @@ def _get_spend_logs_metadata( azure_spillover=azure_spillover, used_client_oauth_token=used_client_oauth_token, litellm_roi_estimator=metadata.get("litellm_roi_estimator") is True, + user_api_key_user_email=_stored_user_email(metadata), ) _raw_key: Final = clean_metadata.get("user_api_key") _trusted_hash: Final = metadata.get("user_api_key_hash") diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 720b4dd8d32..7411f1b4947 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -18,6 +18,7 @@ from litellm.types.llms.base import LiteLLMBaseModel router: Final = APIRouter() LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest" +RELEASE_BY_TAG_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/tags/{tag}" LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5 LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60 LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 @@ -25,6 +26,7 @@ LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" _RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S") _NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b") +_STABLE_TAG_PATTERN: Final = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") _Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -46,7 +48,9 @@ class LatestReleaseUnavailable: class _GitHubRelease(LiteLLMBaseModel): tag_name: str html_url: str - body: str + body: str | None + prerelease: bool = False + draft: bool = False class _AsyncGetClient(Protocol): @@ -87,29 +91,71 @@ def count_release_bullets(body: str) -> Mapping[_Bucket, int]: return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)) -def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable: - if response.status_code != 200: - return LatestReleaseUnavailable(reason=f"GitHub responded with status {response.status_code}") - try: - release: Final = _GitHubRelease.model_validate_json(response.content) - except ValidationError as e: - return LatestReleaseUnavailable(reason=f"GitHub release payload was not the expected shape: {e}") - counts: Final = count_release_bullets(release.body) +def _stable_version(tag_name: str) -> tuple[int, int, int] | None: + match: Final = _STABLE_TAG_PATTERN.match(tag_name) + return None if match is None else (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]: + """Tags of the patches that precede ``version`` on its minor line (v1.105.0 for v1.105.1).""" + major, minor, patch = version + return tuple(f"v{major}.{minor}.{earlier}" for earlier in range(patch)) + + +def summarize_release_line(latest: _GitHubRelease, earlier: tuple[_GitHubRelease, ...]) -> LatestReleaseInfo: + """A patch release's notes only list its backports, so count the whole minor line it belongs to.""" + counts: Final = sum( + (Counter[_Bucket](count_release_bullets(release.body or "")) for release in (latest, *earlier)), + Counter[_Bucket](), + ) return LatestReleaseInfo( - version=release.tag_name.removeprefix("v"), + version=latest.tag_name.removeprefix("v"), new_features=counts.get("new_features", 0), bug_fixes=counts.get("bug_fixes", 0), other_updates=counts.get("other_updates", 0), - release_url=release.html_url, + release_url=latest.html_url, ) +def parse_github_release(response: httpx.Response) -> _GitHubRelease | LatestReleaseUnavailable: + if response.status_code != 200: + return LatestReleaseUnavailable(reason=f"GitHub responded with status {response.status_code}") + try: + return _GitHubRelease.model_validate_json(response.content) + except ValidationError as e: + return LatestReleaseUnavailable(reason=f"GitHub release payload was not the expected shape: {e}") + + +async def _fetch_earlier_release(client: _AsyncGetClient, tag: str) -> _GitHubRelease | LatestReleaseUnavailable | None: + """The stable release for ``tag``, or None when that patch was never published as one.""" + response: Final = await client.get(RELEASE_BY_TAG_URL.format(tag=tag), timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + if response.status_code == 404: + return None + release: Final = parse_github_release(response) + if isinstance(release, _GitHubRelease) and (release.prerelease or release.draft): + return None + return release + + async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable: try: - response: Final = await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + latest: Final = parse_github_release( + await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + ) + if isinstance(latest, LatestReleaseUnavailable): + return latest + version: Final = _stable_version(latest.tag_name) + if version is None: + return summarize_release_line(latest, ()) + earlier: Final = await asyncio.gather( + *(_fetch_earlier_release(client, tag) for tag in earlier_patch_tags(version)) + ) except httpx.HTTPError as e: return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}") - return parse_latest_release(response) + unavailable: Final = next((release for release in earlier if isinstance(release, LatestReleaseUnavailable)), None) + if unavailable is not None: + return unavailable + return summarize_release_line(latest, tuple(release for release in earlier if isinstance(release, _GitHubRelease))) async def get_latest_release_info( @@ -144,7 +190,8 @@ async def latest_release_info( fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)], ) -> LatestReleaseInfo | None: """ - Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. + Latest stable LiteLLM GitHub release with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) + split into new features, bug fixes and other updates. Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render. """ result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) diff --git a/litellm/proxy/vector_store_endpoints/endpoints.py b/litellm/proxy/vector_store_endpoints/endpoints.py index 22721dc26bc..1b54dddcf4f 100644 --- a/litellm/proxy/vector_store_endpoints/endpoints.py +++ b/litellm/proxy/vector_store_endpoints/endpoints.py @@ -9,12 +9,14 @@ from typing import ( from fastapi import APIRouter, Depends, HTTPException, Request, Response +from litellm.constants import VECTOR_STORE_CALLER_METADATA_KWARG from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( LiteLLM_ManagedVectorStore, ) from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.common_utils.http_parsing_utils import read_raw_json_body from litellm.proxy.utils import jsonify_object from litellm.proxy.vector_store_endpoints.utils import ( assert_proxy_admin_for_vector_store_index_management, @@ -23,6 +25,7 @@ from litellm.proxy.vector_store_endpoints.utils import ( ) from litellm.repositories.table_repositories import ManagedVectorStoreIndexRepository from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse +from litellm.vector_stores.utils import VectorStoreWriteBody, callers_metadata_snapshot from litellm.vector_stores.vector_store_registry import VectorStoreIndexRegistry router: Final = APIRouter() @@ -213,6 +216,9 @@ async def vector_store_create( ) data: Final = await read_request_body(request=request) + data[VECTOR_STORE_CALLER_METADATA_KWARG] = callers_metadata_snapshot( + await read_raw_json_body(request), VectorStoreWriteBody.model_validate(data) + ) # Check for target_model_names parameter target_model_names: Final = data.pop("target_model_names", None) @@ -448,6 +454,9 @@ async def vector_store_update( data = await read_request_body(request=request) # rebind-ok: pre-existing rebinding on a rename-only line if "vector_store_id" not in data: data["vector_store_id"] = vector_store_id + data[VECTOR_STORE_CALLER_METADATA_KWARG] = callers_metadata_snapshot( + await read_raw_json_body(request), VectorStoreWriteBody.model_validate(data) + ) data = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict diff --git a/litellm/rag/ingestion/base_ingestion.py b/litellm/rag/ingestion/base_ingestion.py index f9e39e042f1..8b2cf6e0a4e 100644 --- a/litellm/rag/ingestion/base_ingestion.py +++ b/litellm/rag/ingestion/base_ingestion.py @@ -34,6 +34,10 @@ if TYPE_CHECKING: from litellm import Router +class RAGIngestBadRequestError(ValueError): + status_code: Final = 400 + + class BaseRAGIngestion(ABC): """ Base class for RAG ingestion. @@ -319,13 +323,18 @@ class BaseRAGIngestion(ABC): file_id=file_id, ) - try: - if existing_file_id and not self.supports_existing_file_id: - raise ValueError( - f"{self.__class__.__name__} does not support ingesting an existing file_id. " - "Upload file data or provide file_url instead." - ) + if existing_file_id and not self.supports_existing_file_id: + return RAGIngestResponse( + id=self.ingest_id, + status="failed", + vector_store_id="", + file_id=None, + error=f"{self.__class__.__name__} does not support ingesting an existing file_id. " + "Upload file data or provide file_url instead.", + error_status_code=400, + ) + try: # Step 2: OCR (optional) extracted_text: Final = await self.ocr( file_content=file_content, @@ -361,10 +370,12 @@ class BaseRAGIngestion(ABC): except Exception as e: verbose_logger.exception("RAG Pipeline failed: %s", e) + error_status_code: Final[object] = getattr(e, "status_code", None) return RAGIngestResponse( id=self.ingest_id, status="failed", vector_store_id="", file_id=None, error=str(e), + error_status_code=error_status_code if isinstance(error_status_code, int) else None, ) diff --git a/litellm/rag/ingestion/bedrock_ingestion.py b/litellm/rag/ingestion/bedrock_ingestion.py index e412b7bcc8d..1e7aa6ac1c6 100644 --- a/litellm/rag/ingestion/bedrock_ingestion.py +++ b/litellm/rag/ingestion/bedrock_ingestion.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Final from litellm._logging import verbose_logger from litellm.litellm_core_utils.aws_partition import get_aws_arn_prefix from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM -from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion +from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion, RAGIngestBadRequestError if TYPE_CHECKING: from litellm import Router @@ -149,7 +149,7 @@ class BedrockRAGIngestion(BaseRAGIngestion, BaseAWSLLM): data_sources: Final = ds_response.get("dataSourceSummaries", []) if not data_sources: - raise ValueError( + raise RAGIngestBadRequestError( f"No data sources found for Knowledge Base {self.knowledge_base_id}. " "Please create a data source first or provide data_source_id and s3_bucket." ) @@ -181,7 +181,7 @@ class BedrockRAGIngestion(BaseRAGIngestion, BaseAWSLLM): self.s3_prefix = prefixes[0] else: if not self._s3_bucket: - raise ValueError( + raise RAGIngestBadRequestError( f"Could not auto-detect S3 bucket for data source {self.data_source_id}. " "Please provide s3_bucket in config." ) diff --git a/litellm/rag/ingestion/openai_ingestion.py b/litellm/rag/ingestion/openai_ingestion.py index 6c7c619237d..e6a3f0bb74f 100644 --- a/litellm/rag/ingestion/openai_ingestion.py +++ b/litellm/rag/ingestion/openai_ingestion.py @@ -10,7 +10,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final import litellm -from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion +from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion, RAGIngestBadRequestError from litellm.vector_store_files.main import acreate as vector_store_file_acreate from litellm.vector_stores.main import acreate as vector_store_acreate @@ -87,7 +87,7 @@ class OpenAIRAGIngestion(BaseRAGIngestion): api_base: Final = self.vector_store_config.get("api_base") if existing_file_id and not vector_store_id: - raise ValueError("vector_store_id is required when ingesting an existing file_id") + raise RAGIngestBadRequestError("vector_store_id is required when ingesting an existing file_id") # Create vector store if not provided if not vector_store_id: diff --git a/litellm/rag/ingestion/s3_vectors_ingestion.py b/litellm/rag/ingestion/s3_vectors_ingestion.py index e3eafd680de..f31b068e709 100644 --- a/litellm/rag/ingestion/s3_vectors_ingestion.py +++ b/litellm/rag/ingestion/s3_vectors_ingestion.py @@ -37,7 +37,7 @@ from litellm.llms.s3_vectors.vector_stores.transformation import ( s3_vectors_ingest_embedding_options, s3_vectors_ingest_target, ) -from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion +from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion, RAGIngestBadRequestError if TYPE_CHECKING: import httpx @@ -269,13 +269,13 @@ class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM): # Validate bucket name (AWS S3 naming rules) if len(self.vector_bucket_name) < 3: - raise ValueError( + raise RAGIngestBadRequestError( f"Invalid vector_bucket_name '{self.vector_bucket_name}': " f"AWS S3 bucket names must be at least 3 characters long. " f"Please provide a valid bucket name (e.g., 'my-vector-bucket')." ) if not self.vector_bucket_name.replace("-", "").replace(".", "").isalnum(): - raise ValueError( + raise RAGIngestBadRequestError( f"Invalid vector_bucket_name '{self.vector_bucket_name}': " f"AWS S3 bucket names can only contain lowercase letters, numbers, hyphens, and periods. " f"Please provide a valid bucket name (e.g., 'my-vector-bucket')." diff --git a/litellm/rag/ingestion/vertex_ai_ingestion.py b/litellm/rag/ingestion/vertex_ai_ingestion.py index eff8ad1b8cb..cceddf31117 100644 --- a/litellm/rag/ingestion/vertex_ai_ingestion.py +++ b/litellm/rag/ingestion/vertex_ai_ingestion.py @@ -22,7 +22,7 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase -from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion +from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion, RAGIngestBadRequestError if TYPE_CHECKING: from litellm import Router @@ -134,7 +134,9 @@ class VertexAIRAGIngestion(BaseRAGIngestion, VertexBase): Tuple of (rag_corpus_id, file_id) """ if not self.project_id: - raise ValueError("vertex_project is required for Vertex AI RAG ingestion. Set it in vector_store config.") + raise RAGIngestBadRequestError( + "vertex_project is required for Vertex AI RAG ingestion. Set it in vector_store config." + ) # Get or create RAG corpus rag_corpus_id = self.vector_store_config.get("vector_store_id") diff --git a/litellm/responses/bridge_check.py b/litellm/responses/bridge_check.py index 2552ee8c880..120a0d7c828 100644 --- a/litellm/responses/bridge_check.py +++ b/litellm/responses/bridge_check.py @@ -13,9 +13,52 @@ from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import OpenAIWebSearchOptions from litellm.types.utils import LlmProviders -from litellm.utils import ProviderConfigManager, get_model_info_helper +from litellm.utils import ProviderConfigManager, get_model_info_helper, model_cost_registration_row _OPENAI_DEFAULT_API_BASE: Final = "https://api.openai.com/v1" +RESPONSES_ONLY_PROVIDERS: Final = frozenset({"chatgpt", "github_copilot"}) +ROUTABLE_MODES: Final = frozenset({"chat", "responses"}) + + +def deployment_routing_mode(deployment_model_info: Mapping[str, object] | None, custom_llm_provider: str) -> str | None: + """The mode a router deployment's own ``model_info`` pins its chat requests to, or ``None`` to defer + to the shared cost-map row. Lets two deployments of one backend model route differently without + either rewriting the shared row the SDK and the other deployments read.""" + if deployment_model_info is None or custom_llm_provider in RESPONSES_ONLY_PROVIDERS: + return None + mode: Final = deployment_model_info.get("mode") + return mode if isinstance(mode, str) and mode in ROUTABLE_MODES else None + + +def keep_registered_mode(backend_key: str, shared_model_info: Mapping[str, object]) -> Mapping[str, object]: + """A deployment's ``chat`` or ``responses`` pin routes only that deployment, so it never replaces the + routing mode registered for its shared backend row, which the SDK and every sibling deployment route by, + and no other mode replaces a registered ``responses`` mode either. + + A ``responses`` pin still marks a row that has no routing mode, since that is how a model missing from + the cost map opts into the Responses API. A mode the row inherits from the built-in entry its registration + falls back to counts as registered, since the first registration writes it whichever deployment that is. A ``chatgpt`` or ``github_copilot`` row takes any mode until it + is ``responses``, since those providers read it by name to pick their endpoint. Elsewhere a ``chat`` pin + never marks a row, so the outcome does not depend on which deployment registers first. A ``mode`` that + is not a string names no mode, so it never reaches the row.""" + deployment_mode: Final = shared_model_info.get("mode") + without_mode: Final = {key: value for key, value in shared_model_info.items() if key != "mode"} + if not isinstance(deployment_mode, str): + return without_mode + registered_mode: Final = model_cost_registration_row( + backend_key, shared_model_info.get("litellm_provider", "") + ).get("mode") + if registered_mode != "responses" and ( + deployment_mode not in ROUTABLE_MODES or backend_key.split("/", 1)[0] in RESPONSES_ONLY_PROVIDERS + ): + return shared_model_info + if registered_mode is None and deployment_mode == "chat": + return without_mode + if registered_mode is None: + return shared_model_info + return {**shared_model_info, "mode": registered_mode} + + _OPENAI_API_HOST: Final = "api.openai.com" @@ -38,6 +81,46 @@ def _resolve_openai_api_base(api_base: str | None) -> str: ) +def _mantle_responses_series_name(model: str) -> str | None: + """The bare OpenAI series name of a Bedrock Mantle model that also serves the Responses API, or ``None``.""" + from litellm.llms.bedrock_mantle.common_utils import mantle_supports_responses, split_mantle_region_prefix + + if not mantle_supports_responses(model, litellm.model_cost): + return None + return split_mantle_region_prefix(model)[1].removeprefix("openai.") + + +def str_keyed_mapping(value: object) -> Mapping[str, object] | None: + """A request field such as a deployment's ``model_info`` when it is a mapping, read without narrowing the + caller's variable (``litellm.completion`` reuses ``model_info`` for pricing registration).""" + if not isinstance(value, Mapping): + return None + return cast( # cast-ok: isinstance only narrows to Mapping[Unknown, Unknown]; request JSON keys are str + Mapping[str, object], value + ) + + +def bridge_reasoning_effort( + reasoning_effort: str | Mapping[str, object] | None, extra_body: object +) -> str | Mapping[str, object] | None: + nested_body: Final = str_keyed_mapping(extra_body) + if reasoning_effort is not None or nested_body is None: + return reasoning_effort + nested_effort: Final = nested_body.get("reasoning_effort") + return nested_effort if isinstance(nested_effort, str) else str_keyed_mapping(nested_effort) + + +def backend_serves_chat_completions(shared_model_info: Mapping[str, object]) -> bool: + """Whether the shared cost-map row lets a deployment pin ``mode: chat``. Responses-only backends + (``gpt-5.1-codex``, ``o3-pro``, ``gpt-5.4-pro``) list only ``/v1/responses`` and must stay bridged, and + a ``responses`` row with no ``supported_endpoints`` (regional Azure codex and pro, Databricks codex) is + read the same way.""" + supported_endpoints: Final = shared_model_info.get("supported_endpoints") + if not isinstance(supported_endpoints, Sequence) or isinstance(supported_endpoints, str): + return shared_model_info.get("mode") != "responses" + return "/v1/chat/completions" in supported_endpoints + + def chat_target_accepts_reasoning_summary(model: str, custom_llm_provider: str | None) -> bool: try: local_model, provider, _, _ = get_llm_provider(model=model, custom_llm_provider=custom_llm_provider) @@ -58,7 +141,8 @@ def responses_api_bridge_check( reasoning_effort: str | Mapping[str, object] | None = None, reasoning_summary: object | None = None, api_base: str | None = None, -) -> tuple[dict, str]: + deployment_model_info: Mapping[str, object] | None = None, +) -> tuple[dict[str, object], str]: model_info: dict[str, object] = {} # Global flag: route ALL OpenAI chat completions through Responses API. @@ -70,21 +154,21 @@ def responses_api_bridge_check( try: model_info = cast( - dict, + dict[str, object], get_model_info_helper(model=model, custom_llm_provider=custom_llm_provider), ) - if model_info.get("mode") is None and model.startswith("responses/"): - model = model.replace("responses/", "") - mode = "responses" - model_info["mode"] = mode - except Exception as e: verbose_logger.debug("Error getting model info: %s", e) - if model.startswith("responses/"): # handle azure models - `azure/responses/` - model = model.replace("responses/", "") - mode = "responses" - model_info["mode"] = mode + explicit_responses_prefix: Final = model.startswith("responses/") + pinned_mode: Final = ( + None if explicit_responses_prefix else deployment_routing_mode(deployment_model_info, custom_llm_provider) + ) + if explicit_responses_prefix or pinned_mode == "responses": + model = model.replace("responses/", "") + model_info["mode"] = "responses" + elif pinned_mode == "chat" and backend_serves_chat_completions(model_info): + model_info["mode"] = "chat" if web_search_options is not None and custom_llm_provider == "xai": model_info["mode"] = "responses" @@ -112,6 +196,9 @@ def responses_api_bridge_check( # an explicit effort with function tools is rejected from gpt-5.6 on, and the unset # effort only from gpt-6 on (gpt-5.6 serves tools with reasoning silently off), so the # azure_ai gate keys on those measured boundaries instead of gpt-5.4+. + # - Bedrock Mantle serves the same API and enforces the gpt-5.4+ rule even with the effort + # unset (measured on gpt-5.6-terra), so a Mantle row that also serves Responses follows + # these rules, which keeps a deployment pinned to ``mode: chat`` from a 400 on them. # - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning # summary alias is present with ``reasoning_effort`` (tools alone stay on chat). has_function_tool: Final = any( @@ -140,8 +227,21 @@ def responses_api_bridge_check( on_foundry_openai_endpoint: Final = custom_llm_provider == "azure_ai" and azure_ai_supports_native_responses( model, api_base ) + mantle_series_name: Final = ( + _mantle_responses_series_name(model) if custom_llm_provider == "bedrock_mantle" else None + ) + on_mantle_responses_endpoint: Final = mantle_series_name is not None + summary_requested: Final = reasoning_summary is not None or ( + on_mantle_responses_endpoint + and isinstance(reasoning_effort, Mapping) + and reasoning_effort.get("summary") is not None + ) + gpt_series_name: Final = mantle_series_name or model on_constraint_enforcing_endpoint: Final = ( - custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base) + custom_llm_provider == "azure" + or on_mantle_responses_endpoint + or resolved_api_base == "" + or _is_openai_backed_api_base(resolved_api_base) ) chat_rejects_function_tools: Final = ( has_function_tool @@ -150,20 +250,21 @@ def responses_api_bridge_check( foundry_chat_rejects_function_tools_while_reasoning(model, reasoning_effort) if on_foundry_openai_endpoint else ( - OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) + OpenAIGPT5Config.is_model_gpt_5_4_plus_model(gpt_series_name) and (reasoning_effort is not None or on_constraint_enforcing_endpoint) ) ) ) if ( - (custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint) + (custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint or on_mantle_responses_endpoint) and model_info.get("mode") != "responses" - and OpenAIGPT5Config.is_model_gpt_5_model(model) - and not OpenAIGPT5Config.is_model_gpt_5_search_model(model) + and OpenAIGPT5Config.is_model_gpt_5_model(gpt_series_name) + and not OpenAIGPT5Config.is_model_gpt_5_search_model(gpt_series_name) and ( - (reasoning_effort is not None and reasoning_summary is not None) + (reasoning_effort is not None and summary_requested) or chat_rejects_function_tools or (carries_tool_search and on_constraint_enforcing_endpoint) + or (web_search_options is not None and on_mantle_responses_endpoint) ) ): model_info["mode"] = "responses" diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 35d4b8bf1ba..842230699b7 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -762,8 +762,8 @@ class LiteLLMCompletionResponsesConfig: """ Whether ``litellm.completion`` will route this model back onto the Responses API. - Delegates to the same check ``litellm.completion`` itself runs, and is asked with the - params this transform is about to emit, so the two cannot reach different answers. + Delegates to the same check ``litellm.completion`` itself runs, asked with the params this + transform is about to emit. A deployment's own ``model_info.mode`` pin is not passed here. """ from litellm.main import responses_api_bridge_check diff --git a/litellm/responses/main.py b/litellm/responses/main.py index b7385c3b3d5..e775e155dea 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -28,6 +28,7 @@ from litellm.integrations.anthropic_cache_control_hook import ( configured_injection_points, ) from litellm.litellm_core_utils.asyncify import run_async_function +from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup @@ -62,6 +63,7 @@ from litellm.types.llms.openai import ( # Handle ResponseText import with fallback if TYPE_CHECKING: + from litellm.responses.streaming_iterator import ResponsesWsFrameGuard from litellm.types.llms.openai import ResponseText else: ResponseText = str # Fallback for ResponseText import @@ -1554,7 +1556,7 @@ def responses( ) reasoning_effort: Final[object] = local_vars.get("reasoning_effort") request_reasoning: Final = ( - LiteLLMResponsesTransformationHandler().map_reasoning_effort(reasoning_effort) + LiteLLMResponsesTransformationHandler().map_reasoning_effort(reasoning_effort, model, custom_llm_provider) if current_reasoning is None and reasoning_effort is not None else current_reasoning ) @@ -1653,6 +1655,17 @@ def responses( if custom_llm_provider is None: raise ValueError("custom_llm_provider is required but passed as None") + fake_stream: Final = responses_api_provider_config.should_fake_stream( + model=model, stream=stream, custom_llm_provider=custom_llm_provider + ) + applied_timeout: Final = CompletionTimeout.for_stream( + (StreamKind.FAKED if fake_stream else StreamKind.LIVE) if stream is True else StreamKind.NOT_STREAMED, + CompletionTimeout.as_limit(timeout or request_timeout), + None + if stream is not True or litellm_params.stream_timeout is None + else float(litellm_params.stream_timeout), + ) + litellm_logging_obj.record_applied_timeout(applied_timeout) response = base_llm_http_handler.response_api_handler( model=model, input=input, @@ -1663,12 +1676,10 @@ def responses( logging_obj=litellm_logging_obj, extra_headers=effective_extra_headers, extra_body=extra_body, - timeout=timeout or request_timeout, + timeout=applied_timeout, _is_async=_is_async, client=kwargs.get("client"), - fake_stream=responses_api_provider_config.should_fake_stream( - model=model, stream=stream, custom_llm_provider=custom_llm_provider - ), + fake_stream=fake_stream, litellm_metadata=kwargs.get("litellm_metadata", {}), shared_session=kwargs.get("shared_session"), ) @@ -2598,6 +2609,7 @@ async def _aresponses_websocket( api_base: str | None = None, api_key: str | None = None, timeout: float | None = None, + responses_ws_frame_guard: "ResponsesWsFrameGuard | None" = None, **kwargs, ) -> Exception | None: """ @@ -2690,5 +2702,6 @@ async def _aresponses_websocket( litellm_metadata=_build_litellm_metadata_for_ws(kwargs), custom_llm_provider=_custom_llm_provider, request_defaults=_build_responses_websocket_request_defaults(deployment_kwargs), + frame_guard=responses_ws_frame_guard, **remaining_kwargs, ) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 89c32aed1fb..145189616af 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -7,12 +7,23 @@ import time import traceback import uuid from collections.abc import AsyncIterable, Awaitable, Callable, Coroutine, Iterable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from functools import lru_cache from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Protocol, cast, overload, runtime_checkable +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + NoReturn, + Protocol, + TypeAlias, + cast, + overload, + runtime_checkable, +) import httpx from openai._streaming import SSEDecoder @@ -28,6 +39,7 @@ from litellm.constants import ( from litellm.exceptions import MidStreamFallbackError, RateLimitError from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.core_helpers import process_response_headers +from litellm.litellm_core_utils.exception_mapping_utils import with_deployment_api_base from litellm.litellm_core_utils.internal_call_metadata import without_budget_reservation from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base @@ -264,7 +276,11 @@ def stream_error_status_and_message(error_obj: object) -> tuple[int, str]: def _map_stream_error_to_exception( - error_obj: object, model: str, custom_llm_provider: str, headers: httpx.Headers | None = None + error_obj: object, + model: str, + custom_llm_provider: str, + headers: httpx.Headers | None = None, + logging_obj: LiteLLMLoggingObj | None = None, ) -> Exception: from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -277,13 +293,18 @@ def _map_stream_error_to_exception( body=error_body, headers=headers, ) + litellm_params: Final = ( + None + if logging_obj is None + else _typed_gets_litellm_params(logging_obj.model_call_details.get)("litellm_params", {}) + ) try: return litellm.exception_type( model=model, custom_llm_provider=custom_llm_provider, original_exception=provider_exception, completion_kwargs={}, - extra_kwargs={}, + extra_kwargs=with_deployment_api_base(litellm_params), ) except Exception as mapped_exception: return mapped_exception @@ -676,6 +697,7 @@ class BaseResponsesAPIStreamingIterator: self.model or "", self.custom_llm_provider or "", headers=provider_request_id_headers(self._raw_response_headers), + logging_obj=self.logging_obj, ) def _maybe_raise_for_error_event(self, result: object) -> None: @@ -1921,6 +1943,73 @@ RESPONSES_WS_MASKABLE_TEXT_BLOCK_TYPES: Final = frozenset({"input_text", "output _RESPONSES_WS_FAILURE_EVENT_TYPES: Final = frozenset({"error", "response.failed"}) _PER_TURN_METADATA_KEYS: Final = frozenset({"metadata", "litellm_metadata"}) +_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information" + + +_PII_DELTA_EVENT_TYPES: Final = frozenset( + { + "response.output_text.delta", + "response.reasoning_summary_text.delta", + "response.refusal.delta", + "response.function_call_arguments.delta", + } +) + + +def _unmask_responses_event(response_str: str, pii_tokens: Mapping[str, str], unmask_pii_text: _UnmasksPiiText) -> str: + """Put the original PII back into one client-bound event: ``response.completed`` output text, or a delta.""" + try: + evt_obj: Final = _load_json_object(response_str) + except (json.JSONDecodeError, TypeError): + return response_str + event_type: Final = evt_obj.get("type") + + if event_type == "response.completed": + modified = False + response_obj: Final = evt_obj.get("response") + if not _is_json_object(response_obj): + return response_str + output_items: Final = response_obj.get("output") + for output_item in output_items if _is_json_array(output_items) else []: + if not _is_json_object(output_item): + continue + content = output_item.get("content") + if not _is_json_array(content): + continue + for content_block in content: + if not _is_json_object(content_block): + continue + text = content_block.get("text") + if isinstance(text, str): + unmasked = unmask_pii_text(text, pii_tokens) + if unmasked != text: + content_block["text"] = unmasked + modified = True + return json.dumps(evt_obj) if modified else response_str + + if event_type in _PII_DELTA_EVENT_TYPES: + delta: Final = evt_obj.get("delta") + if isinstance(delta, str): + unmasked = unmask_pii_text(delta, pii_tokens) + if unmasked != delta: + evt_obj["delta"] = unmasked + return json.dumps(evt_obj) + + return response_str + + +@dataclass(frozen=True, slots=True) +class GuardedResponsesWsFrame: + """A ``response.create`` frame after the proxy's pre_call guardrails ran on it: the frame to forward (guardrails + may rewrite ``input`` and ``instructions``), the guardrail records that bill against this turn only, and the + Presidio ``pii_tokens`` the walk masked with, which the handler needs to unmask the provider's reply.""" + + frame: str + guardrail_information: tuple[Mapping[str, object], ...] = () + pii_tokens: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) + + +ResponsesWsFrameGuard: TypeAlias = Callable[[str], Awaitable[GuardedResponsesWsFrame]] _RESPONSES_WS_TERMINAL_EVENT_TYPES: Final = _HISTORY_TERMINAL_EVENT_TYPES | _RESPONSES_WS_FAILURE_EVENT_TYPES @@ -1953,6 +2042,30 @@ def _user_texts_in_input_item(item: object) -> tuple[str, ...]: ) +def _set_turn_guardrail_information( + turn_logging_obj: LiteLLMLoggingObj, guardrail_information: tuple[Mapping[str, object], ...] +) -> None: + """A turn bills the guardrails that ran on its own ``response.create`` frame, never the records the + connection-level logging object carries from an earlier frame.""" + litellm_params: Final = _json_object_or_none( + turn_logging_obj.litellm_params # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Logging.litellm_params is untyped + ) + if litellm_params is None: + return + for key in _PER_TURN_METADATA_KEYS: + _replace_guardrail_information(litellm_params.get(key), guardrail_information) + + +def _replace_guardrail_information(bucket: object, guardrail_information: tuple[Mapping[str, object], ...]) -> None: + if not _is_json_object(bucket): + return + bucket.pop(_GUARDRAIL_INFORMATION_KEY, None) + if guardrail_information: + bucket[_GUARDRAIL_INFORMATION_KEY] = [ # rebind-ok: the turn copy's own metadata bucket + dict(entry) for entry in guardrail_information + ] + + def _logged_user_messages(client_frame: object) -> tuple[Mapping[str, object], ...]: frame: Final = _json_object_or_none(client_frame) if frame is None or frame.get("type") != "response.create": @@ -1983,13 +2096,17 @@ class _ResponsesWsTurn: def with_event(self, event: Mapping[str, object]) -> _ResponsesWsTurn: return _ResponsesWsTurn(logging_obj=self.logging_obj, events=(*self.events, event)) - def failure(self, model: str, custom_llm_provider: str) -> Exception | None: + def failure( + self, model: str, custom_llm_provider: str, connection_logging_obj: LiteLLMLoggingObj + ) -> Exception | None: failed_event: Final = next( (event for event in self.events if event.get("type") in _RESPONSES_WS_FAILURE_EVENT_TYPES), None ) if failed_event is None: return None - return _map_stream_error_to_exception(_ws_event_error(failed_event), model, custom_llm_provider) + return _map_stream_error_to_exception( + _ws_event_error(failed_event), model, custom_llm_provider, logging_obj=connection_logging_obj + ) _RESPONSES_WS_OUTPUT_ITEM_EVENT_TYPES: Final = frozenset({"response.output_item.added", "response.output_item.done"}) @@ -2077,11 +2194,13 @@ class ResponsesWebSocketStreaming: authorized_model: str | None = None, custom_llm_provider: str | None = None, request_defaults: ResponsesWebSocketRequestDefaults | None = None, + frame_guard: ResponsesWsFrameGuard | None = None, ): self.websocket = websocket self.backend_ws = backend_ws self.logging_obj = logging_obj self.user_api_key_dict = user_api_key_dict + self.frame_guard: ResponsesWsFrameGuard | None = frame_guard self.request_data: dict[str, object] = request_data or {} litellm_metadata: Final = self.request_data.get("litellm_metadata") self.litellm_metadata: dict[str, object] = litellm_metadata if _is_json_object(litellm_metadata) else {} @@ -2097,12 +2216,15 @@ class ResponsesWebSocketStreaming: self.authorized_model: str | None = authorized_model self.request_defaults: ResponsesWebSocketRequestDefaults | None = request_defaults - def _start_turn(self, client_frame: str | None) -> None: + def _start_turn( + self, client_frame: str | None, guardrail_information: tuple[Mapping[str, object], ...] = () + ) -> None: start_time: Final = datetime.now() # noqa: DTZ005 # naive to match the logging pipeline start_time turn_logging_obj: Final = self.logging_obj.copy_for_new_call( litellm_call_id=str(uuid.uuid4()), start_time=start_time ) self._detach_budget_reservation() + _set_turn_guardrail_information(turn_logging_obj, guardrail_information) turn_logging_obj.model_call_details["messages"] = list(_logged_user_messages(client_frame)) turn_logging_obj.pre_call(input=client_frame, api_key="") self._open_turns = (*self._open_turns, _ResponsesWsTurn(logging_obj=turn_logging_obj)) @@ -2121,10 +2243,46 @@ class ResponsesWebSocketStreaming: for key, value in litellm_params.items() } - def _record_client_frame(self, client_frame: str) -> None: + def _record_client_frame( + self, client_frame: str, guardrail_information: tuple[Mapping[str, object], ...] = () + ) -> None: frame: Final = _json_object_or_none(client_frame) if frame is not None and frame.get("type") == "response.create": - self._start_turn(client_frame) + self._start_turn(client_frame, guardrail_information) + + async def _guard_frame(self, message: str) -> GuardedResponsesWsFrame | None: + """Run the proxy's pre_call guardrails on one ``response.create`` frame. + + Returns None when a guardrail rejects the frame: the client gets an ``error`` event and the + frame is dropped while the connection stays open for the next turn. + """ + frame: Final = _json_object_or_none(message) + if self.frame_guard is None or frame is None or frame.get("type") != "response.create": + return GuardedResponsesWsFrame(frame=message) + try: + return await self.frame_guard(message) + except Exception as e: # noqa: BLE001 # any guardrail rejection maps to one client-facing error event + verbose_logger.info("Responses WebSocket: pre_call guardrail rejected a response.create frame: %s", e) + await self._send_error_event("invalid_request_error", str(e)) + return None + + async def _forward_guarded_frame(self, message: str) -> None: + guarded: Final = await self._guard_frame(message) + if guarded is None: + return + if guarded.pii_tokens: + self._remember_pii_tokens(guarded.pii_tokens) + masked: Final = await self._mask_response_create(guarded.frame) + self._record_client_frame(masked, guarded.guardrail_information) + await self.backend_ws.send(masked) + + def _remember_pii_tokens(self, pii_tokens: Mapping[str, str]) -> None: + metadata: Final = self.request_data.get("metadata") + known: Final = metadata.get("pii_tokens") if _is_json_object(metadata) else None + self.request_data["metadata"] = { + **(metadata if _is_json_object(metadata) else {}), + "pii_tokens": {**(known if _is_str_mapping(known) else {}), **pii_tokens}, + } def _record_backend_event(self, event: str) -> None: event_obj: Final = _json_object_or_none(event) @@ -2148,7 +2306,7 @@ class ResponsesWebSocketStreaming: def _log_turn(self, turn: _ResponsesWsTurn) -> None: events: Final = list(turn.events) - exception: Final = turn.failure(self.authorized_model or "", self.custom_llm_provider or "") + exception: Final = turn.failure(self.authorized_model or "", self.custom_llm_provider or "", self.logging_obj) if exception is None: asyncio.create_task(turn.logging_obj.dispatch_success_handlers(events, prefer_async_handlers=True)) return @@ -2368,14 +2526,7 @@ class ResponsesWebSocketStreaming: return json.dumps(msg_obj) if modified else message # Delta event types whose ``delta`` field may contain PII tokens. - _DELTA_EVENT_TYPES = frozenset( - { - "response.output_text.delta", - "response.reasoning_summary_text.delta", - "response.refusal.delta", - "response.function_call_arguments.delta", - } - ) + _DELTA_EVENT_TYPES = _PII_DELTA_EVENT_TYPES # Terminal events that carry the full output text or tool-call arguments # already delivered by ``response.completed``. Suppressed when output masking @@ -2415,47 +2566,9 @@ class ResponsesWebSocketStreaming: if not pii_tokens: return response_str - try: - evt_obj: Final = _load_json_object(response_str) - except (json.JSONDecodeError, TypeError): - return response_str - cb: Final = self.guardrail_callbacks[0] unmask_pii_text: Final[_UnmasksPiiText] = getattr(cb, _UNMASK_PII_TEXT_ATTR) - event_type: Final = evt_obj.get("type") - - if event_type == "response.completed": - modified = False - response_obj: Final = evt_obj.get("response") - if not _is_json_object(response_obj): - return response_str - output_items: Final = response_obj.get("output") - for output_item in output_items if _is_json_array(output_items) else []: - if not _is_json_object(output_item): - continue - content = output_item.get("content") - if not _is_json_array(content): - continue - for content_block in content: - if not _is_json_object(content_block): - continue - text = content_block.get("text") - if isinstance(text, str): - unmasked = unmask_pii_text(text, pii_tokens) - if unmasked != text: - content_block["text"] = unmasked - modified = True - return json.dumps(evt_obj) if modified else response_str - - if event_type in self._DELTA_EVENT_TYPES: - delta: Final = evt_obj.get("delta") - if isinstance(delta, str): - unmasked = unmask_pii_text(delta, pii_tokens) - if unmasked != delta: - evt_obj["delta"] = unmasked - return json.dumps(evt_obj) - - return response_str + return _unmask_responses_event(response_str, pii_tokens, unmask_pii_text) async def _mask_response_completed(self, response_str: str) -> str: """ @@ -2549,38 +2662,29 @@ class ResponsesWebSocketStreaming: self.quota_callbacks, self.user_api_key_dict, self.authorized_model, message ) except RateLimitError as e: - try: - await self.websocket.send_text( - json.dumps( - { - "type": "error", - "error": { - "type": "rate_limit_exceeded", - "message": str(e), - }, - } - ) - ) - except Exception: # noqa: BLE001, S110 # client may already be gone - pass + await self._send_error_event("rate_limit_exceeded", str(e)) return False return True + async def _send_error_event(self, error_type: str, message: str) -> None: + try: + await self.websocket.send_text( + json.dumps({"type": "error", "error": {"type": error_type, "message": message}}) + ) + except Exception: # noqa: BLE001, S110 # client may already be gone + pass + async def client_to_backend(self) -> None: """Forward response.create events from client to backend.""" try: if self.first_message is not None and await self._enforce_or_reject_frame(self.first_message): - masked_first: Final = await self._mask_response_create(self.first_message) - self._record_client_frame(masked_first) - await self.backend_ws.send(masked_first) + await self._forward_guarded_frame(self.first_message) while True: message = await self.websocket.receive_text() if not await self._enforce_or_reject_frame(message): continue - masked = await self._mask_response_create(message) - self._record_client_frame(masked) - await self.backend_ws.send(masked) + await self._forward_guarded_frame(message) except Exception as e: verbose_logger.debug("Responses WS client_to_backend ended: %s", e) @@ -2657,10 +2761,15 @@ class ManagedResponsesWebSocketHandler: custom_llm_provider: str | None = None, first_message: str | None = None, quota_callbacks: Sequence[ProjectQuotaCallback] | None = None, + frame_guard: ResponsesWsFrameGuard | None = None, + guardrail_callbacks: Sequence[PresidioGuardrailCallback] | None = None, **kwargs: object, ) -> None: self.websocket = websocket self.model = model + self.frame_guard = frame_guard + self.guardrail_callbacks: tuple[PresidioGuardrailCallback, ...] = tuple(guardrail_callbacks or ()) + self._pii_tokens: Mapping[str, str] = {} self.logging_obj = logging_obj self.user_api_key_dict = user_api_key_dict self.litellm_metadata: Mapping[str, object] = litellm_metadata or {} @@ -2968,6 +3077,27 @@ class ManagedResponsesWebSocketHandler: if self.litellm_metadata: call_kwargs["litellm_metadata"] = dict(self.litellm_metadata) + async def _guard_frame(self, raw_message: str) -> GuardedResponsesWsFrame | None: + """Same per-frame pre_call guardrail walk as the native socket: a rejected frame errors and is not sent.""" + if self.frame_guard is None: + return GuardedResponsesWsFrame(frame=raw_message) + try: + return await self.frame_guard(raw_message) + except Exception as e: # noqa: BLE001 # any guardrail rejection maps to one client-facing error event + verbose_logger.info("ManagedResponsesWS: pre_call guardrail rejected a response.create frame: %s", e) + await self._send_error(str(e), error_type="invalid_request_error") + return None + + @staticmethod + def _metadata_with_turn_guardrails( + existing: object, guardrail_information: tuple[Mapping[str, object], ...] + ) -> Mapping[str, object]: + """Each turn is its own ``litellm.aresponses`` call, so its guardrail records ride on that call's metadata.""" + return { + **(existing if _is_json_object(existing) else {}), + _GUARDRAIL_INFORMATION_KEY: [dict(record) for record in guardrail_information], + } + @staticmethod def _update_proxy_request(call_kwargs: dict[str, Any], model: str) -> None: """Update proxy_server_request body so spend logs record the full request.""" @@ -3011,12 +3141,19 @@ class ManagedResponsesWebSocketHandler: except Exception: pass try: - await self.websocket.send_text(serialized) + await self.websocket.send_text(self._unmask_for_client(serialized)) except Exception as send_exc: verbose_logger.debug("ManagedResponsesWS: error sending chunk to client: %s", send_exc) return terminal_event # Client disconnected return terminal_event + def _unmask_for_client(self, serialized: str) -> str: + """The provider and the turn history keep Presidio's tokens, the client gets the original text back.""" + if not self._pii_tokens or not self.guardrail_callbacks: + return serialized + unmask_pii_text: Final[_UnmasksPiiText] = getattr(self.guardrail_callbacks[0], _UNMASK_PII_TEXT_ATTR) + return _unmask_responses_event(serialized, self._pii_tokens, unmask_pii_text) + def _save_turn_history( self, terminal_event: _MutableJsonObject | None, @@ -3086,7 +3223,13 @@ class ManagedResponsesWebSocketHandler: await self._send_error(str(e), error_type="rate_limit_exceeded") return - call_kwargs: Final = self._build_base_call_kwargs(msg_obj) + guarded: Final = await self._guard_frame(raw_message) + if guarded is None: + return + if guarded.pii_tokens: + self._pii_tokens = {**self._pii_tokens, **guarded.pii_tokens} + + call_kwargs: Final = self._build_base_call_kwargs(_load_json_object(guarded.frame)) call_kwargs["stream"] = True # A frame that repeats the connection's public alias (model_group) must @@ -3106,6 +3249,10 @@ class ManagedResponsesWebSocketHandler: self._apply_history(call_kwargs, previous_response_id, current_messages, prior_history) self._inject_credentials(call_kwargs, model=model) + if guarded.guardrail_information: + call_kwargs["litellm_metadata"] = self._metadata_with_turn_guardrails( + call_kwargs.get("litellm_metadata"), guarded.guardrail_information + ) self._update_proxy_request(call_kwargs, requested_model or self.model_group or model) call_kwargs.update(self.extra_kwargs) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index b16fe72b281..8e5e1c1cfee 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -10,7 +10,7 @@ from typing_extensions import TypeIs # noqa: TID251 # narrows untyped wire pay import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value, is_nested_path -from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig, ReasoningSummaryLevel from litellm.types.llms.openai import ( AllMessageValues, OutputTokensDetails, @@ -1294,3 +1294,19 @@ class ResponseAPILoggingUtils: return chat_usage _transform_response_api_usage_to_chat_usage = transform_response_api_usage_to_chat_usage + + +def auto_reasoning_summary(model: str, custom_llm_provider: object) -> ReasoningSummaryLevel: + """The summary level the auto summary injects for a target, read from its Responses API config.""" + from litellm.utils import ProviderConfigManager + + try: + provider: Final = ( + custom_llm_provider if isinstance(custom_llm_provider, str) else litellm.get_llm_provider(model=model)[1] + ) + except litellm.exceptions.BadRequestError: + return "detailed" + responses_config: Final = ProviderConfigManager.get_provider_responses_api_config( + provider, model.removeprefix(f"{provider}/") + ) + return "detailed" if responses_config is None else responses_config.auto_reasoning_summary() diff --git a/litellm/router.py b/litellm/router.py index 99adc6c5fa6..c30108b4e38 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -81,6 +81,7 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.otel.routing import routing_decision_attributes from litellm.integrations.otel.runtime import phase_attributes, phase_event, phase_span from litellm.litellm_core_utils.asyncify import run_async_function +from litellm.litellm_core_utils.completion_timeout import CompletionTimeout from litellm.litellm_core_utils.core_helpers import ( coerce_token_limit, get_litellm_metadata_from_kwargs, @@ -139,6 +140,7 @@ from litellm.llms.openai_like.model_info import ( MODEL_INFO_REFRESH_SECONDS, get_openai_compatible_model_info, ) +from litellm.responses.bridge_check import keep_registered_mode from litellm.router_strategy.base_routing_strategy import BaseRoutingStrategy from litellm.router_strategy.budget_limiter import RouterBudgetLimiting from litellm.router_strategy.complexity_router.context_compaction import ( @@ -346,6 +348,7 @@ from litellm.types.router import ( DiscoveredDeploymentModelInfo, FallbackAccessCheck, FallbackBudgetCheck, + GenericLiteLLMParams, GuardrailTypedDict, LiteLLM_Params, LiteLLMParamsTypedDict, @@ -510,6 +513,7 @@ _CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS: Final = 3600 CLAUDE_CODE_SESSION_ROUTER_BINDING_TARGET: Final = "claude_code_session_router_binding" _PRE_CALL_CHECK_FILTERED_PARAMS: Final = frozenset({"response_format"}) +_STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS: Final = frozenset({"anthropic_messages", "aresponses", "responses"}) _RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = MappingProxyType( { "prompt_caching": PromptCachingDeploymentCheck, @@ -548,6 +552,10 @@ _SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str]) _RESOLVED_RETRY_POLICY_ADAPTER: Final = TypeAdapter(RetryPolicy | None) _ROUTING_KWARGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None) _FALLBACK_HOP_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_DEPLOYMENT_PARAMS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | GenericLiteLLMParams]] = TypeAdapter( + Mapping[str, object] | GenericLiteLLMParams +) _OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object) _DEPLOYMENT_SELECTED_EVENT: Final = "litellm.request.deployment_selected" _CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]]) @@ -2945,7 +2953,7 @@ class Router: ).start() kwargs.setdefault("messages", messages) - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True) kwargs.pop("silent_model", None) # Ensure it's not in kwargs either model_name = litellm_params["model"] potential_model_client: Final = self._get_client(deployment=deployment, kwargs=kwargs) @@ -4288,7 +4296,7 @@ class Router: ) kwargs.setdefault("messages", messages) - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True) kwargs.pop("silent_model", None) # Ensure it's not in kwargs either model_name = litellm_params["model"] @@ -4553,6 +4561,7 @@ class Router: deployment: dict, kwargs: dict, function_name: str | None = None, + splits_stream_timeout: bool = False, ) -> None: """ 4 jobs: @@ -4670,14 +4679,35 @@ class Router: if self.request_timeout is not None else self.default_litellm_params.get("stream_timeout") ) - kwargs["timeout"] = resolve_llm_passthrough_timeout( - kwargs=kwargs, + passthrough_timeout: Final = partial( + resolve_llm_passthrough_timeout, litellm_params=deployment["litellm_params"], router_timeout=_router_timeout, router_stream_timeout=_router_stream_timeout, ) + kwargs.update( + CompletionTimeout.stream_request_kwargs( + passthrough_timeout(kwargs={**kwargs, "stream": False}), passthrough_timeout(kwargs=kwargs) + ) + if splits_stream_timeout and kwargs.get("stream") + else {"timeout": passthrough_timeout(kwargs=kwargs)} + ) + elif splits_stream_timeout: + request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + deployment_params: Final = _DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"]) + kwargs.update( + CompletionTimeout.stream_request_kwargs( + self._get_non_stream_timeout(kwargs=request_kwargs, data=deployment_params), + self._get_stream_timeout(kwargs=request_kwargs, data=deployment_params) + if request_kwargs.get("stream") + else None, + ) + ) else: - kwargs["timeout"] = self._get_timeout(kwargs=kwargs, data=deployment["litellm_params"]) + kwargs["timeout"] = self._get_timeout( + kwargs=_REQUEST_KWARGS_ADAPTER.validate_python(kwargs), + data=_DEPLOYMENT_PARAMS_ADAPTER.validate_python(deployment["litellm_params"]), + ) self._update_kwargs_with_default_litellm_params(kwargs=kwargs, metadata_variable_name=metadata_variable_name) hop_kwargs: Final = _FALLBACK_HOP_ADAPTER.validate_python(kwargs) @@ -4714,7 +4744,9 @@ class Router: return model_client - def _get_stream_timeout(self, kwargs: dict, data: dict) -> float | int | None: + def _get_stream_timeout( + self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams + ) -> object: """Helper to get stream timeout from kwargs or deployment params""" return ( kwargs.get("stream_timeout", None) # the params dynamically set by user @@ -4724,9 +4756,11 @@ class Router: or self.default_litellm_params.get("stream_timeout", None) ) - def _get_non_stream_timeout(self, kwargs: dict, data: dict) -> float | int | None: + def _get_non_stream_timeout( + self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams + ) -> object: """Helper to get non-stream timeout from kwargs or deployment params""" - timeout: Final = ( + return ( kwargs.get("timeout", None) # the params dynamically set by user or kwargs.get("request_timeout", None) # the params dynamically set by user or data.get("timeout", None) # timeout set on litellm_params for this deployment @@ -4735,18 +4769,11 @@ class Router: or self.timeout # timeout set on router (router_settings.timeout) or self.default_litellm_params.get("timeout", None) ) - return timeout - def _get_timeout(self, kwargs: dict, data: dict) -> float | int | None: + def _get_timeout(self, kwargs: Mapping[str, object], data: Mapping[str, object] | GenericLiteLLMParams) -> object: """Helper to get timeout from kwargs or deployment params""" - timeout: float | int | None = None - if kwargs.get("stream", False): - timeout = self._get_stream_timeout(kwargs=kwargs, data=data) - if timeout is None: - timeout = self._get_non_stream_timeout( - kwargs=kwargs, data=data - ) # default to this if no stream specific timeout set - return timeout + stream_timeout: Final = self._get_stream_timeout(kwargs=kwargs, data=data) if kwargs.get("stream") else None + return stream_timeout if stream_timeout is not None else self._get_non_stream_timeout(kwargs=kwargs, data=data) async def abatch_completion( self, @@ -5088,6 +5115,8 @@ class Router: } ) litellm_logging_object = cast(LiteLLMLogging, litellm_logging_object) + caller_timeout: Final = kwargs.get("timeout") or kwargs.get("request_timeout") + caller_stream_timeout: Final = kwargs.get("stream_timeout") specific_deployment: Final = kwargs.pop("specific_deployment", None) prompt_management_deployment: Final = await self.async_get_available_deployment( model=model, @@ -5096,7 +5125,9 @@ class Router: request_kwargs=kwargs, ) - self._update_kwargs_with_deployment(deployment=prompt_management_deployment, kwargs=kwargs) + self._update_kwargs_with_deployment( + deployment=prompt_management_deployment, kwargs=kwargs, splits_stream_timeout=True + ) data: Final = prompt_management_deployment["litellm_params"].copy() litellm_model: Final = data.get("model", None) @@ -5158,7 +5189,21 @@ class Router: kwargs.pop("original_function") return await litellm.acompletion(**kwargs) - return await self.async_function_with_fallbacks(**kwargs) + target_group_timeouts: Final = { + key: value + for key, value in ( + ("timeout", caller_timeout if caller_timeout is not None else filtered_data.get("timeout")), + ( + "stream_timeout", + caller_stream_timeout if caller_stream_timeout is not None else filtered_data.get("stream_timeout"), + ), + ) + if value is not None + } + return await self.async_function_with_fallbacks( + **{key: value for key, value in kwargs.items() if key not in ("timeout", "stream_timeout")}, + **target_group_timeouts, + ) def image_generation(self, prompt: str, model: str, **kwargs): try: @@ -5595,7 +5640,7 @@ class Router: specific_deployment=kwargs.pop("specific_deployment", None), request_kwargs=kwargs, ) - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True) data: Final = deployment["litellm_params"].copy() model_name: Final = data["model"] @@ -5665,7 +5710,7 @@ class Router: specific_deployment=kwargs.pop("specific_deployment", None), request_kwargs=kwargs, ) - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, splits_stream_timeout=True) data: Final = deployment["litellm_params"].copy() model_name: Final = data["model"] @@ -5900,7 +5945,13 @@ class Router: return await original_generic_function(model=model, **kwargs) raise e - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name=function_name) + self._update_kwargs_with_deployment( + deployment=deployment, + kwargs=kwargs, + function_name=function_name, + splits_stream_timeout=getattr(original_generic_function, "__name__", None) + in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS, + ) data: Final = deployment["litellm_params"].copy() model_name: Final = data["model"] @@ -6055,6 +6106,9 @@ class Router: including pings and provider error frames, is forwarded live and verbatim instead. """ + from litellm.llms.anthropic.pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, + ) from litellm.llms.anthropic.pass_through.messages.streaming_iterator import ( aclose_if_supported, aclose_superseded_attempt, @@ -6066,6 +6120,7 @@ class Router: ) source_iterator: Final = response + replays_a_finished_agentic_turn: Final = isinstance(source_iterator, FakeAnthropicMessagesStreamIterator) async def close_source() -> None: with anyio.CancelScope(shield=True), contextlib.suppress(BaseException): @@ -6126,7 +6181,11 @@ class Router: if not has_generated_content and error_event is None else None ) - if refusal_stop_details is not None and self._refusal_fallback_available(model, initial_kwargs): + if ( + refusal_stop_details is not None + and not replays_a_finished_agentic_turn + and self._refusal_fallback_available(model, initial_kwargs) + ): refusal_error = safeguard_refusal_error(model=model, stop_details=refusal_stop_details) raise MidStreamFallbackError( message=refusal_error.message, @@ -6604,7 +6663,12 @@ class Router: specific_deployment=kwargs.pop("specific_deployment", None), request_kwargs=kwargs, ) - self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="generic_api_call") + self._update_kwargs_with_deployment( + deployment=deployment, + kwargs=kwargs, + function_name="generic_api_call", + splits_stream_timeout=handler_name in _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS, + ) data: Final = deployment["litellm_params"].copy() model_name: Final = data["model"] @@ -11002,7 +11066,6 @@ class Router: ## OLD MODEL REGISTRATION ## Kept to prevent breaking changes backend_keys: Final = Router._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider) - backend_key: Final = backend_keys[0] # For the shared backend key, keep only cost-map schema fields # (minus custom pricing) so that one deployment's pricing overrides @@ -11011,34 +11074,8 @@ class Router: # name. Each deployment's full model_info is already stored under # its unique model_id above. shared_model_info: Final = shared_backend_model_info(model_info) - existing_shared_mode: Final = (cast(dict | None, litellm.model_cost.get(backend_key, {})) or {}).get("mode") - deployment_mode: Final = shared_model_info.get("mode") - # Keep the built-in bridge mode stable for shared backend keys. - # Multiple aliases can point at the same provider/model backend, - # but their deployment-level overrides should not downgrade the - # backend from responses -> chat via last-write-wins registration. - # Only preserve in that specific direction so legitimate upgrades - # (e.g. chat -> responses) and unrelated mode changes still apply, - # and so a missing deployment mode does not silently clear the - # existing shared backend mode. - is_responses_to_chat_downgrade: Final = existing_shared_mode == "responses" and deployment_mode == "chat" - would_clear_existing_mode: Final = existing_shared_mode is not None and deployment_mode is None - if is_responses_to_chat_downgrade or would_clear_existing_mode: - if deployment_mode is not None: - verbose_router_logger.warning( - "Router: preserving existing mode=%s for shared backend " - "key %s instead of the deployment-specified mode=%s " - "(prevents alias registration from downgrading the " - "shared backend mode).", - existing_shared_mode, - backend_key, - deployment_mode, - ) - shared_model_info["mode"] = existing_shared_mode - - # Always register the (possibly mode-preserved) shared backend info. litellm.register_model( - model_cost={_key: shared_model_info for _key in backend_keys}, + model_cost={_key: keep_registered_mode(_key, shared_model_info) for _key in backend_keys}, persist_across_reloads=False, ) @@ -12557,26 +12594,56 @@ class Router: members: Final = routing_group.models if routing_group is not None else () return served_model if served_model == target or served_model in members else model_name - def map_team_model(self, team_model_name: str | None, team_id: str) -> str | None: + def map_team_model( + self, team_model_name: str | None, team_id: str, custom_llm_provider: str | None = None + ) -> str | None: """ - Check if team_model_name resolves to team-specific deployments. + Resolve explicit team models or select a deployment for model-less calls. - Returns the public model name (unchanged) so the router can find all - sibling deployments via team_id filtering, instead of collapsing to a - single internal model_name. - - When team_model_name is None (e.g. vector store / file endpoints that - don't include a model in their request), returns the first matching - team deployment's team_public_model_name so the router can inject BYOK - credentials from the team-scoped deployment. - - Returns: - - str: the team_model_name if team deployments exist for this team - - None: if no team-specific model is found + Model-less calls with no provider use the first team deployment. With a provider, they + use the first team deployment of that provider, pinned by ID when its public name also + covers another provider. """ models: Final = self.get_model_list(model_name=team_model_name, team_id=team_id) if not models: return None + if team_model_name is None and custom_llm_provider is not None: + + def team_deployment_public_name(deployment: DeploymentTypedDict) -> str | None: + model_info: Final = _configured_model_info(deployment) + if model_info.get("team_id") != team_id: + return None + team_public_model_name: Final = model_info.get("team_public_model_name") + return ( + team_public_model_name + if isinstance(team_public_model_name, str) and team_public_model_name + else deployment["model_name"] + ) + + matching_deployment: Final = next( + ( + model + for model in models + if team_deployment_public_name(model) is not None + and provider_for_generic_call(model["litellm_params"]) == custom_llm_provider + ), + None, + ) + if matching_deployment is None: + return None + public_name: Final = team_deployment_public_name(matching_deployment) + if public_name is None: + return None + same_public_name_deployments: Final = tuple( + candidate for candidate in models if team_deployment_public_name(candidate) == public_name + ) + if all( + provider_for_generic_call(candidate["litellm_params"]) == custom_llm_provider + for candidate in same_public_name_deployments + ): + return public_name + deployment_id: Final = _configured_model_info(matching_deployment).get("id") + return deployment_id if isinstance(deployment_id, str) else None for model in models: if model.get("model_info", {}).get("team_id") == team_id: if team_model_name is None: diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index b0da3ed264a..b2f0c658161 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -21,7 +21,11 @@ from litellm.litellm_core_utils.token_counter import offload_token_count from litellm.llms.bedrock.common_utils import bedrock_cache_point_ttl_supported, bedrock_capability_model from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import CallTypes, StandardLoggingPayload -from litellm.utils import get_prompt_cache_min_tokens, is_prompt_caching_valid_prompt +from litellm.utils import ( + get_base_model_from_metadata, + get_prompt_cache_min_tokens, + is_prompt_caching_valid_prompt, +) from ..prompt_caching_cache import PromptCachingCache @@ -61,7 +65,9 @@ def _honors_one_hour_cache_ttl( ) -> bool: if standard_logging_object["custom_llm_provider"] == "bedrock": deployment_model: Final = standard_logging_object["model"] - capability_model: Final = bedrock_capability_model(deployment_model, litellm_params.get("base_model")) + capability_model: Final = bedrock_capability_model( + deployment_model, get_base_model_from_metadata({"litellm_params": litellm_params}) + ) return bedrock_cache_point_ttl_supported(capability_model, deployment_model) model_map_value: Final = standard_logging_object["model_map_information"]["model_map_value"] return model_map_value is not None and (model_map_value.get("cache_creation_input_token_cost_above_1hr") or 0) > 0 diff --git a/litellm/types/completion.py b/litellm/types/completion.py index 97b77b4ca01..a6693030e89 100644 --- a/litellm/types/completion.py +++ b/litellm/types/completion.py @@ -229,6 +229,7 @@ class CompletionDispatchContext: model: str model_response: ModelResponse optional_params: dict + optional_params_for_model_info: Callable[[Mapping[str, object] | None], dict[str, object]] organization: str | None provider_config: BaseConfig | None request_params: Mapping[str, object] @@ -237,6 +238,7 @@ class CompletionDispatchContext: temperature: float | None text_completion: bool timeout: float | str | httpx.Timeout | None + timeout_for_optional_params: Callable[[Mapping[str, object]], float | httpx.Timeout] top_p: float | None diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 33db8cbc6f0..ff063d2e233 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from datetime import datetime from enum import Enum from types import MappingProxyType -from typing import Final, Literal, cast +from typing import Annotated, Final, Literal, TypeAlias, cast from pydantic import ConfigDict, Field, field_validator, model_validator from pydantic.fields import FieldInfo @@ -1012,6 +1012,22 @@ def _default_if_blank(field: FieldInfo | None, value: object) -> object: return field.get_default(call_default_factory=True) +GuardrailNumRetries: TypeAlias = Annotated[ + int | None, + Field( + ge=0, + le=5, + description=( + "How many more times to send a guardrail check whose connection timed out waiting for the answer " + "or was dropped after the request went out. Each retry opens a fresh connection and gets the full " + "timeout. Connect failures, HTTP error statuses and BLOCKED verdicts are not retried. Unset or 0 " + "sends each check once, and the most allowed is 5. Implemented by guardrail='generic_guardrail_api', " + "other guardrails ignore it." + ), + ), +] + + class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch update guardrails api_key: str | None = Field(default=None, description="API key for the guardrail service") api_base: str | None = Field(default=None, description="Base URL for the guardrail service API") @@ -1220,6 +1236,8 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up description="Python-like code containing the apply_guardrail function for custom guardrail logic", ) + num_retries: GuardrailNumRetries = None + timeout: float | None = Field( default=None, description=( @@ -1437,7 +1455,7 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o super().__init__(**kwargs) # pyright: ignore[reportArgumentType] # pydantic validates each field's type at runtime - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: # Define custom behavior for the 'in' operator return hasattr(self, key) diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 3dc7a56239d..0480d6b1f3e 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -9,6 +9,7 @@ from typing_extensions import ( ) from litellm.types.llms.openai import ChatCompletionFileObject, EmbeddingInput +from litellm.types.token_count import TokenCountSourceFields # Gemini supports nested-list inputs (e.g. [["text", "image"]]) as an explicit # opt-in for combined embeddings — a provider-specific extension of the @@ -320,12 +321,13 @@ class UsageMetadata(TypedDict, total=False): candidatesTokensDetails: list[PromptTokensDetails] # Alternative key name used in some responses -class TokenCountDetailsResponse(TypedDict): +class TokenCountDetailsResponse(TokenCountSourceFields): """ Response structure for token count details with modality breakdown. Example: - {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}]} + {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}], + 'litellm_count_source': 'provider'} """ totalTokens: int diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index d34a3a13838..d38e54986b9 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -4,6 +4,7 @@ from typing import Any, Final, Literal, cast # noqa: TID251 # JSON chat rows h from pydantic import ConfigDict, Field, SerializerFunctionWrapHandler, model_serializer from typing_extensions import TypedDict +from litellm.types.guardrails import GuardrailNumRetries from litellm.types.llms.base import LiteLLMBaseModel from litellm.types.llms.openai import ( AllMessageValues, @@ -70,6 +71,8 @@ class GenericGuardrailAPIOptionalParams(LiteLLMBaseModel): ), ) + num_retries: GuardrailNumRetries = None + forward_messages_as_sent: bool | None = Field( default=False, description=( diff --git a/litellm/types/rag.py b/litellm/types/rag.py index 00c070b0996..2f912d21587 100644 --- a/litellm/types/rag.py +++ b/litellm/types/rag.py @@ -219,11 +219,12 @@ class RAGIngestOptions(TypedDict, total=False): class RAGIngestResponse(TypedDict, total=False): """Response from RAG ingest API.""" - id: str # Unique ingest job ID - status: Literal["completed", "in_progress", "failed"] - vector_store_id: str # The vector store ID (created or existing) - file_id: str | None # The file ID in the vector store - error: str | None # Error message if status is "failed" + id: ReadOnly[str] # Unique ingest job ID + status: ReadOnly[Literal["completed", "in_progress", "failed"]] + vector_store_id: ReadOnly[str] # The vector store ID (created or existing) + file_id: ReadOnly[str | None] # The file ID in the vector store + error: ReadOnly[str | None] # Error message if status is "failed" + error_status_code: ReadOnly[int | None] class RAGIngestRequest(LiteLLMBaseModel): diff --git a/litellm/types/router.py b/litellm/types/router.py index 96add8bb2a1..1bd1c2e5502 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -502,7 +502,8 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): itpm: int | None = None otpm: int | None = None timeout: float | str | httpx.Timeout | SDKTimeout | None = None # if str, pass in as os.environ/ - stream_timeout: float | str | None = None # timeout when making stream=True calls, if str, pass in as os.environ/ + # read timeout of a live stream, a faked stream takes the longer of this and timeout. If str, pass in as os.environ/ + stream_timeout: float | str | None = None max_retries: int | None = None drop_params: bool | str | None = None organization: str | None = None # for openai orgs diff --git a/litellm/types/token_count.py b/litellm/types/token_count.py new file mode 100644 index 00000000000..3b21401066c --- /dev/null +++ b/litellm/types/token_count.py @@ -0,0 +1,16 @@ +from typing import Literal, TypeAlias + +from typing_extensions import NotRequired, ReadOnly, TypedDict + +TokenCountSource: TypeAlias = Literal["provider", "token_count_model", "local_estimate"] +TokenCountTokenizer: TypeAlias = Literal["kimi_k3"] + + +class TokenCountModelInfo(TypedDict): + model: ReadOnly[str] + region: ReadOnly[NotRequired[str]] + + +class TokenCountSourceFields(TypedDict, total=False): + litellm_count_source: ReadOnly[TokenCountSource] + litellm_count_model: ReadOnly[str] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 8e56ebdc39b..8b2b319d1e0 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -58,6 +58,7 @@ from litellm.types.llms.base import ( LiteLLMPydanticObjectBase, ) from litellm.types.mcp import MCPServerCostInfo +from litellm.types.token_count import TokenCountModelInfo, TokenCountSource, TokenCountTokenizer from litellm.types.workload_identity import ( ANTHROPIC_WIF_KWARGS_KEYS, OPENAI_WIF_KWARGS_KEYS, @@ -230,6 +231,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False): bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None bedrock_converse_supports_strict_tools: bool | None supports_regex_lookaround: ReadOnly[bool | None] + token_count_framing: ReadOnly[int | None] + token_count_model: ReadOnly[TokenCountModelInfo | None] + token_count_tokenizer: ReadOnly[TokenCountTokenizer | None] class SearchContextCostPerQuery(TypedDict, total=False): @@ -4051,11 +4055,14 @@ DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset( {"supports_regex_lookaround", "supports_assistant_prefill", "supports_bedrock_native_structured_output"} ) +DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"}) + SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = ( frozenset(ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__) - frozenset(CustomPricingLiteLLMParams.model_fields) - DEPLOYMENT_SCOPED_PRICING_FIELDS - DEPLOYMENT_SCOPED_CAPABILITY_FIELDS + - DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS ) @@ -4389,6 +4396,7 @@ class LlmProviders(str, Enum): CORTECS = "cortecs" SCX_AI = "scx-ai" PRISM = "prism" + OPENINFER = "openinfer" INFERENCEAPIS = "inferenceapis" DARKBLOOM = "darkbloom" META = "meta" @@ -4491,6 +4499,7 @@ class TokenCountResponse(LiteLLMPydanticObjectBase): request_model: str model_used: str tokenizer_type: str + count_source: TokenCountSource | None = None original_response: dict | None = None """ Original Response from upstream API call - if an API call was made for token counting @@ -4501,6 +4510,7 @@ class TokenCountResponse(LiteLLMPydanticObjectBase): HTTP status code from the token counting API (e.g., 200 for success, 429 for rate limit, 400 for bad request) """ status_code: int | None = None + rejected_before_provider_call: bool = Field(default=False, exclude=True) class CustomHuggingfaceTokenizer(TypedDict): diff --git a/litellm/utils.py b/litellm/utils.py index fc3acd3065d..142a1745143 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -44,7 +44,7 @@ from httpx import Proxy from httpx._utils import get_environment_proxies from openai.lib import _parsing, _pydantic # pyright: ignore[reportPrivateUsage] # OpenAI parser module is private from openai.types.chat.completion_create_params import ResponseFormat -from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, with_config +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, with_config import litellm import litellm.litellm_core_utils @@ -89,6 +89,7 @@ from litellm.litellm_core_utils.fallback_generalizations import ( match_capability_generalizations, match_fill_missing_generalizations, ) +from litellm.litellm_core_utils.get_model_cost_map import AliasedCostEntry from litellm.litellm_core_utils.model_param_helper import peek_reasoning_summary_aliases from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload from litellm.litellm_core_utils.tokenizer import strip_special_tokens @@ -1481,15 +1482,16 @@ def _get_wrapper_num_retries(kwargs: dict[str, Any], exception: Exception) -> tu return num_retries, kwargs -def _get_wrapper_timeout(kwargs: dict[str, object], exception: Exception) -> float | int | httpx.Timeout | None: - """ - Get the timeout from the kwargs - Used for the wrapper functions. - """ +def _get_wrapper_timeout(kwargs: dict[str, object]) -> object: + from litellm.litellm_core_utils.litellm_logging import Logging - timeout: Final = cast(float | int | httpx.Timeout | None, kwargs.get("timeout", None)) - - return timeout + if kwargs.get("stream") is not True: + return kwargs.get("timeout") + logging_obj: Final = kwargs.get("litellm_logging_obj") + if isinstance(logging_obj, Logging) and logging_obj.applied_timeout is not None: + return logging_obj.applied_timeout + stream_timeout: Final = kwargs.get("stream_timeout") + return kwargs.get("timeout") if stream_timeout is None else stream_timeout def check_coroutine(value) -> bool: @@ -2352,7 +2354,7 @@ def client(original_function): if deployment_num_retries is not None: setattr(e, "num_retries", deployment_num_retries) - timeout: Final = _get_wrapper_timeout(kwargs=kwargs, exception=e) + timeout: Final = _get_wrapper_timeout(kwargs=kwargs) setattr(e, "timeout", timeout) raise e @@ -3303,46 +3305,176 @@ _BEDROCK_REGION_PREFIXES: Final = ( "ap-northeast-1.", ) -_CACHE_PRICING_FIELDS: Final = ( - "cache_creation_input_token_cost", - "cache_creation_input_token_cost_above_1hr", - "cache_creation_input_token_cost_above_200k_tokens", - "cache_read_input_token_cost", - "cache_read_input_token_cost_above_200k_tokens", +_TOKEN_COST_FIELDS: Final = ("input_cost_per_token", "output_cost_per_token") + + +def _builtin_cost_map() -> dict[str, Mapping[str, object]]: + return cast(dict[str, Mapping[str, object]], litellm.model_cost) + + +def _replace_cost_map_entry(cost_map: dict[str, Mapping[str, object]], key: str, entry: Mapping[str, object]) -> None: + current: Final = cost_map.get(key) + merged: Final = {**(current or {}), **entry} + if not isinstance(current, AliasedCostEntry): + cost_map.update({key: merged}) + return + sharing_names: Final = tuple(name for name in current.names if cost_map.get(name) is current) + cost_map.update(dict.fromkeys((key, *sharing_names), AliasedCostEntry(merged, current.names))) + + +def _without_synthesized_costs( + model_info: Mapping[str, object], key: str, value: Mapping[str, object] +) -> Mapping[str, object]: + """``_get_model_info_helper`` synthesizes ``input_cost_per_token`` / ``output_cost_per_token`` = 0 when the raw + entry has neither (#30198). Persisting those zeros would turn a name-priced entry into a free one and make + ``_is_cost_explicitly_configured`` disable budget enforcement on the next re-registration.""" + builtin_cost_map: Final = _builtin_cost_map() + raw_fields: Final = frozenset(builtin_cost_map.get(str(model_info["key"]), builtin_cost_map.get(key, {}))) + return MappingProxyType( + { + name: field_value + for name, field_value in model_info.items() + if name not in _TOKEN_COST_FIELDS or name in raw_fields or name in value + } + ) + + +_DIRECTION_RATE_FIELDS: Final = MappingProxyType( + { + "input_cost_per_token": ("input_cost_per_token",), + "output_cost_per_token": ("output_cost_per_token", "output_cost_per_reasoning_token"), + } ) +_RATE_TABLE: Final = TypeAdapter(Mapping[str, object]) +_RATE_TIERS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) -def _resolve_builtin_model_cost_entry(key: str, provider: str) -> dict[str, object] | None: +def _is_overridden_rate(name: str, overridden_fields: tuple[str, ...]) -> bool: + is_variant: Final = name.startswith(tuple(f"{rate_field}_" for rate_field in overridden_fields)) + return name in overridden_fields or (is_variant and "_cache_" not in name) + + +def _without_overridden_rates(rates: Mapping[str, object], overridden_fields: tuple[str, ...]) -> Mapping[str, object]: + return MappingProxyType( + {name: rate for name, rate in rates.items() if not _is_overridden_rate(name, overridden_fields)} + ) + + +def _rate_table_with_flat_prices( + name: str, table: object, overridden_fields: tuple[str, ...], flat_prices: Mapping[str, object] +) -> object: + try: + if name == "tiered_pricing": + return [ + {**_without_overridden_rates(tier, overridden_fields), **flat_prices} + for tier in _RATE_TIERS.validate_python(table) + ] + if name == "off_peak_pricing": + return dict(_without_overridden_rates(_RATE_TABLE.validate_python(table), overridden_fields)) + except ValidationError: + return table + return table + + +def _without_overridden_price_variants( + entry: Mapping[str, object], value: Mapping[str, object] +) -> Mapping[str, object]: + """A caller's flat ``input_cost_per_token`` / ``output_cost_per_token`` is that direction's price above every + token threshold, for every service tier and batch, in every ``tiered_pricing`` tier and off-peak window, so each + inherited rate that would stand in for it there, the reasoning-token rate included for output, is dropped, and + each tier carries the flat price instead.""" + flat_prices: Final = MappingProxyType( + {cost_field: value[cost_field] for cost_field in _TOKEN_COST_FIELDS if value.get(cost_field) is not None} + ) + overridden_fields: Final = tuple( + itertools.chain.from_iterable(_DIRECTION_RATE_FIELDS[cost_field] for cost_field in flat_prices) + ) + if not overridden_fields: + return entry + return MappingProxyType( + { + name: _rate_table_with_flat_prices(name, field_value, overridden_fields, flat_prices) + for name, field_value in entry.items() + if not _is_overridden_rate(name, overridden_fields) + } + ) + + +@dataclass(frozen=True, slots=True) +class _BuiltinCostMatch: + key: str + entry: Mapping[str, object] + names_the_row: bool + + +@dataclass(frozen=True, slots=True) +class _LookupMiss: + tripped: bool + + +@dataclass(frozen=True, slots=True) +class _FallbackRegistration: + entry: Mapping[str, object] + matched_builtin: bool + + +def _fallback_registration(key: str, provider: str, value: Mapping[str, object]) -> _FallbackRegistration: + """The key keeps its own entry, seeded from the capability rules an unregistered lookup resolves it to, with the + matched built-in entry on top. The matched row's provider stays behind, since the key's own lookup can reject it + (an ``azure_ai/`` key never resolves to an ``openai`` entry), and the key keeps the provider its caller gave.""" + match: Final = _resolve_builtin_model_cost_entry(key=key, provider=provider) + generalized: Final[Mapping[str, object]] = match_capability_generalizations(key) or {} + if match is None: + return _FallbackRegistration(entry=generalized, matched_builtin=False) + inherited: Final = match.entry if match.names_the_row else _without_overridden_price_variants(match.entry, value) + own_fields: Final = {name: field_value for name, field_value in inherited.items() if name != "litellm_provider"} + return _FallbackRegistration(entry={**generalized, **own_fields}, matched_builtin=True) + + +def _resolve_builtin_model_cost_entry(key: str, provider: str) -> _BuiltinCostMatch | None: """Best-effort lookup of a built-in ``model_cost`` entry for a custom key whose shape ``get_model_info`` cannot resolve (repeated provider prefixes like ``bedrock/bedrock/bedrock/us.anthropic.claude-sonnet-4-6`` or region - aliases). + aliases), or whose lookup tripped. - Returns a copy of the matching entry so the caller can inherit its defaults - (most importantly cache pricing) without mutating the shared built-in. - Returns ``None`` when no safe match exists. + The key itself is tried first, then the key behind its provider prefixes + and bedrock route, so a catalog row whose lookup tripped inherits from + itself rather than from a region-stripped sibling priced at the base rate. + ``names_the_row`` is set when the match is the row the key names, which + ``get_model_info`` resolves it to, so the caller keeps that row's rates as + an untripped registration does. Returns ``None`` when no candidate exists + under a provider ``get_model_info`` would accept for ``provider``, or, when + the value names none, for the provider prefix the key itself carries, + which is what ``get_model_info`` derives when it reads the key back. """ - candidates: Final[list[str]] = [] + from litellm.llms.bedrock.common_utils import strip_bedrock_routing_prefix + segments: Final = key.split("/") - idx = 0 - while idx < len(segments) - 1 and segments[idx] in LlmProvidersSet: - idx += 1 - candidates.append("/".join(segments[idx:])) - - base: Final = candidates[-1] if candidates else key - for region_prefix in _BEDROCK_REGION_PREFIXES: - if base.startswith(region_prefix): - candidates.append(base[len(region_prefix) :]) - - if provider: - stripped: Final = _strip_model_name(model=base, custom_llm_provider=provider) - if stripped != base: - candidates.append(stripped) + match_provider: Final = provider or (segments[0] if len(segments) > 1 and segments[0] in LlmProvidersSet else "") + provider_prefix_count: Final = next( + (idx for idx, segment in enumerate(segments[:-1]) if segment not in LlmProvidersSet), len(segments) - 1 + ) + prefix_stripped: Final = tuple("/".join(segments[idx:]) for idx in range(1, provider_prefix_count + 1)) + prefix_free: Final = prefix_stripped[-1] if prefix_stripped else key + base: Final = ( + strip_bedrock_routing_prefix(prefix_free) if match_provider in ("bedrock", "bedrock_converse") else prefix_free + ) + region_stripped: Final = tuple( + base[len(region_prefix) :] for region_prefix in _BEDROCK_REGION_PREFIXES if base.startswith(region_prefix) + ) + stripped: Final = _strip_model_name(model=base, custom_llm_provider=match_provider) if match_provider else base + row_names: Final = (key, *prefix_stripped, base, stripped) + candidates: Final = (key, *prefix_stripped, base, *region_stripped, stripped) + builtin_cost_map: Final = _builtin_cost_map() for candidate in candidates: - entry = litellm.model_cost.get(candidate) - if entry is not None and entry.get("litellm_provider") is not None: - return dict(entry) + entry = builtin_cost_map.get(candidate) + if ( + entry is not None + and entry.get("litellm_provider") is not None + and _check_provider_match(dict(entry), match_provider) + ): + return _BuiltinCostMatch(key=candidate, entry=dict(entry), names_the_row=candidate in row_names) return None @@ -3360,19 +3492,129 @@ def is_generalized_model_info(model_info: ModelInfo) -> bool: return key not in litellm.model_cost and match_capability_generalizations(key) is not None -def _get_builtin_model_info_for_registration(model: str, custom_llm_provider: str | None) -> ModelInfo | None: - """Resolve ``model`` to its built-in cost-map entry for registration merging. +_REGISTRATION_ATTEMPTS: Final = 3 - Returns ``None`` when the lookup raises or when it resolved via a - fallback-generalization capability rule. A rule-derived entry carries no - pricing, so treating it as a hit would skip the built-in cache-pricing - inheritance for prefix-mangled keys. - """ + +def _lookup_for_registration(model: str, custom_llm_provider: str | None) -> ModelInfo | _LookupMiss: try: info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except ModelNotMappedError: + return _LookupMiss(tripped=False) except Exception: - return None - return None if is_generalized_model_info(info) else info + return _LookupMiss(tripped=True) + return _LookupMiss(tripped=False) if is_generalized_model_info(info) else info + + +def _get_builtin_model_info_for_registration(model: str, custom_llm_provider: str | None) -> ModelInfo | _LookupMiss: + """Resolve ``model`` to its built-in cost-map entry for registration merging. + + Misses when the lookup cannot map the model or resolved it via a + fallback-generalization capability rule, since a rule-derived entry carries no + pricing. A lookup that raised anything else, such as a concurrent registration + changing the map mid-walk, is retried, so a transient failure registers exactly + where the untripped lookup would. + """ + lookups: Final = (_lookup_for_registration(model, custom_llm_provider) for _ in range(_REGISTRATION_ATTEMPTS)) + return next( + (lookup for lookup in lookups if not (isinstance(lookup, _LookupMiss) and lookup.tripped)), + _LookupMiss(tripped=True), + ) + + +def _skips_builtin_registration_lookup(key: str, provider: object) -> bool: + return provider in PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO or any( + key.startswith(f"{p}/") for p in PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO + ) + + +@dataclass(frozen=True, slots=True) +class _RegistrationWrite: + key: str + entry: Mapping[str, object] + + +def model_cost_registration_row(key: str, provider: object) -> Mapping[str, object]: + """The row a ``register_model`` write of a ``key`` entry with this ``litellm_provider`` merges onto: the + ``litellm.model_cost`` row it resolves to (``openai/o3-pro`` lands on ``o3-pro``), else the row it seeds ``key`` + with from the built-in entry it falls back to (an OpenAI fine-tune takes its base model's row).""" + base: Final = _registration_base( + key, {"litellm_provider": provider}, custom_llm_provider=None, warning_display_name=None + ) + return _builtin_cost_map().get(base.key) or base.entry + + +def _registration_base( + key: str, value: Mapping[str, object], custom_llm_provider: str | None, warning_display_name: str | None +) -> _RegistrationWrite: + provider: Final = str(value.get("litellm_provider") or "") + if _skips_builtin_registration_lookup(key, provider): + return _RegistrationWrite(key=key, entry=dict(_builtin_cost_map().get(key, {}))) + builtin_model_info: Final = _get_builtin_model_info_for_registration( + model=key, custom_llm_provider=custom_llm_provider + ) + if not isinstance(builtin_model_info, _LookupMiss): + return _RegistrationWrite( + key=builtin_model_info["key"], + entry=dict(_without_synthesized_costs(builtin_model_info, key=key, value=value)), + ) + fallback: Final = _fallback_registration(key=key, provider=provider, value=value) + if not fallback.matched_builtin and ( + value.get("cache_creation_input_token_cost") is None + and value.get("cache_read_input_token_cost") is None + and value.get("tiered_pricing") is None + and (value.get("input_cost_per_token") is not None or value.get("output_cost_per_token") is not None) + ): + verbose_logger.warning( + "register_model: model=%s has custom pricing but not in built-in cost map and no prefix/region variant matched; cache_creation_input_token_cost and cache_read_input_token_cost will default to 0 for this model (input/output cost tracking is unaffected). To track cache cost, add them to model_info", + warning_display_name or key, + ) + return _RegistrationWrite(key=key, entry=dict(fallback.entry)) + + +def _registration_write( + key: str, value: Mapping[str, object], custom_llm_provider: str | None, warning_display_name: str | None +) -> _RegistrationWrite: + base: Final = _registration_base(key, value, custom_llm_provider, warning_display_name) + # ``get_model_info`` returns ``litellm_provider: None`` when the + # provider is unknown (e.g. custom deployments registered via + # ``Router.add_deployment``). Persisting that None into + # ``litellm.model_cost`` causes ``_check_provider_match`` to drop + # custom pricing on subsequent cost lookups. + existing_model: Final = { + name: field_value + for name, field_value in base.entry.items() + if name != "litellm_provider" or field_value is not None + } + return _RegistrationWrite(key=base.key, entry=update_dictionary(existing_model, value)) + + +def _registration_attempt( + key: str, + value: Mapping[str, object], + custom_llm_provider: str | None, + warning_display_name: str | None, + final_attempt: bool, +) -> _RegistrationWrite | None: + source: Final = _builtin_cost_map() + write: Final = _registration_write(key, value, custom_llm_provider, warning_display_name) + _replace_cost_map_entry(_builtin_cost_map() if final_attempt else source, write.key, write.entry) + return write if final_attempt or litellm.model_cost is source else None + + +def _register_on_current_map( + key: str, value: Mapping[str, object], custom_llm_provider: str | None, warning_display_name: str | None +) -> _RegistrationWrite: + """A price data reload that swaps the map while the entry is being built or written would otherwise get the old + map's prices written over its own, or drop the entry with the old map, so each attempt writes into the map it was + built from and the entry is rebuilt when a reload replaced that map before the write finished. The last attempt + writes into the current map, so the entry is never lost.""" + attempts: Final = ( + _registration_attempt( + key, value, custom_llm_provider, warning_display_name, final_attempt=attempt == _REGISTRATION_ATTEMPTS - 1 + ) + for attempt in range(_REGISTRATION_ATTEMPTS) + ) + return next(write for write in attempts if write is not None) _runtime_registered_model_cost: Final[dict[str, dict[str, object]]] = {} # mutable-ok: replayed on reload @@ -3492,79 +3734,15 @@ def register_model( for _registered_key, _registered_value in _registrations.items(): _runtime_registered_model_cost[_registered_key] = dict(_registered_value) - _skip_get_model_info_providers: Final = PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO - for key, value in loaded_model_cost.items(): - ## get model info ## - provider = value.get("litellm_provider", "") - _key_str = str(key) - if provider in _skip_get_model_info_providers or any( - _key_str.startswith(f"{p}/") for p in _skip_get_model_info_providers - ): - existing_model = litellm.model_cost.get(key, {}) - model_cost_key = key - else: - builtin_model_info = _get_builtin_model_info_for_registration( - model=_key_str, custom_llm_provider=custom_llm_provider - ) - if builtin_model_info is not None: - existing_model = cast(dict, builtin_model_info) - model_cost_key = existing_model["key"] - else: - # An exact entry ends the lookup ladder before the capability rules are - # consulted, so seed from them: otherwise registering an unmapped model - # shadows the very defaults it would have resolved to unregistered. - existing_model = dict(match_capability_generalizations(_key_str) or {}) - model_cost_key = key - builtin_entry = _resolve_builtin_model_cost_entry(key=_key_str, provider=provider) - if builtin_entry is not None: - for field in _CACHE_PRICING_FIELDS: - if value.get(field) is None and builtin_entry.get(field) is not None: - existing_model[field] = builtin_entry[field] - elif ( - value.get("cache_creation_input_token_cost") is None - and value.get("cache_read_input_token_cost") is None - and value.get("tiered_pricing") is None - and ( - value.get("input_cost_per_token") is not None or value.get("output_cost_per_token") is not None - ) - ): - verbose_logger.warning( - "register_model: model=%s has custom pricing but not in built-in cost map and no prefix/region variant matched; cache_creation_input_token_cost and cache_read_input_token_cost will default to 0 for this model (input/output cost tracking is unaffected). To track cache cost, add them to model_info", - warning_display_name or key, - ) - # ``get_model_info`` returns ``litellm_provider: None`` when the - # provider is unknown (e.g. custom deployments registered via - # ``Router.add_deployment``). Persisting that None into - # ``litellm.model_cost`` causes ``_check_provider_match`` to drop - # custom pricing on subsequent cost lookups. - if existing_model.get("litellm_provider") is None: - existing_model.pop("litellm_provider", None) - # Same pattern for cost fields (#30198): ``_get_model_info_helper`` - # synthesizes ``input_cost_per_token`` / ``output_cost_per_token`` - # = 0 when they are absent from the raw entry. Writing those zeros - # back flips a sparse entry from "no cost keys" (priced via name) - # to "cost keys = 0" (free), which makes - # ``_is_cost_explicitly_configured`` return True and silently - # disables budget enforcement on the next re-registration. - _raw_entry = litellm.model_cost.get(model_cost_key) - if _raw_entry is None: - _raw_entry = litellm.model_cost.get(key) - if _raw_entry is None: - _raw_entry = {} - for _cost_field in ("input_cost_per_token", "output_cost_per_token"): - if _cost_field not in _raw_entry and _cost_field not in value: - existing_model.pop(_cost_field, None) - ## override / add new keys to the existing model cost dictionary - updated_dictionary = update_dictionary(existing_model, value) - litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary) + write = _register_on_current_map(str(key), value, custom_llm_provider, warning_display_name) # Invalidate case-insensitive lookup map since model_cost was modified _invalidate_model_cost_lowercase_map() - verbose_logger.debug("added/updated model=%s in litellm.model_cost: %s", model_cost_key, model_cost_key) + verbose_logger.debug("added/updated model=%s in litellm.model_cost: %s", write.key, write.key) if add_to_provider_model_sets and value.get("litellm_provider") == "openrouter": - litellm.openrouter_models.add(_key_str.split("/", 1)[-1]) + litellm.openrouter_models.add(str(key).split("/", 1)[-1]) if add_to_provider_model_sets: litellm.add_known_models(loaded_model_cost) return model_cost @@ -5732,7 +5910,7 @@ def _rebuild_model_cost_lowercase_map() -> dict[str, str]: The rebuilt map (guaranteed to be not None). """ global _model_cost_lowercase_map - _model_cost_lowercase_map = {k.lower(): k for k in litellm.model_cost} + _model_cost_lowercase_map = {k.lower(): k for k in tuple(litellm.model_cost)} return _model_cost_lowercase_map @@ -5747,9 +5925,7 @@ def _handle_stale_map_entry_rebuild( Returns: The matched key if found after rebuild, None otherwise. """ - global _model_cost_lowercase_map - _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() - matched_key: Final = _model_cost_lowercase_map.get(potential_key_lower) + matched_key: Final = _rebuild_model_cost_lowercase_map().get(potential_key_lower) if matched_key is not None and matched_key in litellm.model_cost: return matched_key return None @@ -5767,7 +5943,7 @@ def _handle_new_key_with_scan( The matched key if found, None otherwise. """ global _model_cost_lowercase_map - for key in litellm.model_cost: + for key in tuple(_builtin_cost_map()): if key.lower() == potential_key_lower: _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() return key @@ -5789,18 +5965,19 @@ def get_model_cost_key(potential_key: str) -> str | None: called and confirmed not to cause performance issues, add it to the allowed_helpers list in: tests/code_coverage_tests/check_get_model_cost_key_performance.py """ - global _model_cost_lowercase_map - # Exact match (O(1)) if potential_key in litellm.model_cost: return potential_key - # Case-insensitive lookup via map (O(1)) - if _model_cost_lowercase_map is None: - _model_cost_lowercase_map = _rebuild_model_cost_lowercase_map() + # Case-insensitive lookup via map (O(1)); bound locally because another thread's + # registration can invalidate the module-level map at any point + cached_lowercase_map: Final = _model_cost_lowercase_map + lowercase_map: Final = ( + cached_lowercase_map if cached_lowercase_map is not None else _rebuild_model_cost_lowercase_map() + ) potential_key_lower: Final = potential_key.lower() - matched_key = _model_cost_lowercase_map.get(potential_key_lower) + matched_key = lowercase_map.get(potential_key_lower) # Verify key exists (O(1) - handles model_cost.pop() case) if matched_key is not None and matched_key in litellm.model_cost: @@ -6088,11 +6265,13 @@ def get_provider_info(model: str, custom_llm_provider: str | None) -> ProviderSp model=model, provider=LlmProviders(custom_llm_provider) ) - model_info: ProviderSpecificModelInfo | None = None if provider_config: - model_info = provider_config.get_provider_info(model=model) + return provider_config.get_provider_info(model=model) - return model_info + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + json_provider: Final = JSONProviderRegistry.get(custom_llm_provider) if custom_llm_provider else None + return json_provider.provider_info if json_provider is not None and json_provider.provider_info else None def _is_potential_model_name_in_model_cost( @@ -6515,6 +6694,9 @@ def get_model_info_helper( uses_embed_content=_model_info.get("uses_embed_content", None), supports_image_size=_model_info.get("supports_image_size", None), supported_audio_formats=_model_info.get("supported_audio_formats", None), + token_count_framing=_model_info.get("token_count_framing", None), + token_count_model=_model_info.get("token_count_model", None), + token_count_tokenizer=_model_info.get("token_count_tokenizer", None), vertex_ai_audio_api=_model_info.get("vertex_ai_audio_api", None), ) for cost_key, cost_value in _model_info.items(): @@ -6549,7 +6731,7 @@ def _build_model_info( provider_info: Final = get_provider_info(model=model, custom_llm_provider=custom_llm_provider) if provider_info: for key, value in provider_info.items(): - if value is not None: + if value is not None and _model_info.get(key) is None: _model_info[key] = value # if verbose_logger.isEnabledFor(logging.DEBUG): diff --git a/litellm/vector_stores/utils.py b/litellm/vector_stores/utils.py index a2b5a6732a8..eb61491853f 100644 --- a/litellm/vector_stores/utils.py +++ b/litellm/vector_stores/utils.py @@ -1,11 +1,92 @@ +import copy +from collections.abc import Container, Mapping from typing import Final, cast, get_type_hints +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from litellm.constants import ROUTING_REQUEST_TAGS_METADATA_KEY, VECTOR_STORE_CALLER_METADATA_KWARG from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.types.vector_stores import ( VectorStoreCreateOptionalRequestParams, VectorStoreSearchOptionalRequestParams, ) +LITELLM_WRITTEN_METADATA_KEYS: Final = frozenset( + { + "requester_ip_address", + "user_agent", + "endpoint", + "litellm_api_version", + "deployment", + "deployment_model_name", + "api_base", + "agent_id", + "actor_agent_id", + "target_agent_id", + "billing_agent_id", + "agent_execution_mode", + "verified_human_user_id", + "litellm_caller_session_id", + } +) + + +def is_litellm_written_metadata_key(key: str) -> bool: + return key == "user_api_key" or key.startswith("user_api_key_") or key in LITELLM_WRITTEN_METADATA_KEYS + + +def _is_callers_key(key: object) -> bool: + return not isinstance(key, str) or not is_litellm_written_metadata_key(key) + + +def _sent_by_proxy_or_router(params: Mapping[str, object], metadata: Container[object]) -> bool: + return params.get("proxy_server_request") is not None or ROUTING_REQUEST_TAGS_METADATA_KEY in metadata + + +def _callers_metadata(metadata: Mapping[object, object]) -> Mapping[object, object]: + return {key: value for key, value in metadata.items() if _is_callers_key(key)} + + +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object]) + + +class VectorStoreWriteBody(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + metadata: object = None + + +def _json_object(raw: str | bytes) -> Mapping[str, object] | None: + try: + return _JSON_OBJECT.validate_json(raw) + except ValidationError: + return None + + +def _decoded_metadata(metadata: object) -> object: + if not isinstance(metadata, str): + return metadata + decoded: Final = _json_object(metadata) + return metadata if decoded is None else decoded + + +def callers_metadata_snapshot(raw_json_body: bytes | None, parsed_body: VectorStoreWriteBody) -> object: + raw_body: Final = None if raw_json_body is None else _json_object(raw_json_body) + metadata: Final = copy.deepcopy(parsed_body.metadata) if raw_body is None else raw_body.get("metadata") + return _decoded_metadata(metadata) + + +def _metadata_for_provider(params: Mapping[str, object]) -> object: + if VECTOR_STORE_CALLER_METADATA_KWARG in params: + return params[VECTOR_STORE_CALLER_METADATA_KWARG] + metadata: Final = params.get("metadata") + if not isinstance(metadata, Mapping): + return metadata + request_metadata: Final = cast(Mapping[object, object], metadata) # cast-ok: request metadata is a JSON object + if not _sent_by_proxy_or_router(params, request_metadata): + return request_metadata + return _callers_metadata(request_metadata) + class VectorStoreRequestUtils: """Helper utils for constructing Vector Store search requests""" @@ -48,7 +129,11 @@ class VectorStoreRequestUtils: Returns: VectorStoreCreateOptionalRequestParams instance with only the valid parameters """ - valid_keys: Final = get_type_hints(VectorStoreCreateOptionalRequestParams).keys() + valid_keys: Final = get_type_hints(VectorStoreCreateOptionalRequestParams).keys() - {"metadata"} filtered_params: Final = {k: v for k, v in params.items() if k in valid_keys and v is not None} - - return cast(VectorStoreCreateOptionalRequestParams, filtered_params) + metadata: Final = _metadata_for_provider(params) + if metadata is None: + return cast(VectorStoreCreateOptionalRequestParams, filtered_params) + return cast( # cast-ok: same params as above plus the metadata the provider should store + VectorStoreCreateOptionalRequestParams, {**filtered_params, "metadata": metadata} + ) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 58338f185c7..da625869e9a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -2323,6 +2323,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2362,6 +2363,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2401,6 +2403,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2440,6 +2443,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2479,6 +2483,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2518,6 +2523,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2594,6 +2600,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2633,6 +2640,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2672,6 +2680,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2711,6 +2720,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2750,6 +2760,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -2789,6 +2800,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -3016,7 +3028,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -3301,7 +3313,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -10904,7 +10916,6 @@ }, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -10973,7 +10984,6 @@ "output_cost_per_token_batches": 4e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -11005,7 +11015,6 @@ "output_cost_per_token_batches": 4e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supported_endpoints": [ - "/v1/chat/completions", "/v1/batch", "/v1/responses" ], @@ -12877,6 +12886,7 @@ "supports_prompt_caching": true }, "azure_ai/ministral-3b": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 4e-08, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -12940,6 +12950,7 @@ "supports_vision": true }, "azure_ai/mistral-medium-2505": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 4e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, @@ -12977,6 +12988,7 @@ "supports_tool_choice": true }, "azure_ai/mistral-small-2503": { + "deprecation_date": "2026-12-09", "input_cost_per_token": 1e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, @@ -21132,6 +21144,7 @@ "input_cost_per_token": 1.4e-07, "input_dbu_cost_per_token": 2e-06, "litellm_provider": "databricks", + "token_count_framing": 97, "max_input_tokens": 1000000, "max_output_tokens": 393216, "max_tokens": 393216, @@ -21154,6 +21167,11 @@ "supports_tool_choice": true, "supports_vision": false }, + "databricks/system.ai.deepseek-v4-flash-0731": { + "litellm_provider": "databricks", + "mode": "chat", + "token_count_framing": 97 + }, "databricks/databricks-deepseek-v4-pro-0813": { "cache_creation_input_token_cost": 1.31999e-06, "cache_read_input_token_cost": 1.3202e-07, @@ -22122,6 +22140,7 @@ "input_cost_per_token": 2.99999e-06, "input_dbu_cost_per_token": 4.2857e-05, "litellm_provider": "databricks", + "token_count_framing": 100, "max_input_tokens": 1000000, "max_output_tokens": 1048576, "max_tokens": 1048576, @@ -22145,6 +22164,11 @@ "supports_tool_choice": true, "supports_vision": true }, + "databricks/system.ai.kimi-k3": { + "litellm_provider": "databricks", + "mode": "chat", + "token_count_framing": 100 + }, "databricks/databricks-llama-4-maverick": { "cache_creation_input_token_cost": 5.0001e-07, "cache_read_input_token_cost": 5.0001e-07, @@ -24683,7 +24707,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -24746,6 +24770,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "eu.meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.3e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -24753,10 +24778,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.3e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "eu.meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -24764,6 +24791,7 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.9e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, @@ -29699,6 +29727,7 @@ }, "vertex_ai/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -29806,6 +29835,7 @@ "supports_native_streaming": true }, "vertex_ai/gemini-3.8-live": { + "deprecation_date": "2027-09-25", "input_cost_per_audio_token": 3e-06, "input_cost_per_image_token": 1e-06, "input_cost_per_token": 7.5e-07, @@ -31960,6 +31990,7 @@ }, "gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, + "deprecation_date": "2027-09-02", "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, @@ -33882,7 +33913,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -40514,6 +40545,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "meta.llama3-1-405b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 2.4e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40556,6 +40588,7 @@ "supports_tool_choice": false }, "meta.llama3-2-11b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40569,6 +40602,7 @@ "supports_vision": true }, "meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40576,10 +40610,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -40587,10 +40623,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-90b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -45425,6 +45463,38 @@ "output_cost_per_token": 0.0, "source": "https://docs.perplexity.ai/docs/getting-started/pricing" }, + "perplexity/pplx-decider-v1.2": { + "input_cost_per_token": 2e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-latest": { + "input_cost_per_token": 2e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-flash-v1.2": { + "input_cost_per_token": 1e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, + "perplexity/pplx-decider-flash-latest": { + "input_cost_per_token": 1e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 262144, + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://docs.perplexity.ai/docs/decisions/quickstart" + }, "perplexity/sonar": { "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", @@ -48433,6 +48503,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -48467,6 +48538,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -48868,7 +48940,7 @@ "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, + "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", @@ -48947,6 +49019,7 @@ "supports_vision": false }, "us.meta.llama3-1-405b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 2.4e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -48989,6 +49062,7 @@ "supports_tool_choice": false }, "us.meta.llama3-2-11b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49002,6 +49076,7 @@ "supports_vision": true }, "us.meta.llama3-2-1b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49009,10 +49084,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-1b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-3b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -49020,10 +49097,12 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07, + "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-3-2-3b-instruct.md", "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-90b-instruct-v1:0": { + "deprecation_date": "2026-07-07", "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, @@ -65561,6 +65640,40 @@ "supports_vision": false, "source": "https://deepinfra.com/pricing" }, + "deepinfra/deepseek-ai/DeepSeek-V4.1-Flash": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 4.2e-07, + "cache_read_input_token_cost": 4.2e-09, + "supports_prompt_caching": true, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://deepinfra.com/deepseek-ai/DeepSeek-V4.1-Flash" + }, + "deepinfra/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 4.2e-07, + "cache_read_input_token_cost": 4.2e-09, + "supports_prompt_caching": true, + "litellm_provider": "deepinfra", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_vision": true, + "source": "https://deepinfra.com/deepseek-ai/DeepSeek-V4.1-Flash-1017" + }, "deepinfra/openai/gpt-oss-120b-Ultra": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -67074,6 +67187,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67107,6 +67221,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67344,6 +67459,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -67377,6 +67493,7 @@ "supports_max_reasoning_effort": true, "supports_mid_conversation_system": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, "supports_output_config": true, "supports_parallel_tool_use_config": true, "supports_pdf_input": true, @@ -71435,6 +71552,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -71465,6 +71584,8 @@ "input_cost_per_token_flex": 1.5e-06, "input_cost_per_token_priority": 5.25e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -71495,6 +71616,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -72729,6 +72852,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -72861,6 +72985,7 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", @@ -72993,6 +73118,10 @@ "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -73066,6 +73195,10 @@ "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "au.anthropic.claude-sonnet-5-5": { + "token_count_model": { + "model": "au.anthropic.claude-opus-5-5", + "region": "ap-southeast-4" + }, "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 2.75e-06, "cache_creation_input_token_cost_above_1hr": 4.4e-06, @@ -73266,6 +73399,10 @@ "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "global.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -73475,6 +73612,10 @@ "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 2.2e-06, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "us.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74275,6 +74416,8 @@ "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "litellm_provider": "bedrock_converse", + "token_count_framing": 64, + "token_count_tokenizer": "kimi_k3", "max_input_tokens": 1000000, "max_output_tokens": 131072, "max_tokens": 131072, @@ -74448,6 +74591,10 @@ "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74526,6 +74673,10 @@ "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json" }, "au.anthropic.claude-haiku-5-5": { + "token_count_model": { + "model": "au.anthropic.claude-opus-5-5", + "region": "ap-southeast-4" + }, "input_cost_per_token_above_100k_tokens": 5.5e-07, "output_cost_per_token_above_100k_tokens": 2.75e-06, "cache_creation_input_token_cost_above_100k_tokens": 6.875e-07, @@ -74756,6 +74907,10 @@ "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "global.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -74903,6 +75058,10 @@ "cache_read_input_token_cost": 1.1e-08, "input_cost_per_token": 1.1e-07, "litellm_provider": "bedrock_converse", + "token_count_model": { + "model": "us.anthropic.claude-opus-5-5", + "region": "us-east-1" + }, "supports_tool_search": true, "max_input_tokens": 1000000, "max_output_tokens": 128000, @@ -75323,6 +75482,7 @@ "web_search_billing_unit": "per_query" }, "vertex_ai/gemini-4-argon": { + "deprecation_date": "2027-10-13", "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, @@ -75456,6 +75616,42 @@ "supports_reasoning": true, "supports_vision": true }, + "openinfer/@oi/GLM-5.3-Flash": { + "max_tokens": 393216, + "max_input_tokens": 393216, + "max_output_tokens": 393216, + "input_cost_per_token": 1.95e-07, + "output_cost_per_token": 1.56e-06, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, + "openinfer/@oi/Qwen3.5-27B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1.95e-07, + "output_cost_per_token": 1.56e-06, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, + "openinfer/@oi/Qwen3.5-9B": { + "max_tokens": 100000, + "max_input_tokens": 100000, + "max_output_tokens": 100000, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 1.5e-07, + "litellm_provider": "openinfer", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://api.openinfer.ai/v1/models" + }, "xai/grok-voice-3.0": { "input_cost_per_second": 0.001333333333333, "input_cost_per_text_message": 0.004, diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 33a784e2dae..de57fc0616c 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -1210,6 +1210,36 @@ "additionalProperties": false } }, + "token_count_framing": { + "type": "integer", + "minimum": 0, + "description": "Tokens added once per request to a local count estimate, for the provider's chat framing." + }, + "token_count_model": { + "type": "object", + "required": [ + "model" + ], + "additionalProperties": false, + "properties": { + "model": { + "type": "string", + "pattern": "\\S" + }, + "region": { + "type": "string", + "pattern": "\\S" + } + }, + "description": "Model that counts tokens for this one, and the AWS region its count API runs in." + }, + "token_count_tokenizer": { + "type": "string", + "description": "Tokenizer litellm counts this model's requests with when the provider cannot count them.", + "enum": [ + "kimi_k3" + ] + }, "tpm": { "type": "integer", "minimum": 0, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ad38b064903..c2cec2c5490 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -3254,6 +3254,23 @@ "rerank": false, "a2a": false } + }, + "openinfer": { + "display_name": "OpenInfer (`openinfer`)", + "url": "https://docs.litellm.ai/docs/providers/openinfer", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } } }, "endpoints": { diff --git a/terraform/litellm/aws/.terraform.lock.hcl b/terraform/litellm/aws/.terraform.lock.hcl index 30b2a194c4c..79097043254 100644 --- a/terraform/litellm/aws/.terraform.lock.hcl +++ b/terraform/litellm/aws/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = "~> 5.60" hashes = [ "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", @@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" { version = "3.8.1" constraints = "~> 3.6" hashes = [ + "h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=", "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", diff --git a/terraform/litellm/aws/README.md b/terraform/litellm/aws/README.md index 6fcbdc2f500..bedb6f4e98b 100644 --- a/terraform/litellm/aws/README.md +++ b/terraform/litellm/aws/README.md @@ -419,8 +419,9 @@ To run *many* tenants from a single config, call the module with ```hcl module "litellm" { for_each = toset(["acme", "globex"]) - source = "github.com/BerriAI/litellm//terraform/litellm/aws?ref=" - tenant = each.key + source = "github.com/BerriAI/litellm//terraform/litellm/aws?ref=" + image_tag = "" + tenant = each.key env = "prod" region = "us-west-2" azs = ["us-west-2a", "us-west-2b"] @@ -485,6 +486,8 @@ provider "aws" { module "litellm" { source = "github.com/BerriAI/litellm//terraform/litellm/aws?ref=" + image_tag = "" # a git ref ships no image default, a registry version does + region = "us-west-2" tenant = "acme" env = "prod" @@ -499,12 +502,20 @@ top — set org-wide tags there, per-deployment tags via the `tags` input. ## Image pulls -The defaults pull from `ghcr.io/berriai/litellm-:v1.86.0-dev`, +The defaults pull `ghcr.io/berriai/litellm-:${image_tag}`, which is anonymous-readable. There are four images: `litellm-gateway`, `litellm-backend`, `litellm-ui`, and `litellm-migrations` (slim image used only by the one-off migration task — runs `prisma migrate deploy` against -the writer DB and exits). Bump them together when bumping LiteLLM. To pull -from a private registry: +the writer DB and exits). A published module version (Terraform Registry +`BerriAI/litellm/aws`) ships with `image_tag` stamped to its own release +tag, so bumping the module version bumps all four images with it. A git +ref of this monorepo has no published images of its own, so there +`image_tag` is required. Set `image_tag` to pin a different LiteLLM +release, or +`image_registry` to pull the same four images from a mirror. Set +`gateway_image` / `backend_image` / `ui_image` / `migrations_image` only +for a per-component override (custom build, different tag). To pull from a +private registry: - **ECR (same account)**: the execution role already has `AmazonECSTaskExecutionRolePolicy`, which grants ECR pull for repos in diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index 2b235c2bad5..412e6c423f5 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -229,7 +229,7 @@ locals { gateway_metrics_container = local.metrics_enabled ? [ { name = "metrics" - image = var.gateway_image + image = local.gateway_image essential = false entryPoint = ["python", "-m", "litellm.proxy.prometheus_metrics_server"] command = ["--port", tostring(var.gateway_metrics_port)] @@ -306,7 +306,7 @@ locals { collector_container = var.collector_enabled ? [{ name = "collector" - image = var.gateway_image + image = local.gateway_image essential = false cpu = var.collector_cpu memory = var.collector_memory @@ -389,7 +389,7 @@ resource "aws_ecs_task_definition" "gateway" { merge( { name = "gateway" - image = var.gateway_image + image = local.gateway_image essential = true portMappings = [{ containerPort = 4000, protocol = "tcp" }] @@ -499,7 +499,7 @@ resource "aws_ecs_task_definition" "backend" { merge( { name = "backend" - image = var.backend_image + image = local.backend_image essential = true portMappings = [{ containerPort = 4001, protocol = "tcp" }] @@ -591,7 +591,7 @@ resource "aws_ecs_task_definition" "ui" { container_definitions = jsonencode([ { name = "ui" - image = var.ui_image + image = local.ui_image essential = true portMappings = [{ containerPort = 3000, protocol = "tcp" }] diff --git a/terraform/litellm/aws/examples/default/.terraform.lock.hcl b/terraform/litellm/aws/examples/default/.terraform.lock.hcl index 4a059b2b268..ce530fa767b 100644 --- a/terraform/litellm/aws/examples/default/.terraform.lock.hcl +++ b/terraform/litellm/aws/examples/default/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = "~> 5.60" hashes = [ "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", @@ -29,6 +30,7 @@ provider "registry.terraform.io/hashicorp/random" { constraints = "~> 3.6" hashes = [ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", diff --git a/terraform/litellm/aws/examples/default/main.tf b/terraform/litellm/aws/examples/default/main.tf index 115003f7ddd..5e7a65f907f 100644 --- a/terraform/litellm/aws/examples/default/main.tf +++ b/terraform/litellm/aws/examples/default/main.tf @@ -38,6 +38,9 @@ module "litellm" { litellm_license = var.litellm_license ui_password = var.ui_password + image_registry = var.image_registry + image_tag = var.image_tag != "" ? var.image_tag : null + acm_certificate_arn = var.acm_certificate_arn allow_plaintext_alb = var.allow_plaintext_alb s3_force_destroy = var.s3_force_destroy diff --git a/terraform/litellm/aws/examples/default/terraform.tfvars.example b/terraform/litellm/aws/examples/default/terraform.tfvars.example index 880ecf56555..dd392d9ecc5 100644 --- a/terraform/litellm/aws/examples/default/terraform.tfvars.example +++ b/terraform/litellm/aws/examples/default/terraform.tfvars.example @@ -58,12 +58,15 @@ env = "stage" # s3_force_destroy = false # skip_final_snapshot = false -# Component images and per-task sizing/autoscaling are NOT exposed as -# variables in this example (it keeps the curated surface small). They -# default to working public GHCR images. To pin images or tune -# CPU/memory/workers/autoscaling, set those inputs directly on the -# `module "litellm"` block in main.tf — the full list is in -# ../../variables.tf — or call the module from your own root config. +# Images. image_tag is applied to all four litellm-* images. A published +# module version ships with it stamped to its own release tag, a checkout of +# the monorepo has no default and needs it set. image_registry can point at +# an ECR pull-through mirror. Per-component *_image overrides and per-task +# sizing/autoscaling are NOT exposed here. Set them directly on the +# `module "litellm"` block in main.tf (full list in ../../variables.tf) or +# call the module from your own root config. +# image_registry = "ghcr.io/berriai" +# image_tag = "" # ---------- proxy_config (mirrors helm gateway.config.proxy_config) ---------- # proxy_config = { diff --git a/terraform/litellm/aws/examples/default/variables.tf b/terraform/litellm/aws/examples/default/variables.tf index f8140266fca..27c6c95e553 100644 --- a/terraform/litellm/aws/examples/default/variables.tf +++ b/terraform/litellm/aws/examples/default/variables.tf @@ -105,6 +105,18 @@ variable "ui_password" { } # TLS — provide an ACM cert for production, or opt into HTTP-only for dev. +variable "image_registry" { + description = "Registry path prefix; images composed as /litellm-:." + type = string + default = "ghcr.io/berriai" +} + +variable "image_tag" { + description = "Tag applied to all four litellm-* images. Bump in lockstep." + type = string + default = "" +} + variable "acm_certificate_arn" { description = "ACM cert ARN for the ALB HTTPS listener. Empty → no TLS." type = string diff --git a/terraform/litellm/aws/examples/default/versions.tf b/terraform/litellm/aws/examples/default/versions.tf index 73b88e91dce..06ebe5bd2c5 100644 --- a/terraform/litellm/aws/examples/default/versions.tf +++ b/terraform/litellm/aws/examples/default/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.6.0" + required_version = ">= 1.9.0" required_providers { aws = { diff --git a/terraform/litellm/aws/locals.tf b/terraform/litellm/aws/locals.tf index b89bd486d02..c06b171d6bd 100644 --- a/terraform/litellm/aws/locals.tf +++ b/terraform/litellm/aws/locals.tf @@ -11,6 +11,11 @@ locals { # the stack can reference local.name. name = "${var.tenant}-litellm-${var.env}" + gateway_image = var.gateway_image != "" ? var.gateway_image : "${var.image_registry}/litellm-gateway:${var.image_tag}" + backend_image = var.backend_image != "" ? var.backend_image : "${var.image_registry}/litellm-backend:${var.image_tag}" + ui_image = var.ui_image != "" ? var.ui_image : "${var.image_registry}/litellm-ui:${var.image_tag}" + migrations_image = var.migrations_image != "" ? var.migrations_image : "${var.image_registry}/litellm-migrations:${var.image_tag}" + # This is a reusable module — it declares no `provider` block, so the AWS # provider's `default_tags` is the caller's concern, not ours. To keep the # same per-resource tagging the stack had when it owned the provider, the diff --git a/terraform/litellm/aws/migrations.tf b/terraform/litellm/aws/migrations.tf index e924b29eba0..57c33f69a8b 100644 --- a/terraform/litellm/aws/migrations.tf +++ b/terraform/litellm/aws/migrations.tf @@ -28,7 +28,7 @@ resource "aws_ecs_task_definition" "migrations" { container_definitions = jsonencode([{ name = "migrations" - image = var.migrations_image + image = local.migrations_image essential = true # No entryPoint/command override — the image's ENTRYPOINT runs run.py. diff --git a/terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl b/terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl index 5a619bc98b1..702ba37184c 100644 --- a/terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl +++ b/terraform/litellm/aws/tests/byo_infrastructure.tftest.hcl @@ -14,6 +14,7 @@ mock_provider "aws" { mock_provider "random" {} variables { + image_tag = "v9.9.9" region = "us-east-1" tenant = "acme" env = "test" diff --git a/terraform/litellm/aws/tests/collector.tftest.hcl b/terraform/litellm/aws/tests/collector.tftest.hcl index 1465130232f..082f17b2c08 100644 --- a/terraform/litellm/aws/tests/collector.tftest.hcl +++ b/terraform/litellm/aws/tests/collector.tftest.hcl @@ -13,6 +13,7 @@ mock_provider "aws" { mock_provider "random" {} variables { + image_tag = "v9.9.9" region = "us-east-1" tenant = "acme" env = "test" @@ -66,7 +67,7 @@ run "enabled_adds_a_sidecar_that_shares_the_gateway_transport" { assert { condition = ( - local.collector_container[0].image == var.gateway_image && + local.collector_container[0].image == local.gateway_image && local.collector_container[0].entryPoint == ["sh", "-c"] && local.collector_container[0].command == ["exec python -m litellm.proxy.collector"] && local.collector_container[0].essential == false && diff --git a/terraform/litellm/aws/tests/connection_pool.tftest.hcl b/terraform/litellm/aws/tests/connection_pool.tftest.hcl index 7408b1179ed..c618bd11768 100644 --- a/terraform/litellm/aws/tests/connection_pool.tftest.hcl +++ b/terraform/litellm/aws/tests/connection_pool.tftest.hcl @@ -12,6 +12,7 @@ mock_provider "aws" { mock_provider "random" {} variables { + image_tag = "v9.9.9" region = "us-east-1" tenant = "acme" env = "test" diff --git a/terraform/litellm/aws/tests/images.tftest.hcl b/terraform/litellm/aws/tests/images.tftest.hcl new file mode 100644 index 00000000000..2d03a46b7a3 --- /dev/null +++ b/terraform/litellm/aws/tests/images.tftest.hcl @@ -0,0 +1,104 @@ +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } +} +mock_provider "random" {} + +variables { + image_tag = "v9.9.9" + region = "us-east-1" + tenant = "acme" + env = "test" + allow_plaintext_alb = true + azs = ["us-east-1a", "us-east-1b"] +} + +run "every_component_follows_image_registry_and_image_tag" { + command = plan + + variables { + image_registry = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror" + image_tag = "v9.9.9" + } + + assert { + condition = ( + local.gateway_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-gateway:v9.9.9" && + local.backend_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-backend:v9.9.9" && + local.ui_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-ui:v9.9.9" && + local.migrations_image == "123456789012.dkr.ecr.us-east-1.amazonaws.com/mirror/litellm-migrations:v9.9.9" + ) + error_message = "All four images must be composed as /litellm-:." + } +} + +run "an_empty_image_tag_fails_closed_unless_every_image_is_overridden" { + command = plan + + variables { + image_tag = "" + } + + expect_failures = [var.image_tag] +} + +run "a_null_image_tag_takes_the_module_default" { + command = plan + + variables { + image_tag = null + } + + expect_failures = [var.image_tag] +} + +run "four_explicit_uris_need_no_image_tag" { + command = plan + + variables { + image_tag = "" + gateway_image = "example.com/custom/gateway:nightly" + backend_image = "example.com/custom/backend:nightly" + ui_image = "example.com/custom/ui:nightly" + migrations_image = "example.com/custom/migrations:nightly" + } + + assert { + condition = local.migrations_image == "example.com/custom/migrations:nightly" + error_message = "Explicit per-component URIs must satisfy the module without an image_tag." + } +} + +run "composed_defaults_pull_from_ghcr_at_the_image_tag" { + command = plan + + assert { + condition = alltrue([ + for image in [local.gateway_image, local.backend_image, local.ui_image, local.migrations_image] : + startswith(image, "ghcr.io/berriai/litellm-") && endswith(image, ":${var.image_tag}") + ]) + error_message = "With no overrides every component must come from ghcr.io/berriai at image_tag." + } +} + +run "a_per_component_uri_wins_over_the_composed_image" { + command = plan + + variables { + image_tag = "v9.9.9" + gateway_image = "example.com/custom/gateway:nightly" + } + + assert { + condition = ( + local.gateway_image == "example.com/custom/gateway:nightly" && + local.backend_image == "ghcr.io/berriai/litellm-backend:v9.9.9" && + local.ui_image == "ghcr.io/berriai/litellm-ui:v9.9.9" && + local.migrations_image == "ghcr.io/berriai/litellm-migrations:v9.9.9" + ) + error_message = "A per-component override must replace only that component's image." + } +} diff --git a/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl b/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl index de839d7e5be..b330d4b91c3 100644 --- a/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl +++ b/terraform/litellm/aws/tests/metrics_sidecar.tftest.hcl @@ -11,6 +11,7 @@ mock_provider "aws" { mock_provider "random" {} variables { + image_tag = "v9.9.9" region = "us-east-1" tenant = "acme" env = "test" diff --git a/terraform/litellm/aws/tests/workload_autoscaling.tftest.hcl b/terraform/litellm/aws/tests/workload_autoscaling.tftest.hcl index 94e281faf94..ea6c343767c 100644 --- a/terraform/litellm/aws/tests/workload_autoscaling.tftest.hcl +++ b/terraform/litellm/aws/tests/workload_autoscaling.tftest.hcl @@ -11,6 +11,7 @@ mock_provider "aws" { mock_provider "random" {} variables { + image_tag = "v9.9.9" region = "us-east-1" tenant = "acme" env = "test" diff --git a/terraform/litellm/aws/variables.tf b/terraform/litellm/aws/variables.tf index 580a0cc657a..9b7a279a907 100644 --- a/terraform/litellm/aws/variables.tf +++ b/terraform/litellm/aws/variables.tf @@ -135,38 +135,53 @@ variable "azs" { # ---------- Component images ---------- # -# Defaults pin the four componentized images at the same release tag on -# GHCR. Override on a per-component basis in tfvars when bumping; bump them -# together when bumping the LiteLLM release. +variable "image_registry" { + description = "Registry path prefix used to compose the four LiteLLM image URIs as `/litellm-:`. Per-component overrides (`gateway_image`, `backend_image`, `ui_image`, `migrations_image`) bypass this entirely when set." + type = string + default = "ghcr.io/berriai" +} + +variable "image_tag" { + description = "Tag applied to all four litellm-* images when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set." + type = string + default = "" + nullable = false + + validation { + condition = var.image_tag != "" || (var.gateway_image != "" && var.backend_image != "" && var.ui_image != "" && var.migrations_image != "") + error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set all four *_image URIs." + } +} variable "gateway_image" { - description = "Container image for the gateway (data plane, port 4000). Tag must match a tag actually published to GHCR — the split images use the `v`-prefixed semver convention." + description = "Full image URI for the gateway (data plane, port 4000). Empty (default) composes from `image_registry` + `image_tag`." type = string - default = "ghcr.io/berriai/litellm-gateway:v1.86.0-dev" + default = "" } variable "backend_image" { - description = "Container image for the backend (management API, port 4001)." + description = "Full image URI for the backend (management API, port 4001). Empty (default) composes from `image_registry` + `image_tag`." type = string - default = "ghcr.io/berriai/litellm-backend:v1.86.0-dev" + default = "" } variable "ui_image" { - description = "Container image for the UI (nginx static export, port 3000)." + description = "Full image URI for the UI (nginx static export, port 3000). Empty (default) composes from `image_registry` + `image_tag`." type = string - default = "ghcr.io/berriai/litellm-ui:v1.86.0-dev" + default = "" } variable "migrations_image" { description = <<-EOT - Container image for the one-off prisma migration task. Built from + Full image URI for the one-off prisma migration task. Empty (default) + composes from `image_registry` + `image_tag`. Built from `migrations/Dockerfile` — slim image whose ENTRYPOINT runs `python3 /app/run.py` (assembles DATABASE_URL from DATABASE_* env vars via DatabaseURLSettings, then runs `prisma migrate deploy`). Should track the same release tag as gateway/backend/ui. EOT type = string - default = "ghcr.io/berriai/litellm-migrations:v1.86.0-dev" + default = "" } # ---------- Service sizing ---------- diff --git a/terraform/litellm/aws/versions.tf b/terraform/litellm/aws/versions.tf index 73b88e91dce..06ebe5bd2c5 100644 --- a/terraform/litellm/aws/versions.tf +++ b/terraform/litellm/aws/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.6.0" + required_version = ">= 1.9.0" required_providers { aws = { diff --git a/terraform/litellm/gcp/.terraform.lock.hcl b/terraform/litellm/gcp/.terraform.lock.hcl index 013391b53d4..ad8b77ce422 100644 --- a/terraform/litellm/gcp/.terraform.lock.hcl +++ b/terraform/litellm/gcp/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/google" { constraints = "~> 6.10" hashes = [ "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "h1:faTJQOetP9/RYuHwA3r2SWnuYoyzQNm4tUWZrZggcgY=", "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", @@ -26,6 +27,7 @@ provider "registry.terraform.io/hashicorp/google-beta" { constraints = "~> 6.10" hashes = [ "h1:P2GiUJM1frlPtBViwKn1A9V2dVBdGuWcX80w9TdH8ZE=", + "h1:uxh4ME3hhSzVjmiWgA1IQqYqg25MV6FMVArHyA6Ki5o=", "zh:18b442bd0a05321d39dda1e9e3f1bdede4e61bc2ac62cc7a67037a3864f75101", "zh:2e387c51455862828bec923a3ec81abf63a4d998da470cf00e09003bda53d668", "zh:3942e708fa84ebe54996086f4b1398cb747fe19cbcd0be07ace528291fb35dee", @@ -45,6 +47,7 @@ provider "registry.terraform.io/hashicorp/random" { version = "3.8.1" constraints = "~> 3.6" hashes = [ + "h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=", "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index c90af26ebf2..3227dcac179 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -54,11 +54,15 @@ Then point the stack at it via `image_registry`: ```hcl image_registry = "us-central1-docker.pkg.dev/my-gcp-project/litellm/berriai" -image_tag = "v1.86.0-dev" ``` -The four `litellm-:${image_tag}` URIs are composed from those -two vars. Set `gateway_image` / `backend_image` / `ui_image` / +The four `litellm-:${image_tag}` URIs are composed from +`image_registry` and `image_tag`. A published module version (Terraform +Registry `BerriAI/litellm/google`) ships with `image_tag` stamped to its +own release tag, so bumping the module version bumps all four images with +it. A git ref of this monorepo has no published images of its own, so +there `image_tag` is required. Set `image_tag` to pin a different LiteLLM +release. Set `gateway_image` / `backend_image` / `ui_image` / `migrations_image` only if you need a per-component override (custom build, different tag). @@ -485,6 +489,8 @@ provider "google-beta" { module "litellm" { source = "github.com/BerriAI/litellm//terraform/litellm/gcp?ref=" + image_tag = "" # a git ref ships no image default, a registry version does + project = "my-gcp-project" region = "us-central1" tenant = "acme" diff --git a/terraform/litellm/gcp/examples/default/.terraform.lock.hcl b/terraform/litellm/gcp/examples/default/.terraform.lock.hcl index e6285567315..55568afb834 100644 --- a/terraform/litellm/gcp/examples/default/.terraform.lock.hcl +++ b/terraform/litellm/gcp/examples/default/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/google" { constraints = "~> 6.10" hashes = [ "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "h1:faTJQOetP9/RYuHwA3r2SWnuYoyzQNm4tUWZrZggcgY=", "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", @@ -26,6 +27,7 @@ provider "registry.terraform.io/hashicorp/google-beta" { constraints = "~> 6.10" hashes = [ "h1:P2GiUJM1frlPtBViwKn1A9V2dVBdGuWcX80w9TdH8ZE=", + "h1:uxh4ME3hhSzVjmiWgA1IQqYqg25MV6FMVArHyA6Ki5o=", "zh:18b442bd0a05321d39dda1e9e3f1bdede4e61bc2ac62cc7a67037a3864f75101", "zh:2e387c51455862828bec923a3ec81abf63a4d998da470cf00e09003bda53d668", "zh:3942e708fa84ebe54996086f4b1398cb747fe19cbcd0be07ace528291fb35dee", @@ -46,6 +48,7 @@ provider "registry.terraform.io/hashicorp/random" { constraints = "~> 3.6" hashes = [ "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", diff --git a/terraform/litellm/gcp/examples/default/deploystack.json b/terraform/litellm/gcp/examples/default/deploystack.json index 47d1fd914ce..732d4a187b1 100644 --- a/terraform/litellm/gcp/examples/default/deploystack.json +++ b/terraform/litellm/gcp/examples/default/deploystack.json @@ -25,7 +25,7 @@ { "name": "image_tag", "description": "Tag for the four litellm-* images (gateway, backend, ui, migrations). Bump together when bumping LiteLLM", - "default": "v1.86.0-dev" + "default": "" }, { "name": "image_registry", diff --git a/terraform/litellm/gcp/examples/default/main.tf b/terraform/litellm/gcp/examples/default/main.tf index 782d4ee4b65..9b18a6df863 100644 --- a/terraform/litellm/gcp/examples/default/main.tf +++ b/terraform/litellm/gcp/examples/default/main.tf @@ -41,7 +41,7 @@ module "litellm" { ui_password = var.ui_password image_registry = var.image_registry - image_tag = var.image_tag + image_tag = var.image_tag != "" ? var.image_tag : null lb_domains = var.lb_domains allow_plaintext_lb = var.allow_plaintext_lb diff --git a/terraform/litellm/gcp/examples/default/terraform.tfvars.example b/terraform/litellm/gcp/examples/default/terraform.tfvars.example index ec6206734e2..6456b525d08 100644 --- a/terraform/litellm/gcp/examples/default/terraform.tfvars.example +++ b/terraform/litellm/gcp/examples/default/terraform.tfvars.example @@ -38,12 +38,13 @@ env = "stage" # Images. Cloud Run rejects ghcr.io, so a real deploy must point # image_registry at an Artifact Registry remote repo (see README "Image -# pulls"); image_tag is applied to all four litellm-* images. Per-component -# *_image overrides are NOT exposed here — set them directly on the -# `module "litellm"` block in main.tf (see ../../variables.tf) if you need -# to mix-and-match versions. +# pulls"); image_tag is applied to all four litellm-* images. A published +# module version ships with it stamped to its own release tag, a checkout of +# the monorepo has no default and needs it set. Per-component *_image overrides are +# NOT exposed here. Set them directly on the `module "litellm"` block in +# main.tf (see ../../variables.tf) if you need to mix-and-match versions. # image_registry = "us-central1-docker.pkg.dev/my-gcp-project/litellm/berriai" -# image_tag = "v1.86.0-dev" +# image_tag = "" # ---------- proxy_config (mirrors helm gateway.config.proxy_config) ---------- # proxy_config = { diff --git a/terraform/litellm/gcp/examples/default/variables.tf b/terraform/litellm/gcp/examples/default/variables.tf index 08b78346df3..dcf84572fd7 100644 --- a/terraform/litellm/gcp/examples/default/variables.tf +++ b/terraform/litellm/gcp/examples/default/variables.tf @@ -85,7 +85,7 @@ variable "image_registry" { variable "image_tag" { description = "Tag applied to all four litellm-* images. Bump in lockstep." type = string - default = "v1.86.0-dev" + default = "" } # TLS — provide DNS names for a managed cert, or opt into HTTP-only for dev. diff --git a/terraform/litellm/gcp/examples/default/versions.tf b/terraform/litellm/gcp/examples/default/versions.tf index a630c59afd0..55214b5c51a 100644 --- a/terraform/litellm/gcp/examples/default/versions.tf +++ b/terraform/litellm/gcp/examples/default/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.6.0" + required_version = ">= 1.9.0" required_providers { google = { diff --git a/terraform/litellm/gcp/tests/collector.tftest.hcl b/terraform/litellm/gcp/tests/collector.tftest.hcl index 7a5ea781acb..f16d02d9c7e 100644 --- a/terraform/litellm/gcp/tests/collector.tftest.hcl +++ b/terraform/litellm/gcp/tests/collector.tftest.hcl @@ -7,6 +7,7 @@ mock_provider "google-beta" {} mock_provider "random" {} variables { + image_tag = "v9.9.9" project_id = "acme-test" region = "us-central1" tenant = "acme" diff --git a/terraform/litellm/gcp/tests/connection_pool.tftest.hcl b/terraform/litellm/gcp/tests/connection_pool.tftest.hcl index 999e4f0ff95..c0ad842ff95 100644 --- a/terraform/litellm/gcp/tests/connection_pool.tftest.hcl +++ b/terraform/litellm/gcp/tests/connection_pool.tftest.hcl @@ -18,6 +18,7 @@ mock_provider "google-beta" {} mock_provider "random" {} variables { + image_tag = "v9.9.9" project_id = "test-project" tenant = "tenant" env = "test" diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl index 610c49d5b52..11815af9051 100644 --- a/terraform/litellm/gcp/tests/deps_only.tftest.hcl +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -14,6 +14,7 @@ mock_provider "google-beta" {} mock_provider "random" {} variables { + image_tag = "v9.9.9" project_id = "test-project" tenant = "tenant" env = "test" @@ -112,6 +113,20 @@ run "deps_only_creates_no_runtime" { } } +run "deps_only_needs_no_image_tag" { + command = plan + + variables { + create_runtime = false + image_tag = "" + } + + assert { + condition = length(google_cloud_run_v2_service.gateway) == 0 + error_message = "deps-only mode must plan without an image tag since it deploys no images" + } +} + run "existing_network_attaches_data_stores" { command = plan diff --git a/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl b/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl index 7de62065d23..fb5446b4c56 100644 --- a/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl +++ b/terraform/litellm/gcp/tests/metrics_sidecar.tftest.hcl @@ -14,6 +14,7 @@ mock_provider "google-beta" {} mock_provider "random" {} variables { + image_tag = "v9.9.9" project_id = "test-project" tenant = "tenant" env = "test" diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 412f919ab89..6161e893e02 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -144,9 +144,15 @@ variable "image_registry" { } variable "image_tag" { - description = "Tag applied to all four litellm-* images when composed from `image_registry`. Bump in lockstep when bumping LiteLLM. Must match a tag actually published to GHCR — the split images use the `v`-prefixed semver convention (e.g. `v1.86.0-dev`)." + description = "Tag applied to all four litellm-* images when composed from `image_registry`. A published module version ships with this stamped to its own release tag, which matches the `v`-prefixed tag published to GHCR for the same LiteLLM release. Set it to pin a different LiteLLM release. When sourcing the module from a git ref of the monorepo it has no default and must be set." type = string - default = "v1.86.0-dev" + default = "" + nullable = false + + validation { + condition = !var.create_runtime || var.image_tag != "" || (var.gateway_image != "" && var.backend_image != "" && var.ui_image != "" && var.migrations_image != "") + error_message = "image_tag is empty. Published module versions (registry.terraform.io/modules/BerriAI/litellm) ship with it stamped to their own release. A git ref of the monorepo has no published images of its own, so set image_tag to a published LiteLLM release tag or set all four *_image URIs." + } } variable "gateway_image" { diff --git a/terraform/litellm/gcp/versions.tf b/terraform/litellm/gcp/versions.tf index a630c59afd0..55214b5c51a 100644 --- a/terraform/litellm/gcp/versions.tf +++ b/terraform/litellm/gcp/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.6.0" + required_version = ">= 1.9.0" required_providers { google = { diff --git a/tests/code_coverage_tests/check_e2e_no_raw_requests.py b/tests/code_coverage_tests/check_e2e_no_raw_requests.py index 4316f1b57b6..bea177d3b7d 100644 --- a/tests/code_coverage_tests/check_e2e_no_raw_requests.py +++ b/tests/code_coverage_tests/check_e2e_no_raw_requests.py @@ -25,7 +25,6 @@ ALLOWED_RAW_CLIENT_FILES = { "e2e/e2e_http.py": ("requests",), "e2e/conftest.py": ("requests",), "e2e/claude_code/pr_gate_version_resolver.py": ("urllib.request",), - "e2e/mcp/oauth_chat_client.py": ("httpx",), } EXCEPTION_ONLY_NAMES = frozenset({"RequestException", "ConnectionError", "Timeout", "HTTPError"}) diff --git a/tests/code_coverage_tests/ensure_async_clients_test.py b/tests/code_coverage_tests/ensure_async_clients_test.py index 5a534199cbc..93ae2e2837b 100644 --- a/tests/code_coverage_tests/ensure_async_clients_test.py +++ b/tests/code_coverage_tests/ensure_async_clients_test.py @@ -5,6 +5,9 @@ ALLOWED_FILES = [ # Lens data traffic owns one pool per app lifespan, isolated from model traffic and closed on shutdown. "../../litellm/tracing/remote.py", "./litellm/tracing/remote.py", + # Only a guardrail check retry opens a client, on purpose to get a new connection, and closes it right after. + "../../litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py", + "./litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py", # local files "../../litellm/__init__.py", "../../litellm/llms/custom_httpx/http_handler.py", diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py deleted file mode 100644 index c4d12d1282c..00000000000 --- a/tests/code_coverage_tests/test_e2e_changed_gate.py +++ /dev/null @@ -1,156 +0,0 @@ -import os -import subprocess -import sys -import xml.etree.ElementTree as ET -from pathlib import Path -from typing import Final - -import pytest - -GATE: Final = Path(__file__).resolve().parents[2] / ".github/e2e-stack/assert_tests_ran.py" -SELECTED: Final = ("tests/e2e/access_control/test_a.py", "tests/e2e/access_control/test_b.py") - - -@pytest.mark.parametrize( - ("second_outcome", "expected_status"), - (("passed", 0), ("skipped", 1), ("failure", 1), ("error", 1), ("deselected", 1)), -) -def test_each_changed_file_must_run(tmp_path: Path, second_outcome: str, expected_status: int) -> None: - suite: Final = ET.Element("testsuite") - _ = ET.SubElement(suite, "testcase", file=SELECTED[0]) - if second_outcome != "deselected": - second: Final = ET.SubElement(suite, "testcase", file=SELECTED[1]) - if second_outcome != "passed": - _ = ET.SubElement(second, second_outcome) - report: Final = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - - result: Final = subprocess.run([sys.executable, str(GATE), str(report), *SELECTED], capture_output=True, text=True) - - assert result.returncode == expected_status, result.stdout - - -@pytest.mark.parametrize("outcome", ("failure", "error")) -def test_passing_case_does_not_hide_a_failure_in_the_same_file(tmp_path: Path, outcome: str) -> None: - suite: Final = ET.Element("testsuite") - _ = ET.SubElement(suite, "testcase", file=SELECTED[0]) - failed: Final = ET.SubElement(suite, "testcase", file=SELECTED[0]) - _ = ET.SubElement(failed, outcome) - report: Final = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - - result: Final = subprocess.run( - [sys.executable, str(GATE), str(report), SELECTED[0]], capture_output=True, text=True - ) - - assert result.returncode == 1 - - -def test_failed_cases_are_named_per_selected_file(tmp_path: Path) -> None: - suite: Final = ET.Element("testsuite") - _ = ET.SubElement(suite, "testcase", file=SELECTED[0], classname="tests.e2e.access_control.test_a", name="test_ok") - failed: Final = ET.SubElement( - suite, "testcase", file=SELECTED[0], classname="tests.e2e.access_control.test_a", name="test_boom" - ) - _ = ET.SubElement(failed, "failure", message="secret-bearing message") - errored: Final = ET.SubElement( - suite, "testcase", file=SELECTED[1], classname="tests.e2e.access_control.test_b", name="test_setup" - ) - _ = ET.SubElement(errored, "error") - report: Final = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - - result: Final = subprocess.run([sys.executable, str(GATE), str(report), *SELECTED], capture_output=True, text=True) - - assert result.returncode == 1 - assert " failed: tests.e2e.access_control.test_a::test_boom\n" in result.stdout - assert " failed: tests.e2e.access_control.test_b::test_setup\n" in result.stdout - assert "test_ok" not in result.stdout - assert "secret-bearing message" not in result.stdout - - -@pytest.mark.parametrize("contents", ("", "')) -def test_missing_execution_evidence_fails(tmp_path: Path, contents: str) -> None: - report: Final = tmp_path / "report.xml" - _ = report.write_text(contents) - - result: Final = subprocess.run([sys.executable, str(GATE), str(report), *SELECTED], capture_output=True, text=True) - - assert result.returncode == 1 - - -@pytest.mark.parametrize("omitted_role", ("proxy_admin", "team_member", "internal_user_viewer")) -def test_one_passing_management_case_cannot_hide_a_missing_actor(tmp_path: Path, omitted_role: str) -> None: - suite: Final = ET.Element("testsuite") - path: Final = "tests/e2e/management/test_jwt_management_e2e.py" - case: Final = ET.SubElement(suite, "testcase", file=path) - properties: Final = ET.SubElement(case, "properties") - _ = ET.SubElement( - properties, - "property", - name="management_node", - value=f"{path}::TestJwtManagement::test_actor_subject_and_database_role[proxy_admin_viewer]", - ) - report: Final = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - result: Final = subprocess.run([sys.executable, str(GATE), str(report), path], capture_output=True, text=True) - assert result.returncode == 1 - assert f"test_actor_subject_and_database_role[{omitted_role}]" in result.stdout - - -@pytest.mark.parametrize("phase", ("setup", "call", "teardown")) -@pytest.mark.parametrize("required_count", ("1", "4")) -def test_oauth_failure_diagnostics_do_not_publish_private_payloads( - tmp_path: Path, phase: str, required_count: str -) -> None: - suite: Final = ET.Element("testsuite") - case: Final = ET.SubElement(suite, "testcase", file=SELECTED[0]) - private: Final = "private-token-in-exception-message" - failure: Final = ET.SubElement(case, "failure", message=private) - failure.text = private - properties: Final = ET.SubElement(case, "properties") - for name, value in ( - ("oauth_failure_phase", phase), - ("oauth_exception_type", "AssertionError"), - ("oauth_frame", "oauth_gateway.py:120:start"), - ("oauth_frame", f"injected\\n{private}"), - ("unrelated_property", private), - ): - _ = ET.SubElement(properties, "property", name=name, value=value) - report: Final = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - result: Final = subprocess.run( - [sys.executable, "-I", str(GATE), str(report), SELECTED[0]], - capture_output=True, - text=True, - env={**os.environ, "E2E_REQUIRED_TEST_COUNT": required_count}, - ) - assert result.returncode == 1 - assert f"oauth_failure_phase: {phase}" in result.stdout - assert "oauth_exception_type: AssertionError" in result.stdout - assert "oauth_frame: oauth_gateway.py:120:start" in result.stdout - assert private not in result.stdout + result.stderr - - -@pytest.mark.parametrize( - ("count", "skip", "expected"), ((0, False, 1), (3, False, 1), (4, False, 0), (5, False, 1), (4, True, 1)) -) -def test_required_count_reports_cases_before_rejecting(tmp_path: Path, count: int, skip: bool, expected: int) -> None: - suite = ET.Element("testsuite") - for index in range(count): - case = ET.SubElement(suite, "testcase", file=SELECTED[0], classname="OAuth", name=f"variant{index}") - if skip and index == 0: - ET.SubElement(case, "skipped", message="private-skip-reason") - report = tmp_path / "report.xml" - ET.ElementTree(suite).write(report) - result = subprocess.run( - [sys.executable, "-I", str(GATE), str(report), SELECTED[0]], - env={**os.environ, "E2E_REQUIRED_TEST_COUNT": "4"}, - capture_output=True, - text=True, - ) - assert result.returncode == expected - assert f"{count} collected, {int(skip)} skipped" in result.stdout - if skip: - assert "skipped: OAuth::variant0" in result.stdout - assert "private-skip-reason" not in result.stdout + result.stderr diff --git a/tests/code_coverage_tests/test_e2e_junit_report.py b/tests/code_coverage_tests/test_e2e_junit_report.py index 140b9ba6dca..be5fcd245db 100644 --- a/tests/code_coverage_tests/test_e2e_junit_report.py +++ b/tests/code_coverage_tests/test_e2e_junit_report.py @@ -172,12 +172,7 @@ def open_consent() -> None: raise RuntimeError("consent page timed out") -@pytest.mark.mcp_oauth_live -def test_oauth_dies_on_consent() -> None: - open_consent() - - -def test_plain_dies_on_consent() -> None: +def test_dies_on_consent() -> None: open_consent() """ @@ -353,16 +348,13 @@ class TestStepsReachTheReport: def test_a_failed_phase_s_own_report_carries_the_steps(tmp_path: Path) -> None: """Plugins that read the failed setup or call report, not the teardown one - junitxml writes from, see where the test died too, oauth-live or not.""" + junitxml writes from, see where the test died too.""" write_suite(tmp_path, {"test_consent.py": FAILED_PHASE_SUITE, "report_spy.py": REPORT_SPY_PLUGIN}) - child: Final = run_child_pytest(tmp_path, "-p", "report_spy", env={"E2E_MCP_OAUTH_LIVE": "1"}) + child: Final = run_child_pytest(tmp_path, "-p", "report_spy") seen_path: Final = tmp_path / "failed-reports.jsonl" assert seen_path.exists(), f"no failed report reached the spy:\n{child.stdout}\n{child.stderr}" seen: Final = dict(map(FailedReport.validate_json, seen_path.read_text().splitlines())) - assert seen == { - "test_oauth_dies_on_consent": ("open the consent page",), - "test_plain_dies_on_consent": ("open the consent page",), - }, child.stdout + assert seen == {"test_dies_on_consent": ("open the consent page",)}, child.stdout class TestDeclaredPropertiesReachTheReport: diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index d267059cdc7..b32cfb25fa6 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -40,10 +40,10 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family - `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`) - `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials (API surface, not Playwright) - `a2a/` - the A2A (agent-to-agent) surface: admin registration via `/v1/agents`, proxy-fronted card discovery at `/.well-known/agent-card.json`, and JSON-RPC `message/send` invocation, driving agents backed by the litellm completion bridge (a real provider) and asserting protocol-version normalization (0.3 vs 1.0) -- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below), plus the gateway-managed OAuth (authorization_code) path exercised through `/chat/completions` in `test_mcp_chat_completion_oauth_e2e.py` and direct MCP protocol operations in `test_mcp_oauth_happy_path_e2e.py`, the one behavior Datadog's static-header auth cannot reach, seeding the per-user upstream token via the interactive authorize dance driven with the mcp SDK's own OAuth client (headless-browser consent from a saved session) and asserting the completion or protocol call lists and executes the server's tools with the stored per-user token +- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below) - `logging/` - logging-integration delivery (datadog and friends) - `security/` - secret handling and log-leak protection -- `router/` - routing and reliability behavior (fallbacks, cooldowns) plus the memory tests (`test_reliability_memory_e2e.py`: every worker's RSS as read at collection time, before any test traffic, must sit under a fixed idle budget, the release-gate check for a DB-backed boot that idles near the pod limit the way v1.100.x did, and a few hundred failing requests with retries and fallbacks must not grow proxy RSS past a fixed budget nor store a request snapshot past a fixed size, the release-gate check for the v1.100.0 retry-breadcrumb leak) +- `router/` - routing and reliability behavior (fallbacks, cooldowns) plus the memory tests (`test_reliability_memory_e2e.py`: every worker's RSS as read at collection time, before any test traffic, must sit under a fixed idle budget, the release-gate check for a DB-backed boot that idles near the pod limit the way v1.100.x did, and a few hundred failing requests with retries and fallbacks must not grow proxy RSS past a loose ceiling over the measured batches nor store a request snapshot past a fixed size, the release-gate check for the v1.100.0 retry-breadcrumb leak) - `perf/` - performance-category tests, kept OUT of the main suite: throughput/load SLO tests are a different testing category from functional e2e (variance-driven, historically flaky) and live outside this suite until re-implemented as their own pipeline (LIT-5163). Do not add a live load test that runs in the default collection. What lives here: the weekly session-anomaly test (`test_weekly_session_anomaly_e2e.py`, Claude Code-shaped multi-turn sessions against real providers with ceilings on error rate, cache read/write, turn time, and spend, marked `weekly` and deselected unless `E2E_WEEKLY_ANOMALY` is set, driven by `.github/workflows/weekly_load_anomaly.yml`), the Redis chaos test (`test_redis_chaos_e2e.py`, locust load against mock deployments split round robin over `/chat/completions` and `/v1/messages`, one endpoint per simulated user, with `CLIENT PAUSE ALL` on the proxy's Redis mid-run to simulate it being down outright, asserting zero failed requests on every endpoint, budgeting RSS and CPU-per-request as ratios against the same run's healthy phase, and holding p50/p90/p99 latency and log-bytes-per-request to flat ceilings (a ratio cannot bound those two: an open breaker skips Redis instead of waiting on it, so the chaos phase can measure cheaper than baseline while still being far slower than a user should see), needs a proxy booted from `gateway/redis_chaos_ci_config.yml` on the same host with `E2E_PROXY_PID` and `E2E_PROXY_LOG` set, marked `redis_chaos`, deselected unless `E2E_PERF_TESTS` is set and excluded from the per-PR selector like the rest of `perf/`, driven by `.github/workflows/test-e2e-redis-chaos.yml` and by the Buildkite `e2e-redis-chaos` step in project-releaser, which runs the proxy, Postgres and Valkey co-located with pytest in one pod and sets the opt-in). New perf tests go here too, marked `perf`, behind the same `E2E_PERF_TESTS` opt-in. The perf cases (`test_perf_cases.py`, one `PerformanceTestCase` per module in `perf/cases/`, no `_e2e` suffix) are the request-performance runs. A case names what the gateway was deployed with (model groups and guardrails, written out in the gateway's config: the `proxy_config` block of project-releaser's perf helm values in the lane, `gateway/perf_ci_config.yml` locally, with each deployment's mock knobs in an `x-mock-knobs` extra header so the model ids are real ones like `gpt-6-luna` and `anthropic.claude-sonnet-5`) and lists what the test creates and sends: teams, Keycloak JWT users, virtual keys, `RequestShape`s (a coding-agent session from `perf/setup/corpus/` whose input-token size is drawn per request from the shape's range, with or without tools, and the output cap), a `Traffic` plan (seed, rps, request count, endpoint, shape and stream shares) and `Limit`s (one metric at one percentile under one ceiling). The infra side lives in `perf/setup/`: `provision.py` checks the deployed config matches the case and builds the callers, `plan.py` draws the request list from the seed, `run.py` replays it from a separate Locust process (`locustfile.py`) that builds each body right before sending and records status, header, first-byte, first-token and total time per request, and `observe.py` joins all of that with what the mocks received and the workers' RSS to score every `Limit`. Limits are about what the caller sees and what the gateway costs: every non-blocked request is a 200 (`unexpected_status_count`), the seconds the gateway added per request at p50 and p99 (`gateway_added_seconds`, to the first token on streams), the client's time to the first token of a stream at p50 and p99 (`time_to_first_token_seconds`, mock delay included), worker memory growth (`memory_growth_megabytes`), and the Postgres statements and Redis commands a request waits on before it is forwarded (`postgres_calls_per_request`, `redis_calls_per_request`: auth and routing reads, the rate-limiter script), with the work the gateway does after the response or on a timer (spend writes, daily aggregates, config reloads) reported separately as `postgres_background_calls_per_request` and `redis_background_calls_per_request`; `datastore.py` reads `pg_stat_statements` before and after the run and holds a Redis `MONITOR` connection during it, sorts each statement by verb and table and each command by key, and the run output lists the top statements and commands per bucket so a red run names what grew. Whether a body, a guardrail call or a spend row is right is printed as a diagnostic, never a limit, that is the functional suites' job. The providers and the guardrail are out-of-process servers in `perf/setup/mocks/` (`server.py openai|anthropic|bedrock|guardrail`, each answering with a reply generated for the request in that provider's wire format, sized from the prompt and output cap, a tool call when tools were offered, waiting the case's `mock_latency` first, request logs at `/mock/requests` and `/mock/calls`), reached through `E2E_PERF_MOCK_*_URL` by both the gateway config and the test. Adding a perf test is adding a module with a `CASE` to `perf/cases/`; a new deployment or guardrail is a config change; a new metric is a name in `Metric` plus a branch in `observe.observe` - `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate, JWT auth (access tokens issued by a real Keycloak realm, `idp.py` plus `idp_realm.json`, whose JWKS the proxy's `JWT_PUBLIC_KEY_URL` points at, see CONTRIBUTING.md for the start command and config block), and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite - `secret_manager/` - the gateway's `key_management_system` against a real secret manager: deployment keys resolved from it (`os.environ/` where the name exists only in the manager) and virtual keys written to and deleted from it. The tests are backend-agnostic and each backend is its own lane, because the setting is global to the proxy: `E2E_SECRET_MANAGER=` opts in and picks the backend from `secret_backends.BACKENDS`, the proxy is booted from `gateway/secret_manager__ci_config.yml` against the live manager, and the tests reach that manager through the backend's `SecretStore` (`secret_store_.py`). A test needing something not every backend does carries `requires_capability(...)` and is deselected on lanes that lack it. `secret_manager/backend.sh up ` runs a backend in Docker and writes the proxy's and the tests' env. Marked `secret_manager`, deselected unless `E2E_SECRET_MANAGER` is set, and kept out of the per-PR selector. Backends today: `hashicorp_vault` and `cyberark` (CyberArk Conjur, which cannot delete, so the delete test is Vault-only) @@ -53,14 +53,14 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family ## MCP suite: real Datadog only -Every test under `tests/e2e/mcp/` must exercise the proxy against the real Datadog remote MCP server, except the two Linear OAuth tests `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Do not add a compose service, FastMCP fixture, mock upstream, or any other fake MCP host for this suite +Every test under `tests/e2e/mcp/` must exercise the proxy against the real Datadog remote MCP server. Do not add a compose service, FastMCP fixture, mock upstream, or any other fake MCP host for this suite - Register via `register_datadog_mcp` in `tests/e2e/mcp/datadog_mcp.py` (or extend that helper if you need a different `toolsets=` / `allowed_tools` slice of the same Datadog endpoint). That posts `/v1/mcp/server` with `url=datadog_mcp_url(...)` and static headers `DD-API-KEY` / `DD-APPLICATION-KEY` from the process env - Auth is Datadog's documented CI/header path, not a browser OAuth authorize/token dance. Hard-fail when `DD_API_KEY` or `DD_APP_KEY` is missing (`assert_dd_mcp_creds`). Never skip for a missing fake upstream - Prefer calling real Datadog tools that prove the product path (e.g. `search_datadog_logs` for list/call and permission denials). Seed a unique marker (`e2e-datadog-mcp-*`) in a chat completion when you need a log the tool can find. Dual-read with `dd_logs` from conftest when delivery matters - Delete the MCP server (and any keys) through `resources.defer` the same way every other suite tears down - If a new MCP behavior cannot be covered with Datadog's tool surface, say so in the PR and get agreement before inventing another upstream. The default is always Datadog -- The two standing exceptions are `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Datadog authenticates with the static `DD-API-KEY` / `DD-APPLICATION-KEY` headers and exposes no authorize/token dance at all, so these tests drive a real Linear MCP server instead. They are still real remote upstreams, so the no-mock, no-fixture rule above holds unchanged. The direct OAuth test also uses the existing live provider edge to inspect forwarded headers without replay, and owns a separate source-built gateway for cold restarts +- Gateway-managed OAuth (an authorize/token dance and per-user stored tokens) is out of scope here, since Datadog only takes static headers. Those flows are integration tests against an owned authorization-server double, in `tests/integration/mcp/test_mcp_oauth_flows.py` and, for a chat completion calling the tool with the caller's stored token, the F2-chat cases of `tests/integration/security/test_mcp_slots.py`. Do not add a third-party OAuth upstream that needs a saved browser login ## Lay the pattern down in a class @@ -243,7 +243,7 @@ MCPs - endpoint features with the protocol op as the variant mcp... operation : list_tools | call_tool | list_resources | read_resource | list_prompts | get_prompt auth_family : none | api_key | bearer | oauth - assertion : succeeds | denied_without_permission | persists_across_processes + assertion : succeeds | denied_without_permission e.g. mcp.call_tool.oauth.succeeds ``` diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index a0059122ff0..b4379425854 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -261,55 +261,3 @@ The semantic header set is `content-type`, `accept`, `anthropic-version`, `anthr Excluded transport and telemetry headers are `host`, `content-length`, `connection`, `accept-encoding`, `user-agent`, `traceparent`, `tracestate`, `x-request-id`, `x-client-request-id` and `x-stainless-*`. Inbound transfer-encoding is unsupported; send JSON with content-length framing. The destination represents host identity and the relay carries original body bytes. Replay does not verify credentials, SDK timeout/retry behavior, transport performance, model availability or stateful remote IDs. Live relay uses original request bytes and header values, never the stored identity Strict replay harness regression tests live in `tests/code_coverage_tests/test_provider_replay_harness.py`. The `python` job in `.github/workflows/test-linting.yml` runs them alongside the provider-edge and fixture tests at the root of `tests/e2e_harness/` with `--noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures`; they need only synthetic HTTP providers and temporary fixture storage - - -## MCP OAuth happy path - -`test_mcp_oauth_happy_path_e2e.py` runs one shared scenario with four variants: -aggregate gateway SSO and explicitly configured per-server JWT, each directly -against Linear and through the live provider edge. The edge forwards to real -Linear without replay and compares the forwarded bearer to the encrypted -canonical user/server credential. This observes the forwarding boundary, not -Linear's internal logs. Direct variants independently exercise discovery - -Use the existing database preparation, Prisma generation and Keycloak setup. -Build and stage the dashboard from the tested checkout as in the UI runner. -Provide `DATABASE_URL`, `LITELLM_MASTER_KEY`, `LITELLM_SALT_KEY`, `LITELLM_LICENSE`, -and the `E2E_KEYCLOAK_*` settings. Capture a test-account Linear login using -`mcp/linear_session_capture.py` and set `E2E_LINEAR_STORAGE_STATE` to that private -file. The test workspace must contain a team. Do not publish browser state or -raw test/proxy output - -```bash -E2E_MCP_OAUTH_LIVE=1 E2E_FIXTURE_MODE=live E2E_PROVIDER_CACHE=0 \ - uv run --no-sync pytest tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py \ - --rootdir=. --reruns 0 -``` - -The test starts and restarts its own source-built proxy on a free loopback port, -retaining its database and SSO client but no Redis or process-local cache. It -does not restart an existing proxy or clear shared databases. Gateway login, -consent, immediate list/call and post-restart reconnect must all succeed. The -aggregate client never injects a gateway header; the explicitly labeled JWT -variant configures `x-litellm-api-key` for the first consent and reconnects with -only its gateway JWT after restart - -`.github/workflows/test-mcp-oauth-e2e.yml` automatically requests a run for -same-repository pull requests changing MCP, gateway authentication/SSO, consent -UI, dependencies or the relevant E2E harness/workflow paths. It retains manual -`workflow_dispatch` for targeted verification. The four cases run in the -protected `e2e-changed` environment after its normal deployment approval; -reviewers should approve and inspect this separate OAuth check when it appears. -Fork pull requests do not run this credentialed job; use a reviewed -same-repository branch for their verification. The workflow's path-filtered -check is not configured here as a globally required branch-protection check. -Provision `E2E_LINEAR_STORAGE_STATE_B64` as a secret there and retain the existing E2E license/AWS role configuration. A missing or -expired session fails the job; collection, deselection and skips are not passes. -No LLM call is needed - -Coverage remains limited to authorization-code OAuth over HTTP. M2M, OBO, -PKCE passthrough, static/BYOK, ID-JAG, forwarding, SigV4 and stdio are outside this -scenario; consult the registry and LIT-3559 for their existing coverage and gaps. -LIT-4506 owns broader isolation/failure regressions. LIT-7737 retains ownership -of dependency/Python compatibility and its matrix; this test reuses its delivered -environment and does not change dependency constraints or compatibility gates diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3bb4ab0d3be..4c7c57ca18f 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -17,7 +17,6 @@ import functools import os from collections.abc import Generator, Iterator from datetime import datetime, timezone -from pathlib import Path from types import MappingProxyType from typing import Final @@ -29,7 +28,6 @@ from e2e_config import ( FIXTURE_DIR, FIXTURE_MODE_RAW, MANAGED_FILES_OPT_IN_ENV, - MCP_OAUTH_LIVE_OPT_IN_ENV, OTEL_TLS_OPT_IN_ENV, OTEL_V2_OPT_IN_ENV, OWNED_GATEWAY_OPT_IN_ENV, @@ -72,7 +70,6 @@ OPT_IN_MARKERS: Final = MappingProxyType( "redis_chaos": PERF_TESTS_OPT_IN_ENV, "perf": PERF_TESTS_OPT_IN_ENV, "cli_determinism": CLI_DETERMINISM_OPT_IN_ENV, - "mcp_oauth_live": MCP_OAUTH_LIVE_OPT_IN_ENV, "provider_edge_host": PROVIDER_EDGE_HOST_OPT_IN_ENV, "owned_gateway": OWNED_GATEWAY_OPT_IN_ENV, "otel_v2": OTEL_V2_OPT_IN_ENV, @@ -170,11 +167,6 @@ def pytest_configure(config: pytest.Config) -> None: "quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; " "every other test waits for it to finish", ) - config.addinivalue_line( - "markers", - "mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless " - "E2E_MCP_OAUTH_LIVE is set", - ) config.addinivalue_line( "markers", "provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the " @@ -331,8 +323,6 @@ def pytest_runtest_setup(item: pytest.Item) -> None: item.user_properties.extend(item.config.stash[_IDLE_RSS].junit_properties) if not _reaches_proxy(item): return - if isinstance(item, pytest.Function) and "oauth_gateway" in item.fixturenames: - return reason = _proxy_fail_reason() if reason is not None: pytest.fail(reason) @@ -376,12 +366,6 @@ def pytest_runtest_makereport( report = yield if report.when in ("setup", "call"): attach_step_properties(item) - if item.get_closest_marker("mcp_oauth_live") is not None and call.excinfo is not None: - # Publish code locations only, never exception messages, source text or locals. - item.user_properties.append(("oauth_failure_phase", report.when)) - item.user_properties.append(("oauth_exception_type", call.excinfo.type.__name__)) - for entry in call.excinfo.traceback: - item.user_properties.append(("oauth_frame", f"{Path(entry.path).name}:{entry.lineno + 1}:{entry.name}")) if report.when == "call": item.stash[_CALL_PASSED] = report.passed report.user_properties = list(item.user_properties) diff --git a/tests/e2e/coverage_registry/mcp.yaml b/tests/e2e/coverage_registry/mcp.yaml index cdd194b267f..2ad815930a9 100644 --- a/tests/e2e/coverage_registry/mcp.yaml +++ b/tests/e2e/coverage_registry/mcp.yaml @@ -79,14 +79,6 @@ assertions: [succeeds] source: "db.py user_oauth_credential lookup" rationale: OAuth2 token passthrough; per-user credential storage -- id: mcp.call_tool.oauth.persists_across_processes - module: mcp - tier: P1 - operation: call_tool - auth_family: oauth - assertions: [persists_across_processes] - source: "outbound_credentials/per_user_oauth_store.py V2PerUserTokenStore" - rationale: Stored per-user token survives a verified restart of an owned gateway with no Redis cache - id: mcp.list_tools.none.succeeds module: mcp tier: P1 diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 8fc7e50d95c..724aa2362e7 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -111,10 +111,6 @@ CHEAP_ANTHROPIC_MODEL = os.environ.get("E2E_CHEAP_ANTHROPIC_MODEL", "claude-haik CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5") S3_PARTITION_GRANULARITY = os.environ.get("E2E_S3_PARTITION_GRANULARITY", "day") -LINEAR_MCP_URL = os.environ.get("E2E_LINEAR_MCP_URL", "https://mcp.linear.app/mcp") -LINEAR_STORAGE_STATE = os.environ.get("E2E_LINEAR_STORAGE_STATE", "") -LINEAR_READONLY_TOOL: Final = "list_teams" # as listed by tools/list on mcp.linear.app when PR #33787 landed - # Jaeger query API of the compose stack's OTEL trace destination (the `jaeger` # service in docker-compose.yml maps it to host 16686). Trace-completeness tests # read exported spans back through it. @@ -207,7 +203,6 @@ MANAGED_FILES_OPT_IN_ENV = "E2E_MANAGED_FILES_STACK" PROMPT_CACHING_OPT_IN_ENV = "E2E_PROMPT_CACHING_STACK" PERF_TESTS_OPT_IN_ENV: Final = "E2E_PERF_TESTS" CLI_DETERMINISM_OPT_IN_ENV = "E2E_CLI_DETERMINISM" -MCP_OAUTH_LIVE_OPT_IN_ENV: Final = "E2E_MCP_OAUTH_LIVE" PROVIDER_EDGE_HOST_OPT_IN_ENV: Final = "E2E_PROVIDER_EDGE_HOST_REACHABLE" OWNED_GATEWAY_OPT_IN_ENV: Final = "E2E_OWNED_GATEWAY" OTEL_V2_OPT_IN_ENV: Final = "E2E_OTEL_V2" @@ -237,7 +232,7 @@ MEMORY_TRANSCRIPT_TURNS = int(os.environ.get("E2E_MEMORY_TRANSCRIPT_TURNS", "40" MEMORY_CONCURRENCY = int(os.environ.get("E2E_MEMORY_CONCURRENCY", "4")) MEMORY_RSS_SETTLE_SAMPLES = int(os.environ.get("E2E_MEMORY_RSS_SETTLE_SAMPLES", "15")) MEMORY_RSS_SAMPLE_INTERVAL_SECONDS = float(os.environ.get("E2E_MEMORY_RSS_SAMPLE_INTERVAL_SECONDS", "1")) -MEMORY_RSS_BUDGET_MB = float(os.environ.get("E2E_MEMORY_RSS_BUDGET_MB", "48")) +MEMORY_RSS_CEILING_MB = float(os.environ.get("E2E_MEMORY_RSS_CEILING_MB", "200")) MEMORY_IDLE_RSS_BUDGET_MB = float(os.environ.get("E2E_MEMORY_IDLE_RSS_BUDGET_MB", "768")) MEMORY_STORED_REQUEST_BUDGET_KB = float(os.environ.get("E2E_MEMORY_STORED_REQUEST_BUDGET_KB", "64")) diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 90a9e9367eb..2aea32909ce 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -507,11 +507,12 @@ def get_text_external( ca_bundle: str | None = None, ) -> str | NetworkError: """GET an absolute URL outside the proxy whose body is plain text (e.g. a pod log), not JSON. - A non-2xx status is reported as a NetworkError so the caller can fail loudly.""" + A non-2xx status is reported as a NetworkError so the caller can fail loudly. JSON is accepted too so an API + that answers an error as a JSON document (the Kubernetes Status on a pod log) returns that error instead of a 406.""" try: resp = requests.get( url, - headers={"Accept": "text/plain", **(_headers(headers) if headers is not None else {})}, + headers={"Accept": "text/plain, application/json", **(_headers(headers) if headers is not None else {})}, timeout=timeout, verify=ca_bundle if ca_bundle is not None else True, ) diff --git a/tests/e2e/gateway/redis_chaos_ci_config.yml b/tests/e2e/gateway/redis_chaos_ci_config.yml index f7a71c50a71..1b098966a57 100644 --- a/tests/e2e/gateway/redis_chaos_ci_config.yml +++ b/tests/e2e/gateway/redis_chaos_ci_config.yml @@ -5,6 +5,7 @@ general_settings: litellm_settings: callbacks: ["prometheus"] + service_callback: ["prometheus_system"] require_auth_for_metrics_endpoint: false enable_redis_auth_cache: true cache: true diff --git a/tests/e2e/llm_translation/test_count_tokens_source_e2e.py b/tests/e2e/llm_translation/test_count_tokens_source_e2e.py new file mode 100644 index 00000000000..9df4f14ae6e --- /dev/null +++ b/tests/e2e/llm_translation/test_count_tokens_source_e2e.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import base64 +import io +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Final + +import pytest +from anthropic import Anthropic, BadRequestError, Omit, omit +from anthropic.types import DocumentBlockParam, MessageParam, TextBlockParam, ToolParam +from e2e_config import unique_marker +from e2e_http import unwrap +from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta +from lifecycle import ResourceManager +from models import ( + ChatMessage, + CountTokensResponse, + LiteLLMParamsBody, + ModelInfoBody, + ModelNewBody, + ResponsesInputTokensResponse, + TokenCounterBody, + TokenCountModelBody, +) +from proxy_client import ProxyClient +from reportlab.lib.pagesizes import LETTER +from reportlab.pdfgen.canvas import Canvas +from sdk_clients import NO_PROXY_CACHE, SdkClients + +pytestmark = pytest.mark.e2e + +BEDROCK_SONNET: Final = "bedrock/us.anthropic.claude-sonnet-5-5" +BEDROCK_HAIKU: Final = "bedrock/us.anthropic.claude-haiku-5-5" +COST_MAP_COUNT_MODEL: Final = "us.anthropic.claude-opus-5-5" +CONFIGURED_COUNT_MODEL: Final = "us.anthropic.claude-sonnet-5" +ANTHROPIC_SONNET: Final = "anthropic/claude-sonnet-5-5" +BEDROCK_KIMI: Final = "bedrock/us.moonshotai.kimi-k3" +BEDROCK_NOVA: Final = "bedrock/us.amazon.nova-pro-v1:0" +OPENAI_GPT: Final = "openai/gpt-5.6" + +COUNT_TOLERANCE: Final = 0.05 +MAX_TOKENS: Final = 16 +PDF_LINES_PER_PAGE: Final = 45 + +LARGE_SYSTEM: Final = " ".join( + f"Rule {n}: answer in plain English, cite clause {n} when it applies, and never invent a figure." + for n in range(1, 301) +) +LARGE_TOOL: Final[ToolParam] = { + "name": "search_orders", + "description": " ".join( + f"Filter {n} narrows the order search by the attribute recorded in field_{n}." for n in range(1, 151) + ), + "input_schema": { + "type": "object", + "properties": { + f"field_{n}": {"type": "string", "description": f"Order attribute number {n}, matched exactly."} + for n in range(1, 61) + }, + "required": ["field_1"], + }, +} + + +@dataclass(frozen=True, slots=True) +class Served: + model: str + key: str + + +@dataclass(frozen=True, slots=True) +class PromptShape: + name: str + system: str | None = None + tool: ToolParam | None = None + pdf_pages: int = 0 + + +SHAPES: Final = ( + PromptShape(name="baseline"), + PromptShape(name="large_system", system=LARGE_SYSTEM), + PromptShape(name="large_tool", tool=LARGE_TOOL), + PromptShape(name="system_and_tool", system=LARGE_SYSTEM, tool=LARGE_TOOL), + PromptShape(name="pdf_1_page", pdf_pages=1), + PromptShape(name="pdf_12_pages", pdf_pages=12), +) +SHAPE_IDS: Final = tuple(shape.name for shape in SHAPES) +TEXT_SHAPES: Final = SHAPES[:2] +TEXT_SHAPE_IDS: Final = SHAPE_IDS[:2] + + +def _bedrock(model: str) -> LiteLLMParamsBody: + return LiteLLMParamsBody( + model=model, + aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID", + aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", + aws_region_name="os.environ/AWS_REGION", + ) + + +def _serve(proxy: ProxyClient, backend: LiteLLMParamsBody, model_info: ModelInfoBody | None = None) -> Iterator[Served]: + resources: Final = ResourceManager(client=proxy) + try: + model: Final = f"e2e-count-source-{unique_marker()}" + model_id: Final = proxy.register_model( + ModelNewBody(model_name=model, litellm_params=backend, model_info=model_info or ModelInfoBody()) + ) + resources.defer(lambda: proxy.delete_model(model_id)) + yield Served(model=model, key=resources.key()) + finally: + resources.teardown() + + +@pytest.fixture(scope="module") +def bedrock_sonnet(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, _bedrock(BEDROCK_SONNET)) + + +@pytest.fixture(scope="module") +def bedrock_haiku(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, _bedrock(BEDROCK_HAIKU)) + + +@pytest.fixture(scope="module") +def bedrock_sonnet_with_configured_count_model(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve( + proxy, + _bedrock(BEDROCK_SONNET), + ModelInfoBody(token_count_model=TokenCountModelBody(model=CONFIGURED_COUNT_MODEL)), + ) + + +@pytest.fixture(scope="module") +def anthropic_sonnet(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, LiteLLMParamsBody(model=ANTHROPIC_SONNET, api_key="os.environ/ANTHROPIC_API_KEY")) + + +@pytest.fixture(scope="module") +def bedrock_kimi(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, _bedrock(BEDROCK_KIMI)) + + +@pytest.fixture(scope="module") +def bedrock_nova(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, _bedrock(BEDROCK_NOVA)) + + +@pytest.fixture(scope="module") +def openai_gpt(proxy: ProxyClient) -> Iterator[Served]: + yield from _serve(proxy, LiteLLMParamsBody(model=OPENAI_GPT, api_key="os.environ/OPENAI_API_KEY")) + + +def _pdf(pages: int) -> str: + buffer: Final = io.BytesIO() + canvas: Final = Canvas(buffer, pagesize=LETTER, invariant=True) + for page in range(1, pages + 1): + for line in range(1, PDF_LINES_PER_PAGE + 1): + canvas.drawString( + 72, 740 - 14 * line, f"Page {page}, line {line}: revenue, costs and headcount for region {line}." + ) + canvas.showPage() + canvas.save() + return base64.b64encode(buffer.getvalue()).decode() + + +def _user_turn(shape: PromptShape) -> MessageParam: + question: Final[TextBlockParam] = {"type": "text", "text": f"Reply with one word. {unique_marker()}"} + if shape.pdf_pages == 0: + return {"role": "user", "content": [question]} + document: Final[DocumentBlockParam] = { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": _pdf(shape.pdf_pages)}, + } + return {"role": "user", "content": [document, question]} + + +def _count_and_bill(client: Anthropic, model: str, shape: PromptShape) -> tuple[CountTokensResponse, int]: + messages: Final = [_user_turn(shape)] + system: Final[str | Omit] = omit if shape.system is None else shape.system + tools: Final[list[ToolParam] | Omit] = omit if shape.tool is None else [shape.tool] + counted: Final = CountTokensResponse.model_validate_json( + client.messages.with_raw_response.count_tokens(model=model, messages=messages, system=system, tools=tools).text + ) + usage: Final = client.messages.create( + model=model, + max_tokens=MAX_TOKENS, + messages=messages, + system=system, + tools=tools, + extra_body=NO_PROXY_CACHE, + ).usage + billed: Final = usage.input_tokens + (usage.cache_creation_input_tokens or 0) + (usage.cache_read_input_tokens or 0) + return counted, billed + + +def _within_tolerance(counted: int, billed: int) -> bool: + return abs(counted - billed) <= billed * COUNT_TOLERANCE + + +def _assert_counted_by_the_cost_map_count_model(served: Served, sdk: SdkClients, shape: PromptShape) -> None: + counted, billed = _count_and_bill(sdk.anthropic(served.key), served.model, shape) + + assert counted.litellm_count_source == "token_count_model", counted + assert counted.litellm_count_model == COST_MAP_COUNT_MODEL, counted + assert _within_tolerance(counted.input_tokens, billed), (counted, billed) + + +class TestBedrockClaudeCountsWithCostMapCountModel: + @pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS) + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_SONNET, COST_MAP_COUNT_MODEL), + capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT), + mode=Mode.NONSTREAM, + ) + ) + def test_sonnet_count_names_the_count_model_and_matches_billed_input( + self, bedrock_sonnet: Served, sdk: SdkClients, shape: PromptShape + ) -> None: + _assert_counted_by_the_cost_map_count_model(bedrock_sonnet, sdk, shape) + + @pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS) + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_HAIKU, COST_MAP_COUNT_MODEL), + capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT), + mode=Mode.NONSTREAM, + ) + ) + def test_haiku_count_names_the_count_model_and_matches_billed_input( + self, bedrock_haiku: Served, sdk: SdkClients, shape: PromptShape + ) -> None: + _assert_counted_by_the_cost_map_count_model(bedrock_haiku, sdk, shape) + + +class TestAnthropicClaudeCountsWithProvider: + @pytest.mark.parametrize("shape", SHAPES, ids=SHAPE_IDS) + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.ANTHROPIC,), + models=(ANTHROPIC_SONNET,), + capabilities=(Capability.FUNCTION_CALLING, Capability.PDF_INPUT), + mode=Mode.NONSTREAM, + ) + ) + def test_count_comes_from_the_provider_and_matches_billed_input( + self, anthropic_sonnet: Served, sdk: SdkClients, shape: PromptShape + ) -> None: + counted, billed = _count_and_bill(sdk.anthropic(anthropic_sonnet.key), anthropic_sonnet.model, shape) + + assert counted.litellm_count_source == "provider", counted + assert counted.litellm_count_model is None, counted + assert _within_tolerance(counted.input_tokens, billed), (counted, billed) + + +class TestBedrockKimiCountsWithFramedLocalEstimate: + @pytest.mark.parametrize("shape", TEXT_SHAPES, ids=TEXT_SHAPE_IDS) + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_KIMI,), + mode=Mode.NONSTREAM, + ) + ) + def test_text_estimate_matches_billed_input(self, bedrock_kimi: Served, sdk: SdkClients, shape: PromptShape) -> None: + counted, billed = _count_and_bill(sdk.anthropic(bedrock_kimi.key), bedrock_kimi.model, shape) + + assert counted.litellm_count_source == "local_estimate", counted + assert _within_tolerance(counted.input_tokens, billed), (counted, billed) + + +class TestConfiguredCountModelOverridesCostMap: + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_SONNET, CONFIGURED_COUNT_MODEL), + ) + ) + def test_count_names_the_model_info_count_model( + self, bedrock_sonnet_with_configured_count_model: Served, sdk: SdkClients + ) -> None: + served: Final = bedrock_sonnet_with_configured_count_model + raw: Final = sdk.anthropic(served.key).messages.with_raw_response.count_tokens( + model=served.model, messages=[_user_turn(SHAPES[0])] + ) + counted: Final = CountTokensResponse.model_validate_json(raw.text) + + assert counted.litellm_count_source == "token_count_model", counted + assert counted.litellm_count_model == CONFIGURED_COUNT_MODEL, counted + assert counted.input_tokens > 0, counted + + +class TestProviderCountErrorWithoutCountModel: + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_NOVA,), + ) + ) + def test_count_keeps_the_provider_400_instead_of_estimating(self, bedrock_nova: Served, sdk: SdkClients) -> None: + with pytest.raises(BadRequestError) as rejected: + _ = sdk.anthropic(bedrock_nova.key).messages.count_tokens( + model=bedrock_nova.model, messages=[_user_turn(SHAPES[0])] + ) + + assert rejected.value.status_code == 400, rejected.value + + +class TestCountSourceOnOtherCountEndpoints: + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.OPENAI,), + models=(OPENAI_GPT,), + ) + ) + def test_responses_input_tokens_says_the_provider_counted(self, openai_gpt: Served, sdk: SdkClients) -> None: + raw: Final = sdk.openai(openai_gpt.key).responses.input_tokens.with_raw_response.count( + model=openai_gpt.model, input=f"Reply with one word. {unique_marker()}" + ) + counted: Final = ResponsesInputTokensResponse.model_validate_json(raw.text) + + assert counted.litellm_count_source == "provider", counted + assert counted.input_tokens > 0, counted + + @meta( + Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.COUNT_TOKENS, + providers=(Provider.BEDROCK,), + models=(BEDROCK_KIMI,), + ) + ) + def test_token_counter_says_kimi_was_estimated_locally(self, proxy: ProxyClient, bedrock_kimi: Served) -> None: + counted: Final = unwrap( + proxy.token_counter( + bedrock_kimi.key, + TokenCounterBody( + model=bedrock_kimi.model, + messages=[ChatMessage(role="user", content=f"Reply with one word. {unique_marker()}")], + ), + ) + ) + + assert counted.count_source == "local_estimate", counted + assert counted.total_tokens > 0, counted diff --git a/tests/e2e/mcp/linear_session_capture.py b/tests/e2e/mcp/linear_session_capture.py deleted file mode 100644 index 1c867e17e22..00000000000 --- a/tests/e2e/mcp/linear_session_capture.py +++ /dev/null @@ -1,58 +0,0 @@ -"""One-time helper to capture a logged-in Linear browser session for the -real-Linear MCP e2e test. - -The real-Linear test drives the genuine gateway-managed authorization_code -dance against ``mcp.linear.app``. The only step that cannot be scripted is -Linear's login (magic link / SSO), so a human authenticates once here and the -resulting session (cookies + local storage) is persisted to disk. The e2e test -then loads that session in a headless Playwright context and clicks Approve on -Linear's consent screen every run, with no human and no login automation. - -Run it with the e2e venv, log into Linear in the window that opens, then return -to the terminal and press Enter: - - LITELLM=~/litellm-mcpe2e - "$LITELLM"/.venv/bin/python "$LITELLM"/tests/e2e/mcp/linear_session_capture.py - -The session is written to ``E2E_LINEAR_STORAGE_STATE`` (default -``~/.litellm-e2e/linear_storage_state.json``), outside the repo. It is a -secret: never commit it. Re-run this whenever Linear expires the session. -""" - -from __future__ import annotations - -import os -from pathlib import Path - -from playwright.sync_api import sync_playwright - -DEFAULT_STATE_PATH = Path.home() / ".litellm-e2e" / "linear_storage_state.json" - - -def capture(state_path: Path) -> None: - """Open a headed browser at Linear, wait for the human to log in, then save - the authenticated session to ``state_path``.""" - state_path.parent.mkdir(parents=True, exist_ok=True) - with sync_playwright() as playwright: - browser = playwright.chromium.launch(headless=False) - context = browser.new_context() - page = context.new_page() - page.goto("https://linear.app/login", wait_until="domcontentloaded") - print("\n" + "=" * 72) - print("Log into Linear in the browser window that just opened.") - print("If Linear emails you a magic link, paste the link into THIS window's") - print("address bar (opening it in your default browser won't capture the") - print("session). Google SSO works too as long as you complete it here.") - print("When your Linear workspace has loaded, come back and press Enter.") - print("=" * 72) - input("Press Enter once you are logged in... ") - page.goto("https://mcp.linear.app/", wait_until="domcontentloaded") - context.storage_state(path=str(state_path)) - browser.close() - print(f"\nSaved Linear session to {state_path}") - print("Point the e2e test at it with:") - print(f' export E2E_LINEAR_STORAGE_STATE="{state_path}"') - - -if __name__ == "__main__": - capture(Path(os.environ.get("E2E_LINEAR_STORAGE_STATE", str(DEFAULT_STATE_PATH)))) diff --git a/tests/e2e/mcp/oauth_chat_client.py b/tests/e2e/mcp/oauth_chat_client.py deleted file mode 100644 index 68e838d8745..00000000000 --- a/tests/e2e/mcp/oauth_chat_client.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Client for the mcp chat-completion OAuth e2e suite. - -Registers a gateway-managed OAuth (authorization_code) MCP server, seeds the -per-user upstream token by driving the interactive authorize dance with the -official mcp SDK's OAuthClientProvider (the browser leg is a headless Chromium -primed with a human's saved Linear session), then exercises the server through -/chat/completions, where the gateway lists and executes its tools with the -stored per-user token. - -Management routes (/v1/mcp/server CRUD, /chat/completions) go through the -shared ProxyClient transport. The MCP protocol used to seed the token goes through -the mcp SDK, the same library production MCP hosts run. -""" - -from __future__ import annotations - -import asyncio -import re -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING, Final -from urllib.parse import parse_qsl - -import httpx -import httpx2 -import pytest -from e2e_config import PROXY_BASE_URL, REQUEST_TIMEOUT -from e2e_http import AuthHeaders, NoBody, unwrap -from e2e_metadata import step -from idp import Identity -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from mcp.types import TextContent -from models import ( - ChatBody, - ChatResponse, - McpOauthUserCredentialStatus, - McpServerCreateBody, - McpServerInfo, - McpServerUserCredentialListResponse, - McpServerUserCredentialRow, -) -from proxy_client import ProxyClient - -if TYPE_CHECKING: - from playwright.async_api import Route - -# Where the "browser" lands at the end of the authorize dance. Nothing listens -# here: the route interceptor short-circuits the final redirect and reads the -# code/state off its query string, exactly like a desktop MCP host intercepting -# its loopback redirect. -OAUTH_CLIENT_REDIRECT_URI = "http://127.0.0.1:53682/e2e/callback" -BROWSER_CONSENT_TIMEOUT = 60.0 - - -def _mcp_url(alias: str, base_url: str = PROXY_BASE_URL) -> str: - return f"{base_url.rstrip('/')}/{alias}/mcp" - - -class InMemoryTokenStorage: - """The mcp SDK's TokenStorage protocol, in memory for one dance: the - DCR-registered client and the gateway tokens minted for it.""" - - def __init__(self) -> None: - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info - - -async def _browser_follow_authorize( - start_url: str, - storage_state_path: str, - identity: Identity | None = None, - server_alias: str | None = None, - allow_upstream_consent: bool = True, -) -> tuple[str, str | None]: - """Play the browser's role for a real upstream whose authorize endpoint - serves an interactive consent page (Linear). A headless Chromium primed - with a human's saved Linear session opens the gateway authorize URL and - clicks through Linear's consent screens (the mcp.linear.app Approve form, - then the linear.app workspace-selection page), riding the rest of the chain - (Linear -> gateway callback -> host redirect_uri). The final hop is - intercepted and short-circuited, since nothing listens there, and its - code/state are read off the query string.""" - from playwright.async_api import async_playwright - - captured: dict[str, str] = {} # mutable-ok: hand-off from the request listener - trail: list[str] = [] # mutable-ok: navigation diagnostics for a failed dance - - def _note_request(request: object) -> None: - url = getattr(request, "url", "") - host = httpx.URL(url).host - if not allow_upstream_consent and (host == "linear.app" or host.endswith(".linear.app")): - captured["upstream_consent"] = "seen" - if url.startswith(OAUTH_CLIENT_REDIRECT_URI) and "url" not in captured: - captured["url"] = url - - async def _swallow_redirect(route: Route) -> None: - await route.fulfill(status=200, content_type="text/plain", body="ok") - - async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=True) - context = await browser.new_context(storage_state=storage_state_path) - await context.route(re.compile(re.escape(OAUTH_CLIENT_REDIRECT_URI) + r".*"), _swallow_redirect) - page = await context.new_page() - context.on("request", _note_request) - page.on("framenavigated", lambda frame: trail.append(frame.url.split("?", 1)[0])) - await page.goto(start_url, wait_until="domcontentloaded") - deadline = time.monotonic() + BROWSER_CONSENT_TIMEOUT - while "url" not in captured and time.monotonic() < deadline: - try: - await page.wait_for_load_state("networkidle", timeout=8000) - except Exception: # noqa: BLE001 - a busy consent page never idles; fall through and try to advance it - pass - if "upstream_consent" in captured or "url" in captured: - break - if await page.locator("#username").count() and identity is not None: - await page.locator("#username").fill(identity.username) - await page.locator("#password").fill(identity.password) - await page.locator("#kc-login").click() - continue - if "/ui/connect" in page.url and server_alias is not None: - card = page.locator("div.cursor-pointer").filter(has=page.get_by_text(server_alias, exact=True)) - if await card.count() != 1: - await asyncio.sleep(0.5) - continue - connect = card.get_by_text("Connect", exact=True) - if await connect.count(): - await connect.click() - continue - if not await card.locator("svg.text-success").count(): - await asyncio.sleep(0.5) - continue - finish = page.get_by_role("button", name="Finish connecting", exact=True) - if await finish.count() and await finish.is_enabled(): - await finish.click() - continue - control = page.locator( - 'button[name="action"][value="approve"], button:has-text("Authorize"), ' - 'button:has-text("Allow"), button:has-text("@"), a:has-text("@")' - ).first - try: - await control.click(timeout=5000) - except Exception: # noqa: BLE001 - nothing to advance yet; loop and re-check - await asyncio.sleep(0.5) - final_url = page.url - await browser.close() - - # A redirect chain can finish inside goto/networkidle before the loop checks the page. - assert "upstream_consent" not in captured, "cold reconnect required upstream consent" - landing = captured.get("url") - assert landing is not None, ( - f"consent flow never reached {OAUTH_CLIENT_REDIRECT_URI}; " - f"final={final_url.split('?', 1)[0]!r}; trail={trail[-6:]}" - ) - params = dict(parse_qsl(httpx.URL(landing).query.decode())) - assert "code" in params, "client redirect_uri carried no authorization code" - return params["code"], params.get("state") - - -def _oauth_provider( - url: str, - storage: InMemoryTokenStorage, - storage_state_path: str | None, - identity: Identity | None = None, - server_alias: str | None = None, - allow_upstream_consent: bool = True, -) -> OAuthClientProvider: - """The SDK's real OAuth machinery (RFC 9728/8414 discovery, RFC 7591 DCR, - PKCE, token exchange) with the browser leg driven by Playwright against the - upstream's consent screen.""" - code_holder: dict[str, str | None] = {} # mutable-ok: hand-off between the two SDK callbacks - - async def _reject_redirect(_: str) -> None: - raise AssertionError("gateway demanded a fresh upstream consent; stored per-user token was not reused") - - async def _follow_redirect(authorize_url: str) -> None: - assert storage_state_path is not None - code, state = await _browser_follow_authorize( - authorize_url, storage_state_path, identity, server_alias, allow_upstream_consent - ) - code_holder["code"] = code - code_holder["state"] = state - - redirect_handler: Final = _reject_redirect if storage_state_path is None else _follow_redirect - - async def callback_handler() -> AuthorizationCodeResult: - code = code_holder.get("code") - assert code is not None, "callback_handler ran before the authorize redirect completed" - return AuthorizationCodeResult(code=code, state=code_holder.get("state")) - - return OAuthClientProvider( - server_url=url, - client_metadata=OAuthClientMetadata.model_validate( - { - "redirect_uris": [OAUTH_CLIENT_REDIRECT_URI], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "client_name": "e2e-mcp-host", - } - ), - storage=storage, - redirect_handler=redirect_handler, - callback_handler=callback_handler, - ) - - -class _HeaderInjectingTransport(httpx2.AsyncBaseTransport): - """Adds the caller's LiteLLM key header to every outgoing SDK request - (discovery, DCR, token exchange), so the gateway resolves which user to - store the upstream token for from the key on the token exchange, exactly - like a production MCP host configured with a LiteLLM key header.""" - - def __init__(self, inner: httpx2.AsyncBaseTransport, headers: dict[str, str], gateway_url: str) -> None: - self._inner = inner - self._headers = headers - self._gateway_url = httpx2.URL(gateway_url) - - @staticmethod - def _port(url: httpx2.URL) -> int | None: - if url.port is not None: - return url.port - return {"http": 80, "https": 443}.get(url.scheme) - - async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: - same_origin: Final = ( - request.url.scheme == self._gateway_url.scheme - and request.url.host == self._gateway_url.host - and self._port(request.url) == self._port(self._gateway_url) - ) - if same_origin: - for name, value in self._headers.items(): - if name not in request.headers: - request.headers[name] = value - else: - for name, value in self._headers.items(): - if request.headers.get(name) == value: - del request.headers[name] - return await self._inner.handle_async_request(request) - - async def aclose(self) -> None: - await self._inner.aclose() - - -def _oauth_http_client( - headers: dict[str, str], auth: OAuthClientProvider, gateway_url: str = PROXY_BASE_URL -) -> httpx2.AsyncClient: - return httpx2.AsyncClient( - auth=auth, - timeout=httpx2.Timeout(REQUEST_TIMEOUT), - follow_redirects=True, - transport=_HeaderInjectingTransport(httpx2.AsyncHTTPTransport(), headers, gateway_url), - ) - - -async def _seed_via_dance( - url: str, headers: dict[str, str], storage: InMemoryTokenStorage, storage_state_path: str -) -> tuple[str, ...]: - async with _oauth_http_client(headers, _oauth_provider(url, storage, storage_state_path)) as http_client: - async with streamable_http_client(url, http_client=http_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - listed = await session.list_tools() - return tuple(sorted(tool.name for tool in listed.tools)) - - -@dataclass(frozen=True, slots=True) -class OauthToolRun: - tools: tuple[str, ...] - is_error: bool - text: str - - -async def _list_and_call( - url: str, - headers: dict[str, str], - storage: InMemoryTokenStorage, - storage_state_path: str | None, - tool: str, - arguments: dict[str, str], - gateway_url: str = PROXY_BASE_URL, - identity: Identity | None = None, - server_alias: str | None = None, - allow_upstream_consent: bool = True, -) -> OauthToolRun: - async with _oauth_http_client( - headers, - _oauth_provider(url, storage, storage_state_path, identity, server_alias, allow_upstream_consent), - gateway_url, - ) as http_client: - async with streamable_http_client(url, http_client=http_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - listed: Final = await session.list_tools() - result: Final = await session.call_tool(tool, arguments) - text: Final = "".join(content.text for content in result.content if isinstance(content, TextContent)) - return OauthToolRun( - tools=tuple(sorted(tool_item.name for tool_item in listed.tools)), - is_error=result.is_error, - text=text, - ) - - -@dataclass(frozen=True, slots=True) -class ChatMcpClient: - proxy: ProxyClient - - @step("Register the MCP server with the alias {body.alias}") - def create_server(self, body: McpServerCreateBody) -> McpServerInfo: - return unwrap( - self.proxy.transport.post( - "/v1/mcp/server", - headers=self.proxy.transport.master, - json=body, - response_type=McpServerInfo, - ) - ) - - @step("Read the MCP server back from /v1/mcp/server") - def server_info(self, server_id: str) -> McpServerInfo: - return unwrap( - self.proxy.transport.get( - f"/v1/mcp/server/{server_id}", - headers=self.proxy.transport.master, - params=NoBody(), - response_type=McpServerInfo, - ) - ) - - @step("Delete the MCP server") - def delete_server(self, server_id: str) -> None: - _ = self.proxy.transport.delete( - f"/v1/mcp/server/{server_id}", - headers=self.proxy.transport.master, - json=NoBody(), - response_type=NoBody, - ) - - @step("Sign the key's user in to the MCP server {alias} through the OAuth consent flow") - def seed_user_token(self, alias: str, key: str, storage_state_path: str) -> tuple[str, ...]: - """Drive the interactive authorize dance for `key`'s user so the gateway - stores their upstream token, retried to the shared deadline since the - just-created server and key propagate asynchronously. The LiteLLM key - rides x-litellm-api-key so the gateway binds the token to that user. - Returns the upstream tool names the dance listed, proof the token works.""" - headers = {"x-litellm-api-key": f"Bearer {key}"} - storage = InMemoryTokenStorage() - deadline = time.monotonic() + self.proxy.poll_timeout - last_error: Exception | None = None - while time.monotonic() < deadline: - try: - return asyncio.run(_seed_via_dance(_mcp_url(alias), headers, storage, storage_state_path)) - except Exception as exc: # noqa: BLE001 - retried to the deadline; the last error surfaces below - last_error = exc - time.sleep(self.proxy.poll_interval) - pytest.fail( - f"authorize dance for {alias!r} never completed within {self.proxy.poll_timeout}s; " - f"last error: {last_error!r}" - ) - - @step("List the tools on the MCP server {alias} and call {tool} over the MCP protocol") - def list_and_call( - self, - alias: str, - headers: dict[str, str], - storage: InMemoryTokenStorage, - storage_state_path: str | None, - tool: str, - arguments: dict[str, str], - base_url: str = PROXY_BASE_URL, - identity: Identity | None = None, - allow_upstream_consent: bool = True, - ) -> OauthToolRun: - return asyncio.run( - _list_and_call( - f"{base_url.rstrip('/')}/mcp" if identity is not None else _mcp_url(alias, base_url), - headers, - storage, - storage_state_path, - tool, - arguments, - base_url, - identity, - alias, - allow_upstream_consent, - ) - ) - - @step("List the users with a stored OAuth token for the MCP server") - def server_user_credentials(self, server_id: str) -> tuple[McpServerUserCredentialRow, ...]: - return unwrap( - self.proxy.transport.get( - f"/v1/mcp/server/{server_id}/user-credentials", - headers=self.proxy.transport.master, - params=NoBody(), - response_type=McpServerUserCredentialListResponse, - ) - ).root - - @step("Revoke the user's stored OAuth token for the MCP server") - def revoke_user_token(self, server_id: str, headers: AuthHeaders) -> None: - _ = unwrap( - self.proxy.transport.delete( - f"/v1/mcp/server/{server_id}/oauth-user-credential", - headers=headers, - json=NoBody(), - response_type=McpOauthUserCredentialStatus, - ) - ) - - @step("Send a /chat/completions request to {body.model} with an MCP server attached as a tool") - def chat_with_mcp(self, headers: AuthHeaders, body: ChatBody) -> ChatResponse: - """POST /chat/completions carrying the LiteLLM key in `headers` (either - ingress form) with an MCP server attached in `body.tools`. The gateway - resolves the user from the key and lists/executes the server's tools - with that user's stored upstream token.""" - return unwrap( - self.proxy.transport.post( - "/chat/completions", - headers=headers, - json=body, - response_type=ChatResponse, - ) - ) - - -def build_chat_client(proxy: ProxyClient) -> ChatMcpClient: - return ChatMcpClient(proxy=proxy) diff --git a/tests/e2e/mcp/oauth_gateway.py b/tests/e2e/mcp/oauth_gateway.py deleted file mode 100644 index 2abe8262b6e..00000000000 --- a/tests/e2e/mcp/oauth_gateway.py +++ /dev/null @@ -1,197 +0,0 @@ -"""An owned, source-built OAuth gateway with cold restarts and credential observations. - -Only this child process is restarted. Its database and SSO client survive while -its process-local caches do not; Redis is deliberately absent from its config. -The optional live edge measures headers without recording credentials or bodies. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import threading -import time -from collections.abc import Callable, Mapping -from contextlib import ExitStack -from dataclasses import dataclass, field -from pathlib import Path -from typing import Final - -import psycopg -from e2e_config import INHERITED_ENV_PREFIXES, available_port -from e2e_http import NoBody -from e2e_metadata import step -from idp import Keycloak, stop_process_group -from proxy_client import ProxyClient, build_proxy_client -from psycopg.rows import class_row -from pydantic import BaseModel, SecretStr, ValidationError - - -class StoredOAuth(BaseModel): - type: str - access_token: SecretStr - - -@dataclass(frozen=True, slots=True) -class CredentialRow: - credential_b64: str = field(repr=False) - - -@step("Read the user's stored OAuth credential for the MCP server from the database and decrypt it") -def stored_oauth(user_id: str, server_id: str) -> StoredOAuth: - """Read the encrypted credential because management APIs omit the plaintext token.""" - from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper - - with psycopg.Connection[CredentialRow].connect( - os.environ["DATABASE_URL"], row_factory=class_row(CredentialRow) - ) as conn: - row: Final = conn.execute( - 'SELECT credential_b64 FROM "LiteLLM_MCPUserCredentials" WHERE user_id = %s AND server_id = %s', - (user_id, server_id), - ).fetchone() - assert row is not None, "canonical user/server has no persisted credential" - plaintext: Final = decrypt_value_helper( - row.credential_b64, "e2e_mcp_oauth", exception_type="debug", return_original_value=False - ) - assert plaintext is not None, "persisted credential must decrypt with the gateway salt" - assert plaintext != row.credential_b64, "persisted credential must be encrypted" - try: - credential: Final = StoredOAuth.model_validate_json(plaintext) - except ValidationError: - raise AssertionError("decrypted credential is not an OAuth payload") from None - assert credential.type == "oauth2" - assert bool(credential.access_token.get_secret_value()), "stored upstream token is empty" - return credential - - -class RpcMethod(BaseModel): - method: str = "" - - -@dataclass(slots=True) -class OAuthObservation: - gateway_token: str = field(default="", repr=False) - _seen: tuple[tuple[str, str, bool], ...] = field(default=(), init=False, repr=False) - _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) - - def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None: - if body is None or not url.endswith("/mcp"): - return - try: - operation: Final = RpcMethod.model_validate_json(body).method - except ValidationError: - return - if operation not in ("tools/list", "tools/call"): - return - received: Final = headers.get("authorization", "") - gateway_leaked: Final = any( - value in (self.gateway_token, f"Bearer {self.gateway_token}") for value in headers.values() - ) - with self._lock: - self._seen = (*self._seen, (operation, received, gateway_leaked)) - - def assert_forwarded(self, expected: StoredOAuth) -> None: - with self._lock: - snapshot: Final = self._seen - self._seen = () - assert {item[0] for item in snapshot} == {"tools/list", "tools/call"}, "missing upstream observations" - expected_header: Final = f"Bearer {expected.access_token.get_secret_value()}" - assert all(item[1] == expected_header for item in snapshot), "upstream bearer did not match the stored token" - assert all(not item[2] for item in snapshot), "gateway bearer leaked to the upstream" - - -@dataclass(slots=True) -class OAuthGateway: - base_url: str - proxy: ProxyClient - _environment: Mapping[str, str] = field(repr=False) - _command: tuple[str, ...] = field(repr=False) - _log_path: Path - _child: subprocess.Popen[bytes] | None = field(default=None, init=False, repr=False) - - @step("Start the separate LiteLLM proxy and wait for /health/liveliness") - def start(self) -> None: - with self._log_path.open("ab") as log: - self._child = subprocess.Popen( - self._command, - env=self._environment, - stdout=log, - stderr=log, - start_new_session=True, - ) - deadline: Final = time.monotonic() + 120 - while time.monotonic() < deadline: - assert self._child.poll() is None, "owned OAuth gateway exited; inspect its private log" - result = self.proxy.transport.probe("/health/liveliness", params=NoBody()) - if result.status_code == 200: - return - time.sleep(0.5) - raise AssertionError("owned OAuth gateway did not become ready") - - @step("Stop the separate LiteLLM proxy") - def stop(self) -> None: - if self._child is not None: - stop_process_group(self._child) - assert self._child.poll() is not None, "old gateway process is still alive" - - @step("Restart the separate LiteLLM proxy process so its in-memory caches start empty") - def restart(self) -> None: - assert self._child is not None - previous: Final = self._child.pid - self.stop() - self.start() - assert self._child.pid != previous, "gateway restart did not create a new process" - - -@step("Start a separate LiteLLM proxy from source with JWT auth against Keycloak") -def owned_gateway(idp: Keycloak, directory: Path, cleanup: ExitStack) -> OAuthGateway: - for name in ("DATABASE_URL", "LITELLM_LICENSE", "LITELLM_SALT_KEY", "LITELLM_MASTER_KEY"): - assert os.environ.get(name), f"{name} is required for the owned OAuth gateway" - port: Final = available_port() - base_url: Final = f"http://127.0.0.1:{port}" - - def defer(callback: Callable[[], object]) -> None: - cleanup.callback(callback) - - browser: Final = idp.browser_client(callback_url=f"{base_url}/sso/callback", defer=defer) - config: Final = directory / "oauth-gateway.yaml" - config.write_text( - "model_list: []\n" - "general_settings:\n" - " master_key: os.environ/LITELLM_MASTER_KEY\n" - " database_url: os.environ/DATABASE_URL\n" - " enable_jwt_auth: true\n" - " litellm_jwtauth:\n" - " user_id_jwt_field: sub\n" - " user_email_jwt_field: email\n" - " team_ids_jwt_field: groups\n" - " user_id_upsert: true\n" - ) - environment: Final = { - **{key: value for key, value in os.environ.items() if not key.startswith(INHERITED_ENV_PREFIXES)}, - **browser.environment(idp.discovery()), - "PROXY_BASE_URL": base_url, - "JWT_PUBLIC_KEY_URL": idp.jwks_url, - "JWT_ISSUER": idp.issuer, - "JWT_AUDIENCE": "litellm-e2e", - "DISABLE_SCHEMA_UPDATE": "true", - "STORE_MODEL_IN_DB": "True", - "PYTHONPATH": str(Path(__file__).resolve().parents[3]), - } - gateway: Final = OAuthGateway( - base_url=base_url, - proxy=build_proxy_client( - base_url=base_url, - control_plane_base_url=base_url, - replica_urls=(base_url,), - control_replica_urls=(base_url,), - master_key=os.environ["LITELLM_MASTER_KEY"], - ), - _environment=environment, - _command=(sys.executable, "-m", "litellm.proxy.proxy_cli", "--config", str(config), "--port", str(port)), - _log_path=directory / "oauth-gateway.log", - ) - cleanup.callback(gateway.stop) - gateway.start() - return gateway diff --git a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py b/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py deleted file mode 100644 index 9d7e4713963..00000000000 --- a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py +++ /dev/null @@ -1,218 +0,0 @@ -"""On-demand e2e: a chat completion drives a gateway-managed OAuth MCP server. - -The real end-user flow for MCP over an OAuth server: a user registers a Linear -authorization_code server, authorizes it once so the gateway stores their -upstream token, then sends a normal /chat/completions request with the Linear -MCP attached. The gateway resolves the user from the LiteLLM key, lists Linear's -tools with the stored per-user token, lets the model call one, executes it -upstream with that token, and returns the answer. This is proven against the -real Linear MCP server (mcp.linear.app) and a real Anthropic model, once per -documented ingress header (x-litellm-api-key and Authorization). - -The authorize dance is seeded through the mcp SDK's OAuthClientProvider; the one -step Linear cannot auto-approve is the human consent, so it is captured once out -of band (mcp/linear_session_capture.py) into a saved browser session and a -headless Chromium clicks Approve every run. The test therefore skips unless -E2E_LINEAR_STORAGE_STATE points at that session, so it never runs on the per-PR -CI path; it is a nightly/on-demand real-server smoke test. - -Fail-before-fix: without the stored per-user token the gateway lists no Linear -tools, so mcp_list_tools comes back empty, nothing is called, and the -assertions fail; a served, called, non-empty Linear tool proves the gateway -pulled and used the user's token. -""" - -from __future__ import annotations - -import os - -import pytest - -from e2e_config import CHEAP_ANTHROPIC_MODEL, LINEAR_MCP_URL, LINEAR_STORAGE_STATE, unique_marker -from e2e_http import AuthHeaders -from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta -from lifecycle import ResourceManager -from models import ChatBody, ChatMessage, KeyGenerateBody, McpChatTool, McpServerCreateBody, ObjectPermission -from proxy_client import ProxyClient - -pytest.importorskip("mcp", reason="mcp SDK not installed; run `uv sync --inexact --group e2e-dev`") -pytest.importorskip( - "playwright.async_api", - reason="playwright not installed; run `uv pip install playwright` and `playwright install chromium`", -) - -from oauth_chat_client import ChatMcpClient, build_chat_client # noqa: E402 # imports follow the importorskip guards - -pytestmark = [ - pytest.mark.e2e, - pytest.mark.skipif( - not LINEAR_STORAGE_STATE or not os.path.exists(LINEAR_STORAGE_STATE), - reason="set E2E_LINEAR_STORAGE_STATE to a Linear session captured via mcp/linear_session_capture.py", - ), -] - -# Pinned from a live dance during verification (never guessed); the gateway -# prefixes every upstream tool name with the server alias. list_teams is a -# read-only Linear tool that takes no arguments and returns the caller's teams. -LINEAR_READONLY_TOOL = "list_teams" -LINEAR_PROMPT = "Use the list_teams tool to list my Linear teams, then reply with the name of one of them." - - -@pytest.fixture(scope="session") -def chat_client(proxy: ProxyClient) -> ChatMcpClient: - return build_chat_client(proxy) - - -class TestMcpChatCompletionOauth: - """A scoped internal-user key on a real Linear authorization_code server, - used through /chat/completions once per ingress header: the gateway pulls - the user's stored upstream token, lists and executes Linear's tools during - the completion, and returns the answer.""" - - @pytest.mark.covers("mcp.list_tools.oauth.succeeds") - @pytest.mark.covers("mcp.call_tool.oauth.succeeds") - @meta( - Subject( - domain=Domain.MCP, - route=Route.CHAT_COMPLETIONS, - providers=(Provider.ANTHROPIC,), - models=(CHEAP_ANTHROPIC_MODEL,), - capabilities=(Capability.FUNCTION_CALLING,), - mode=Mode.NONSTREAM, - ) - ) - def test_chat_completion_uses_linear_with_x_litellm_api_key_header( - self, chat_client: ChatMcpClient, resources: ResourceManager - ) -> None: - marker = unique_marker() - alias = f"e2elinear{marker}" - created = chat_client.create_server( - McpServerCreateBody( - alias=alias, - url=LINEAR_MCP_URL, - allow_all_keys=False, - auth_type="oauth2", - oauth2_flow="authorization_code", - ) - ) - resources.defer(lambda: chat_client.delete_server(created.server_id)) - - stored = chat_client.server_info(created.server_id) - assert stored.auth_type == "oauth2" - assert stored.oauth2_flow == "authorization_code" - assert stored.allow_all_keys is False - - key = chat_client.proxy.generate_key( - KeyGenerateBody( - user_id="e2e-test-user", - object_permission=ObjectPermission(mcp_servers=[created.server_id]), - ) - ) - resources.defer(lambda: chat_client.proxy.delete_key(key)) - - seeded = chat_client.seed_user_token(alias, key, LINEAR_STORAGE_STATE) - assert f"{alias}-{LINEAR_READONLY_TOOL}" in seeded, ( - f"the authorize dance listed {seeded}, expected it to include {alias}-{LINEAR_READONLY_TOOL}" - ) - - response = chat_client.chat_with_mcp( - AuthHeaders.model_validate({"x-litellm-api-key": f"Bearer {key}"}), - ChatBody( - model=CHEAP_ANTHROPIC_MODEL, - messages=[ChatMessage(role="user", content=LINEAR_PROMPT)], - tools=[ - McpChatTool( - server_url=f"litellm_proxy/mcp/{alias}", - server_label=alias, - require_approval="never", - ) - ], - ), - ) - - message = response.choices[0].message - assert message is not None and message.content, f"completion returned no answer: {response}" - meta = message.provider_specific_fields - assert meta is not None, f"no MCP metadata on the completion: {response}" - listed = {t.function.name for t in (meta.mcp_list_tools or []) if t.function} - assert f"{alias}-{LINEAR_READONLY_TOOL}" in listed, ( - f"the gateway listed {sorted(listed)}, expected the stored token to surface {alias}-{LINEAR_READONLY_TOOL}" - ) - results = [r for r in (meta.mcp_call_results or []) if r.name == f"{alias}-{LINEAR_READONLY_TOOL}"] - assert results and results[0].result, ( - f"Linear tool {alias}-{LINEAR_READONLY_TOOL} was not executed with a result: {meta.mcp_call_results}" - ) - - @pytest.mark.covers("mcp.list_tools.oauth.succeeds") - @pytest.mark.covers("mcp.call_tool.oauth.succeeds") - @meta( - Subject( - domain=Domain.MCP, - route=Route.CHAT_COMPLETIONS, - providers=(Provider.ANTHROPIC,), - models=(CHEAP_ANTHROPIC_MODEL,), - capabilities=(Capability.FUNCTION_CALLING,), - mode=Mode.NONSTREAM, - ) - ) - def test_chat_completion_uses_linear_with_authorization_bearer_header( - self, chat_client: ChatMcpClient, resources: ResourceManager - ) -> None: - marker = unique_marker() - alias = f"e2elinear{marker}" - created = chat_client.create_server( - McpServerCreateBody( - alias=alias, - url=LINEAR_MCP_URL, - allow_all_keys=False, - auth_type="oauth2", - oauth2_flow="authorization_code", - ) - ) - resources.defer(lambda: chat_client.delete_server(created.server_id)) - - stored = chat_client.server_info(created.server_id) - assert stored.auth_type == "oauth2" - assert stored.oauth2_flow == "authorization_code" - assert stored.allow_all_keys is False - - key = chat_client.proxy.generate_key( - KeyGenerateBody( - user_id="e2e-test-user", - object_permission=ObjectPermission(mcp_servers=[created.server_id]), - ) - ) - resources.defer(lambda: chat_client.proxy.delete_key(key)) - - seeded = chat_client.seed_user_token(alias, key, LINEAR_STORAGE_STATE) - assert f"{alias}-{LINEAR_READONLY_TOOL}" in seeded, ( - f"the authorize dance listed {seeded}, expected it to include {alias}-{LINEAR_READONLY_TOOL}" - ) - - response = chat_client.chat_with_mcp( - AuthHeaders.model_validate({"authorization": f"Bearer {key}"}), - ChatBody( - model=CHEAP_ANTHROPIC_MODEL, - messages=[ChatMessage(role="user", content=LINEAR_PROMPT)], - tools=[ - McpChatTool( - server_url=f"litellm_proxy/mcp/{alias}", - server_label=alias, - require_approval="never", - ) - ], - ), - ) - - message = response.choices[0].message - assert message is not None and message.content, f"completion returned no answer: {response}" - meta = message.provider_specific_fields - assert meta is not None, f"no MCP metadata on the completion: {response}" - listed = {t.function.name for t in (meta.mcp_list_tools or []) if t.function} - assert f"{alias}-{LINEAR_READONLY_TOOL}" in listed, ( - f"the gateway listed {sorted(listed)}, expected the stored token to surface {alias}-{LINEAR_READONLY_TOOL}" - ) - results = [r for r in (meta.mcp_call_results or []) if r.name == f"{alias}-{LINEAR_READONLY_TOOL}"] - assert results and results[0].result, ( - f"Linear tool {alias}-{LINEAR_READONLY_TOOL} was not executed with a result: {meta.mcp_call_results}" - ) diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py deleted file mode 100644 index b57c941cb18..00000000000 --- a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Real OAuth consent, immediate MCP operations and cold-restart persistence. - -Aggregate SSO uses the SDK's normal authentication. The per-server variant is -explicitly a configured two-header client, not an Authorization-only OAuth host. -The observed variants forward to the same real Linear upstream and compare its -bearer at the forwarding boundary; direct variants retain unmodified discovery. -""" - -from __future__ import annotations - -import os -from collections.abc import Iterator -from contextlib import ExitStack -from pathlib import Path -from types import MappingProxyType -from typing import Final, Literal - -import pytest -from e2e_config import LINEAR_MCP_URL, LINEAR_READONLY_TOOL, LINEAR_STORAGE_STATE, unique_marker -from e2e_http import AuthHeaders, NoBody, get_external, unwrap -from e2e_metadata import Domain, Route, Subject, meta -from idp import Identity, Keycloak -from lifecycle import ResourceManager -from models import ( - McpOauthCredentials, - McpServerCreateBody, - ObjectPermission, - TeamMemberAddBody, - TeamMemberEntry, - TeamUpdateBody, -) -from oauth_chat_client import ChatMcpClient, InMemoryTokenStorage, OauthToolRun, build_chat_client -from oauth_gateway import OAuthGateway, OAuthObservation, owned_gateway, stored_oauth -from provider_edge import LiveEdge, start_provider_edge -from proxy_client import ProxyClient -from pydantic import BaseModel, ValidationError - -pytestmark = [pytest.mark.e2e, pytest.mark.mcp_oauth_live, pytest.mark.provider_live] - - -class OAuthMetadata(BaseModel): - issuer: str - authorization_endpoint: str - token_endpoint: str - registration_endpoint: str - - -class LinearTeam(BaseModel): - id: str - name: str - - -class LinearTeams(BaseModel): - teams: tuple[LinearTeam, ...] - - -def assert_tool_result(run: OauthToolRun, tool: str) -> None: - assert tool in run.tools - assert run.is_error is False - try: - result: Final = LinearTeams.model_validate_json(run.text) - except ValidationError: - raise AssertionError("list_teams did not return the expected teams payload") from None - assert result.teams, "the test workspace must contain at least one team" - assert all(team.id and team.name for team in result.teams), "team results must contain identifiers and names" - - -@pytest.fixture(scope="module") -def oauth_gateway(idp: Keycloak, tmp_path_factory: pytest.TempPathFactory) -> Iterator[OAuthGateway]: - assert LINEAR_STORAGE_STATE and Path(LINEAR_STORAGE_STATE).is_file(), ( - "E2E_LINEAR_STORAGE_STATE must name a captured Linear login; see mcp/linear_session_capture.py" - ) - assert os.environ.get("E2E_FIXTURE_MODE", "live") == "live", "OAuth acceptance cannot use replay" - with ExitStack() as cleanup: - yield owned_gateway(idp, tmp_path_factory.mktemp("mcp-oauth"), cleanup) - - -@pytest.fixture(scope="module") -def proxy(oauth_gateway: OAuthGateway) -> ProxyClient: - return oauth_gateway.proxy - - -@pytest.fixture(scope="module") -def client(proxy: ProxyClient) -> ChatMcpClient: - return build_chat_client(proxy) - - -class TestMcpOauthHappyPath: - @pytest.mark.covers("mcp.list_tools.oauth.succeeds") - @pytest.mark.covers("mcp.call_tool.oauth.succeeds") - @pytest.mark.covers("mcp.call_tool.oauth.persists_across_processes") - @meta( - Subject( - domain=Domain.MCP, - route=Route.MCP, - ) - ) - @pytest.mark.parametrize("route", ("aggregate_sso", "explicit_header_jwt")) - @pytest.mark.parametrize("observed", (False, True), ids=("direct", "observed")) - def test_consent_list_call_and_cold_restart( - self, - client: ChatMcpClient, - resources: ResourceManager, - jwt_identity: Identity, - idp: Keycloak, - oauth_gateway: OAuthGateway, - route: Literal["aggregate_sso", "explicit_header_jwt"], - observed: bool, - ) -> None: - alias: Final = f"e2elinear{unique_marker()}" - tool: Final = f"{alias}-{LINEAR_READONLY_TOOL}" - token: Final = idp.access_token(jwt_identity) - observation: Final = OAuthObservation(gateway_token=token) - edge: Final = ( - start_provider_edge( - LiveEdge(observe_request=observation.observe), - mounts=MappingProxyType( - {"linear": "https://mcp.linear.app", ".well-known": "https://mcp.linear.app/.well-known"} - ), - ) - if observed - else None - ) - if edge is not None: - resources.defer(edge.shutdown) - metadata: Final = ( - unwrap( - get_external( - "https://mcp.linear.app/.well-known/oauth-authorization-server", - response_type=OAuthMetadata, - ) - ) - if observed - else None - ) - created: Final = client.create_server( - McpServerCreateBody( - alias=alias, - server_name=alias, - url=f"{edge.edge.api_base('linear')}/mcp" if edge is not None else LINEAR_MCP_URL, - transport="http", - allow_all_keys=False, - auth_type="oauth2", - oauth2_flow="authorization_code", - per_server_oauth_discovery=route == "explicit_header_jwt", - issuer=metadata.issuer if metadata else None, - authorization_url=metadata.authorization_endpoint if metadata else None, - token_url=metadata.token_endpoint if metadata else None, - registration_url=metadata.registration_endpoint if metadata else None, - credentials=McpOauthCredentials(upstream_resource=LINEAR_MCP_URL) if observed else None, - ) - ) - resources.defer(lambda: client.delete_server(created.server_id)) - assert client.server_user_credentials(created.server_id) == (), ( - "scenario must start without upstream credentials" - ) - unwrap( - client.proxy.transport.post( - "/team/member_add", - headers=client.proxy.transport.master, - json=TeamMemberAddBody( - team_id=jwt_identity.group, member=TeamMemberEntry(user_id=jwt_identity.user_id, role="user") - ), - response_type=NoBody, - ) - ) - client.proxy.update_team( - TeamUpdateBody( - team_id=jwt_identity.group, - object_permission=ObjectPermission(mcp_servers=[created.server_id]), - ) - ) - headers: Final = {"x-litellm-api-key": f"Bearer {token}"} if route == "explicit_header_jwt" else {} - resources.defer( - lambda: client.revoke_user_token( - created.server_id, - AuthHeaders(authorization=f"Bearer {idp.access_token(jwt_identity)}"), - ) - ) - identity: Final = jwt_identity if route == "aggregate_sso" else None - first: Final = client.list_and_call( - alias, - headers, - InMemoryTokenStorage(), - LINEAR_STORAGE_STATE, - tool, - {}, - base_url=oauth_gateway.base_url, - identity=identity, - ) - assert_tool_result(first, tool) - credentials: Final = client.server_user_credentials(created.server_id) - assert len(credentials) == 1 - assert credentials[0].user_id == jwt_identity.user_id - assert credentials[0].credential_type == "oauth2" - first_stored_oauth: Final = stored_oauth(jwt_identity.user_id, created.server_id) - if observed: - observation.assert_forwarded(first_stored_oauth) - oauth_gateway.restart() - fresh_token: Final = idp.access_token(jwt_identity) - observation.gateway_token = fresh_token - second: Final = client.list_and_call( - alias, - {"Authorization": f"Bearer {fresh_token}"} if identity is None else {}, - InMemoryTokenStorage(), - LINEAR_STORAGE_STATE if identity is not None else None, - tool, - {}, - base_url=oauth_gateway.base_url, - identity=identity, - allow_upstream_consent=False, - ) - assert_tool_result(second, tool) - second_stored_oauth: Final = stored_oauth(jwt_identity.user_id, created.server_id) - if observed: - observation.assert_forwarded(second_stored_oauth) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index d7a10c6da1f..f0a8d67b444 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -677,13 +677,39 @@ class AnthropicMessagesResponse(BaseModel): usage: Usage | None = None -class CountTokensResponse(BaseModel): +TokenCountSource = Literal["provider", "token_count_model", "local_estimate"] + + +class LitellmCountSource(BaseModel, frozen=True): + litellm_count_source: TokenCountSource | None = None + litellm_count_model: str | None = None + + +class CountTokensResponse(LitellmCountSource, frozen=True): """`/v1/messages/count_tokens` answer. `input_tokens` is required so a 200 whose body lacks it fails validation instead of passing vacuously.""" input_tokens: int +class ResponsesInputTokensResponse(LitellmCountSource, frozen=True): + object: str + input_tokens: int + + +class TokenCounterBody(BaseModel, frozen=True): + model: str + messages: list[ChatMessage] + + +class TokenCounterResponse(BaseModel, frozen=True): + total_tokens: int + request_model: str + model_used: str + tokenizer_type: str + count_source: TokenCountSource | None = None + + class AnthropicErrorBody(BaseModel): type: str message: str @@ -706,28 +732,14 @@ class McpInfo(BaseModel): logo_url: str | None = None -class McpOauthCredentials(BaseModel): - upstream_resource: str - - class McpServerCreateBody(BaseModel): - """POST /v1/mcp/server. For a gateway-managed OAuth server, `auth_type` is - `oauth2` and `oauth2_flow` is `authorization_code`; the upstream endpoints - are discovered and registered via DCR when left unset. `allow_all_keys` - false scopes the server to keys granted it through object_permission.""" + """POST /v1/mcp/server. `allow_all_keys` false scopes the server to keys + granted it through object_permission.""" alias: str url: str transport: str = "http" allow_all_keys: bool = True - auth_type: str | None = None - oauth2_flow: Literal["client_credentials", "authorization_code"] | None = None - per_server_oauth_discovery: bool | None = None - issuer: str | None = None - authorization_url: str | None = None - token_url: str | None = None - registration_url: str | None = None - credentials: McpOauthCredentials | None = Field(default=None, repr=False) server_name: str | None = None description: str | None = None mcp_info: McpInfo | None = None @@ -767,26 +779,6 @@ class McpServerListResponse(RootModel[list[McpServerRow]]): """GET /v1/mcp/server answers with a bare array of servers.""" -class McpServerUserCredentialRow(BaseModel): - user_id: str - credential_type: Literal["oauth2", "byok"] - expires_at: str | None = None - connected_at: str | None = None - updated_at: str - - -class McpServerUserCredentialListResponse(RootModel[tuple[McpServerUserCredentialRow, ...]]): - """GET /v1/mcp/server/{server_id}/user-credentials answers with a bare array.""" - - -class McpOauthUserCredentialStatus(BaseModel): - server_id: str - has_credential: bool - expires_at: str | None = None - is_expired: bool = False - connected_at: str | None = None - - class ToolsetTool(BaseModel): server_id: str tool_name: str @@ -1426,6 +1418,11 @@ class LiteLLMParamsBody(BaseModel): ModelMode = Literal["chat", "batch", "realtime", "image_generation"] +class TokenCountModelBody(BaseModel, frozen=True): + model: str + region: str | None = None + + class ModelInfoBody(BaseModel): # id is left unset so the proxy assigns a unique model_id per deployment. # Pinning it to the model_name made re-registrations of a fixed-name model @@ -1438,6 +1435,7 @@ class ModelInfoBody(BaseModel): team_id: str | None = None allowed_fails: int | None = None allowed_fails_policy: dict[str, int] | None = None + token_count_model: TokenCountModelBody | None = None class ModelNewBody(BaseModel): diff --git a/tests/e2e/perf/cases/event_loop_stalls.py b/tests/e2e/perf/cases/event_loop_stalls.py index 66c99686151..0d67a98257f 100644 --- a/tests/e2e/perf/cases/event_loop_stalls.py +++ b/tests/e2e/perf/cases/event_loop_stalls.py @@ -32,7 +32,7 @@ CASE: Final = replace( allowlist=( KnownStall("garbage collection", max_count=4), KnownStall("LifespanOn.main", max_count=4), - KnownStall("RequestResponseCycle.run_asgi", max_count=6), + KnownStall("RequestResponseCycle.run_asgi", max_count=2), KnownStall("with_proxy_compaction_executor", max_count=2), KnownStall("Logging.async_success_handler", max_count=2), KnownStall("Logging.dispatch_success_handlers", max_count=2), @@ -43,6 +43,7 @@ CASE: Final = replace( KnownStall("RedisBatch.flush", max_count=2), KnownStall("run_coroutine_job", max_count=2), KnownStall("user_api_key_auth.py", max_count=2), + KnownStall("route_streaming_logging_to_handler", max_count=2), ), ), ) diff --git a/tests/e2e/perf/setup/event_loop.py b/tests/e2e/perf/setup/event_loop.py index 00d05c79f73..dee45c374d9 100644 --- a/tests/e2e/perf/setup/event_loop.py +++ b/tests/e2e/perf/setup/event_loop.py @@ -115,13 +115,11 @@ def _parse_event(line: str) -> Stall | GcPass | None: def _with_passes(stall: Stall, passes: tuple[GcPass, ...]) -> Stall: - if not passes: + fitting: Final = tuple(gc_pass for gc_pass in passes if gc_pass.seconds <= stall.seconds) + if not fitting: return stall - return replace( - stall, - gc_seconds=sum(gc_pass.seconds for gc_pass in passes), - gc_generation=max(gc_pass.generation for gc_pass in passes), - ) + longest: Final = max(fitting, key=lambda gc_pass: gc_pass.seconds) + return replace(stall, gc_seconds=longest.seconds, gc_generation=longest.generation) def parse_stalls(lines: Iterable[str]) -> tuple[Stall, ...]: @@ -137,7 +135,7 @@ def parse_stalls(lines: Iterable[str]) -> tuple[Stall, ...]: def _reported(spec: EventLoopStalls, stall: Stall) -> Stall: """A hold the collector accounts for, what is left under the threshold, is the collector's, not the task's.""" - if stall.gc_generation is None or stall.gc_seconds > stall.seconds: + if stall.gc_generation is None: return stall if stall.seconds - stall.gc_seconds >= spec.max_hold_seconds: return stall diff --git a/tests/e2e/perf/setup/locustfile.py b/tests/e2e/perf/setup/locustfile.py index 1609617bd73..ccc7cf698f8 100644 --- a/tests/e2e/perf/setup/locustfile.py +++ b/tests/e2e/perf/setup/locustfile.py @@ -19,9 +19,10 @@ from typing import Final, cast import gevent from locust import FastHttpUser, task from locust.contrib.fasthttp import FastResponse, ResponseContextManager -from setup.wire import Endpoint, WireRecord, WireRequest, request_body from pydantic import TypeAdapter +from setup.wire import Endpoint, PlanProgress, WireRecord, WireRequest, request_body, response_id + _PLAN_ADAPTER: Final = TypeAdapter(list[WireRequest]) _JSON: Final = TypeAdapter[object](object) _PLAN: Final = _PLAN_ADAPTER.validate_json(Path(os.environ["PERF_PLAN_FILE"]).read_bytes()) @@ -33,7 +34,8 @@ _INTERVAL_S: Final = _USERS / _RPS _JITTER: Final = 0.25 _RNG: Final = random.Random(_PLAN[0].marker if _PLAN else "empty-plan") _RECORDS: Final = _RECORDS_PATH.open("a", encoding="utf-8") -_written = 0 +_PROGRESS: Final = PlanProgress(len(_PLAN)) +_STOP_FILE: Final = os.environ.get("PERF_STOP_FILE") BLOCK_REASON: Final = "perf-test-block" _TOKEN_MAX_AGE_S: Final = 240.0 _tokens: dict[str, tuple[str, float]] = {} @@ -155,10 +157,9 @@ def _judge(item: WireRequest, status: int, body_text: str) -> tuple[bool, str | def _record(record: WireRecord) -> None: - global _written _ = _RECORDS.write(record.model_dump_json() + "\n") _RECORDS.flush() - _written += 1 + _PROGRESS.completed += 1 def _authorization(user: FastHttpUser, item: WireRequest) -> str: @@ -203,7 +204,7 @@ class PlanUser(FastHttpUser): a random point of its first interval and jitters every later interval by up to `_JITTER` either way, so the users never fire in lockstep; the mean interval is unchanged and `rps` stays the total rate.""" - network_timeout = 120.0 + network_timeout = float(os.environ.get("PERF_REQUEST_TIMEOUT", "120")) connection_timeout = 30.0 def on_start(self) -> None: @@ -221,9 +222,10 @@ class PlanUser(FastHttpUser): @task def send(self) -> None: - item: Final = next(_NEXT, None) + take: Final = _PROGRESS.take(stop=_STOP_FILE is not None and Path(_STOP_FILE).exists()) + item: Final = next(_NEXT, None) if take else None if item is None: - if _written >= len(_PLAN): + if _PROGRESS.drained: _quit(self) return started_at: Final = time.time() @@ -237,6 +239,7 @@ class PlanUser(FastHttpUser): error: str | None = None header_s = 0.0 response: FastResponse | None = None + identifier: str | None = None # rebind-ok: the ID arrives in a JSON body or the first SSE event try: headers: Final = {"Authorization": _authorization(self, item), "Content-Type": "application/json"} context: ResponseContextManager @@ -265,6 +268,7 @@ class PlanUser(FastHttpUser): buffer += line events, buffer = _events(buffer) for event in events: + identifier = identifier or response_id(event) if first_token_s is None and token_in_event(item.endpoint, event): first_token_s = now text += delta_of_event(item.endpoint, event) @@ -272,7 +276,9 @@ class PlanUser(FastHttpUser): body_text = context.text or "" first_byte_s = time.perf_counter() - started if status == 200: - text = text_of_json(item.endpoint, _as_dict(_JSON.validate_json(body_text))) + payload: Final = _as_dict(_JSON.validate_json(body_text)) + identifier = response_id(payload) + text = text_of_json(item.endpoint, payload) ok, error = _judge(item, status, body_text) if ok: context.success() @@ -302,7 +308,8 @@ class PlanUser(FastHttpUser): mock_delay_s=float(delay_header) if delay_header else None, call_id=_header(response, "x-litellm-call-id") if response is not None else None, error=error, + response_id=identifier, ) ) - if _written >= len(_PLAN): + if _PROGRESS.drained: _quit(self) diff --git a/tests/e2e/perf/setup/run.py b/tests/e2e/perf/setup/run.py index 9ba9b45104d..272c64754c4 100644 --- a/tests/e2e/perf/setup/run.py +++ b/tests/e2e/perf/setup/run.py @@ -9,9 +9,11 @@ import shutil import subprocess import sys import tempfile +import time +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Final +from typing import BinaryIO, Final from e2e_config import PROXY_BASE_URL from pydantic import TypeAdapter @@ -73,7 +75,59 @@ def _keep_records_artifact(records_path: Path) -> None: _ = shutil.copyfile(records_path, Path(artifact_dir) / "perf-records.jsonl") -def run_plan(case: PerformanceTestCase, plan: tuple[PlannedRequest, ...], setup: CaseSetup) -> RunOutcome: +class RecordStream: + def __init__(self, source: BinaryIO) -> None: + self.source: Final = source + self.partial = b"" + + def read(self) -> tuple[WireRecord, ...]: + chunks: Final = (self.partial + self.source.read()).split(b"\n") + self.partial = chunks[-1] + return tuple(_RECORD_ADAPTER.validate_json(line) for line in chunks[:-1] if line) + + +def run_until( + command: list[str], + env: dict[str, str], + records_path: Path, + stop_path: Path, + stop_when: Callable[[tuple[WireRecord, ...]], bool], + timeout: int, +) -> subprocess.CompletedProcess[str]: + with records_path.open("rb") as records, records_path.with_suffix(".log").open("w+") as output: + with subprocess.Popen(command, env=env, stdout=output, stderr=subprocess.STDOUT, text=True) as process: + deadline: Final = time.monotonic() + timeout + reader: Final = RecordStream(records) + try: + while process.poll() is None: + if stop_when(reader.read()): + stop_path.touch() + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(command, timeout) + try: + _ = process.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + finally: + stop_path.touch() + try: + _ = process.wait(timeout=35) + except subprocess.TimeoutExpired: + process.kill() + _ = process.wait(timeout=10) + _keep_records_artifact(records_path) + _ = output.seek(0) + return subprocess.CompletedProcess(command, process.returncode, output.read(), "") + + +def run_plan( + case: PerformanceTestCase, + plan: tuple[PlannedRequest, ...], + setup: CaseSetup, + *, + stop_when: Callable[[tuple[WireRecord, ...]], bool] | None = None, + request_timeout: float = 120.0, +) -> RunOutcome: """Replay `plan` with Locust in a child process and collect its records. The run-time ceiling is well past the planned duration so Locust's own clock never cuts a slow run short; the locustfile quits itself once the last record is written.""" @@ -82,6 +136,7 @@ def run_plan(case: PerformanceTestCase, plan: tuple[PlannedRequest, ...], setup: with tempfile.TemporaryDirectory(prefix="e2e-perf-") as directory: plan_path: Final = Path(directory) / "plan.json" records_path: Final = Path(directory) / "records.jsonl" + stop_path: Final = Path(directory) / "stop" _ = plan_path.write_bytes(_PLAN_ADAPTER.dump_json(wire)) records_path.touch() env: Final = { @@ -91,33 +146,33 @@ def run_plan(case: PerformanceTestCase, plan: tuple[PlannedRequest, ...], setup: "PERF_RECORDS_FILE": str(records_path), "PERF_RPS": str(case.traffic.rps), "PERF_USERS": str(case.traffic.concurrency), + "PERF_REQUEST_TIMEOUT": str(request_timeout), + **({"PERF_STOP_FILE": str(stop_path)} if stop_when is not None else {}), } - completed: Final = subprocess.run( - [ - sys.executable, - "-P", - "-m", - "locust", - "--headless", - "--only-summary", - "--locustfile", - str(_LOCUSTFILE), - "--host", - PROXY_BASE_URL, - "--users", - str(case.traffic.concurrency), - "--spawn-rate", - str(case.traffic.concurrency), - "--run-time", - f"{ceiling_s}s", - "--exit-code-on-error", - "0", - ], - env=env, - capture_output=True, - text=True, - timeout=ceiling_s + 120, - check=False, + command: Final = [ + sys.executable, + "-P", + "-m", + "locust", + "--headless", + "--only-summary", + "--locustfile", + str(_LOCUSTFILE), + "--host", + PROXY_BASE_URL, + "--users", + str(case.traffic.concurrency), + "--spawn-rate", + str(case.traffic.concurrency), + "--run-time", + f"{ceiling_s}s", + "--exit-code-on-error", + "0", + ] + completed: Final = ( + run_until(command, env, records_path, stop_path, stop_when, ceiling_s + 120) + if stop_when is not None + else subprocess.run(command, env=env, capture_output=True, text=True, timeout=ceiling_s + 120, check=False) ) records: Final = tuple( _RECORD_ADAPTER.validate_json(line) diff --git a/tests/e2e/perf/setup/upgrade.py b/tests/e2e/perf/setup/upgrade.py new file mode 100644 index 00000000000..37e5c7bc904 --- /dev/null +++ b/tests/e2e/perf/setup/upgrade.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import math +import time +from collections.abc import Callable +from dataclasses import replace +from typing import Final + +import psycopg +from cases.small_bodies_5rps import CASE +from e2e_config import MASTER_KEY +from e2e_http import send_text_external +from prometheus_client.parser import text_string_to_metric_families +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from setup.performance_test_case import PerformanceTestCase +from setup.upgrade_worker import UpgradeState, fingerprints, missing_indexes, read_state, release_indexes +from setup.wire import WireRecord + +MAX_TRAFFIC_SECONDS: Final = 6 * 3600 +_ROWS: Final = TypeAdapter(tuple[tuple[str, str | None], ...]) +_ACTIVITY: Final = TypeAdapter(tuple[list[int], int]) +_COUNT: Final = TypeAdapter(tuple[int]) +_SERVICES: Final = ("postgres", "batch_write_to_db") + + +def upgrade_case() -> PerformanceTestCase: + return replace( + CASE, + name="database_upgrade_5rps", + traffic=replace(CASE.traffic, total_requests=int(CASE.traffic.rps * MAX_TRAFFIC_SECONDS)), + limits=tuple( + limit + for limit in CASE.limits + if limit.metric + in { + "unexpected_status_count", + "gateway_added_seconds", + "time_to_first_token_seconds", + "memory_growth_megabytes", + } + ), + ) + + +class MetricsHeaders(BaseModel): + model_config = ConfigDict(frozen=True) + authorization: str = Field(default=f"Bearer {MASTER_KEY}", alias="Authorization") + + +def postgres_failures(text: str) -> float: + families: Final = tuple(text_string_to_metric_families(text)) + names: Final = frozenset(family.name for family in families) + required: Final = frozenset(f"litellm_{service}_failed_requests" for service in _SERVICES) + samples: Final = tuple(sample for family in families for sample in family.samples) + has_database_activity: Final = any( + sample.name == "litellm_postgres_total_requests_total" and math.isfinite(sample.value) and sample.value > 0 + for sample in samples + ) + assert required <= names or has_database_activity, f"Missing database service counters: {required - names}" + failures: Final = tuple(sample.value for sample in samples if sample.name in {f"{name}_total" for name in required}) + assert all(math.isfinite(value) and value >= 0 for value in failures), "Invalid database failure counter" + return sum(failures) + + +def assert_database_metrics(urls: tuple[str, ...]) -> None: + assert urls, "E2E_DATABASE_METRICS_URLS must name every gateway/backend /metrics endpoint" + for url in urls: + assert_database_metrics_url(url) + + +def assert_database_metrics_url(url: str) -> None: + result: Final = send_text_external("GET", url, headers=MetricsHeaders(), timeout=10) + assert result.status_code == 200, f"Database metrics unavailable at {url}: {result.status_code}" + assert postgres_failures(result.body) == 0, f"Failed PostgreSQL operations reported at {url}" + + +def persisted(connection: psycopg.Connection[tuple[object, ...]], records: tuple[WireRecord, ...]) -> frozenset[str]: + ids: Final = tuple(record.call_id for record in records if record.call_id) + if not ids: + return frozenset() + rows: Final = _ROWS.validate_python( + connection.execute( + 'SELECT request_id, litellm_call_id FROM "LiteLLM_SpendLogs" WHERE litellm_call_id=ANY(%s)', (list(ids),) + ).fetchall() + ) + return matched_response_ids(records, rows) + + +def matched_response_ids(records: tuple[WireRecord, ...], rows: tuple[tuple[str, str | None], ...]) -> frozenset[str]: + call_ids: Final = frozenset(call_id for _, call_id in rows if call_id) + return frozenset(record.response_id for record in records if record.response_id and record.call_id in call_ids) + + +def activity(connection: psycopg.Connection[tuple[object, ...]]) -> tuple[list[int], int]: + return _ACTIVITY.validate_python( + connection.execute("""SELECT + ARRAY(SELECT pid FROM pg_stat_progress_create_index WHERE datname=current_database()), + (SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock') + """).fetchone() + ) + + +def assert_finished(connection: psycopg.Connection[tuple[object, ...]], state: UpgradeState) -> None: + assert state.phase == "complete", f"Upgrade did not finish: {state.phase}" + assert not missing_indexes(connection, state.required_indexes), "Required target indexes are missing or invalid" + (missing,) = _COUNT.validate_python( + connection.execute( + """ + SELECT count(*) FROM unnest(%s::text[]) name WHERE NOT EXISTS ( + SELECT 1 FROM "_prisma_migrations" WHERE migration_name=name + AND finished_at IS NOT NULL AND rolled_back_at IS NULL)""", + (list(state.required_migrations),), + ).fetchone() + ) + assert missing == 0, f"{missing} target migrations were not applied" + (unfinished,) = _COUNT.validate_python( + connection.execute(""" + SELECT count(*) FROM "_prisma_migrations" WHERE finished_at IS NULL AND rolled_back_at IS NULL + """).fetchone() + ) + assert unfinished == 0, f"{unfinished} unfinished migrations" + assert fingerprints(connection, tuple(state.baseline_rows)) == state.baseline_rows, ( + "Baseline rows changed or disappeared" + ) + + +class UpgradeMonitor: + def __init__( + self, + *, + state: Callable[[], UpgradeState], + check_metrics: Callable[[], None], + read_activity: Callable[[], tuple[list[int], int]], + read_persisted: Callable[[tuple[WireRecord, ...]], frozenset[str]], + start_indexes: Callable[[], None], + clock: Callable[[], float] = time.monotonic, + wall_clock: Callable[[], float] = time.time, + ) -> None: + self.state: Final = state + self.check_metrics: Final = check_metrics + self.read_activity: Final = read_activity + self.read_persisted: Final = read_persisted + self.start_indexes: Final = start_indexes + self.clock: Final = clock + self.wall_clock: Final = wall_clock + self.started: Final = clock() + self.status_limit: Final = next(limit.max for limit in CASE.limits if limit.metric == "unexpected_status_count") + self.pending: tuple[WireRecord, ...] = () + self.processed = 0 + self.successes = 0 + self.persisted_count = 0 + self.overlap_writes = 0 + self.max_lock_waiters = 0 + self.previous_builds: frozenset[int] = frozenset() + self.build_seen_at: float | None = None + self.released = False + self.completed_at: float | None = None + + def poll(self, records: tuple[WireRecord, ...]) -> bool: + self.check_metrics() + current: Final = self.state() + assert current.phase != "failed", "Target migration/index worker failed" + assert self.clock() - self.started < MAX_TRAFFIC_SECONDS, "Upgrade exceeded its six-hour operational timeout" + successful: Final = tuple(record for record in records if record.status == 200) + self.processed += len(records) + self.successes += len(successful) + assert self.processed - self.successes <= self.status_limit, "Too many unexpected non-200 responses" + assert all(record.ok and record.text for record in successful), "Failed or empty inference response" + assert all(record.response_id and record.call_id for record in successful), ( + "Response missing spend-log identifiers" + ) + builds, lock_waiters = self.read_activity() + if not self.previous_builds.intersection(builds): + self.build_seen_at = self.wall_clock() if builds else None + self.max_lock_waiters = max(self.max_lock_waiters, lock_waiters) + self.pending += successful + found: Final = self.read_persisted(self.pending) + self.persisted_count += len(found) + still_building, _ = self.read_activity() + since: Final = self.build_seen_at + if since is not None and frozenset(builds).intersection(still_building): + self.overlap_writes += sum( + 1 for record in self.pending if record.response_id in found and record.started_at >= since + ) + self.previous_builds = frozenset(builds) + self.pending = tuple(record for record in self.pending if record.response_id not in found) + if self.successes and not self.released: + self.start_indexes() + self.released = True + if current.phase == "complete" and self.completed_at is None: + self.completed_at = self.clock() + return self.completed_at is not None and self.clock() - self.completed_at >= 60 and self.successes >= 300 + + def assert_complete(self, state: UpgradeState) -> None: + assert self.successes >= 300, f"Only {self.successes} requests completed" + assert not self.pending, f"{len(self.pending)} successful requests have no matching spend log" + assert self.persisted_count == self.successes, "Spend-log count does not match successful requests" + assert not state.pending_indexes or self.overlap_writes > 0, "No persisted requests observed during index work" + + +def monitor_for(connection: psycopg.Connection[tuple[object, ...]], urls: tuple[str, ...]) -> UpgradeMonitor: + return UpgradeMonitor( + state=lambda: read_state(connection), + check_metrics=lambda: assert_database_metrics(urls), + read_activity=lambda: activity(connection), + read_persisted=lambda records: persisted(connection, records), + start_indexes=lambda: release_indexes(connection), + ) diff --git a/tests/e2e/perf/setup/upgrade_worker.py b/tests/e2e/perf/setup/upgrade_worker.py new file mode 100644 index 00000000000..e69e841814f --- /dev/null +++ b/tests/e2e/perf/setup/upgrade_worker.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from typing import Final, Literal + +import psycopg +from pydantic import BaseModel, ConfigDict, TypeAdapter + + +class UpgradeState(BaseModel): + model_config = ConfigDict(frozen=True) + + phase: Literal["schema", "schema_ready", "indexes", "complete", "failed"] + started_at: float + schema_seconds: float = 0 + index_seconds: float = 0 + required_indexes: tuple[str, ...] + required_migrations: tuple[str, ...] + pending_indexes: tuple[str, ...] + baseline_rows: dict[str, str] + + +_STATE: Final = TypeAdapter(tuple[UpgradeState]) +_BOOL: Final = TypeAdapter(tuple[bool]) +_STRINGS: Final = TypeAdapter(tuple[tuple[str], ...]) +_FINGERPRINTS: Final = TypeAdapter(tuple[tuple[str, str], ...]) +_FINGERPRINT: Final = """md5(ROW(request_id, api_key, spend, total_tokens, prompt_tokens, + completion_tokens, model, "startTime", "endTime")::text)""" + + +def connect() -> psycopg.Connection[tuple[object, ...]]: + return psycopg.connect( + os.environ["DATABASE_URL"], + autocommit=True, + connect_timeout=10, + options="-c statement_timeout=10000 -c timezone=UTC", + ) + + +def fingerprints(connection: psycopg.Connection[tuple[object, ...]], ids: tuple[str, ...]) -> dict[str, str]: + rows: Final = connection.execute( + f'SELECT request_id, {_FINGERPRINT} FROM "LiteLLM_SpendLogs" WHERE request_id = ANY(%s)', (list(ids),) + ).fetchall() + return dict(_FINGERPRINTS.validate_python(rows)) + + +def missing_indexes(connection: psycopg.Connection[tuple[object, ...]], names: tuple[str, ...]) -> tuple[str, ...]: + rows: Final = connection.execute( + """SELECT name FROM unnest(%s::text[]) AS name WHERE NOT EXISTS ( + SELECT 1 FROM pg_index WHERE indexrelid = to_regclass(format('public.%%I', name)) + AND indisvalid AND indisready)""", + (list(names),), + ).fetchall() + return tuple(row[0] for row in _STRINGS.validate_python(rows)) + + +def read_state(connection: psycopg.Connection[tuple[object, ...]]) -> UpgradeState: + (state,) = _STATE.validate_python( + connection.execute('SELECT state FROM "_LiteLLMPerfUpgrade" WHERE id=1').fetchone() + ) + return state + + +def write_state(connection: psycopg.Connection[tuple[object, ...]], state: UpgradeState) -> None: + _ = connection.execute( + """INSERT INTO "_LiteLLMPerfUpgrade" (id, state) VALUES (1, %s::jsonb) + ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state""", + (state.model_dump_json(),), + ) + + +def release_indexes(connection: psycopg.Connection[tuple[object, ...]]) -> None: + _ = connection.execute('UPDATE "_LiteLLMPerfUpgrade" SET traffic_ready=true WHERE id=1') + + +def wait_for_traffic(connection: psycopg.Connection[tuple[object, ...]], deadline: float) -> bool: + while time.monotonic() < deadline: + (ready,) = _BOOL.validate_python( + connection.execute('SELECT traffic_ready FROM "_LiteLLMPerfUpgrade" WHERE id=1').fetchone() + ) + if ready: + return True + time.sleep(1) + return False + + +def main() -> int: + assert os.environ.get("E2E_DATABASE_UPGRADE") == "1", "Only run against a disposable restored baseline" + assert os.environ.get("DATABASE_PERF_CONFIRM_DISPOSABLE") == "1", "Disposable database confirmation required" + from litellm_proxy_extras import request_log_indexes + from litellm_proxy_extras.utils import ProxyExtrasDBManager + + with connect() as connection: + _ = connection.execute("""CREATE TABLE "_LiteLLMPerfUpgrade" ( + id integer PRIMARY KEY CHECK (id=1), state jsonb NOT NULL, traffic_ready boolean NOT NULL DEFAULT false)""") + sample: Final = connection.execute(""" + (SELECT request_id FROM "LiteLLM_SpendLogs" ORDER BY request_id LIMIT 32) + UNION (SELECT request_id FROM "LiteLLM_SpendLogs" ORDER BY request_id DESC LIMIT 32)""").fetchall() + ids: Final = tuple(row[0] for row in _STRINGS.validate_python(sample)) + assert ids, "Restored baseline has no spend logs" + names: Final = tuple(index.name for index in request_log_indexes.REQUEST_LOG_INDEXES) + migrations: Final = Path(request_log_indexes.__file__).parent / "migrations" + initial: Final = UpgradeState( + phase="schema", + started_at=time.time(), + required_indexes=names, + required_migrations=tuple(sorted(path.parent.name for path in migrations.glob("*/migration.sql"))), + pending_indexes=missing_indexes(connection, names), + baseline_rows=fingerprints(connection, ids), + ) + assert initial.required_migrations and initial.required_indexes, "Target schema requirements are missing" + write_state(connection, initial) + try: + assert ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True), ( + "Schema migration failed" + ) + ready: Final = initial.model_copy( + update={ + "phase": "schema_ready", + "schema_seconds": time.time() - initial.started_at, + "pending_indexes": missing_indexes(connection, names), + } + ) + write_state(connection, ready) + assert wait_for_traffic(connection, time.monotonic() + 1800), "Traffic did not start within 30 minutes" + write_state(connection, ready.model_copy(update={"phase": "indexes"})) + index_start: Final = time.monotonic() + assert ProxyExtrasDBManager.build_request_log_indexes(), "Request-log index build failed" + complete: Final = ready.model_copy( + update={"phase": "complete", "index_seconds": time.monotonic() - index_start} + ) + write_state(connection, complete) + _ = sys.stdout.write(complete.model_dump_json() + "\n") + return 0 + except Exception as exc: + write_state(connection, read_state(connection).model_copy(update={"phase": "failed"})) + _ = sys.stderr.write(json.dumps({"error": type(exc).__name__}) + "\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/perf/setup/wire.py b/tests/e2e/perf/setup/wire.py index 07dc5d170ae..ced79d6970c 100644 --- a/tests/e2e/perf/setup/wire.py +++ b/tests/e2e/perf/setup/wire.py @@ -9,12 +9,13 @@ from collections.abc import Mapping from types import MappingProxyType from typing import Final, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter from setup.corpus.session import Endpoint as CorpusEndpoint from setup.corpus.session import request_body as corpus_body Endpoint = Literal["chat_completions", "responses", "messages"] +_PAYLOAD: Final = TypeAdapter(dict[str, object]) class TokenSource(BaseModel): @@ -70,6 +71,33 @@ class WireRecord(BaseModel): mock_delay_s: float | None call_id: str | None error: str | None + response_id: str | None = None + + +def response_id(payload: Mapping[str, object]) -> str | None: + nested: Final = payload.get("response") or payload.get("message") + source: Final = _PAYLOAD.validate_python(nested) if isinstance(nested, Mapping) else payload + identifier: Final = source.get("id") + return identifier if isinstance(identifier, str) else None + + +class PlanProgress: + def __init__(self, total: int) -> None: + self.total: Final = total + self.issued = 0 + self.completed = 0 + self.stopped = False + + def take(self, *, stop: bool) -> bool: + self.stopped = self.stopped or stop + if self.stopped or self.issued >= self.total: + return False + self.issued += 1 + return True + + @property + def drained(self) -> bool: + return (self.stopped or self.issued == self.total) and self.completed == self.issued _CORPUS_ENDPOINT: Final[Mapping[Endpoint, CorpusEndpoint]] = MappingProxyType( diff --git a/tests/e2e/perf/test_database_upgrade.py b/tests/e2e/perf/test_database_upgrade.py new file mode 100644 index 00000000000..f165b9b738f --- /dev/null +++ b/tests/e2e/perf/test_database_upgrade.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path +from typing import Final + +import pytest +from idp import Keycloak +from lifecycle import ResourceManager +from memory_readings import read_rss_everywhere +from proxy_client import ProxyClient +from setup.datastore import read_db_counters, read_redis_counters +from setup.observe import Observation, evaluate, read_mock_requests, render, reset_mock, set_mock_latency +from setup.plan import build_plan +from setup.podcpu import gateway_cpu_sampler +from setup.provision import MOCK_PROVIDERS, build_setup, mock_url +from setup.run import run_plan +from setup.upgrade import assert_finished, monitor_for, upgrade_case +from setup.upgrade_worker import connect, read_state + +pytestmark = [pytest.mark.perf, pytest.mark.quiet_stack] + + +@pytest.mark.skipif(os.environ.get("E2E_DATABASE_UPGRADE") != "1", reason="Dedicated disposable snapshot lane only") +def test_database_upgrade(proxy: ProxyClient, idp: Keycloak, resources: ResourceManager) -> None: + case: Final = upgrade_case() + urls: Final = tuple(mock_url(provider) for provider in MOCK_PROVIDERS) + for url in urls: + _ = reset_mock(url) + _ = set_mock_latency(url, case.mock_latency) + setup: Final = build_setup(case, proxy=proxy, idp=idp, resources=resources) + metrics: Final = tuple(url.strip() for url in os.environ["E2E_DATABASE_METRICS_URLS"].split(",") if url.strip()) + with connect() as connection: + assert read_state(connection).phase == "schema_ready", "Expected the target schema ready, index work waiting" + rss_before: Final = read_rss_everywhere(proxy) + db_before: Final = read_db_counters() + redis_before: Final = read_redis_counters() + monitor: Final = monitor_for(connection, metrics) + with gateway_cpu_sampler() as cpu: + outcome: Final = run_plan(case, build_plan(case), setup, stop_when=monitor.poll, request_timeout=30) + assert outcome.locust_exit == 0, outcome.locust_output + remaining: Final = outcome.records[monitor.processed :] + _ = monitor.poll(remaining) + deadline: Final = time.monotonic() + 120 + while monitor.pending and time.monotonic() < deadline: + time.sleep(1) + _ = monitor.poll(()) + state: Final = read_state(connection) + _ = sys.stdout.write( + f"successful_requests={monitor.successes} persisted_requests={monitor.persisted_count} " + f"unmatched_requests={len(monitor.pending)}\n" + ) + monitor.assert_complete(state) + assert_finished(connection, state) + rss_after: Final = read_rss_everywhere(proxy) + assert {r.worker for r in rss_before.readings} == {r.worker for r in rss_after.readings}, ( + "Gateway workers restarted" + ) + observed: Final = Observation( + records=outcome.records, + mock_requests=tuple(seen for url in urls for seen in read_mock_requests(url)), + guardrail_calls=(), + rss_before=rss_before, + rss_after=rss_after, + wall_seconds=outcome.wall_seconds, + spend_logged_share=1, + db_before=db_before, + db_after=read_db_counters(), + redis_before=redis_before, + redis_after=read_redis_counters(), + gateway_cpu_seconds=cpu.seconds, + ) + rows, exceeded = evaluate(case, observed) + _ = sys.stdout.write(render(case, observed, rows) + "\n") + _ = sys.stdout.write( + f"schema_seconds={state.schema_seconds:.3f} index_seconds={state.index_seconds:.3f} " + f"overlap_writes={monitor.overlap_writes} max_lock_waiters={monitor.max_lock_waiters} " + f"pending_indexes={state.pending_indexes}\n" + ) + artifact: Final = os.environ.get("E2E_ARTIFACT_DIR") + if artifact: + _ = (Path(artifact) / "database-upgrade.json").write_text(state.model_dump_json(), encoding="utf-8") + assert not exceeded, "\n".join(exceeded) diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index 23ab6487889..8cc7bf0c2b4 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -100,6 +100,8 @@ from models import ( TeamNewBody, TeamNewResponse, TeamUpdateBody, + TokenCounterBody, + TokenCounterResponse, ToolsetCreateBody, ToolsetRow, ToolsetUpdateBody, @@ -1049,6 +1051,15 @@ class ProxyClient: response_type=CountTokensResponse, ) + @step("Count tokens with /utils/token_counter for {body.model}") + def token_counter(self, key: str, body: TokenCounterBody) -> Result[TokenCounterResponse]: + return self.transport.post( + "/utils/token_counter", + headers=self.transport.bearer(key), + json=body, + response_type=TokenCounterResponse, + ) + @step("Send a /v1/messages request to {body.model}") def messages( self, key: str, body: AnthropicMessagesBody, *, session_id: str | None = None diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini index da3dd72d795..f15cfd4e98b 100644 --- a/tests/e2e/pytest.ini +++ b/tests/e2e/pytest.ini @@ -14,7 +14,6 @@ markers = cli_determinism: drives the real claude CLI for several seconds; deselected unless E2E_CLI_DETERMINISM is set redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_PERF_TESTS is set quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; every other test waits for it to finish - mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless E2E_MCP_OAUTH_LIVE is set provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the gateway must reach the pytest host; deselected unless E2E_PROVIDER_EDGE_HOST_REACHABLE is set owned_gateway: boots its own proxy from source against the stack's Postgres, so it needs DATABASE_URL on the pytest host; deselected unless E2E_OWNED_GATEWAY is set otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set diff --git a/tests/e2e/router/test_reliability_memory_e2e.py b/tests/e2e/router/test_reliability_memory_e2e.py index 196784b5345..fb114092303 100644 --- a/tests/e2e/router/test_reliability_memory_e2e.py +++ b/tests/e2e/router/test_reliability_memory_e2e.py @@ -1,5 +1,5 @@ """Live e2e: a few hundred requests that fail before any provider answers must not -grow the proxy's resident memory past a fixed budget once the proxy is warm. +grow the proxy's resident memory past a loose ceiling once the proxy is warm. The regression this guards shipped in v1.100.0: every retry breadcrumb copied the whole request and the copies nested into one router-global list, so a proxy under @@ -13,26 +13,27 @@ deployment that fails a handful of times in a row is benched (a bad-credential 4 included), the router answers "No deployments available" without retrying, and the retry loop that leaks stops running; cooldown_time 0 keeps it running. -Three identical batches run back to back. The first two are the warmup that grows -the proxy's caches and allocator arenas to their steady state, the third is the one -the budget applies to, so a healthy proxy shows the third batch adding roughly -nothing while a leaking one adds a fixed amount per request. One warmup batch is not -enough: each batch's in-flight retries peak well above the live heap, the allocator -keeps that high-water mark, and on a cold worker the second batch still set a new one -(about 50 MB of RSS with the live Python heap flat to within 1 MB), where the later -batches each settled under 25 MB. RSS is read through /debug/memory/summary on every +Four identical batches run back to back. The first two are the warmup that grows +the proxy's caches and allocator arenas, the last two are the measured window, and +each worker's RSS may grow at most a loose ceiling over that window. RSS cannot be +held to a tight per-batch budget: on the x86 release gate (rc/1.107.0 and v1.105.1 +on isolated stacks, 28 batches each) the allocator kept setting new high-water marks +for the first several batches, about 60 MB on the third batch of a fresh stack and +more after the rest of the suite, with the gc-tracked object count flat to 0.1%, and +only settled to a couple of MB per batch after twenty. So the ceiling catches a gross +leak and nothing slower. RSS is read through /debug/memory/summary on every configured replica; a burst of failing calls leaves a transient bulge of garbage that gc reclaims within seconds, so each checkpoint samples until no new worker has answered for a settle window and keeps the lowest reading per worker. A checkpoint runs before the warmup and after every batch, and each batch's settled growth per worker rides out as a repeated `rss_batch_growth` JUnit property as soon as that -batch settles, so a run that fails keeps the curve up to the batch it failed in. The budget is judged per worker -(by replica address, hostname and pid, since pods in their own pid namespaces report -the same pids) so each worker is compared with itself, and the checkpoints around the -measured batch must see the same workers: a single load-balanced address reaches the -workers behind it one answer at a time, and a worker that answered only one -checkpoint would otherwise drop out of the comparison, which is where a leaking -worker could hide. +batch settles, so a run that fails keeps the curve up to the batch it failed in. The +ceiling is judged per worker (by replica address, hostname and pid, since pods in +their own pid namespaces report the same pids) so each worker is compared with +itself, and the checkpoints around the measured window must see the same workers: a +single load-balanced address reaches the workers behind it one answer at a time, and +a worker that answered only one checkpoint would otherwise drop out of the +comparison, which is where a leaking worker could hide. RSS alone is a coarse gauge: on the release stack (spend logs storing prompts, json logs, prometheus and otel callbacks) the same v1.100.0 breadcrumbs grew RSS @@ -79,7 +80,7 @@ from e2e_config import ( MEMORY_IDLE_RSS_BUDGET_MB, MEMORY_REQUESTS_PER_PHASE, MEMORY_RETRIES_PER_REQUEST, - MEMORY_RSS_BUDGET_MB, + MEMORY_RSS_CEILING_MB, MEMORY_RSS_SAMPLE_INTERVAL_SECONDS, MEMORY_RSS_SETTLE_SAMPLES, MEMORY_STORED_REQUEST_BUDGET_KB, @@ -99,7 +100,9 @@ pytestmark = [pytest.mark.e2e, pytest.mark.quiet_stack] DEPLOYMENTS_PER_GROUP: Final = 2 RSS_SAMPLE_CAP: Final = 4 * MEMORY_RSS_SETTLE_SAMPLES WARMUP_BATCHES: Final = 2 -MEASURED_BATCH: Final = WARMUP_BATCHES + 1 +MEASURED_BATCHES: Final = 2 +FIRST_MEASURED_BATCH: Final = WARMUP_BATCHES + 1 +LAST_BATCH: Final = WARMUP_BATCHES + MEASURED_BATCHES @dataclass(frozen=True, slots=True) @@ -207,7 +210,7 @@ def _record_batch_growth( ) -def _settled_rss_around_measured_batch( +def _settled_rss_after_batches( proxy: ProxyClient, key: str, model: str, @@ -216,21 +219,25 @@ def _settled_rss_around_measured_batch( record_property: Callable[[str, object], None], before: Mapping[WorkerKey, RssReading], batch: int, -) -> tuple[Mapping[WorkerKey, RssReading], Mapping[WorkerKey, RssReading]]: + last_batch: int, +) -> Mapping[WorkerKey, RssReading]: calls: Final = _fail_many(proxy, key, model, override) _assert_every_call_failed_through_fallback(calls, fallback) after: Final = _settled_rss_per_worker(proxy) _record_batch_growth(record_property, batch, before, after) - if batch == MEASURED_BATCH: - return before, after - return _settled_rss_around_measured_batch(proxy, key, model, override, fallback, record_property, after, batch + 1) + if batch == last_batch: + return after + return _settled_rss_after_batches( + proxy, key, model, override, fallback, record_property, after, batch + 1, last_batch + ) def _heaviest_worker_growth( warm: Mapping[WorkerKey, RssReading], after: Mapping[WorkerKey, RssReading] ) -> WorkerGrowth: assert warm.keys() == after.keys(), ( - f"the workers answering /debug/memory/summary changed around measured batch {MEASURED_BATCH}, so not every " + f"the workers answering /debug/memory/summary changed around measured batches {FIRST_MEASURED_BATCH}-" + f"{LAST_BATCH}, so not every " f"worker can be compared with itself: gone after it {sorted(warm.keys() - after.keys())} (a worker " f"that died or was restarted under failing traffic, which is what an OOM kill looks like), first seen " f"after it {sorted(after.keys() - warm.keys())} (the warm window never reached them, so they have no " @@ -332,7 +339,7 @@ class TestReliabilityMemory: f"request into the stored snapshot the way the v1.100.0 ones did" ) - warm, after = _settled_rss_around_measured_batch( + warm: Final = _settled_rss_after_batches( client.proxy, scoped_key, primary, @@ -341,16 +348,26 @@ class TestReliabilityMemory: record_property, _settled_rss_per_worker(client.proxy), 1, + WARMUP_BATCHES, + ) + after: Final = _settled_rss_after_batches( + client.proxy, + scoped_key, + primary, + override, + fallback, + record_property, + warm, + FIRST_MEASURED_BATCH, + LAST_BATCH, ) heaviest: Final = _heaviest_worker_growth(warm, after) - assert heaviest.growth_mb <= MEMORY_RSS_BUDGET_MB, ( - f"proxy RSS grew {heaviest.growth_mb:.1f} MB over measured batch {MEASURED_BATCH} of " - f"{MEMORY_REQUESTS_PER_PHASE} failing requests ({MEMORY_RETRIES_PER_REQUEST} retries each plus a fallback) " - f"after {WARMUP_BATCHES} identical warmup batches, past the {MEMORY_RSS_BUDGET_MB:.0f} MB budget: " - f"worker pid {heaviest.warm.worker_pid} on " + assert heaviest.growth_mb <= MEMORY_RSS_CEILING_MB, ( + f"proxy RSS grew {heaviest.growth_mb:.1f} MB over measured batches {FIRST_MEASURED_BATCH}-{LAST_BATCH} " + f"({MEASURED_BATCHES * MEMORY_REQUESTS_PER_PHASE} failing requests) after {WARMUP_BATCHES} identical " + f"warmup batches, past the {MEMORY_RSS_CEILING_MB:.0f} MB ceiling: worker pid {heaviest.warm.worker_pid} on " f"{heaviest.warm.hostname or 'an unnamed host'} behind {heaviest.warm.replica} settled at " - f"{heaviest.warm.ram_usage_mb:.1f} MB warm and " - f"{heaviest.after.ram_usage_mb:.1f} MB after; failing requests are leaking memory the way the " - f"v1.100.0 retry breadcrumbs did" + f"{heaviest.warm.ram_usage_mb:.1f} MB warm and {heaviest.after.ram_usage_mb:.1f} MB after; failing " + f"requests are leaking memory the way the v1.100.0 retry breadcrumbs did" ) diff --git a/tests/e2e_harness/perf/setup/test_event_loop.py b/tests/e2e_harness/perf/setup/test_event_loop.py index 4728003db7f..57868305716 100644 --- a/tests/e2e_harness/perf/setup/test_event_loop.py +++ b/tests/e2e_harness/perf/setup/test_event_loop.py @@ -78,6 +78,16 @@ def test_a_hold_well_past_its_collector_pass_stays_on_the_task() -> None: assert [(row.coroutine, row.offending) for row in rows] == [("TaskHandle._run_coro()", True)] +def test_a_hold_is_charged_to_the_longest_collector_pass_that_fits_it_not_to_their_sum() -> None: + thread_side_pass: Final = _GC_PASS.replace("0.711", "0.627") + rows: Final = summarize( + _SPEC, parse_stalls((thread_side_pass, _GC_PASS.replace("0.711", "0.605"), _STREAM.replace("0.412", "0.614"))) + ) + assert [(row.coroutine, row.where, row.max_seconds, row.offending) for row in rows] == [ + ("garbage collection", "gen=2", 0.614, False) + ] + + def test_a_collector_pass_explains_only_the_next_hold_and_only_one_at_least_as_long() -> None: rows: Final = summarize(_SPEC, parse_stalls((_GC_PASS, _STARTUP, _HANDLER))) assert sorted((row.coroutine, row.offending) for row in rows) == [ diff --git a/tests/e2e_harness/perf/test_run.py b/tests/e2e_harness/perf/test_run.py new file mode 100644 index 00000000000..23f62c9c560 --- /dev/null +++ b/tests/e2e_harness/perf/test_run.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Final + +import pytest +from setup.run import run_until +from setup.wire import WireRecord +from test_upgrade import record + + +class StopAfterRecord: + def __init__(self, *, partial: Path, resume: Path, fail: bool = False) -> None: + self.partial: Final = partial + self.resume: Final = resume + self.fail: Final = fail + self.received: tuple[WireRecord, ...] = () + + def __call__(self, records: tuple[WireRecord, ...]) -> bool: + self.received += records + if self.partial.exists(): + self.resume.touch() + assert not (self.fail and records), "Database failed" + return bool(records) + + +@pytest.mark.parametrize("fail", [False, True]) +def test_controlled_process_preserves_partial_lines_and_drains_on_stop_or_failure(tmp_path: Path, fail: bool) -> None: + records: Final = tmp_path / "records.jsonl" + stop: Final = tmp_path / "stop" + partial: Final = tmp_path / "partial" + resume: Final = tmp_path / "resume" + records.touch() + encoded: Final = record().model_dump_json() + "\n" + program: Final = f""" +import time +from pathlib import Path +with Path({str(records)!r}).open("w") as f: + text = {encoded!r} + f.write(text[:10]) + f.flush() + Path({str(partial)!r}).touch() + while not Path({str(resume)!r}).exists(): + time.sleep(0.01) + f.write(text[10:]) + f.flush() + while not Path({str(stop)!r}).exists(): + time.sleep(0.01) + f.write({record(1).model_dump_json()!r} + "\\n") + f.flush() +""" + callback: Final = StopAfterRecord(partial=partial, resume=resume, fail=fail) + command: Final = [sys.executable, "-c", program] + if fail: + with pytest.raises(AssertionError, match="Database failed"): + _ = run_until(command, dict(os.environ), records, stop, callback, timeout=10) + else: + result: Final = run_until(command, dict(os.environ), records, stop, callback, timeout=10) + assert result.returncode == 0, result.stdout + assert callback.received == (record(),) + assert tuple(WireRecord.model_validate_json(line) for line in records.read_text().splitlines()) == ( + record(), + record(1), + ) diff --git a/tests/e2e_harness/perf/test_upgrade.py b/tests/e2e_harness/perf/test_upgrade.py new file mode 100644 index 00000000000..0904b7a7b5a --- /dev/null +++ b/tests/e2e_harness/perf/test_upgrade.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Final + +import pytest +from cases.small_bodies_5rps import CASE +from memory_readings import RssCapture, RssReading +from setup.observe import Observation, evaluate +from setup.upgrade import MAX_TRAFFIC_SECONDS, UpgradeMonitor, matched_response_ids, postgres_failures, upgrade_case +from setup.upgrade_worker import UpgradeState +from setup.wire import PlanProgress, WireRecord, response_id + + +def record(seq: int = 0, *, started_at: float = 100) -> WireRecord: + return WireRecord( + seq=seq, + marker=f"request-{seq}", + caller="key", + endpoint="chat_completions", + stream=False, + blocked=False, + started_at=started_at, + status=200, + header_s=1, + first_byte_s=1, + first_token_s=None, + total_s=1, + text="answer", + ok=True, + mock_delay_s=1, + call_id=f"call-{seq}", + error=None, + response_id=f"response-{seq}", + ) + + +def test_spend_logs_match_call_ids_when_response_ids_are_transformed() -> None: + records: Final = (record(0), record(1), record(2)) + rows: Final = (("provider-response-id", "call-0"), ("response-1", "unrelated-call")) + assert matched_response_ids(records, rows) == frozenset({"response-0"}) + + +def state(phase: str = "schema_ready", *, pending: bool = True) -> UpgradeState: + return UpgradeState.model_validate( + { + "phase": phase, + "started_at": 0, + "required_indexes": ["index"], + "required_migrations": ["migration"], + "pending_indexes": ["index"] if pending else [], + "baseline_rows": {"seed-row": "hash"}, + } + ) + + +class Probe: + def __init__(self) -> None: + self.now = 0.0 + self.current = state() + self.builds: list[int] = [] + self.found: frozenset[str] = frozenset() + self.releases = 0 + self.metric_checks = 0 + self.errors = False + + def check_metrics(self) -> None: + self.metric_checks += 1 + assert not self.errors, "Failed PostgreSQL operation" + + def persisted(self, records: tuple[WireRecord, ...]) -> frozenset[str]: + return frozenset(row.response_id for row in records if row.response_id in self.found and row.response_id) + + def release(self) -> None: + self.releases += 1 + + def monitor(self) -> UpgradeMonitor: + return UpgradeMonitor( + state=lambda: self.current, + check_metrics=self.check_metrics, + read_activity=lambda: (self.builds, 2), + read_persisted=self.persisted, + start_indexes=self.release, + clock=lambda: self.now, + wall_clock=lambda: self.now, + ) + + +def test_stop_drains_issued_requests_without_issuing_the_rest() -> None: + progress: Final = PlanProgress(100) + assert progress.take(stop=False) + assert progress.take(stop=False) + progress.completed = 1 + assert not progress.take(stop=True) + assert not progress.drained + assert not progress.take(stop=False) + progress.completed = 2 + assert progress.drained + assert progress.issued == 2 + + +def test_finite_plan_still_waits_for_its_last_response() -> None: + progress: Final = PlanProgress(1) + assert progress.take(stop=False) + assert not progress.take(stop=False) + assert not progress.drained + progress.completed = 1 + assert progress.drained + + +@pytest.mark.parametrize( + "payload,expected", + [ + ({"id": "chat-id"}, "chat-id"), + ({"type": "response.created", "response": {"id": "response-id"}}, "response-id"), + ({"type": "message_start", "message": {"id": "message-id"}}, "message-id"), + ({"type": "response.output_item.added", "item": {"id": "item-id"}}, None), + ({"type": "content_block_delta", "delta": {"text": "hello"}}, None), + ], +) +def test_response_id_uses_response_not_output_item(payload: Mapping[str, object], expected: str | None) -> None: + assert response_id(payload) == expected + + +def metrics(*, failure: str = "0", batch: str = "0") -> str: + return f"""# TYPE litellm_postgres_failed_requests_total counter +litellm_postgres_failed_requests_total{{function_name="query",error_class="Timeout"}} {failure} +# TYPE litellm_batch_write_to_db_failed_requests_total counter +litellm_batch_write_to_db_failed_requests_total{{function_name="flush",error_class="DatabaseError"}} {batch} +""" + + +def test_failed_sql_and_async_batch_writes_are_both_counted() -> None: + assert postgres_failures(metrics(failure="2", batch="3")) == 5 + assert postgres_failures(metrics()) == 0 + + +def test_no_error_label_series_is_a_valid_zero() -> None: + assert ( + postgres_failures("""# TYPE litellm_postgres_failed_requests_total counter +# TYPE litellm_batch_write_to_db_failed_requests_total counter +""") + == 0 + ) + + +@pytest.mark.parametrize("service", [None, "postgres", "batch_write_to_db"]) +def test_multiprocess_metrics_omit_failure_families_until_a_failure(service: str | None) -> None: + total: Final = """# TYPE litellm_postgres_total_requests_total counter +litellm_postgres_total_requests_total{postgres="postgres"} 1 +""" + failure: Final = ( + f"# TYPE litellm_{service}_failed_requests_total counter\n" + f'litellm_{service}_failed_requests_total{{function_name="query",error_class="Timeout"}} 1\n' + if service is not None + else "" + ) + assert postgres_failures(total + failure) == (1 if service is not None else 0) + + +@pytest.mark.parametrize("total", ["0", "-1", "NaN", "+Inf"]) +def test_missing_failure_families_require_observed_database_activity(total: str) -> None: + with pytest.raises(AssertionError): + _ = postgres_failures( + "# TYPE litellm_postgres_total_requests_total counter\n" + f'litellm_postgres_total_requests_total{{postgres="postgres"}} {total}\n' + ) + + +@pytest.mark.parametrize("body", ["", "# TYPE unrelated counter\n", metrics(failure="NaN"), metrics(batch="-1")]) +def test_missing_or_invalid_counters_do_not_pass_as_zero(body: str) -> None: + with pytest.raises(AssertionError): + _ = postgres_failures(body) + + +def test_index_completion_stops_after_the_post_upgrade_window() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + assert not monitor.poll((record(),)) + assert probe.releases == 1 + probe.builds = [17] + probe.now = 101 + assert not monitor.poll(()) + probe.found = frozenset({"response-0", "response-1"}) + probe.now = 103 + assert not monitor.poll((record(1, started_at=102),)) + assert monitor.overlap_writes == 1 + assert monitor.persisted_count == 2 + assert not monitor.pending + assert monitor.max_lock_waiters == 2 + probe.current = state("complete") + assert not monitor.poll(()) + probe.now = 162 + assert not monitor.poll(()) + probe.now = 163 + assert not monitor.poll(()) + probe.found = frozenset(f"response-{i}" for i in range(300)) + assert monitor.poll(tuple(record(i) for i in range(2, 300))) + assert probe.releases == 1 + assert probe.metric_checks == 7 + + +def test_http_200_without_spend_row_cannot_complete() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + records: Final = tuple(record(i) for i in range(300)) + _ = monitor.poll(records) + with pytest.raises(AssertionError, match="no matching spend log"): + monitor.assert_complete(state("complete", pending=False)) + + +def test_no_pending_index_work_still_requires_300_persisted_requests() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + records: Final = tuple(record(i) for i in range(300)) + probe.found = frozenset(f"response-{i}" for i in range(300)) + _ = monitor.poll(records) + monitor.assert_complete(state("complete", pending=False)) + assert monitor.successes == monitor.persisted_count == 300 + with pytest.raises(AssertionError, match="during index work"): + monitor.assert_complete(state("complete", pending=True)) + + +def test_postgres_failure_fails_even_when_inference_succeeded() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + probe.errors = True + with pytest.raises(AssertionError, match="PostgreSQL"): + _ = monitor.poll((record(),)) + + +def test_failed_migration_and_operational_timeout_are_not_success() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + probe.current = state("failed") + with pytest.raises(AssertionError, match="worker failed"): + _ = monitor.poll(()) + probe.current = state() + probe.now = MAX_TRAFFIC_SECONDS + with pytest.raises(AssertionError, match="operational timeout"): + _ = monitor.poll(()) + + +def test_upgrade_case_uses_5rps_with_the_same_request_and_memory_limits() -> None: + case: Final = upgrade_case() + assert case.traffic.rps == 5 + assert not {"cpu_seconds_per_request", "postgres_calls_per_request", "redis_calls_per_request"}.intersection( + limit.metric for limit in case.limits + ) + assert case.limits == tuple( + limit + for limit in CASE.limits + if limit.metric + in { + "unexpected_status_count", + "gateway_added_seconds", + "time_to_first_token_seconds", + "memory_growth_megabytes", + } + ) + + +def failed_record(seq: int) -> WireRecord: + return record(seq).model_copy(update={"status": 503, "ok": False, "text": "", "response_id": None, "call_id": None}) + + +def test_three_non_200s_do_not_start_indexes_or_require_spend_rows() -> None: + probe: Final = Probe() + monitor: Final = probe.monitor() + assert not monitor.poll(tuple(failed_record(i) for i in range(3))) + assert probe.releases == 0 + assert monitor.processed == 3 + assert monitor.successes == 0 + assert not monitor.pending + probe.found = frozenset(f"response-{i}" for i in range(3, 303)) + assert not monitor.poll(tuple(record(i) for i in range(3, 303))) + monitor.assert_complete(state("complete", pending=False)) + assert probe.releases == 1 + assert monitor.processed == 303 + assert monitor.successes == monitor.persisted_count == 300 + + +def test_fourth_non_200_fails_across_poll_batches() -> None: + monitor: Final = Probe().monitor() + assert not monitor.poll(tuple(failed_record(i) for i in range(3))) + with pytest.raises(AssertionError, match="Too many unexpected non-200"): + _ = monitor.poll((failed_record(3),)) + + +@pytest.mark.parametrize( + "failed", [record().model_copy(update={"text": ""}), record().model_copy(update={"ok": False})] +) +def test_invalid_200_is_not_an_allowed_non_200(failed: WireRecord) -> None: + with pytest.raises(AssertionError, match="Failed or empty inference"): + _ = Probe().monitor().poll((failed,)) + + +@pytest.mark.parametrize( + "failures,overhead,ttft,growth,expected", + [ + (3, 0.29, 3.7, 100, ()), + (4, 0.29, 3.7, 100, ("unexpected_status_count",)), + (3, 0.30, 3.7, 100, ("gateway_added_seconds percentile 50",)), + (3, 0.29, 3.8, 100, ("time_to_first_token_seconds percentile 99",)), + (3, 0.29, 3.7, 101, ("memory_growth_megabytes",)), + ], +) +def test_upgrade_verdict_uses_regular_limits_without_cpu_or_datastore_samples( + failures: int, overhead: float, ttft: float, growth: float, expected: tuple[str, ...] +) -> None: + nonstream: Final = tuple( + record(i).model_copy(update={"total_s": overhead, "mock_delay_s": 0}) for i in range(100) + ) + stream: Final = record(100).model_copy(update={"stream": True, "first_token_s": ttft}) + observed: Final = Observation( + records=(*nonstream, stream, *(failed_record(i) for i in range(101, 101 + failures))), + mock_requests=(), + guardrail_calls=(), + rss_before=RssCapture((RssReading("gateway", "host", 1, 1000),), ()), + rss_after=RssCapture((RssReading("gateway", "host", 1, 1000 + growth),), ()), + wall_seconds=60, + ) + rows, exceeded = evaluate(upgrade_case(), observed) + assert tuple(row.label for row in rows if row.over or row.missing) == expected + assert len(exceeded) == len(expected) diff --git a/tests/integration/_support/process.py b/tests/integration/_support/process.py index 00b9dca5184..d5ce6f916a2 100644 --- a/tests/integration/_support/process.py +++ b/tests/integration/_support/process.py @@ -17,7 +17,7 @@ from ipaddress import IPv4Address from itertools import accumulate from pathlib import Path from types import MappingProxyType -from typing import Final +from typing import TYPE_CHECKING, Final from urllib.parse import urlsplit import httpx @@ -30,9 +30,13 @@ from cryptography.x509.oid import NameOID from pydantic import TypeAdapter from tests.integration._support.budget import import_seconds, readiness_for, readiness_seconds -from tests.integration._support.client import GATEWAY_LIMITS, Gateway +from tests.integration._support.client import GATEWAY_LIMITS, Gateway, gateway_from_environment +from tests.integration._support.database import scratch_database from tests.integration._support.ports import allocate_port +if TYPE_CHECKING: + import pytest + DB_PUSH: Final = ("--use_prisma_db_push",) MIGRATE_DEPLOY: Final = () LEGACY_MIGRATE_DEPLOY: Final = ("--use_legacy_migration_resolver",) @@ -111,7 +115,7 @@ def _is_serving_worker(process: psutil.Process) -> bool: def _is_gone(process: psutil.Process) -> bool: try: - return process.status() == psutil.STATUS_ZOMBIE + return process.status() == psutil.STATUS_ZOMBIE and process.num_threads() <= 1 except psutil.NoSuchProcess: return True @@ -379,6 +383,18 @@ def owned_proxy( yield owned.gateway +@contextmanager +def isolated_gateway(directory: Path, environment: "pytest.MonkeyPatch") -> Iterator[Gateway]: + with gateway_from_environment() as shared, scratch_database() as database: + environment.setenv("DATABASE_URL", database) + environment.setenv("INTEGRATION_PROXY_DATABASE_URL", database) + environment.delenv("INTEGRATION_PROXY_READ_REPLICA_URL", raising=False) + environment.delenv("DATABASE_URL_READ_REPLICA", raising=False) + with owned_proxy(shared, directory, {}) as owned: + owned.post("/config/update", {"router_settings": {"num_retries": 0}}) + yield owned + + def _stop(process: subprocess.Popen[bytes]) -> None: try: root_stopped: Final = stop_root_process(process) diff --git a/tests/integration/authorization/test_rag_query_vector_store_allowlist.py b/tests/integration/authorization/test_rag_query_vector_store_allowlist.py index 39f0ad1c7e9..f4b5063441f 100644 --- a/tests/integration/authorization/test_rag_query_vector_store_allowlist.py +++ b/tests/integration/authorization/test_rag_query_vector_store_allowlist.py @@ -321,6 +321,32 @@ def test_deny_by_default_searches_explicitly_granted_store(strict_gateway: Stric assert marker in str(object_value(searches[0]["body"])["query"]), searches +def test_jwt_team_route_outside_operator_allowed_routes_is_denied_before_search( + strict_gateway: StrictGateway, +) -> None: + with strict_gateway.gateway.scenario() as scenario: + model: Final = scenario.model() + store_id: Final = f"vs_unregistered_{uuid.uuid4().hex}" + team: Final = scenario.team(models=_json_array(model), object_permission=_permission_for_stores(store_id)) + member: Final = scenario.member(team) + marker: Final = f"lit9370 alias {uuid.uuid4().hex}" + + response: Final = _rag_query( + strict_gateway.gateway, + model, + marker, + strict_gateway.jwt(member, (team,)), + store_id=store_id, + path="/rag/query", + ) + + assert response.status_code == 403, response.text + assert ( + f"Team {team} can access model {model} but route /rag/query is not in litellm_jwtauth.team_allowed_routes" + ) in response.text, response.text + assert _searches_for_marker(strict_gateway.upstream, marker, store_id) == () + + @pytest.mark.parametrize( ("scope", "error_type"), (("key", "key_vector_store_access_denied"), ("team", "team_vector_store_access_denied")), diff --git a/tests/integration/authorization/test_rag_team_provider_credentials.py b/tests/integration/authorization/test_rag_team_provider_credentials.py new file mode 100644 index 00000000000..8d556b89107 --- /dev/null +++ b/tests/integration/authorization/test_rag_team_provider_credentials.py @@ -0,0 +1,2316 @@ +from __future__ import annotations + +import asyncio +import json +import re +import threading +import time +import uuid +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final, Literal +from urllib.parse import urlsplit + +import anthropic +import httpx +import jwt +import openai +import psutil +import pytest +import yaml +from cryptography.hazmat.primitives.asymmetric import rsa +from integration._support.client import ( + JSON_OBJECT, + Gateway, + Scenario, + eventually, + gateway_from_environment, + object_value, + string_value, +) +from integration._support.database import read_rows +from integration._support.ports import allocate_port +from integration._support.process import graceful_stop_seconds, owned_proxy, owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue + +TEAM_API_KEY: Final = "integration-rag-team-key" +WILDCARD_API_KEY: Final = "integration-rag-wildcard-key" +SHARED_API_KEY: Final = "integration-rag-shared-key" +ENVIRONMENT_API_KEY: Final = "integration-rag-environment-key" +CALLER_API_KEY: Final = "integration-rag-caller-key" +FORWARDED_API_KEY: Final = "integration-rag-forwarded-key" +CREDENTIAL_API_KEY: Final = "integration-rag-credential-key" +GEMINI_API_KEY: Final = "integration-rag-gemini-key" +AZURE_API_KEY: Final = "integration-rag-azure-key" +JWT_KEY_ID: Final = "integration-rag-team-provider-jwt" +WILDCARD_MODEL: Final = "openai/gpt-4o-mini" +BARE_WILDCARD_MODEL: Final = "gpt-4o-mini" +OPENAI: Final[Mapping[str, JsonValue]] = MappingProxyType({"custom_llm_provider": "openai"}) +DOCUMENT: Final = ("document.txt", b"team provider credentials", "text/plain") +BATCH_INPUT_ROW: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "custom_id": "integration-rag-batch", + "method": "POST", + "url": "/v1/chat/completions", + "body": {"model": BARE_WILDCARD_MODEL, "messages": [{"role": "user", "content": "batch input"}]}, + } +) +BATCH_INPUT: Final = ("batch.jsonl", (json.dumps(dict(BATCH_INPUT_ROW)) + "\n").encode(), "application/jsonl") +FAILING_STORE_NAMES: Final = MappingProxyType({"rag-upstream-401": 401, "rag-upstream-429": 429}) +OWNED_PROXY_CELL_SECONDS: Final = 2 * graceful_stop_seconds() + 120 +STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") +PREFIXED: Final = re.compile(r"^/(team|wildcard|shared|environment|credential|caller|gemini|azure|bedrock)(/[^?]*)") +STORE_FILES: Final = re.compile(r"^/v1/vector_stores/([^/]+)/files$") +FILE_CONTENT: Final = re.compile(r"^/v1/files/([^/]+)/content$") +FILE_ITEM: Final = re.compile(r"^/v1/files/([^/]+)$") +BATCH_ITEM: Final = re.compile(r"^/v1/batches/([^/]+)$") +BATCH_CANCEL: Final = re.compile(r"^/v1/batches/([^/]+)/cancel$") +BEDROCK_DATA_SOURCES: Final = re.compile(r"^/knowledgebases/([^/]+)/datasources/$") +BEDROCK_DATA_SOURCE: Final = re.compile(r"^/knowledgebases/([^/]+)/datasources/([^/]+)$") +EMPTY_KNOWLEDGE_BASE: Final = "EMPTYKB000" +BUCKETLESS_KNOWLEDGE_BASE: Final = "NOBUCKET00" +WEB_DATA_SOURCE: Final = "WEBSOURCE0" +STORE_SEARCH: Final = re.compile(r"^(?:/v1|/openai)/vector_stores/([^/]+)/search$") +GEMINI_UPLOAD: Final = re.compile(r"^/upload/v1beta/fileSearchStores/([^:]+):uploadToFileSearchStore$") + +JWT_TEAM_COUNT: Final = 10 +AWS_AMBIENT_CREDENTIALS: Final = ( + "AWS_PROFILE", + "AWS_SESSION_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ROLE_ARN", + "AWS_ROLE_NAME", + "AWS_SESSION_NAME", + "AWS_WEB_IDENTITY_TOKEN_FILE", +) + +pytestmark: Final = pytest.mark.timeout(OWNED_PROXY_CELL_SECONDS) + +Signature = tuple[str, str, str, str | None] + + +@dataclass(frozen=True, slots=True) +class Call: + prefix: str + method: str + route: str + authorization: str | None + google_key: str | None + azure_key: str | None + + @property + def signature(self) -> Signature: + return (self.prefix, self.method, self.route, self.authorization) + + +@dataclass(frozen=True, slots=True) +class Tenants: + shared_model: str + global_model: str + team_a: str + team_a_model: str + team_a_key: str + restricted_key: str + viewer_key: str + jwt_member: str + team_w: str + team_w_key: str + wildcard_bare_key: str + wildcard_prefixed_key: str + wildcard_global_key: str + team_s: str + team_s_key: str + teamless_key: str + jwt_teams: tuple[str, ...] + jwt_team_model: str + jwt_multi_member: str + azure_restricted_key: str + credential_name: str + unreachable_credential_name: str + + +@dataclass(frozen=True, slots=True) +class RagRig: + gateway: Gateway + upstream: Wire + outage: threading.Event + tenants: Tenants + signing_key: rsa.RSAPrivateKey + scenario: Scenario + + def jwt(self, subject: str, *team_ids: JsonValue) -> str: + issued_at: Final = int(time.time()) + return jwt.encode( + {"sub": subject, "groups": list(team_ids), "iat": issued_at, "exp": issued_at + 300}, + self.signing_key, + algorithm="RS256", + headers={"kid": JWT_KEY_ID}, + ) + + def calls(self) -> tuple[Call, ...]: + return tuple(_call(request) for request in self.upstream.drain()) + + def signatures(self) -> tuple[Signature, ...]: + return tuple(call.signature for call in self.calls()) + + +def _call(request: Request) -> Call: + matched: Final = PREFIXED.match(request.target) + assert matched is not None, request.target + return Call( + prefix=matched[1], + method=request.method, + route=matched[2], + authorization=request.headers.get("authorization"), + google_key=request.headers.get("x-goog-api-key"), + azure_key=request.headers.get("api-key"), + ) + + +def _json_body(request: Request) -> Mapping[str, JsonValue]: + if not request.body or not request.headers.get("content-type", "").startswith("application/json"): + return MappingProxyType({}) + return object_value(json.loads(request.body)) + + +def _json_reply(payload: Mapping[str, JsonValue], status: int = 200) -> Reply: + return Reply(status=status, body=json.dumps(payload).encode()) + + +def _error_reply(status: int) -> Reply: + return _json_reply( + {"error": {"message": f"scripted provider {status}", "type": "invalid_request_error", "code": str(status)}}, + status, + ) + + +def _vector_store_reply(request: Request, outage: threading.Event) -> Reply: + if outage.is_set(): + return _error_reply(503) + name: Final = str(_json_body(request).get("name")) + failing: Final = FAILING_STORE_NAMES.get(name) + if failing is not None: + return _error_reply(failing) + return _json_reply( + { + "id": f"vs_{uuid.uuid4().hex}", + "object": "vector_store", + "created_at": int(time.time()), + "name": name, + "usage_bytes": 0, + "status": "completed", + "file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0}, + } + ) + + +def _owned_by(prefix: str, identity: str) -> bool: + return identity.startswith((f"file-{prefix}-", f"batch_{prefix}_")) + + +def _file_object(identity: str) -> dict[str, JsonValue]: + return { + "id": identity, + "object": "file", + "bytes": len(DOCUMENT[1]), + "created_at": int(time.time()), + "filename": DOCUMENT[0], + "purpose": "assistants", + "status": "processed", + } + + +def _file_reply(prefix: str) -> Reply: + return _json_reply(_file_object(f"file-{prefix}-{uuid.uuid4().hex}")) + + +def _batch_input_reply() -> Reply: + return Reply(status=200, body=BATCH_INPUT[1]) + + +def _batch_object(identity: str, input_file_id: str, status: str) -> dict[str, JsonValue]: + return { + "id": identity, + "object": "batch", + "endpoint": "/v1/chat/completions", + "input_file_id": input_file_id, + "completion_window": "24h", + "status": status, + "created_at": int(time.time()), + "errors": None, + "output_file_id": None, + "error_file_id": None, + "request_counts": {"total": 0, "completed": 0, "failed": 0}, + "metadata": None, + } + + +def _batch_reply(request: Request, prefix: str) -> Reply: + body: Final = _json_body(request) + return _json_reply( + _batch_object(f"batch_{prefix}_{uuid.uuid4().hex}", str(body.get("input_file_id")), "validating") + ) + + +def _list_reply() -> Reply: + return _json_reply({"object": "list", "data": [], "first_id": None, "last_id": None, "has_more": False}) + + +def _owned_reply(prefix: str, identity: str, payload: Mapping[str, JsonValue]) -> Reply: + return _json_reply(payload) if _owned_by(prefix, identity) else _error_reply(404) + + +def _openai_get(route: str, prefix: str) -> Reply: + if route in {"/v1/files", "/v1/batches"}: + return _list_reply() + content: Final = FILE_CONTENT.match(route) + if content is not None: + return _batch_input_reply() if _owned_by(prefix, content[1]) else _error_reply(404) + file_item: Final = FILE_ITEM.match(route) + if file_item is not None: + return _owned_reply(prefix, file_item[1], _file_object(file_item[1])) + batch_item: Final = BATCH_ITEM.match(route) + if batch_item is not None: + return _owned_reply(prefix, batch_item[1], _batch_object(batch_item[1], f"file-{prefix}-input", "validating")) + return _error_reply(404) + + +def _openai_delete(route: str, prefix: str) -> Reply: + file_item: Final = FILE_ITEM.match(route) + if file_item is None: + return _error_reply(404) + return _owned_reply(prefix, file_item[1], {"id": file_item[1], "object": "file", "deleted": True}) + + +def _embedding_reply(request: Request) -> Reply: + body: Final = _json_body(request) + inputs: Final = body.get("input") + count: Final = len(inputs) if isinstance(inputs, list) else 1 + return _json_reply( + { + "object": "list", + "data": [{"object": "embedding", "index": index, "embedding": [0.1, 0.2, 0.3]} for index in range(count)], + "model": str(body.get("model")), + "usage": {"prompt_tokens": count, "total_tokens": count}, + } + ) + + +def _bedrock_reply(request: Request, route: str) -> Reply: + listed: Final = BEDROCK_DATA_SOURCES.match(route) + if request.method == "POST" and listed is not None: + summary: Final[dict[str, JsonValue]] = { + "knowledgeBaseId": listed[1], + "dataSourceId": WEB_DATA_SOURCE, + "name": "integration-web-source", + "status": "AVAILABLE", + "updatedAt": "2026-01-01T00:00:00Z", + } + return _json_reply({"dataSourceSummaries": [] if listed[1] == EMPTY_KNOWLEDGE_BASE else [summary]}) + detail: Final = BEDROCK_DATA_SOURCE.match(route) + if request.method == "GET" and detail is not None: + return _json_reply( + { + "dataSource": { + "knowledgeBaseId": detail[1], + "dataSourceId": detail[2], + "name": "integration-web-source", + "status": "AVAILABLE", + "dataSourceConfiguration": {"type": "WEB"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + } + } + ) + return _error_reply(404) + + +def _attach_reply(request: Request, store_id: str) -> Reply: + return _json_reply( + { + "id": str(_json_body(request).get("file_id")), + "object": "vector_store.file", + "created_at": int(time.time()), + "vector_store_id": store_id, + "status": "completed", + "last_error": None, + "usage_bytes": 0, + } + ) + + +def _search_reply(request: Request) -> Reply: + return _json_reply( + { + "object": "vector_store.search_results.page", + "search_query": _json_body(request).get("query", ""), + "data": [], + "has_more": False, + "next_page": None, + } + ) + + +def _sse(payload: Mapping[str, JsonValue]) -> bytes: + return f"data: {json.dumps(payload)}\n\n".encode() + + +def _chunk(identity: str, model: str, delta: Mapping[str, JsonValue], finish_reason: str | None) -> bytes: + return _sse( + { + "id": identity, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish_reason}], + } + ) + + +def _chat_reply(request: Request) -> Reply: + body: Final = _json_body(request) + model: Final = str(body.get("model")) + identity: Final = f"chatcmpl-{uuid.uuid4().hex}" + if body.get("stream") is True: + return Reply( + content_type="text/event-stream", + chunks=( + _chunk(identity, model, {"role": "assistant", "content": "integration response"}, None), + _chunk(identity, model, {}, "stop"), + b"data: [DONE]\n\n", + ), + ) + return _json_reply( + { + "id": identity, + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "integration response"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ) + + +def _responses_reply(request: Request) -> Reply: + identity: Final = uuid.uuid4().hex + completed: Final = _responses_object(identity, str(_json_body(request).get("model"))) + if _json_body(request).get("stream") is not True: + return _json_reply(completed) + events: Final[tuple[tuple[str, Mapping[str, JsonValue]], ...]] = ( + ( + "response.created", + { + "type": "response.created", + "sequence_number": 0, + "response": {**completed, "status": "in_progress", "output": []}, + }, + ), + ( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "sequence_number": 1, + "item_id": f"msg_{identity}", + "output_index": 0, + "content_index": 0, + "delta": "integration response", + "logprobs": [], + }, + ), + ("response.completed", {"type": "response.completed", "sequence_number": 2, "response": completed}), + ) + return Reply( + content_type="text/event-stream", + chunks=tuple(f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() for name, payload in events), + ) + + +def _responses_object(identity: str, model: str) -> dict[str, JsonValue]: + return { + "id": f"resp_{identity}", + "object": "response", + "created_at": int(time.time()), + "status": "completed", + "model": model, + "output": [ + { + "type": "message", + "id": f"msg_{identity}", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "integration response", "annotations": []}], + } + ], + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + + +def _gemini_reply(request: Request, route: str) -> Reply: + if request.method == "POST" and route == "/v1beta/fileSearchStores": + return _json_reply({"name": f"fileSearchStores/rag-{uuid.uuid4().hex}"}) + upload: Final = GEMINI_UPLOAD.match(route) + if request.method == "POST" and upload is not None: + session_url: Final = f"http://{request.headers['host']}/gemini/upload-session/{upload[1]}" + return Reply(body=b"{}", headers={"x-goog-upload-url": session_url}) + if request.method == "PUT" and route.startswith("/upload-session/"): + return _json_reply({"name": f"documents/{uuid.uuid4().hex}"}) + return _error_reply(404) + + +def _openai_reply(request: Request, prefix: str, route: str, outage: threading.Event) -> Reply: + if request.method == "GET": + return _openai_get(route, prefix) + if request.method == "DELETE": + return _openai_delete(route, prefix) + if request.method != "POST": + return _error_reply(404) + if route == "/v1/vector_stores": + return _vector_store_reply(request, outage) + if route in {"/v1/files", "/openai/files"}: + return _file_reply(prefix) + if route == "/v1/batches": + return _batch_reply(request, prefix) + cancelled: Final = BATCH_CANCEL.match(route) + if cancelled is not None: + return _owned_reply(prefix, cancelled[1], _batch_object(cancelled[1], f"file-{prefix}-input", "cancelling")) + if route == "/v1/chat/completions": + return _chat_reply(request) + if route == "/v1/responses": + return _responses_reply(request) + if route == "/v1/embeddings": + return _embedding_reply(request) + attach: Final = STORE_FILES.match(route) + if attach is not None: + return _attach_reply(request, attach[1]) + if STORE_SEARCH.match(route) is not None: + return _search_reply(request) + return _error_reply(404) + + +def _responder(outage: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + matched: Final = PREFIXED.match(request.target) + if matched is None: + return _error_reply(404) + if matched[1] == "gemini": + return _gemini_reply(request, matched[2]) + if matched[1] == "bedrock": + return _bedrock_reply(request, matched[2]) + return _openai_reply(request, matched[1], matched[2], outage) + + return respond + + +def _proxy_config(directory: Path) -> Path: + base: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + general_settings: Final = object_value(base["general_settings"]) + config: Final = { + **base, + "general_settings": { + **general_settings, + "enable_jwt_auth": True, + "litellm_jwtauth": {"user_id_jwt_field": "sub", "team_ids_jwt_field": "groups"}, + "forward_llm_provider_auth_headers": True, + }, + "files_settings": [{"custom_llm_provider": "openai"}, {"custom_llm_provider": "azure"}], + } + path: Final = directory / "rag_team_provider_credentials.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _provider_environment(upstream: str) -> Mapping[str, str]: + return MappingProxyType( + { + "OPENAI_API_KEY": ENVIRONMENT_API_KEY, + "OPENAI_API_BASE": f"{upstream}/environment/v1", + "OPENAI_BASE_URL": f"{upstream}/environment/v1", + "GEMINI_API_KEY": GEMINI_API_KEY, + "GEMINI_API_BASE": f"{upstream}/gemini", + "AZURE_API_KEY": AZURE_API_KEY, + "AZURE_API_BASE": f"{upstream}/azure", + "AZURE_API_VERSION": "2025-04-01-preview", + } + ) + + +def _aws_environment(upstream: str) -> Mapping[str, str]: + return MappingProxyType( + { + "AWS_ENDPOINT_URL_BEDROCK_AGENT": f"{upstream}/bedrock", + "AWS_ACCESS_KEY_ID": "integration-rag-aws-access-key", + "AWS_SECRET_ACCESS_KEY": "integration-rag-aws-secret-key", + "AWS_REGION_NAME": "us-east-1", + "AWS_DEFAULT_REGION": "us-east-1", + "AWS_EC2_METADATA_DISABLED": "true", + } + ) + + +def _jwt_team_key(team_id: str) -> str: + return f"integration-rag-jwt-{team_id}" + + +def _jwt_team(gateway: Gateway, scenario: Scenario, upstream: str, model: str, member: str) -> str: + team_id: Final = scenario.team(models=[model]) + _deployment( + gateway, + scenario, + name=model, + model="openai/gpt-4o-mini", + api_key=_jwt_team_key(team_id), + api_base=f"{upstream}/team/v1", + team_id=team_id, + ) + gateway.post("/team/member_add", {"team_id": team_id, "member": {"user_id": member, "role": "user"}}) + return team_id + + +def _deployment( + gateway: Gateway, + scenario: Scenario, + *, + name: str, + model: str, + api_key: str, + api_base: str, + team_id: str | None, + model_id: str | None = None, +) -> str: + created: Final = gateway.post( + "/model/new", + { + "model_name": name, + "litellm_params": {"model": model, "api_key": api_key, "api_base": api_base}, + "model_info": ({} if team_id is None else {"team_id": team_id}) + | ({} if model_id is None else {"id": model_id}), + }, + ) + model_id: Final = string_value(object_value(created["model_info"])["id"]) + scenario.cleanups.callback(scenario.delete_model, model_id) + return model_id + + +def _delete_credential(gateway: Gateway, name: str) -> None: + response: Final = gateway.request("DELETE", f"/credentials/{name}") + assert response.status_code == 200, response.text + + +def _credential(gateway: Gateway, scenario: Scenario, api_base: str) -> str: + name: Final = f"integration-rag-credential-{uuid.uuid4().hex}" + gateway.post( + "/credentials", + { + "credential_name": name, + "credential_values": {"api_key": CREDENTIAL_API_KEY, "api_base": api_base}, + "credential_info": {}, + }, + ) + scenario.cleanups.callback(_delete_credential, gateway, name) + return name + + +def _tenants(gateway: Gateway, scenario: Scenario, upstream: str) -> Tenants: + shared_model: Final = f"integration-rag-shared-{uuid.uuid4().hex}" + _deployment( + gateway, + scenario, + name=shared_model, + model="openai/gpt-4o-mini", + api_key=SHARED_API_KEY, + api_base=f"{upstream}/shared/v1", + team_id=None, + ) + global_model: Final = f"openai/integration-rag-global-{uuid.uuid4().hex}" + _deployment( + gateway, + scenario, + name=global_model, + model="openai/gpt-4o-mini", + api_key=SHARED_API_KEY, + api_base=f"{upstream}/shared/v1", + team_id=None, + ) + team_a_model: Final = f"integration-rag-team-{uuid.uuid4().hex}" + team_a: Final = scenario.team(models=[team_a_model, shared_model]) + _deployment( + gateway, + scenario, + name=team_a_model, + model="openai/gpt-4o-mini", + api_key=TEAM_API_KEY, + api_base=f"{upstream}/team/v1", + team_id=team_a, + ) + viewer: Final = scenario.user(user_role="internal_user_viewer") + gateway.post("/team/member_add", {"team_id": team_a, "member": {"user_id": viewer, "role": "user"}}) + team_w: Final = scenario.team(models=[shared_model]) + _deployment( + gateway, + scenario, + name="openai/*", + model="openai/*", + api_key=WILDCARD_API_KEY, + api_base=f"{upstream}/wildcard/v1", + team_id=team_w, + ) + team_s: Final = scenario.team(models=[shared_model]) + team_z: Final = scenario.team(models=[shared_model]) + _deployment( + gateway, + scenario, + name="azure/*", + model="azure/*", + api_key=WILDCARD_API_KEY, + api_base=f"{upstream}/azure", + team_id=team_z, + ) + jwt_team_model: Final = f"integration-rag-jwt-{uuid.uuid4().hex}" + jwt_multi_member: Final = scenario.user(user_role="internal_user") + return Tenants( + shared_model=shared_model, + global_model=global_model, + team_a=team_a, + team_a_model=team_a_model, + team_a_key=scenario.key(team_id=team_a), + restricted_key=scenario.key(team_id=team_a, models=[shared_model]), + viewer_key=scenario.key(user_id=viewer, team_id=team_a), + jwt_member=scenario.member(team_a), + team_w=team_w, + team_w_key=scenario.key(team_id=team_w), + wildcard_bare_key=scenario.key(team_id=team_w, models=[BARE_WILDCARD_MODEL]), + wildcard_prefixed_key=scenario.key(team_id=team_w, models=[WILDCARD_MODEL]), + wildcard_global_key=scenario.key(team_id=team_w, models=[global_model]), + team_s=team_s, + team_s_key=scenario.key(team_id=team_s), + teamless_key=scenario.key(), + jwt_teams=tuple( + _jwt_team(gateway, scenario, upstream, jwt_team_model, jwt_multi_member) for _ in range(JWT_TEAM_COUNT) + ), + jwt_team_model=jwt_team_model, + jwt_multi_member=jwt_multi_member, + azure_restricted_key=scenario.key(team_id=team_z, models=["azure/gpt-4o-mini"]), + credential_name=_credential(gateway, scenario, f"{upstream}/credential/v1"), + unreachable_credential_name=_credential(gateway, scenario, _closed_url()), + ) + + +@pytest.fixture(scope="module") +def rag_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[RagRig]: + signing_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwks_body: Final = json.dumps( + { + "keys": [ + { + **json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key())), + "kid": JWT_KEY_ID, + "use": "sig", + "alg": "RS256", + } + ] + } + ).encode() + + def jwks_reply(request: Request) -> Reply: + assert request.method == "GET", request + return Reply(body=jwks_body) + + outage: Final = threading.Event() + directory: Final = tmp_path_factory.mktemp("rag_team_provider_credentials") + with ( + gateway_from_environment() as admin_gateway, + admin_gateway.scenario() as scenario, + wire_server(_responder(outage)) as upstream, + wire_server(jwks_reply) as jwks, + ): + tenants: Final = _tenants(admin_gateway, scenario, upstream.url) + with ( + owned_proxy( + admin_gateway, + directory, + { + **_provider_environment(upstream.url), + **_aws_environment(upstream.url), + "JWT_PUBLIC_KEY_URL": jwks.url, + }, + config=_proxy_config(directory), + remove_environment=("GOOGLE_API_KEY", *AWS_AMBIENT_CREDENTIALS), + workers=2, + ) as gateway, + gateway.scenario() as owned_scenario, + ): + yield RagRig(gateway, upstream, outage, tenants, signing_key, owned_scenario) + + +@pytest.fixture(autouse=True) +def _drain_calls_an_earlier_failed_cell_left(request: pytest.FixtureRequest) -> None: + if "rag_rig" in request.fixturenames: + request.getfixturevalue("rag_rig").upstream.drain() + + +def _ingest_request(name: str, vector_store: Mapping[str, JsonValue]) -> Mapping[str, str]: + return MappingProxyType( + {"request": json.dumps({"ingest_options": {"name": name, "vector_store": dict(vector_store)}})} + ) + + +def _ingest( + gateway: Gateway, + key: str, + vector_store: Mapping[str, JsonValue] = OPENAI, + *, + name: str = "litellm-rag-ingest", + path: str = "/v1/rag/ingest", +) -> httpx.Response: + return gateway.request_multipart(path, _ingest_request(name, vector_store), {"file": DOCUMENT}, key=key) + + +def _query( + gateway: Gateway, + key: str, + model: str, + retrieval_config: Mapping[str, JsonValue], + *, + stream: bool = False, + path: str = "/v1/rag/query", +) -> httpx.Response: + return gateway.request( + "POST", + path, + { + "model": model, + "messages": [{"role": "user", "content": f"query team vector store {uuid.uuid4().hex}"}], + "retrieval_config": {"top_k": 1, **retrieval_config}, + **({"stream": True} if stream else {}), + }, + key=key, + ) + + +def _ingest_signatures(prefix: str, api_key: str, store_id: str) -> tuple[Signature, ...]: + bearer: Final = f"Bearer {api_key}" + return ( + (prefix, "POST", "/v1/vector_stores", bearer), + (prefix, "POST", "/v1/files", bearer), + (prefix, "POST", f"/v1/vector_stores/{store_id}/files", bearer), + ) + + +def _search_signature(prefix: str, api_key: str, store_id: str) -> Signature: + return (prefix, "POST", f"/v1/vector_stores/{store_id}/search", f"Bearer {api_key}") + + +def _chat_signature(prefix: str, api_key: str) -> Signature: + return (prefix, "POST", "/v1/chat/completions", f"Bearer {api_key}") + + +def _completed_store(rig: RagRig, response: httpx.Response) -> str: + assert response.status_code == 200, response.text + body: Final = object_value(response.json()) + assert body["status"] == "completed", response.text + store_id: Final = string_value(body["vector_store_id"]) + rig.scenario.cleanups.callback(rig.gateway.post, "/vector_store/delete", {"vector_store_id": store_id}) + return store_id + + +def _store_rows(store_id: str) -> list[dict[str, JsonValue]]: + return read_rows( + 'SELECT vector_store_id, team_id, litellm_params, vector_store_metadata FROM "LiteLLM_ManagedVectorStoresTable" ' + "WHERE vector_store_id = %s", + (store_id,), + ) + + +def _closed_url() -> str: + return f"http://127.0.0.1:{allocate_port()}/v1" + + +def test_viewer_ingest_and_team_and_jwt_query_use_team_provider_credentials(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, tenants.viewer_key)) + assert rag_rig.signatures() == _ingest_signatures("team", TEAM_API_KEY, store_id) + + team_query: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": store_id} + ) + assert team_query.status_code == 200, team_query.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + jwt_query: Final = _query( + rag_rig.gateway, + rag_rig.jwt(tenants.jwt_member, tenants.team_a), + tenants.team_a_model, + {**OPENAI, "vector_store_id": store_id}, + ) + assert jwt_query.status_code == 200, jwt_query.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +@pytest.mark.parametrize( + ("name", "status"), (("rag-upstream-401", 401), ("rag-upstream-429", 429)), ids=("provider_401", "provider_429") +) +def test_failed_ingest_returns_the_provider_status_without_saving_a_store( + rag_rig: RagRig, name: str, status: int +) -> None: + before: Final = _store_rows("") + + response: Final = _ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, name=name) + + assert response.status_code == status, response.text + assert f"scripted provider {status}" in response.text, response.text + assert rag_rig.signatures() == (("team", "POST", "/v1/vector_stores", f"Bearer {TEAM_API_KEY}"),) + assert _store_rows("") == before + + +def test_unreachable_credential_api_base_fails_ingest_without_saving_a_store(rag_rig: RagRig) -> None: + before: Final = _store_rows("") + + response: Final = _ingest( + rag_rig.gateway, + rag_rig.tenants.team_a_key, + {**OPENAI, "litellm_credential_name": rag_rig.tenants.unreachable_credential_name}, + ) + + assert response.status_code == 500, response.text + assert rag_rig.calls() == () + assert _store_rows("") == before + + +def test_rag_alias_ingest_and_query_use_the_team_wildcard_deployment(rag_rig: RagRig) -> None: + store_id: Final = _completed_store( + rag_rig, _ingest(rag_rig.gateway, rag_rig.tenants.team_w_key, path="/rag/ingest") + ) + assert rag_rig.signatures() == _ingest_signatures("wildcard", WILDCARD_API_KEY, store_id) + rows: Final = _store_rows(store_id) + assert len(rows) == 1, rows + assert WILDCARD_API_KEY not in json.dumps(rows), rows + + response: Final = _query( + rag_rig.gateway, + rag_rig.tenants.team_w_key, + WILDCARD_MODEL, + {**OPENAI, "vector_store_id": store_id}, + path="/rag/query", + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("wildcard", WILDCARD_API_KEY, store_id), + _chat_signature("wildcard", WILDCARD_API_KEY), + ) + + +def test_jwt_team_member_ingest_uses_team_provider_credentials(rag_rig: RagRig) -> None: + token: Final = rag_rig.jwt(rag_rig.tenants.jwt_member, rag_rig.tenants.team_a) + + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, token)) + + assert rag_rig.signatures() == _ingest_signatures("team", TEAM_API_KEY, store_id) + rows: Final = _store_rows(store_id) + assert [row["team_id"] for row in rows] == [rag_rig.tenants.team_a], rows + + +def test_caller_credential_name_wins_over_the_team_deployment(rag_rig: RagRig) -> None: + store_id: Final = _completed_store( + rag_rig, + _ingest( + rag_rig.gateway, + rag_rig.tenants.team_a_key, + {**OPENAI, "litellm_credential_name": rag_rig.tenants.credential_name}, + ), + ) + + assert rag_rig.signatures() == _ingest_signatures("credential", CREDENTIAL_API_KEY, store_id) + + +@pytest.mark.parametrize("field", ("api_key", "api_base")) +@pytest.mark.parametrize("value", ("set", "null", "empty")) +def test_caller_api_key_or_api_base_on_ingest_is_rejected_before_any_upstream_call( + rag_rig: RagRig, field: Literal["api_key", "api_base"], value: Literal["set", "null", "empty"] +) -> None: + set_values: Final = MappingProxyType({"api_key": CALLER_API_KEY, "api_base": f"{rag_rig.upstream.url}/caller/v1"}) + sent: Final[JsonValue] = MappingProxyType({"set": set_values[field], "null": None, "empty": ""})[value] + + response: Final = _ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, {**OPENAI, field: sent}) + + assert response.status_code == 400, response.text + assert f"'{field}' cannot be set in ingest_options.vector_store" in response.text, response.text + assert rag_rig.calls() == () + + +@pytest.mark.parametrize("blank", (None, ""), ids=("null", "empty")) +def test_blank_caller_credential_name_falls_back_to_the_team_deployment(rag_rig: RagRig, blank: str | None) -> None: + tenants: Final = rag_rig.tenants + + store_id: Final = _completed_store( + rag_rig, _ingest(rag_rig.gateway, tenants.team_a_key, {**OPENAI, "litellm_credential_name": blank}) + ) + assert rag_rig.signatures() == _ingest_signatures("team", TEAM_API_KEY, store_id) + + response: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": store_id} + ) + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +@pytest.mark.parametrize("caller", ("teamless_key", "team_s_key", "restricted_key")) +def test_ingest_without_a_usable_team_deployment_uses_the_environment_credentials( + rag_rig: RagRig, caller: Literal["teamless_key", "team_s_key", "restricted_key"] +) -> None: + key: Final = getattr(rag_rig.tenants, caller) + + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, key)) + + assert rag_rig.signatures() == _ingest_signatures("environment", ENVIRONMENT_API_KEY, store_id) + + +@pytest.mark.parametrize( + ("caller", "prefix", "api_key"), + (("team_a_key", "team", TEAM_API_KEY), ("team_s_key", "shared", SHARED_API_KEY)), + ids=("team_deployment", "shared_deployment"), +) +def test_files_upload_keeps_the_accessible_deployment_fallback( + rag_rig: RagRig, caller: Literal["team_a_key", "team_s_key"], prefix: str, api_key: str +) -> None: + response: Final = rag_rig.gateway.request_multipart( + "/v1/files", {"purpose": "assistants"}, {"file": DOCUMENT}, key=getattr(rag_rig.tenants, caller) + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ((prefix, "POST", "/v1/files", f"Bearer {api_key}"),) + + +@pytest.mark.parametrize( + ("caller", "model"), + (("wildcard_bare_key", BARE_WILDCARD_MODEL), ("wildcard_prefixed_key", WILDCARD_MODEL)), + ids=("bare_model", "prefixed_model"), +) +def test_key_restricted_to_a_model_under_the_team_wildcard_uses_the_team_deployment( + rag_rig: RagRig, caller: Literal["wildcard_bare_key", "wildcard_prefixed_key"], model: str +) -> None: + key: Final = getattr(rag_rig.tenants, caller) + + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, key)) + assert rag_rig.signatures() == _ingest_signatures("wildcard", WILDCARD_API_KEY, store_id) + rows: Final = _store_rows(store_id) + assert [row["team_id"] for row in rows] == [rag_rig.tenants.team_w], rows + assert WILDCARD_API_KEY not in json.dumps(rows), rows + + response: Final = _query(rag_rig.gateway, key, model, {**OPENAI, "vector_store_id": store_id}) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("wildcard", WILDCARD_API_KEY, store_id), + _chat_signature("wildcard", WILDCARD_API_KEY), + ) + + +def test_key_restricted_to_a_global_model_under_the_team_wildcard_splits_rag_and_chat(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, tenants.wildcard_global_key)) + assert rag_rig.signatures() == _ingest_signatures("wildcard", WILDCARD_API_KEY, store_id) + + response: Final = _query( + rag_rig.gateway, tenants.wildcard_global_key, tenants.global_model, {**OPENAI, "vector_store_id": store_id} + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("wildcard", WILDCARD_API_KEY, store_id), + _chat_signature("shared", SHARED_API_KEY), + ) + + +def test_files_upload_by_a_key_restricted_under_the_team_wildcard_uses_the_team_deployment(rag_rig: RagRig) -> None: + response: Final = rag_rig.gateway.request_multipart( + "/v1/files", {"purpose": "assistants"}, {"file": DOCUMENT}, key=rag_rig.tenants.wildcard_bare_key + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == (("wildcard", "POST", "/v1/files", f"Bearer {WILDCARD_API_KEY}"),) + + +@pytest.mark.parametrize("batch_options", ({}, {"litellm_credential_name": "no-such-credential"}), ids=("plain", "credential_name")) +@pytest.mark.parametrize( + ("caller", "prefix", "api_key", "reads_input"), + (("team_a_key", "team", TEAM_API_KEY, False), ("wildcard_bare_key", "wildcard", WILDCARD_API_KEY, True)), + ids=("team_key", "restricted_wildcard_key"), +) +def test_batch_create_reads_the_input_and_creates_the_batch_through_the_team_deployment( + rag_rig: RagRig, + caller: Literal["team_a_key", "wildcard_bare_key"], + prefix: str, + api_key: str, + reads_input: bool, + batch_options: Mapping[str, JsonValue], +) -> None: + key: Final = getattr(rag_rig.tenants, caller) + uploaded: Final = rag_rig.gateway.request_multipart( + "/v1/files", {"purpose": "batch"}, {"file": BATCH_INPUT}, key=key + ) + assert uploaded.status_code == 200, uploaded.text + file_id: Final = string_value(object_value(uploaded.json())["id"]) + + created: Final = rag_rig.gateway.request( + "POST", + "/v1/batches", + {"input_file_id": file_id, "endpoint": "/v1/chat/completions", "completion_window": "24h", **batch_options}, + key=key, + ) + + assert created.status_code == 200, created.text + assert object_value(created.json())["input_file_id"] == file_id, created.text + bearer: Final = f"Bearer {api_key}" + input_read: Final = ((prefix, "GET", f"/v1/files/{file_id}/content", bearer),) if reads_input else () + assert rag_rig.signatures() == ( + (prefix, "POST", "/v1/files", bearer), + *input_read, + (prefix, "POST", "/v1/batches", bearer), + ) + + +def test_gemini_ingest_never_receives_the_team_openai_key(rag_rig: RagRig) -> None: + store_id: Final = _completed_store( + rag_rig, _ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, {"custom_llm_provider": "gemini"}) + ) + + store_name: Final = store_id.removeprefix("fileSearchStores/") + assert tuple( + (call.prefix, call.method, call.route, call.google_key, call.authorization) for call in rag_rig.calls() + ) == ( + ("gemini", "POST", "/v1beta/fileSearchStores", GEMINI_API_KEY, None), + ( + "gemini", + "POST", + f"/upload/v1beta/fileSearchStores/{store_name}:uploadToFileSearchStore", + GEMINI_API_KEY, + None, + ), + ("gemini", "PUT", f"/upload-session/{store_name}", None, None), + ) + + +@pytest.mark.parametrize("provider", (7, ["openai"], "", "x" * 5120), ids=("int", "list", "empty", "five_kb")) +def test_malformed_ingest_provider_is_rejected_before_any_upstream_call(rag_rig: RagRig, provider: JsonValue) -> None: + response: Final = _ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, {"custom_llm_provider": provider}) + + assert response.status_code == 400, response.text + assert rag_rig.calls() == () + + +@pytest.mark.parametrize("stream", (False, True), ids=("json", "stream")) +def test_unmanaged_store_query_searches_with_the_team_deployment(rag_rig: RagRig, stream: bool) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = f"vs_unmanaged_{uuid.uuid4().hex}" + + response: Final = _query( + rag_rig.gateway, + tenants.team_a_key, + tenants.team_a_model, + {**OPENAI, "vector_store_id": store_id}, + stream=stream, + ) + + assert response.status_code == 200, response.text + assert "integration response" in response.text, response.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +@pytest.mark.parametrize("case", ("api_key", "api_base", "null_api_key", "empty_api_key")) +def test_caller_credentials_in_retrieval_config_never_reach_an_unmanaged_search( + rag_rig: RagRig, case: Literal["api_key", "api_base", "null_api_key", "empty_api_key"] +) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = f"vs_unmanaged_{uuid.uuid4().hex}" + caller_fields: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "api_key": {"api_key": CALLER_API_KEY}, + "api_base": {"api_base": f"{rag_rig.upstream.url}/caller/v1"}, + "null_api_key": {"api_key": None}, + "empty_api_key": {"api_key": ""}, + }[case] + ) + + response: Final = _query( + rag_rig.gateway, + tenants.team_a_key, + tenants.team_a_model, + {**OPENAI, **caller_fields, "vector_store_id": store_id}, + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +def _managed_store(rig: RagRig, scenario: Scenario, litellm_params: Mapping[str, JsonValue]) -> str: + store_id: Final = f"vs_managed_{uuid.uuid4().hex}" + rig.gateway.post( + "/vector_store/new", + {"vector_store_id": store_id, "custom_llm_provider": "openai", "litellm_params": dict(litellm_params)}, + ) + scenario.cleanups.callback(rig.gateway.post, "/vector_store/delete", {"vector_store_id": store_id}) + return store_id + + +@pytest.mark.parametrize( + ("stored", "prefix", "api_key"), + (("own_key", "caller", CALLER_API_KEY), ("null_key", "team", TEAM_API_KEY), ("empty_key", "team", TEAM_API_KEY)), + ids=("own_key", "null_key", "empty_key"), +) +def test_managed_store_credentials_decide_the_search_key( + rag_rig: RagRig, stored: Literal["own_key", "null_key", "empty_key"], prefix: str, api_key: str +) -> None: + tenants: Final = rag_rig.tenants + litellm_params: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "own_key": {"api_key": CALLER_API_KEY, "api_base": f"{rag_rig.upstream.url}/caller/v1"}, + "null_key": {"api_key": None}, + "empty_key": {"api_key": ""}, + }[stored] + ) + with rag_rig.gateway.scenario() as scenario: + store_id: Final = _managed_store(rag_rig, scenario, litellm_params) + + response: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": store_id} + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature(prefix, api_key, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +def test_credential_name_in_retrieval_config_is_rejected_before_search(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + response: Final = _query( + rag_rig.gateway, + tenants.team_a_key, + tenants.team_a_model, + { + **OPENAI, + "vector_store_id": f"vs_unmanaged_{uuid.uuid4().hex}", + "litellm_credential_name": tenants.credential_name, + }, + ) + + assert response.status_code == 400, response.text + assert rag_rig.calls() == () + + +def test_azure_query_never_receives_the_team_openai_key(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = f"vs_unmanaged_{uuid.uuid4().hex}" + + response: Final = _query( + rag_rig.gateway, + tenants.team_a_key, + tenants.team_a_model, + {"custom_llm_provider": "azure", "vector_store_id": store_id}, + ) + + assert response.status_code == 200, response.text + assert tuple((call.prefix, call.route, call.azure_key, call.authorization) for call in rag_rig.calls()) == ( + ("azure", f"/openai/vector_stores/{store_id}/search", AZURE_API_KEY, None), + ("team", "/v1/chat/completions", None, f"Bearer {TEAM_API_KEY}"), + ) + + +def test_another_team_cannot_query_a_team_store(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = _completed_store(rag_rig, _ingest(rag_rig.gateway, tenants.team_a_key)) + assert rag_rig.signatures() == _ingest_signatures("team", TEAM_API_KEY, store_id) + + response: Final = _query( + rag_rig.gateway, tenants.team_w_key, WILDCARD_MODEL, {**OPENAI, "vector_store_id": store_id} + ) + + assert response.status_code == 403, response.text + assert rag_rig.calls() == () + + +@pytest.mark.parametrize("vector_store_id", (7, ["vs_a"], None), ids=("int", "list", "null")) +def test_non_string_vector_store_id_is_rejected_before_search(rag_rig: RagRig, vector_store_id: JsonValue) -> None: + tenants: Final = rag_rig.tenants + + response: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": vector_store_id} + ) + + assert response.status_code == 400, response.text + assert "vector_store_id must be a non-empty string" in response.text, response.text + assert rag_rig.calls() == () + + +def test_empty_vector_store_id_is_rejected_before_search(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + response: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": ""} + ) + + assert (response.status_code, rag_rig.signatures()) == (400, ()), response.text + + +def test_five_kb_vector_store_id_searches_with_the_team_deployment(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + store_id: Final = f"vs_{'v' * 5120}" + + response: Final = _query( + rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, {**OPENAI, "vector_store_id": store_id} + ) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + _search_signature("team", TEAM_API_KEY, store_id), + _chat_signature("team", TEAM_API_KEY), + ) + + +@pytest.mark.parametrize("provider", (7, ["openai"], "", "x" * 5120), ids=("int", "list", "empty", "five_kb")) +def test_malformed_query_provider_fails_without_any_upstream_call(rag_rig: RagRig, provider: JsonValue) -> None: + tenants: Final = rag_rig.tenants + + response: Final = _query( + rag_rig.gateway, + tenants.team_a_key, + tenants.team_a_model, + {"custom_llm_provider": provider, "vector_store_id": f"vs_unmanaged_{uuid.uuid4().hex}"}, + ) + + assert (response.status_code, rag_rig.signatures()) == (500, ()), response.text + + +def test_jwt_route_outside_the_team_allowed_routes_names_the_route(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + response: Final = rag_rig.gateway.request( + "POST", + "/v1beta/interactions", + {"model": tenants.team_a_model, "input": "hello"}, + key=rag_rig.jwt(tenants.jwt_member, tenants.team_a), + ) + + assert response.status_code == 403, response.text + assert ( + f"Team {tenants.team_a} can access model {tenants.team_a_model} but route /v1beta/interactions " + "is not in litellm_jwtauth.team_allowed_routes" + ) in response.text, response.text + assert rag_rig.calls() == () + + +def _llm_body(route: str, model: str) -> dict[str, JsonValue]: + prompt: Final = f"hello {uuid.uuid4().hex}" + if route == "/v1/responses": + return {"model": model, "input": prompt} + if route == "/v1/messages": + return {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": prompt}]} + return {"model": model, "messages": [{"role": "user", "content": prompt}]} + + +@pytest.mark.parametrize("route", ("/v1/chat/completions", "/v1/messages", "/v1/responses")) +def test_jwt_team_member_keeps_its_llm_routes(rag_rig: RagRig, route: str) -> None: + tenants: Final = rag_rig.tenants + + response: Final = rag_rig.gateway.request( + "POST", + route, + _llm_body(route, tenants.team_a_model), + key=rag_rig.jwt(tenants.jwt_member, tenants.team_a), + headers={"anthropic-version": "2023-06-01"}, + ) + + assert response.status_code == 200, response.text + calls: Final = rag_rig.calls() + assert [(call.prefix, call.authorization) for call in calls] == [("team", f"Bearer {TEAM_API_KEY}")], calls + + +async def _ingest_burst(rig: RagRig, names: tuple[str, ...]) -> tuple[httpx.Response, ...]: + async with httpx.AsyncClient(base_url=str(rig.gateway.client.base_url), timeout=60, trust_env=False) as client: + return tuple( + await asyncio.gather( + *( + client.post( + "/v1/rag/ingest", + data=_ingest_request(name, OPENAI), + files={"file": DOCUMENT}, + headers={"Authorization": f"Bearer {rig.tenants.team_a_key}"}, + ) + for name in names + ) + ) + ) + + +def _burst_names() -> tuple[str, ...]: + return tuple(f"rag-burst-{uuid.uuid4().hex}" for _ in range(10)) + + +def _all_ingest_signatures(store_ids: Iterable[str]) -> list[Signature]: + return sorted(chain.from_iterable(_ingest_signatures("team", TEAM_API_KEY, store_id) for store_id in store_ids)) + + +async def test_ingest_burst_during_a_provider_outage_fails_each_request_then_recovers(rag_rig: RagRig) -> None: + before: Final = _store_rows("") + rag_rig.outage.set() + try: + failed: Final = await _ingest_burst(rag_rig, _burst_names()) + finally: + rag_rig.outage.clear() + + assert [response.status_code for response in failed] == [503] * 10, [response.text for response in failed] + outage_calls: Final = rag_rig.signatures() + assert outage_calls == (("team", "POST", "/v1/vector_stores", f"Bearer {TEAM_API_KEY}"),) * 10, outage_calls + assert _store_rows("") == before + + recovered: Final = await _ingest_burst(rag_rig, _burst_names()) + + store_ids: Final = tuple(_completed_store(rag_rig, response) for response in recovered) + assert len(frozenset(store_ids)) == 10, store_ids + assert sorted(rag_rig.signatures()) == _all_ingest_signatures(store_ids) + assert [[row["team_id"] for row in _store_rows(store_id)] for store_id in store_ids] == [ + [rag_rig.tenants.team_a] + ] * 10 + + +def _open_upstream_connections(pid: int, upstream: str) -> int: + port: Final = urlsplit(upstream).port + return sum( + 1 + for connection in psutil.Process(pid).net_connections(kind="tcp") + if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port + ) + + +async def _query_burst(gateway: Gateway, key: str, model: str, count: int) -> tuple[httpx.Response, ...]: + async with httpx.AsyncClient(base_url=str(gateway.client.base_url), timeout=90, trust_env=False) as client: + results: Final = await asyncio.gather( + *( + client.post( + "/v1/rag/query", + json={ + "model": model, + "messages": [{"role": "user", "content": f"held query {uuid.uuid4().hex}"}], + "retrieval_config": {**OPENAI, "vector_store_id": f"vs_burst_{uuid.uuid4().hex}", "top_k": 1}, + }, + headers={"Authorization": f"Bearer {key}"}, + ) + for _ in range(count) + ), + return_exceptions=True, + ) + for result in results: + assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result) + return tuple(result for result in results if isinstance(result, httpx.Response)) + + +async def test_worker_sigkill_mid_query_burst_leaves_the_sibling_on_team_credentials(tmp_path: Path) -> None: + release: Final = threading.Event() + held_searches: Final[SimpleQueue[str]] = SimpleQueue() + + def respond(request: Request) -> Reply: + matched: Final = PREFIXED.match(request.target) + if matched is None: + return _error_reply(404) + if STORE_SEARCH.match(matched[2]) is not None: + held_searches.put(request.target) + assert release.wait(timeout=60), "The burst was never released" + return _search_reply(request) + return _openai_reply(request, matched[1], matched[2], threading.Event()) + + with ( + gateway_from_environment() as admin_gateway, + admin_gateway.scenario() as scenario, + wire_server(respond) as upstream, + ): + model: Final = f"integration-rag-kill-{uuid.uuid4().hex}" + team_id: Final = scenario.team(models=[model]) + _deployment( + admin_gateway, + scenario, + name=model, + model="openai/gpt-4o-mini", + api_key=TEAM_API_KEY, + api_base=f"{upstream.url}/team/v1", + team_id=team_id, + ) + key: Final = scenario.key(team_id=team_id) + with owned_proxy_process( + admin_gateway, + tmp_path, + _provider_environment(upstream.url), + remove_environment=("GOOGLE_API_KEY",), + workers=2, + ) as owned: + workers: Final = eventually( + lambda: tuple(int(pid) for pid in STARTED_WORKER.findall(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + burst: Final = asyncio.create_task(_query_burst(owned.gateway, key, model, 20)) + await asyncio.to_thread(eventually, held_searches.qsize, lambda size: size == 20, 60) + held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, upstream.url) for pid in workers}) + assert sum(held_by.values()) == 20, held_by + victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__) + with owned.replacing_worker(victim_pid) as kill: + kill.freeze() + await asyncio.to_thread(kill) + release.set() + served: Final = await burst + assert held_by[survivor_pid] >= 10, held_by + assert [response.status_code for response in served] == [200] * held_by[survivor_pid], held_by + (follow_up,) = await _query_burst(owned.gateway, key, model, 1) + assert follow_up.status_code == 200, follow_up.text + calls: Final = tuple(_call(request) for request in upstream.drain()) + assert {(call.prefix, call.authorization) for call in calls} == {("team", f"Bearer {TEAM_API_KEY}")}, calls + assert sum(1 for call in calls if call.route == "/v1/chat/completions") == held_by[survivor_pid] + 1, calls + + +def _ingest_and_drain( + gateway: Gateway, scenario: Scenario, key: str, upstream: Wire +) -> tuple[str, tuple[Signature, ...]]: + upstream.drain() + response: Final = _ingest(gateway, key) + assert response.status_code == 200, response.text + store_id: Final = string_value(object_value(response.json())["vector_store_id"]) + scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": store_id}) + calls: Final = tuple(_call(request) for request in upstream.drain()) + return store_id, tuple(call.signature for call in calls if call.route != "/v1/models") + + +def _ingests_after_editing( + gateway: Gateway, scenario: Scenario, key: str, upstream: Wire, model_id: str +) -> tuple[tuple[str, tuple[Signature, ...]], ...]: + edited: Final = gateway.request("PATCH", f"/model/{model_id}/update", {"litellm_params": {"timeout": 30}}) + assert edited.status_code == 200, edited.text + return tuple(_ingest_and_drain(gateway, scenario, key, upstream) for _ in range(4)) + + +def test_a_team_with_two_openai_deployments_keeps_ingesting_through_the_older_one_after_either_is_edited( + tmp_path: Path, +) -> None: + with ( + gateway_from_environment() as admin_gateway, + admin_gateway.scenario() as scenario, + wire_server(_responder(threading.Event())) as upstream, + ): + team_model: Final = f"integration-rag-team-{uuid.uuid4().hex}" + wildcard_model: Final = f"integration-rag-wildcard-{uuid.uuid4().hex}" + team_id: Final = scenario.team(models=[team_model, wildcard_model]) + team_deployment: Final = ( + _deployment( + admin_gateway, + scenario, + name=team_model, + model="openai/gpt-4o-mini", + api_key=TEAM_API_KEY, + api_base=f"{upstream.url}/team/v1", + team_id=team_id, + model_id=f"z-{uuid.uuid4().hex}", + ), + "team", + TEAM_API_KEY, + ) + wildcard_deployment: Final = ( + _deployment( + admin_gateway, + scenario, + name=wildcard_model, + model="openai/gpt-4o-mini", + api_key=WILDCARD_API_KEY, + api_base=f"{upstream.url}/wildcard/v1", + team_id=team_id, + model_id=f"a-{uuid.uuid4().hex}", + ), + "wildcard", + WILDCARD_API_KEY, + ) + (picked_id, picked_prefix, picked_key), (other_id, _, _) = team_deployment, wildcard_deployment + key: Final = scenario.key(team_id=team_id) + with ( + owned_proxy( + admin_gateway, + tmp_path, + _provider_environment(upstream.url), + remove_environment=("GOOGLE_API_KEY",), + workers=2, + ) as gateway, + gateway.scenario() as owned_scenario, + ): + ingests: Final = ( + _ingest_and_drain(gateway, owned_scenario, key, upstream), + *_ingests_after_editing(gateway, owned_scenario, key, upstream, picked_id), + *_ingests_after_editing(gateway, owned_scenario, key, upstream, other_id), + ) + + assert [calls for _, calls in ingests] == [ + _ingest_signatures(picked_prefix, picked_key, store_id) for store_id, _ in ingests + ] + + +@pytest.mark.parametrize( + ("stored", "prefix", "api_key"), + (("own_key", "caller", CALLER_API_KEY), ("null_key", "team", TEAM_API_KEY)), + ids=("own_key", "null_key"), +) +def test_ingest_into_a_managed_store_uses_its_own_key_else_the_team_key( + rag_rig: RagRig, stored: Literal["own_key", "null_key"], prefix: str, api_key: str +) -> None: + litellm_params: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "own_key": {"api_key": CALLER_API_KEY, "api_base": f"{rag_rig.upstream.url}/caller/v1"}, + "null_key": {"api_key": None}, + }[stored] + ) + with rag_rig.gateway.scenario() as scenario: + store_id: Final = _managed_store(rag_rig, scenario, litellm_params) + + response: Final = _ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, {**OPENAI, "vector_store_id": store_id}) + + assert response.status_code == 200, response.text + assert object_value(response.json())["vector_store_id"] == store_id, response.text + bearer: Final = f"Bearer {api_key}" + assert rag_rig.signatures() == ( + (prefix, "POST", "/v1/files", bearer), + (prefix, "POST", f"/v1/vector_stores/{store_id}/files", bearer), + ) + + +def _file_id_ingest(gateway: Gateway, key: str, vector_store: Mapping[str, JsonValue]) -> httpx.Response: + return gateway.request( + "POST", + "/v1/rag/ingest", + { + "file_id": "file-team-existing", + "ingest_options": {"name": "litellm-rag-ingest", "vector_store": dict(vector_store)}, + }, + key=key, + ) + + +@pytest.mark.parametrize( + ("vector_store", "ingestion"), + ( + ({"custom_llm_provider": "gemini"}, "GeminiRAGIngestion"), + ({"custom_llm_provider": "bedrock", "vector_store_id": BUCKETLESS_KNOWLEDGE_BASE}, "BedrockRAGIngestion"), + ({"custom_llm_provider": "s3_vectors", "vector_bucket_name": "integration-bucket"}, "S3VectorsRAGIngestion"), + ({"custom_llm_provider": "vertex_ai"}, "VertexAIRAGIngestion"), + ), + ids=("gemini", "bedrock", "s3_vectors", "vertex_ai"), +) +def test_existing_file_id_on_a_store_that_cannot_take_one_is_rejected_before_any_upstream_call( + rag_rig: RagRig, vector_store: Mapping[str, JsonValue], ingestion: str +) -> None: + before: Final = _store_rows("") + + response: Final = _file_id_ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, vector_store) + + assert response.status_code == 400, response.text + assert f"{ingestion} does not support ingesting an existing file_id" in response.text, response.text + assert rag_rig.calls() == () + assert _store_rows("") == before + + +def test_openai_file_id_without_a_vector_store_id_is_rejected_before_any_upstream_call(rag_rig: RagRig) -> None: + before: Final = _store_rows("") + + response: Final = _file_id_ingest(rag_rig.gateway, rag_rig.tenants.team_a_key, OPENAI) + + assert response.status_code == 400, response.text + assert "vector_store_id is required when ingesting an existing file_id" in response.text, response.text + assert rag_rig.calls() == () + assert _store_rows("") == before + + +def test_openai_file_id_attaches_to_an_existing_store_with_the_team_key(rag_rig: RagRig) -> None: + store_id: Final = f"vs_existing_{uuid.uuid4().hex}" + + response: Final = _file_id_ingest( + rag_rig.gateway, rag_rig.tenants.team_a_key, {**OPENAI, "vector_store_id": store_id} + ) + + assert _completed_store(rag_rig, response) == store_id + assert rag_rig.signatures() == (("team", "POST", f"/v1/vector_stores/{store_id}/files", f"Bearer {TEAM_API_KEY}"),) + + +@pytest.mark.parametrize( + ("knowledge_base", "error", "routes"), + ( + ( + EMPTY_KNOWLEDGE_BASE, + f"No data sources found for Knowledge Base {EMPTY_KNOWLEDGE_BASE}", + (("POST", f"/knowledgebases/{EMPTY_KNOWLEDGE_BASE}/datasources/"),), + ), + ( + BUCKETLESS_KNOWLEDGE_BASE, + f"Could not auto-detect S3 bucket for data source {WEB_DATA_SOURCE}", + ( + ("POST", f"/knowledgebases/{BUCKETLESS_KNOWLEDGE_BASE}/datasources/"), + ("GET", f"/knowledgebases/{BUCKETLESS_KNOWLEDGE_BASE}/datasources/{WEB_DATA_SOURCE}"), + ), + ), + ), + ids=("no_data_source", "no_s3_bucket"), +) +def test_bedrock_knowledge_base_the_caller_named_without_a_usable_data_source_fails_ingest_with_400( + rag_rig: RagRig, knowledge_base: str, error: str, routes: tuple[tuple[str, str], ...] +) -> None: + before: Final = _store_rows("") + + response: Final = _ingest( + rag_rig.gateway, + rag_rig.tenants.team_a_key, + {"custom_llm_provider": "bedrock", "vector_store_id": knowledge_base}, + ) + + assert response.status_code == 400, response.text + assert error in response.text, response.text + assert tuple((call.prefix, call.method, call.route) for call in rag_rig.calls()) == tuple( + ("bedrock", method, route) for method, route in routes + ) + assert _store_rows("") == before + + +def test_s3_vectors_bucket_name_the_caller_sent_is_rejected_with_400_after_embedding(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + before: Final = _store_rows("") + request: Final = { + "request": json.dumps( + { + "ingest_options": { + "name": "litellm-rag-ingest", + "embedding": {"model": tenants.shared_model}, + "vector_store": {"custom_llm_provider": "s3_vectors", "vector_bucket_name": "ab", "dimension": 3}, + } + } + ) + } + + response: Final = rag_rig.gateway.request_multipart( + "/v1/rag/ingest", request, {"file": DOCUMENT}, key=tenants.team_a_key + ) + + assert response.status_code == 400, response.text + assert "Invalid vector_bucket_name 'ab'" in response.text, response.text + assert rag_rig.signatures() == (("shared", "POST", "/v1/embeddings", f"Bearer {SHARED_API_KEY}"),) + assert _store_rows("") == before + + +@dataclass(frozen=True, slots=True) +class KeylessRig: + gateway: Gateway + upstream: Wire + key: str + + +@pytest.fixture(scope="module") +def keyless_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[KeylessRig]: + directory: Final = tmp_path_factory.mktemp("rag_keyless_providers") + with ( + gateway_from_environment() as admin_gateway, + admin_gateway.scenario() as scenario, + wire_server(_responder(threading.Event())) as upstream, + ): + key: Final = scenario.key() + with owned_proxy( + admin_gateway, + directory, + {"OPENAI_API_KEY": ENVIRONMENT_API_KEY, "OPENAI_API_BASE": f"{upstream.url}/environment/v1"}, + remove_environment=("GEMINI_API_KEY", "GOOGLE_API_KEY", "VERTEXAI_PROJECT"), + workers=2, + ) as gateway: + yield KeylessRig(gateway, upstream, key) + + +@pytest.mark.parametrize( + ("provider", "status", "error"), + ( + ("gemini", 500, "GEMINI_API_KEY or GOOGLE_API_KEY is required for Gemini File Search"), + ("vertex_ai", 400, "vertex_project is required for Vertex AI RAG ingestion"), + ), + ids=("gemini_without_a_proxy_key", "vertex_ai_without_a_project"), +) +def test_ingest_status_says_whether_the_caller_or_the_proxy_must_fix_the_missing_setting( + keyless_rig: KeylessRig, provider: str, status: int, error: str +) -> None: + before: Final = _store_rows("") + + response: Final = _ingest(keyless_rig.gateway, keyless_rig.key, {"custom_llm_provider": provider}) + + assert response.status_code == status, response.text + assert error in response.text, response.text + assert tuple(keyless_rig.upstream.drain()) == () + assert _store_rows("") == before + + +async def _jwt_chat_burst(rig: RagRig, token: str, model: str, count: int) -> tuple[httpx.Response, ...]: + async with httpx.AsyncClient(base_url=str(rig.gateway.client.base_url), timeout=60, trust_env=False) as client: + return tuple( + await asyncio.gather( + *( + client.post( + "/v1/chat/completions", + json=_llm_body("/v1/chat/completions", model), + headers={"Authorization": f"Bearer {token}"}, + ) + for _ in range(count) + ) + ) + ) + + +async def test_jwt_in_ten_teams_that_hold_the_model_always_resolves_the_lowest_team_id(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + token: Final = rag_rig.jwt(tenants.jwt_multi_member, *reversed(tenants.jwt_teams)) + + responses: Final = await _jwt_chat_burst(rag_rig, token, tenants.jwt_team_model, 16) + + assert [response.status_code for response in responses] == [200] * 16, [response.text for response in responses] + assert ( + rag_rig.signatures() + == (("team", "POST", "/v1/chat/completions", f"Bearer {_jwt_team_key(min(tenants.jwt_teams))}"),) * 16 + ) + + +def _prompt() -> str: + return f"hello {uuid.uuid4().hex}" + + +def _sync_sdk_text(route: str, base_url: str, token: str, model: str, stream: bool) -> str: + if route == "/v1/messages": + messages_client: Final = anthropic.Anthropic(base_url=base_url, api_key=token, max_retries=0) + request: Final = {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": _prompt()}]} + if stream: + return "".join( + event.delta.text + for event in messages_client.messages.create(**request, stream=True) + if event.type == "content_block_delta" and event.delta.type == "text_delta" + ) + message: Final = messages_client.messages.create(**request) + return "".join(block.text for block in message.content if block.type == "text") + client: Final = openai.OpenAI(base_url=f"{base_url}/v1", api_key=token, max_retries=0) + if route == "/v1/responses": + if stream: + return "".join( + event.delta + for event in client.responses.create(model=model, input=_prompt(), stream=True) + if event.type == "response.output_text.delta" + ) + return client.responses.create(model=model, input=_prompt()).output_text + chat_messages: Final = [{"role": "user", "content": _prompt()}] + if stream: + return "".join( + chunk.choices[0].delta.content or "" + for chunk in client.chat.completions.create(model=model, messages=chat_messages, stream=True) + if chunk.choices + ) + return client.chat.completions.create(model=model, messages=chat_messages).choices[0].message.content or "" + + +async def _async_sdk_text(route: str, base_url: str, token: str, model: str, stream: bool) -> str: + if route == "/v1/messages": + messages_client: Final = anthropic.AsyncAnthropic(base_url=base_url, api_key=token, max_retries=0) + request: Final = {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": _prompt()}]} + if stream: + return "".join( + [ + event.delta.text + async for event in await messages_client.messages.create(**request, stream=True) + if event.type == "content_block_delta" and event.delta.type == "text_delta" + ] + ) + message: Final = await messages_client.messages.create(**request) + return "".join(block.text for block in message.content if block.type == "text") + client: Final = openai.AsyncOpenAI(base_url=f"{base_url}/v1", api_key=token, max_retries=0) + if route == "/v1/responses": + if stream: + return "".join( + [ + event.delta + async for event in await client.responses.create(model=model, input=_prompt(), stream=True) + if event.type == "response.output_text.delta" + ] + ) + return (await client.responses.create(model=model, input=_prompt())).output_text + chat_messages: Final = [{"role": "user", "content": _prompt()}] + if stream: + return "".join( + [ + chunk.choices[0].delta.content or "" + async for chunk in await client.chat.completions.create( + model=model, messages=chat_messages, stream=True + ) + if chunk.choices + ] + ) + completion: Final = await client.chat.completions.create(model=model, messages=chat_messages) + return completion.choices[0].message.content or "" + + +@pytest.mark.parametrize("stream", (False, True), ids=("json", "stream")) +@pytest.mark.parametrize("client", ("sync", "async")) +@pytest.mark.parametrize("route", ("/v1/chat/completions", "/v1/messages", "/v1/responses")) +async def test_jwt_team_member_reaches_its_llm_routes_through_the_sdks( + rag_rig: RagRig, route: str, client: Literal["sync", "async"], stream: bool +) -> None: + tenants: Final = rag_rig.tenants + base_url: Final = str(rag_rig.gateway.client.base_url).rstrip("/") + token: Final = rag_rig.jwt(tenants.jwt_member, tenants.team_a) + + text: Final = ( + await asyncio.to_thread(_sync_sdk_text, route, base_url, token, tenants.team_a_model, stream) + if client == "sync" + else await _async_sdk_text(route, base_url, token, tenants.team_a_model, stream) + ) + + assert text == "integration response", text + calls: Final = rag_rig.calls() + assert [(call.prefix, call.authorization) for call in calls] == [("team", f"Bearer {TEAM_API_KEY}")], calls + + +def _batch_for(rig: RagRig, headers: Mapping[str, str]) -> tuple[str, str]: + uploaded: Final = rig.gateway.client.post( + "/v1/files", data={"purpose": "batch"}, files={"file": BATCH_INPUT}, headers=dict(headers) + ) + assert uploaded.status_code == 200, uploaded.text + file_id: Final = string_value(object_value(uploaded.json())["id"]) + created: Final = rig.gateway.request( + "POST", + "/v1/batches", + {"input_file_id": file_id, "endpoint": "/v1/chat/completions", "completion_window": "24h"}, + headers=headers, + ) + assert created.status_code == 200, created.text + rig.upstream.drain() + return file_id, string_value(object_value(created.json())["id"]) + + +FOLLOW_UP_CALLS: Final = ( + ("GET", "/v1/files/{file_id}", "/v1/files/{file_id}"), + ("GET", "/v1/files/{file_id}/content", "/v1/files/{file_id}/content"), + ("DELETE", "/v1/files/{file_id}", "/v1/files/{file_id}"), + ("GET", "/v1/files?purpose=batch&custom_llm_provider=openai", "/v1/files"), + ("GET", "/v1/batches/{batch_id}", "/v1/batches/{batch_id}"), + ("POST", "/v1/batches/{batch_id}/cancel", "/v1/batches/{batch_id}/cancel"), +) +FOLLOW_UP_CALL_IDS: Final = ( + "file_retrieve", + "file_content", + "file_delete", + "file_list", + "batch_retrieve", + "batch_cancel", +) + + +def _bearer(key: str) -> Mapping[str, str]: + return MappingProxyType({"Authorization": f"Bearer {key}"}) + + +@pytest.mark.parametrize(("method", "path", "route"), FOLLOW_UP_CALLS, ids=FOLLOW_UP_CALL_IDS) +def test_files_and_batches_calls_by_a_key_restricted_under_the_team_wildcard_use_the_team_deployment( + rag_rig: RagRig, method: str, path: str, route: str +) -> None: + key: Final = rag_rig.tenants.wildcard_bare_key + file_id, batch_id = _batch_for(rag_rig, _bearer(key)) + + response: Final = rag_rig.gateway.request(method, path.format(file_id=file_id, batch_id=batch_id), key=key) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + ("wildcard", method, route.format(file_id=file_id, batch_id=batch_id), f"Bearer {WILDCARD_API_KEY}"), + ) + + +def test_batch_list_by_a_key_restricted_under_the_team_wildcard_is_answered_by_the_proxy_without_an_upstream_call( + rag_rig: RagRig, +) -> None: + key: Final = rag_rig.tenants.wildcard_bare_key + _batch_for(rag_rig, _bearer(key)) + + response: Final = rag_rig.gateway.request("GET", "/v1/batches?custom_llm_provider=openai", key=key) + + assert response.status_code == 200, response.text + assert object_value(response.json())["object"] == "list", response.text + assert rag_rig.signatures() == () + + +def _upload_batch_input(rig: RagRig, key: str) -> str: + uploaded: Final = rig.gateway.request_multipart("/v1/files", {"purpose": "batch"}, {"file": BATCH_INPUT}, key=key) + assert uploaded.status_code == 200, uploaded.text + return string_value(object_value(uploaded.json())["id"]) + + +def _create_batch(rig: RagRig, key: str, file_id: str) -> httpx.Response: + return rig.gateway.request( + "POST", + "/v1/batches", + {"input_file_id": file_id, "endpoint": "/v1/chat/completions", "completion_window": "24h"}, + key=key, + ) + + +@pytest.mark.parametrize( + ("team", "prefix", "api_key"), + (("team_a", "team", TEAM_API_KEY), ("team_s", "shared", SHARED_API_KEY)), + ids=("team_deployment", "accessible_deployment"), +) +def test_batch_over_the_key_tpm_limit_is_rejected_after_reading_its_input_where_it_was_uploaded( + rag_rig: RagRig, team: Literal["team_a", "team_s"], prefix: str, api_key: str +) -> None: + key: Final = rag_rig.scenario.key(team_id=getattr(rag_rig.tenants, team), tpm_limit=1) + file_id: Final = _upload_batch_input(rag_rig, key) + + response: Final = _create_batch(rag_rig, key, file_id) + + assert response.status_code == 429, response.text + bearer: Final = f"Bearer {api_key}" + assert rag_rig.signatures() == ( + (prefix, "POST", "/v1/files", bearer), + (prefix, "GET", f"/v1/files/{file_id}/content", bearer), + ) + + +def test_batch_whose_input_names_a_model_outside_the_key_allowlist_is_rejected_before_creation( + rag_rig: RagRig, +) -> None: + key: Final = rag_rig.tenants.restricted_key + file_id: Final = _upload_batch_input(rag_rig, key) + + response: Final = _create_batch(rag_rig, key, file_id) + + assert response.status_code == 403, response.text + assert BARE_WILDCARD_MODEL in response.text, response.text + bearer: Final = f"Bearer {SHARED_API_KEY}" + assert rag_rig.signatures() == ( + ("shared", "POST", "/v1/files", bearer), + ("shared", "GET", f"/v1/files/{file_id}/content", bearer), + ) + + +def test_azure_files_upload_by_a_key_restricted_under_the_team_azure_wildcard_uses_the_team_deployment( + rag_rig: RagRig, +) -> None: + response: Final = rag_rig.gateway.request_multipart( + "/v1/files", + {"purpose": "assistants", "custom_llm_provider": "azure"}, + {"file": DOCUMENT}, + key=rag_rig.tenants.azure_restricted_key, + ) + + assert response.status_code == 200, response.text + assert tuple((call.prefix, call.method, call.route, call.azure_key) for call in rag_rig.calls()) == ( + ("azure", "POST", "/openai/files", WILDCARD_API_KEY), + ) + + +def test_query_without_a_vector_store_id_is_rejected_before_search(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + response: Final = _query(rag_rig.gateway, tenants.team_a_key, tenants.team_a_model, OPENAI) + + assert response.status_code == 400, response.text + assert "retrieval_config must contain 'vector_store_id'" in response.text, response.text + assert rag_rig.calls() == () + + +def _forwarding(key: str) -> Mapping[str, str]: + return MappingProxyType({"Authorization": f"Bearer {key}", "x-api-key": FORWARDED_API_KEY}) + + +@pytest.mark.parametrize( + ("stored", "stream", "search"), + ( + (None, False, ("team", FORWARDED_API_KEY)), + (None, True, ("team", FORWARDED_API_KEY)), + ("own_key", False, ("caller", CALLER_API_KEY)), + ("null_key", False, ("team", FORWARDED_API_KEY)), + ), + ids=("unmanaged_json", "unmanaged_stream", "managed_own_key", "managed_null_key"), +) +def test_forwarded_provider_key_on_query_wins_over_team_provider_credentials( + rag_rig: RagRig, stored: Literal["own_key", "null_key"] | None, stream: bool, search: tuple[str, str] +) -> None: + tenants: Final = rag_rig.tenants + with rag_rig.gateway.scenario() as scenario: + store_id: Final = ( + f"vs_unmanaged_{uuid.uuid4().hex}" + if stored is None + else _managed_store( + rag_rig, + scenario, + {"api_key": CALLER_API_KEY, "api_base": f"{rag_rig.upstream.url}/caller/v1"} + if stored == "own_key" + else {"api_key": None}, + ) + ) + + response: Final = rag_rig.gateway.request( + "POST", + "/v1/rag/query", + { + "model": tenants.team_a_model, + "messages": [{"role": "user", "content": f"query forwarded key {uuid.uuid4().hex}"}], + "retrieval_config": {**OPENAI, "top_k": 1, "vector_store_id": store_id}, + **({"stream": True} if stream else {}), + }, + headers=_forwarding(tenants.team_a_key), + ) + + assert response.status_code == 200, response.text + assert "integration response" in response.text, response.text + assert rag_rig.signatures() == ( + _search_signature(search[0], search[1], store_id), + _chat_signature("team", FORWARDED_API_KEY), + ) + + +def test_forwarded_provider_key_on_ingest_wins_over_team_provider_credentials(rag_rig: RagRig) -> None: + tenants: Final = rag_rig.tenants + + response: Final = rag_rig.gateway.client.post( + "/v1/rag/ingest", + data=dict(_ingest_request("litellm-rag-forwarded-key", OPENAI)), + files={"file": DOCUMENT}, + headers=dict(_forwarding(tenants.team_a_key)), + ) + + store_id: Final = _completed_store(rag_rig, response) + assert rag_rig.signatures() == _ingest_signatures("team", FORWARDED_API_KEY, store_id) + + +@pytest.mark.parametrize( + ("caller", "prefix"), + (("team_a_key", "team"), ("wildcard_bare_key", "wildcard")), + ids=("team_key", "restricted_wildcard_key"), +) +def test_forwarded_provider_key_on_files_and_batches_wins_over_team_provider_credentials( + rag_rig: RagRig, caller: Literal["team_a_key", "wildcard_bare_key"], prefix: str +) -> None: + headers: Final = _forwarding(getattr(rag_rig.tenants, caller)) + uploaded: Final = rag_rig.gateway.client.post( + "/v1/files", data={"purpose": "batch"}, files={"file": BATCH_INPUT}, headers=dict(headers) + ) + assert uploaded.status_code == 200, uploaded.text + file_id: Final = string_value(object_value(uploaded.json())["id"]) + + created: Final = rag_rig.gateway.request( + "POST", + "/v1/batches", + {"input_file_id": file_id, "endpoint": "/v1/chat/completions", "completion_window": "24h"}, + headers=headers, + ) + + assert created.status_code == 200, created.text + calls: Final = rag_rig.calls() + assert [(call.prefix, call.authorization) for call in calls] == [ + (prefix, f"Bearer {FORWARDED_API_KEY}") + ] * len(calls), calls + assert {(call.method, call.route) for call in calls} >= {("POST", "/v1/files"), ("POST", "/v1/batches")}, calls + + +@pytest.mark.parametrize("stray_claim", (7, None), ids=("int", "null")) +@pytest.mark.parametrize("route", ("/v1/chat/completions", "/v1/rag/query")) +def test_jwt_team_claim_with_a_non_string_entry_still_resolves_the_string_team( + rag_rig: RagRig, route: str, stray_claim: JsonValue +) -> None: + tenants: Final = rag_rig.tenants + token: Final = rag_rig.jwt(tenants.jwt_member, tenants.team_a, stray_claim) + store_id: Final = f"vs_unmanaged_{uuid.uuid4().hex}" + + response: Final = ( + _query(rag_rig.gateway, token, tenants.team_a_model, {**OPENAI, "vector_store_id": store_id}) + if route == "/v1/rag/query" + else rag_rig.gateway.request("POST", route, _llm_body(route, tenants.team_a_model), key=token) + ) + + assert response.status_code == 200, response.text + search: Final = (_search_signature("team", TEAM_API_KEY, store_id),) if route == "/v1/rag/query" else () + assert rag_rig.signatures() == (*search, _chat_signature("team", TEAM_API_KEY)) + + +@pytest.mark.parametrize("stray_claim", (7, None), ids=("int", "null")) +def test_jwt_header_team_outside_a_claim_with_a_non_string_entry_is_denied_with_403( + rag_rig: RagRig, stray_claim: JsonValue +) -> None: + tenants: Final = rag_rig.tenants + header_team: Final = f"integration-rag-unknown-team-{uuid.uuid4().hex}" + + response: Final = rag_rig.gateway.request( + "POST", + "/v1/chat/completions", + _llm_body("/v1/chat/completions", tenants.team_a_model), + key=rag_rig.jwt(tenants.jwt_member, tenants.team_a, stray_claim), + headers={"x-litellm-team-id": header_team}, + ) + + assert response.status_code == 403, response.text + assert f"x-litellm-team-id '{header_team}' does not resolve to a team id or a unique team alias" in response.text, ( + response.text + ) + assert f"'{tenants.team_a}'" in response.text, response.text + assert rag_rig.calls() == () + + +@pytest.mark.parametrize(("method", "path", "route"), FOLLOW_UP_CALLS, ids=FOLLOW_UP_CALL_IDS) +@pytest.mark.parametrize( + ("caller", "prefix"), + (("team_a_key", "team"), ("wildcard_bare_key", "wildcard")), + ids=("team_key", "restricted_wildcard_key"), +) +def test_forwarded_provider_key_on_files_and_batches_follow_up_calls_replaces_the_team_deployment_key( + rag_rig: RagRig, + caller: Literal["team_a_key", "wildcard_bare_key"], + prefix: str, + method: str, + path: str, + route: str, +) -> None: + headers: Final = _forwarding(getattr(rag_rig.tenants, caller)) + file_id, batch_id = _batch_for(rag_rig, headers) + + response: Final = rag_rig.gateway.request(method, path.format(file_id=file_id, batch_id=batch_id), headers=headers) + + assert response.status_code == 200, response.text + assert rag_rig.signatures() == ( + (prefix, method, route.format(file_id=file_id, batch_id=batch_id), f"Bearer {FORWARDED_API_KEY}"), + ) + + +@pytest.mark.parametrize( + ("x_api_key", "prefix", "api_key"), + ( + (("",), "wildcard", WILDCARD_API_KEY), + (("f" * 5120,), "wildcard", "f" * 5120), + ((FORWARDED_API_KEY, FORWARDED_API_KEY), "wildcard", FORWARDED_API_KEY), + ), + ids=("empty", "five_kb", "repeated"), +) +def test_blank_oversized_or_repeated_forwarded_provider_key_on_files_and_batches( + rag_rig: RagRig, x_api_key: tuple[str, ...], prefix: str, api_key: str +) -> None: + headers: Final = ( + ("Authorization", f"Bearer {rag_rig.tenants.wildcard_bare_key}"), + *(("x-api-key", value) for value in x_api_key), + ) + uploaded: Final = rag_rig.gateway.client.post( + "/v1/files", data={"purpose": "batch"}, files={"file": BATCH_INPUT}, headers=headers + ) + assert uploaded.status_code == 200, uploaded.text + file_id: Final = string_value(JSON_OBJECT.validate_json(uploaded.content)["id"]) + + created: Final = rag_rig.gateway.client.post( + "/v1/batches", + json={"input_file_id": file_id, "endpoint": "/v1/chat/completions", "completion_window": "24h"}, + headers=headers, + ) + + assert created.status_code == 200, created.text + calls: Final = rag_rig.calls() + assert [(call.prefix, call.authorization) for call in calls] == [(prefix, f"Bearer {api_key}")] * len(calls), calls + assert {(call.method, call.route) for call in calls} == { + ("POST", "/v1/files"), + ("GET", f"/v1/files/{file_id}/content"), + ("POST", "/v1/batches"), + }, calls + + +def _forwarded_ingest(rig: RagRig, vector_store: Mapping[str, JsonValue]) -> httpx.Response: + return rig.gateway.client.post( + "/v1/rag/ingest", + data=dict(_ingest_request("litellm-rag-forwarded-key", vector_store)), + files={"file": DOCUMENT}, + headers=dict(_forwarding(rig.tenants.team_a_key)), + ) + + +def test_forwarded_provider_key_on_ingest_never_overrides_a_named_credential(rag_rig: RagRig) -> None: + store_id: Final = _completed_store( + rag_rig, _forwarded_ingest(rag_rig, {**OPENAI, "litellm_credential_name": rag_rig.tenants.credential_name}) + ) + + assert rag_rig.signatures() == _ingest_signatures("credential", CREDENTIAL_API_KEY, store_id) + + +@pytest.mark.parametrize( + ("stored", "prefix", "api_key"), + (("own_key", "caller", CALLER_API_KEY), ("null_key", "team", FORWARDED_API_KEY)), + ids=("own_key", "null_key"), +) +def test_forwarded_provider_key_on_ingest_into_a_managed_store_yields_only_to_the_store_key( + rag_rig: RagRig, stored: Literal["own_key", "null_key"], prefix: str, api_key: str +) -> None: + with rag_rig.gateway.scenario() as scenario: + store_id: Final = _managed_store( + rag_rig, + scenario, + {"api_key": CALLER_API_KEY, "api_base": f"{rag_rig.upstream.url}/caller/v1"} + if stored == "own_key" + else {"api_key": None}, + ) + + response: Final = _forwarded_ingest(rag_rig, {**OPENAI, "vector_store_id": store_id}) + + assert response.status_code == 200, response.text + assert JSON_OBJECT.validate_json(response.content)["vector_store_id"] == store_id, response.text + bearer: Final = f"Bearer {api_key}" + assert rag_rig.signatures() == ( + (prefix, "POST", "/v1/files", bearer), + (prefix, "POST", f"/v1/vector_stores/{store_id}/files", bearer), + ) + + +def test_forwarded_provider_key_never_reaches_a_gemini_ingest(rag_rig: RagRig) -> None: + store_id: Final = _completed_store(rag_rig, _forwarded_ingest(rag_rig, {"custom_llm_provider": "gemini"})) + + requests: Final = rag_rig.upstream.drain() + store_name: Final = store_id.removeprefix("fileSearchStores/") + assert tuple( + (call.prefix, call.method, call.route, call.google_key, call.authorization) + for call in (_call(request) for request in requests) + ) == ( + ("gemini", "POST", "/v1beta/fileSearchStores", GEMINI_API_KEY, None), + ( + "gemini", + "POST", + f"/upload/v1beta/fileSearchStores/{store_name}:uploadToFileSearchStore", + GEMINI_API_KEY, + None, + ), + ("gemini", "PUT", f"/upload-session/{store_name}", None, None), + ) + assert [request.headers.get("x-api-key") for request in requests] == [None] * len(requests), requests diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e82430c3add..92fed7200e1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -38,6 +38,7 @@ REPORTS: Final = pytest.StashKey[list[pytest.TestReport]]() WORKER_INVENTORY: Final = TypeAdapter(tuple[str, ...]) SHARD_PATH: Final[TypeAdapter[Path | None]] = TypeAdapter(Path | None) SHARD_FILES: Final = pytest.StashKey[frozenset[str] | None]() +FAILED_COLLECTIONS: Final = pytest.StashKey[frozenset[str]]() GROUP_SUFFIX: Final = re.compile(r"@integration_fixture_[0-9a-f]{64}$") INTEGER_OPTION: Final = TypeAdapter(int) STRING_ENVIRONMENT: Final = TypeAdapter(dict[str, str]) @@ -104,6 +105,12 @@ class IntegrationReportPlugin: def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: self.config.stash[REPORTS].append(report) + def pytest_collectreport(self, report: pytest.CollectReport) -> None: + if report.failed: + self.config.stash[FAILED_COLLECTIONS] = self.config.stash.get(FAILED_COLLECTIONS, frozenset()) | { + report.nodeid.split("::", 1)[0] + } + @pytest.hookimpl(optionalhook=True) def pytest_xdist_node_collection_finished(self, node: object, ids: Sequence[str]) -> None: self.config.stash[COLLECTED] = tuple(_case_identity(nodeid) for nodeid in ids if _owned(nodeid)) @@ -224,8 +231,12 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item raise pytest.UsageError("Parallel integration runs require CircleCI's shard file selection") if files is not None: available: Final = frozenset(item.nodeid.split("::", 1)[0] for item in owned) - if files - available: - raise pytest.UsageError(f"Integration shard selected uncollected files: {sorted(files - available)}") + for file in sorted(files - available - config.stash.get(FAILED_COLLECTIONS, frozenset())): + config.hook.pytest_collectreport( + report=pytest.CollectReport( + file, "failed", f"Integration shard selected {file}, which has no tests", [] + ) + ) selected: Final = tuple(item for item in items if item not in owned or item.nodeid.split("::", 1)[0] in files) deselected: Final = tuple(item for item in owned if item.nodeid.split("::", 1)[0] not in files) config.hook.pytest_deselected(items=deselected) diff --git a/tests/integration/cost_calculation/cases/anthropic.py b/tests/integration/cost_calculation/cases/anthropic.py index a0c81d8f06d..5ad4afa9530 100644 --- a/tests/integration/cost_calculation/cases/anthropic.py +++ b/tests/integration/cost_calculation/cases/anthropic.py @@ -6899,6 +6899,35 @@ CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON: Final = CLAUDE_OPUS_5_5_MESSAGES_INPUT_T } ) +def _responses_bridged_stream_case( + sibling: CostTrackingTestCase, stream_sibling: CostTrackingTestCase, name: str +) -> CostTrackingTestCase: + return sibling.model_copy( + update={ + "name": name, + "request": {**sibling.request, "stream": True}, + "response": stream_sibling.response, + } + ) + + +CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM: Final = _responses_bridged_stream_case( + CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, CLAUDE_FABLE_5_1_MESSAGES_STREAM, + "claude-fable-5-1-responses_bridged_stream", +) +CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM: Final = _responses_bridged_stream_case( + CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + "claude-haiku-5-5-responses_bridged_stream", +) +CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM: Final = _responses_bridged_stream_case( + CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON, CLAUDE_OPUS_5_5_MESSAGES_STREAM, + "claude-opus-5-5-responses_bridged_stream", +) +CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM: Final = _responses_bridged_stream_case( + CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, CLAUDE_SONNET_5_5_MESSAGES_STREAM, + "claude-sonnet-5-5-responses_bridged_stream", +) + CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K: Final = with_usage( CLAUDE_HAIKU_5_5_INPUT_TEXT, "claude-haiku-5-5-tiered_input_above_100k", @@ -6994,6 +7023,10 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( CLAUDE_SONNET_5_RESPONSES_BRIDGED_JSON, CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, + CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, + CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, + CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM, + CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, CLAUDE_FABLE_5_RESPONSES_BRIDGED_JSON, CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON, @@ -7119,6 +7152,18 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( + StreamParityTestCase( + plain=CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, streamed=CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, streamed=CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON, streamed=CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, streamed=CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM + ), StreamParityTestCase(plain=CLAUDE_HAIKU_4_5_INPUT_TEXT, streamed=CLAUDE_HAIKU_4_5_STREAM), StreamParityTestCase( plain=CLAUDE_HAIKU_4_5_RESPONSE_MODEL_OVERRIDE, streamed=CLAUDE_HAIKU_4_5_STREAM_RESPONSE_MODEL_OVERRIDE diff --git a/tests/integration/cost_calculation/cases/azure.py b/tests/integration/cost_calculation/cases/azure.py index 1360f65b802..d9f17c12bda 100644 --- a/tests/integration/cost_calculation/cases/azure.py +++ b/tests/integration/cost_calculation/cases/azure.py @@ -21,6 +21,8 @@ from integration.cost_calculation.cost_tracking_case import ( ) from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.stream_parity.case import StreamParityTestCase, sse_frames +from integration.cost_calculation.cases.openai import _responses_json_body, _responses_stream_case +from pydantic import JsonValue def _response_for_model( @@ -47,6 +49,37 @@ def _stream_for_model(response: SseResponse, model: str) -> SseResponse: ) +def _azure_responses_json_case( + base: CostTrackingTestCase, name: str, response_model: str, content_filters: JsonValue +) -> CostTrackingTestCase: + response_case: Final = bridge_pairs((base,), ("/v1/responses",))[0].bridged + return response_case.model_copy( + update={ + "name": name, + "response": JsonResponse( + content_type="application/json", + body=_responses_json_body( + response_model, + include_content_filters=True, + content_filters=content_filters, + ), + ), + } + ) + + +def _azure_messages_stream_case( + messages_json: CostTrackingTestCase, stream_case: CostTrackingTestCase, name: str +) -> CostTrackingTestCase: + return messages_json.model_copy( + update={ + "name": name, + "request": {**messages_json.request, "stream": True}, + "response": stream_case.response, + } + ) + + # azure/gpt-5.4-mini AZURE_GPT_5_4_MINI_INPUT_TEXT: Final = CostTrackingTestCase( name="azure-gpt-5.4-mini-input_text", @@ -5812,6 +5845,85 @@ AZURE_GPT_6_ASTRA_CACHE_WRITE_5M: Final = with_response_usage( ExactExpected(spend=0.22375, input_cost=0.2, output_cost=0.02375, prompt_tokens=12928, completion_tokens=380), ) +AZURE_GPT_5_6_TERRA_TIERED_INPUT_ABOVE_272K: Final = with_usage( + AZURE_GPT_5_6_TERRA_INPUT_TEXT, + "azure/gpt-5.6-terra-tiered_input_above_272k", + { + "prompt_tokens": 300000, + "total_tokens": 300412, + "prompt_tokens_details": {"audio_tokens": 0, "cache_write_tokens": 0, "cached_tokens": 0}, + }, + ExactExpected( + spend=1.3281576, input_cost=1.32, output_cost=0.0081576, prompt_tokens=300000, completion_tokens=412 + ), +) +AZURE_GPT_5_6_TERRA_TIERED_CACHE_READ_ABOVE_272K: Final = with_usage( + AZURE_GPT_5_6_TERRA_INPUT_TEXT, + "azure/gpt-5.6-terra-tiered_cache_read_above_272k", + { + "prompt_tokens": 300000, + "total_tokens": 300412, + "prompt_tokens_details": {"audio_tokens": 0, "cache_write_tokens": 0, "cached_tokens": 200000}, + }, + ExactExpected( + spend=0.5361576, input_cost=0.528, output_cost=0.0081576, prompt_tokens=300000, completion_tokens=412 + ), +) + + +AZURE_GPT_6_1_SOL_RESPONSES_JSON: Final = _azure_responses_json_case( + AZURE_GPT_6_1_SOL_INPUT_TEXT, "azure-gpt-6.1-sol-responses_json", "gpt-6.1-sol", None +) +AZURE_GPT_6_LUNA_RESPONSES_JSON: Final = _azure_responses_json_case( + AZURE_GPT_6_LUNA_INPUT_TEXT, "azure-gpt-6-luna-responses_json", "gpt-6-luna", None +) +AZURE_GPT_6_ASTRA_RESPONSES_JSON: Final = _azure_responses_json_case( + AZURE_GPT_6_ASTRA_INPUT_TEXT, "azure-gpt-6-astra-responses_json", "gpt-6-astra", [] +) +AZURE_GPT_5_6_TERRA_RESPONSES_JSON: Final = _azure_responses_json_case( + AZURE_GPT_5_6_TERRA_INPUT_TEXT, "azure-gpt-5.6-terra-responses_json", "gpt-5.6-terra", None +) +AZURE_GPT_6_1_SOL_RESPONSES_STREAM: Final = _responses_stream_case( + AZURE_GPT_6_1_SOL_RESPONSES_JSON, + "azure-gpt-6.1-sol-responses_stream", + "gpt-6.1-sol", + 14, + include_content_filters=True, +) +AZURE_GPT_6_LUNA_RESPONSES_STREAM: Final = _responses_stream_case( + AZURE_GPT_6_LUNA_RESPONSES_JSON, + "azure-gpt-6-luna-responses_stream", + "gpt-6-luna", + 25, + include_content_filters=True, +) +AZURE_GPT_6_ASTRA_RESPONSES_STREAM: Final = _responses_stream_case( + AZURE_GPT_6_ASTRA_RESPONSES_JSON, + "azure-gpt-6-astra-responses_stream", + "gpt-6-astra", + 16, + include_content_filters=True, + content_filters=[], +) +AZURE_GPT_5_6_TERRA_RESPONSES_STREAM: Final = _responses_stream_case( + AZURE_GPT_5_6_TERRA_RESPONSES_JSON, + "azure-gpt-5.6-terra-responses_stream", + "gpt-5.6-terra", + 15, + include_content_filters=True, +) +AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM: Final = _azure_messages_stream_case( + AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_JSON, AZURE_GPT_6_1_SOL_STREAM, "azure-gpt-6.1-sol-messages_bridged_stream" +) +AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_STREAM: Final = _azure_messages_stream_case( + AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_JSON, AZURE_GPT_6_LUNA_STREAM, "azure-gpt-6-luna-messages_bridged_stream" +) +AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM: Final = _azure_messages_stream_case( + AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_JSON, AZURE_GPT_6_ASTRA_STREAM, "azure-gpt-6-astra-messages_bridged_stream" +) +AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM: Final = _azure_messages_stream_case( + AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON, AZURE_GPT_5_6_TERRA_STREAM, "azure-gpt-5.6-terra-messages_bridged_stream" +) CASES: Final[tuple[CostTrackingTestCase, ...]] = ( AZURE_GPT_6_SOL_MESSAGES_BRIDGED_JSON, @@ -5903,6 +6015,8 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( AZURE_GPT_6_LUNA_CACHE_WRITE_5M, AZURE_GPT_6_ASTRA_CACHE_WRITE_5M, AZURE_GPT_5_6_TERRA_INPUT_TEXT, + AZURE_GPT_5_6_TERRA_TIERED_INPUT_ABOVE_272K, + AZURE_GPT_5_6_TERRA_TIERED_CACHE_READ_ABOVE_272K, AZURE_GPT_5_6_TERRA_STREAM, AZURE_GPT_5_6_TERRA_SERVICE_TIER_FLEX, AZURE_GPT_5_6_TERRA_SERVICE_TIER_PRIORITY, @@ -5911,6 +6025,18 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( AZURE_GPT_5_6_TERRA_CACHE_WRITE_5M, AZURE_GPT_5_6_TERRA_REASONING, AZURE_GPT_5_6_TERRA_TOOL_CALL, + AZURE_GPT_6_1_SOL_RESPONSES_JSON, + AZURE_GPT_6_1_SOL_RESPONSES_STREAM, + AZURE_GPT_6_LUNA_RESPONSES_JSON, + AZURE_GPT_6_LUNA_RESPONSES_STREAM, + AZURE_GPT_6_ASTRA_RESPONSES_JSON, + AZURE_GPT_6_ASTRA_RESPONSES_STREAM, + AZURE_GPT_5_6_TERRA_RESPONSES_JSON, + AZURE_GPT_5_6_TERRA_RESPONSES_STREAM, + AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM, + AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_STREAM, + AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM, + AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM, ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -5924,6 +6050,22 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase(plain=AZURE_GPT_6_ASTRA_INPUT_TEXT, streamed=AZURE_GPT_6_ASTRA_STREAM), StreamParityTestCase(plain=AZURE_GPT_6_1_SOL_INPUT_TEXT, streamed=AZURE_GPT_6_1_SOL_STREAM), StreamParityTestCase(plain=AZURE_GPT_5_6_TERRA_INPUT_TEXT, streamed=AZURE_GPT_5_6_TERRA_STREAM), + StreamParityTestCase(plain=AZURE_GPT_6_1_SOL_RESPONSES_JSON, streamed=AZURE_GPT_6_1_SOL_RESPONSES_STREAM), + StreamParityTestCase(plain=AZURE_GPT_6_LUNA_RESPONSES_JSON, streamed=AZURE_GPT_6_LUNA_RESPONSES_STREAM), + StreamParityTestCase(plain=AZURE_GPT_6_ASTRA_RESPONSES_JSON, streamed=AZURE_GPT_6_ASTRA_RESPONSES_STREAM), + StreamParityTestCase(plain=AZURE_GPT_5_6_TERRA_RESPONSES_JSON, streamed=AZURE_GPT_5_6_TERRA_RESPONSES_STREAM), + StreamParityTestCase( + plain=AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_JSON, streamed=AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_JSON, streamed=AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_JSON, streamed=AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM + ), + StreamParityTestCase( + plain=AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON, streamed=AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM + ), ) BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( @@ -5938,4 +6080,10 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( BridgeCostParityTestCase( native=AZURE_GPT_5_6_TERRA_INPUT_TEXT, bridged=AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON ), + BridgeCostParityTestCase(native=AZURE_GPT_6_1_SOL_STREAM, bridged=AZURE_GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase(native=AZURE_GPT_6_LUNA_STREAM, bridged=AZURE_GPT_6_LUNA_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase(native=AZURE_GPT_6_ASTRA_STREAM, bridged=AZURE_GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase( + native=AZURE_GPT_5_6_TERRA_STREAM, bridged=AZURE_GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM + ), ) diff --git a/tests/integration/cost_calculation/cases/bedrock_converse.py b/tests/integration/cost_calculation/cases/bedrock_converse.py index f78bdfecfa3..858db06cafc 100644 --- a/tests/integration/cost_calculation/cases/bedrock_converse.py +++ b/tests/integration/cost_calculation/cases/bedrock_converse.py @@ -6088,6 +6088,71 @@ US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_CACHE_WRITE_1H_ABOVE_100K: Final = with_usa ), ) +def _us_anthropic_messages_bridge_pairs( + input_case: CostTrackingTestCase, stream_case: CostTrackingTestCase +) -> tuple[BridgeCostParityTestCase, ...]: + return tuple( + BridgeCostParityTestCase( + native=pair.native, + bridged=pair.bridged.model_copy( + update={ + "name": ( + f"us.anthropic." + f"{pair.native.name.removeprefix('us.anthropic.').removesuffix('-input_text').removesuffix('-stream')}" + f"-messages_bridged_{'stream' if pair.native.request.get('stream') is True else 'json'}" + ) + } + ), + ) + for pair in bridge_pairs((input_case, stream_case), ("/v1/messages",)) + ) + + +def _us_anthropic_responses_stream_case( + json_case: CostTrackingTestCase, stream_case: CostTrackingTestCase, name: str +) -> CostTrackingTestCase: + return json_case.model_copy( + update={ + "name": name, + "request": {**json_case.request, "stream": True}, + "response": stream_case.response, + } + ) + + +US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( + US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM, + "us.anthropic.claude-fable-5-1-responses_bridged_stream", +) +US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( + US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM, + "us.anthropic.claude-haiku-5-5-responses_bridged_stream", +) +US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( + US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_OPUS_5_5_STREAM, + "us.anthropic.claude-opus-5-5-responses_bridged_stream", +) +US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( + US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_SONNET_5_5_STREAM, + "us.anthropic.claude-sonnet-5-5-responses_bridged_stream", +) +_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( + US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM +) +_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( + US_ANTHROPIC_CLAUDE_HAIKU_5_5_INPUT_TEXT, US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM +) +_US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( + US_ANTHROPIC_CLAUDE_OPUS_5_5_INPUT_TEXT, US_ANTHROPIC_CLAUDE_OPUS_5_5_STREAM +) +_US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( + US_ANTHROPIC_CLAUDE_SONNET_5_5_INPUT_TEXT, US_ANTHROPIC_CLAUDE_SONNET_5_5_STREAM +) + CASES: Final[tuple[CostTrackingTestCase, ...]] = ( US_ANTHROPIC_CLAUDE_SONNET_5_RESPONSES_BRIDGED_JSON, US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, @@ -6210,6 +6275,14 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( US_ANTHROPIC_CLAUDE_FABLE_5_1_THINKING, US_ANTHROPIC_CLAUDE_OPUS_5_5_UPSTREAM_400_ZERO_SPEND, US_ANTHROPIC_CLAUDE_OPUS_5_5_INPUT_TEXT_ROLLUPS, + US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, + US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, + US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM, + US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, + *tuple(pair.bridged for pair in _US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS), + *tuple(pair.bridged for pair in _US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS), + *tuple(pair.bridged for pair in _US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS), + *tuple(pair.bridged for pair in _US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS), ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -6235,6 +6308,38 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_HAIKU_5_5_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM), StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_FABLE_5_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_STREAM), StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM), + StreamParityTestCase( + plain=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, + streamed=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, + ), + StreamParityTestCase( + plain=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, + streamed=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, + ), + StreamParityTestCase( + plain=US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_JSON, + streamed=US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM, + ), + StreamParityTestCase( + plain=US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_JSON, + streamed=US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, + ), + StreamParityTestCase( + plain=_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS[0].bridged, + streamed=_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS[0].bridged, + streamed=_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=_US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS[0].bridged, + streamed=_US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=_US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS[0].bridged, + streamed=_US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS[1].bridged, + ), ) BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( @@ -6267,4 +6372,24 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( BridgeCostParityTestCase( native=US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, bridged=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM, + bridged=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, + ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM, + bridged=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, + ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_OPUS_5_5_STREAM, + bridged=US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM, + ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_SONNET_5_5_STREAM, + bridged=US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, + ), + *_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS, + *_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS, + *_US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS, + *_US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS, ) diff --git a/tests/integration/cost_calculation/cases/bedrock_mantle.py b/tests/integration/cost_calculation/cases/bedrock_mantle.py index 2fa940d66f4..3cd9150033a 100644 --- a/tests/integration/cost_calculation/cases/bedrock_mantle.py +++ b/tests/integration/cost_calculation/cases/bedrock_mantle.py @@ -18,7 +18,7 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_OPUS_5_5_THINKING, CLAUDE_OPUS_5_5_TOOL_CALL, ) -from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase +from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.cost_tracking_case import ( CostTrackingTestCase, Deployment, @@ -535,14 +535,40 @@ _MANTLE_GPT_CHAT_REQUEST: Final = { "messages": [{"role": "user", "content": "cost tracking fixture"}], "max_completion_tokens": 64, } +_MANTLE_GPT_TOOL_REQUEST: Final = { + **_MANTLE_GPT_RESPONSES_REQUEST, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "Get the weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ], + "tool_choice": "required", +} +_MANTLE_GPT_CACHE_WRITE_REQUEST: Final = { + **_MANTLE_GPT_RESPONSES_REQUEST, + "input": "cacheable pricing-harness prefix " * 1000, + "prompt_cache_key": "cost-cases-20261017", + "max_output_tokens": 64, +} def _mantle_gpt_usage( - input_tokens: int, output_tokens: int, reasoning_tokens: int, cached_tokens: int = 0 + input_tokens: int, + output_tokens: int, + reasoning_tokens: int, + cached_tokens: int = 0, + cache_write_tokens: int = 0, ) -> dict[str, JsonValue]: return { "input_tokens": input_tokens, - "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": cached_tokens}, + "input_tokens_details": {"cache_write_tokens": cache_write_tokens, "cached_tokens": cached_tokens}, "output_tokens": output_tokens, "output_tokens_details": {"reasoning_tokens": reasoning_tokens}, "total_tokens": input_tokens + output_tokens, @@ -628,6 +654,20 @@ def _mantle_gpt_cases( response_expected, _MANTLE_GPT_CHAT_REQUEST, ) + chat_stream_case: Final = chat_case.model_copy( + update={ + "name": f"bedrock_mantle-{provider_model}-chat_stream", + "request": {**chat_case.request, "stream": True}, + "response": stream_case.response, + } + ) + messages_pairs: Final = bridge_pairs((response_case, stream_case), ("/v1/messages",)) + messages_json: Final = messages_pairs[0].bridged.model_copy( + update={"name": f"bedrock_mantle-{provider_model}-messages_bridged_json"} + ) + messages_stream: Final = messages_pairs[1].bridged.model_copy( + update={"name": f"bedrock_mantle-{provider_model}-messages_bridged_stream"} + ) cache_case: Final = _mantle_gpt_json_case( BEDROCK_MANTLE_OPENAI_GPT_6_SOL_RESPONSES_JSON, provider_model, @@ -660,7 +700,73 @@ def _mantle_gpt_cases( tiered_boundary_expected, {**_MANTLE_GPT_RESPONSES_REQUEST, "max_output_tokens": 412}, ) - return (response_case, stream_case, reasoning_case, chat_case, cache_case, tiered_input, tiered_cache, tiered_boundary) + return ( + response_case, + stream_case, + reasoning_case, + chat_case, + cache_case, + tiered_input, + tiered_cache, + tiered_boundary, + chat_stream_case, + messages_json, + messages_stream, + ) + + +def _mantle_gpt_audit3_cases( + provider_model: str, + tool_input_tokens: int, + tool_output_tokens: int, + tool_reasoning_tokens: int, + tool_expected: ExactExpected, + cache_output_tokens: int, + cache_reasoning_tokens: int, + cache_expected: ExactExpected, +) -> tuple[CostTrackingTestCase, ...]: + tool_case: Final = _mantle_gpt_json_case( + BEDROCK_MANTLE_OPENAI_GPT_6_SOL_RESPONSES_JSON, + provider_model, + "tool_call", + _mantle_gpt_usage(tool_input_tokens, tool_output_tokens, tool_reasoning_tokens), + tool_expected, + _MANTLE_GPT_TOOL_REQUEST, + ) + if not isinstance(tool_case.response, JsonResponse): + raise TypeError(f"{tool_case.name}: expected a JSON response fixture") + tool_response: Final = tool_case.response.model_copy( + update={ + "body": { + **tool_case.response.body, + "output": [ + { + "arguments": '{"city":"Paris"}', + "call_id": "call_$REQUEST_ID", + "id": "fc_$REQUEST_ID", + "name": "get_weather", + "status": "completed", + "type": "function_call", + } + ], + } + } + ) + tool_case_with_response: Final = tool_case.model_copy(update={"response": tool_response}) + cache_case: Final = _mantle_gpt_json_case( + BEDROCK_MANTLE_OPENAI_GPT_6_SOL_RESPONSES_JSON, + provider_model, + "cache_write_5m", + _mantle_gpt_usage( + 1667, + cache_output_tokens, + cache_reasoning_tokens, + cache_write_tokens=1665, + ), + cache_expected, + _MANTLE_GPT_CACHE_WRITE_REQUEST, + ) + return (tool_case_with_response, cache_case) def _mantle_gpt_bridge(case: CostTrackingTestCase) -> BridgeCostParityTestCase: @@ -723,6 +829,83 @@ _MANTLE_GPT_56_TERRA_CASES: Final = _mantle_gpt_cases( ExactExpected(spend=0.84537376, input_cost=0.83776, output_cost=0.00761376, prompt_tokens=272000, completion_tokens=412), ) +_MANTLE_GPT_AUDIT3_CASES: Final = ( + *_mantle_gpt_audit3_cases( + "openai.gpt-6.1-sol", + 50, + 20, + 0, + ExactExpected(spend=0.000363, input_cost=0.000121, output_cost=0.000242, prompt_tokens=50, completion_tokens=20), + 28, + 0, + ExactExpected( + spend=0.005381765, + input_cost=0.005042965, + output_cost=0.0003388, + prompt_tokens=1667, + completion_tokens=28, + cache_creation_cost=0.005038125, + ), + ), + *_mantle_gpt_audit3_cases( + "openai.gpt-6-luna", + 51, + 18, + 0, + ExactExpected( + spend=0.000018612, input_cost=0.000006732, output_cost=0.00001188, prompt_tokens=51, completion_tokens=18 + ), + 64, + 40, + ExactExpected( + spend=0.000317229, + input_cost=0.000274989, + output_cost=0.00004224, + prompt_tokens=1667, + completion_tokens=64, + cache_creation_cost=0.000274725, + ), + ), + *_mantle_gpt_audit3_cases( + "openai.gpt-6-astra", + 51, + 18, + 0, + ExactExpected( + spend=0.0020163, input_cost=0.0007293, output_cost=0.001287, prompt_tokens=51, completion_tokens=18 + ), + 27, + 0, + ExactExpected( + spend=0.031720975, + input_cost=0.029790475, + output_cost=0.0019305, + prompt_tokens=1667, + completion_tokens=27, + cache_creation_cost=0.029761875, + ), + ), + *_mantle_gpt_audit3_cases( + "openai.gpt-5.6-terra", + 51, + 28, + 8, + ExactExpected( + spend=0.00067452, input_cost=0.00015708, output_cost=0.00051744, prompt_tokens=51, completion_tokens=28 + ), + 28, + 0, + ExactExpected( + spend=0.00693385, + input_cost=0.00641641, + output_cost=0.00051744, + prompt_tokens=1667, + completion_tokens=28, + cache_creation_cost=0.00641025, + ), + ), +) + _MANTLE_CLAUDE_OPUS_MODEL: Final = "bedrock_mantle/anthropic.claude-opus-5-5" _ADAPTIVE_USAGE: Final = { "input_tokens": 24, @@ -737,7 +920,25 @@ _ADAPTIVE_EXPECTED: Final = ExactExpected( spend=0.0007524, input_cost=0.0001584, output_cost=0.000594, prompt_tokens=24, completion_tokens=18 ) -BEDROCK_MANTLE_CLAUDE_OPUS_5_5_INPUT_TEXT: Final = with_usage( + +def _mantle_without_usage_fields(case: CostTrackingTestCase) -> CostTrackingTestCase: + if not isinstance(case.response, JsonResponse): + raise TypeError(f"{case.name}: expected a JSON response fixture") + usage: Final = case.response.body.get("usage") + if not isinstance(usage, dict): + raise TypeError(f"{case.name}: expected a usage object") + response: Final = case.response.model_copy( + update={ + "body": { + **case.response.body, + "usage": {key: value for key, value in usage.items() if key not in {"inference_geo", "speed"}}, + } + } + ) + return case.model_copy(update={"response": response}) + + +BEDROCK_MANTLE_CLAUDE_OPUS_5_5_INPUT_TEXT: Final = _mantle_without_usage_fields(with_usage( CLAUDE_OPUS_5_5_INPUT_TEXT, "bedrock_mantle-anthropic.claude-opus-5-5-input_text", _ADAPTIVE_USAGE, @@ -751,7 +952,7 @@ BEDROCK_MANTLE_CLAUDE_OPUS_5_5_INPUT_TEXT: Final = with_usage( "allowed_openai_params": ["reasoning_effort"], }, } -) +)) _MANTLE_CLAUDE_STREAM_RESPONSE: Final = SseResponse( content_type="text/event-stream", @@ -841,7 +1042,7 @@ BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_STREAM: Final = CLAUDE_OPUS_5_5_MESSAGES } ) -BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_READ: Final = with_usage( +BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_READ: Final = _mantle_without_usage_fields(with_usage( CLAUDE_OPUS_5_5_CACHE_READ, "bedrock_mantle-anthropic.claude-opus-5-5-cache_read", { @@ -856,7 +1057,7 @@ BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_READ: Final = with_usage( ExactExpected( spend=0.00308319, input_cost=0.00153219, output_cost=0.001551, prompt_tokens=4225, completion_tokens=47 ), -).model_copy(update={"model": _MANTLE_CLAUDE_OPUS_MODEL}) +).model_copy(update={"model": _MANTLE_CLAUDE_OPUS_MODEL})) BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_WRITE_5M: Final = with_usage( CLAUDE_OPUS_5_5_CACHE_WRITE_5M, @@ -892,12 +1093,12 @@ BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_WRITE_1H: Final = with_usage( ), ).model_copy(update={"model": _MANTLE_CLAUDE_OPUS_MODEL}) -BEDROCK_MANTLE_CLAUDE_OPUS_5_5_THINKING: Final = with_usage( +BEDROCK_MANTLE_CLAUDE_OPUS_5_5_THINKING: Final = _mantle_without_usage_fields(with_usage( CLAUDE_OPUS_5_5_THINKING, "bedrock_mantle-anthropic.claude-opus-5-5-thinking", _ADAPTIVE_USAGE, _ADAPTIVE_EXPECTED, -).model_copy(update={"model": _MANTLE_CLAUDE_OPUS_MODEL}) +).model_copy(update={"model": _MANTLE_CLAUDE_OPUS_MODEL})) BEDROCK_MANTLE_CLAUDE_OPUS_5_5_TOOL_CALL: Final = with_usage( CLAUDE_OPUS_5_5_TOOL_CALL, @@ -921,6 +1122,21 @@ BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CHAT_BRIDGED_JSON: Final = BEDROCK_MANTLE_CLAUDE_ } ) +BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_JSON: Final = BEDROCK_MANTLE_CLAUDE_OPUS_5_5_INPUT_TEXT.model_copy( + update={ + "name": "bedrock_mantle-anthropic.claude-opus-5-5-responses_json", + "endpoint": "/v1/responses", + "request": {"model": "$MODEL", "input": "summarize the attached material in one line"}, + } +) +BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_STREAM: Final = BEDROCK_MANTLE_CLAUDE_OPUS_5_5_STREAM.model_copy( + update={ + "name": "bedrock_mantle-anthropic.claude-opus-5-5-responses_stream", + "endpoint": "/v1/responses", + "request": {"model": "$MODEL", "input": "summarize the attached material in one line", "stream": True}, + } +) + BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( _mantle_gpt_bridge(_MANTLE_GPT_61_SOL_CASES[0]), _mantle_gpt_bridge(_MANTLE_GPT_6_LUNA_CASES[0]), @@ -930,6 +1146,14 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( native=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, bridged=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CHAT_BRIDGED_JSON, ), + *tuple( + BridgeCostParityTestCase(native=cases[0], bridged=cases[9]) + for cases in (_MANTLE_GPT_61_SOL_CASES, _MANTLE_GPT_6_LUNA_CASES, _MANTLE_GPT_6_ASTRA_CASES, _MANTLE_GPT_56_TERRA_CASES) + ), + *tuple( + BridgeCostParityTestCase(native=cases[1], bridged=cases[10]) + for cases in (_MANTLE_GPT_61_SOL_CASES, _MANTLE_GPT_6_LUNA_CASES, _MANTLE_GPT_6_ASTRA_CASES, _MANTLE_GPT_56_TERRA_CASES) + ), ) CASES: Final[tuple[CostTrackingTestCase, ...]] = ( @@ -945,11 +1169,14 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( *_MANTLE_GPT_6_LUNA_CASES, *_MANTLE_GPT_6_ASTRA_CASES, *_MANTLE_GPT_56_TERRA_CASES, + *_MANTLE_GPT_AUDIT3_CASES, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_INPUT_TEXT, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_STREAM, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_STREAM, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CHAT_BRIDGED_JSON, + BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_JSON, + BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_STREAM, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_READ, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_WRITE_5M, BEDROCK_MANTLE_CLAUDE_OPUS_5_5_CACHE_WRITE_1H, @@ -972,4 +1199,16 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( plain=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, streamed=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_MESSAGES_STREAM, ), + StreamParityTestCase( + plain=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_JSON, + streamed=BEDROCK_MANTLE_CLAUDE_OPUS_5_5_RESPONSES_STREAM, + ), + *tuple( + StreamParityTestCase(plain=cases[3], streamed=cases[8]) + for cases in (_MANTLE_GPT_61_SOL_CASES, _MANTLE_GPT_6_LUNA_CASES, _MANTLE_GPT_6_ASTRA_CASES, _MANTLE_GPT_56_TERRA_CASES) + ), + *tuple( + StreamParityTestCase(plain=cases[9], streamed=cases[10]) + for cases in (_MANTLE_GPT_61_SOL_CASES, _MANTLE_GPT_6_LUNA_CASES, _MANTLE_GPT_6_ASTRA_CASES, _MANTLE_GPT_56_TERRA_CASES) + ), ) diff --git a/tests/integration/cost_calculation/cases/gemini.py b/tests/integration/cost_calculation/cases/gemini.py index bd64f4aeb11..ceca816c300 100644 --- a/tests/integration/cost_calculation/cases/gemini.py +++ b/tests/integration/cost_calculation/cases/gemini.py @@ -2,8 +2,10 @@ PARITY pairs the streamed case with its plain twin where both bill the same row.""" +import json from typing import Final, cast +from pydantic import JsonValue from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.cost_tracking_case import ( CostTrackingTestCase, @@ -17,26 +19,74 @@ from integration.cost_calculation.cost_tracking_case import ( from integration.cost_calculation.stream_parity.case import StreamParityTestCase, sse_frames -def _gemini_case_response(response: JsonResponse | SseResponse, model_version: str) -> JsonResponse | SseResponse: +def _gemini_case_response( + response: JsonResponse | SseResponse, + model_version: str, + usage_fields: dict[str, JsonValue] | None = None, +) -> JsonResponse | SseResponse: match response: case JsonResponse(): - return response.model_copy(update={"body": {**response.body, "modelVersion": model_version}}) + usage: Final = response.body.get("usageMetadata") + body: Final = {**response.body, "modelVersion": model_version} + return response.model_copy( + update={ + "body": { + **body, + **( + { + "usageMetadata": { + **cast(dict[str, JsonValue], usage), + **usage_fields, + } + } + if usage_fields is not None and isinstance(usage, dict) + else {} + ), + } + } + ) case SseResponse(): return response.model_copy( update={ - "frames": tuple(frame.replace("gemini-3.8-flash", model_version) for frame in response.frames) + "frames": tuple( + _gemini_case_frame(frame, model_version, usage_fields) for frame in response.frames + ) } ) +def _gemini_case_frame( + frame: str, model_version: str, usage_fields: dict[str, JsonValue] | None +) -> str: + replaced: Final = frame.replace("gemini-3.8-flash", model_version) + if usage_fields is None: + return replaced + prefix, separator, payload = replaced.partition("data: ") + if not separator or payload == "[DONE]": + return replaced + event: Final = cast(dict[str, JsonValue], json.loads(payload)) + usage: Final = event.get("usageMetadata") + if not isinstance(usage, dict): + return replaced + return f"{prefix}data: {json.dumps({**event, 'usageMetadata': {**usage, **usage_fields}})}" + + def _gemini_case( - sibling: CostTrackingTestCase, name: str, model: str, model_version: str, expected: ExactExpected + sibling: CostTrackingTestCase, + name: str, + model: str, + model_version: str, + expected: ExactExpected, + *, + usage_fields: dict[str, JsonValue] | None = None, ) -> CostTrackingTestCase: return sibling.model_copy( update={ "name": name, "model": model, - "response": _gemini_case_response(cast(JsonResponse | SseResponse, sibling.response), model_version), + "response": _gemini_case_response( + cast(JsonResponse | SseResponse, sibling.response), model_version, usage_fields + ), "expected": expected, } ) @@ -1540,6 +1590,7 @@ GEMINI_GEMINI_3_8_FLASH_INPUT_TEXT: Final = CostTrackingTestCase( "candidatesTokenCount": 412, "totalTokenCount": 2252, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1840}], + "serviceTier": "standard", }, "modelVersion": "gemini-3.8-flash", }, @@ -1594,6 +1645,7 @@ GEMINI_GEMINI_3_8_FLASH_CACHE_READ: Final = CostTrackingTestCase( "totalTokenCount": 13308, "cachedContentTokenCount": 12288, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12928}], + "serviceTier": "standard", }, "modelVersion": "gemini-3.8-flash", }, @@ -1653,6 +1705,7 @@ GEMINI_GEMINI_3_8_FLASH_REASONING: Final = CostTrackingTestCase( "thoughtsTokenCount": 3480, "totalTokenCount": 5280, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1240}], + "serviceTier": "standard", }, "modelVersion": "gemini-3.8-flash", }, @@ -1934,6 +1987,7 @@ GEMINI_GEMINI_3_8_FLASH_STREAM: Final = CostTrackingTestCase( "candidatesTokenCount": 412, "totalTokenCount": 2252, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1840}], + "serviceTier": "standard", }, "modelVersion": "gemini-3.8-flash", }, @@ -3275,19 +3329,46 @@ GEMINI_GEMINI_3_1_PRO_CACHE_CREATION: Final = CostTrackingTestCase( GEMINI_GEMINI_4_ARGON_INPUT_TEXT: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_INPUT_TEXT, "gemini-gemini-4-argon-input_text", "gemini/gemini-4-argon", - "gemini-4-argon", ExactExpected(spend=0.00858, input_cost=0.004048, output_cost=0.004532, prompt_tokens=1840, completion_tokens=412) + "gemini-4-argon", ExactExpected(spend=0.00858, input_cost=0.004048, output_cost=0.004532, prompt_tokens=1840, completion_tokens=412), + usage_fields={"candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}]}, ) GEMINI_GEMINI_4_ARGON_STREAM: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_STREAM, "gemini-gemini-4-argon-stream", "gemini/gemini-4-argon", - "gemini-4-argon", ExactExpected(spend=0.00858, input_cost=0.004048, output_cost=0.004532, prompt_tokens=1840, completion_tokens=412) + "gemini-4-argon", ExactExpected(spend=0.00858, input_cost=0.004048, output_cost=0.004532, prompt_tokens=1840, completion_tokens=412), + usage_fields={"candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}]}, ) GEMINI_GEMINI_4_ARGON_CACHE_READ: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_CACHE_READ, "gemini-gemini-4-argon-cache_read", "gemini/gemini-4-argon", - "gemini-4-argon", ExactExpected(spend=0.00693968, input_cost=0.00275968, output_cost=0.00418, prompt_tokens=12928, completion_tokens=380) + "gemini-4-argon", ExactExpected(spend=0.00693968, input_cost=0.00275968, output_cost=0.00418, prompt_tokens=12928, completion_tokens=380), + usage_fields={"candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 380}]}, ) GEMINI_GEMINI_4_ARGON_REASONING: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_REASONING, "gemini-gemini-4-argon-reasoning", "gemini/gemini-4-argon", - "gemini-4-argon", ExactExpected(spend=0.047168, input_cost=0.002728, output_cost=0.04444, prompt_tokens=1240, completion_tokens=4040) + "gemini-4-argon", ExactExpected(spend=0.047168, input_cost=0.002728, output_cost=0.04444, prompt_tokens=1240, completion_tokens=4040), + usage_fields={"candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 560}]}, +) +GEMINI_GEMINI_4_ARGON_SERVICE_TIER_FLEX: Final = _gemini_case( + GEMINI_GEMINI_3_8_FLASH_SERVICE_TIER_FLEX, + "gemini-gemini-4-argon-service_tier_flex", + "gemini/gemini-4-argon", + "gemini-4-argon", + ExactExpected(spend=0.00429, input_cost=0.002024, output_cost=0.002266, prompt_tokens=1840, completion_tokens=412), +) +GEMINI_GEMINI_4_ARGON_SERVICE_TIER_PRIORITY: Final = _gemini_case( + GEMINI_GEMINI_3_8_FLASH_SERVICE_TIER_PRIORITY, + "gemini-gemini-4-argon-service_tier_priority", + "gemini/gemini-4-argon", + "gemini-4-argon", + ExactExpected(spend=0.015444, input_cost=0.0072864, output_cost=0.0081576, prompt_tokens=1840, completion_tokens=412), +) +GEMINI_GEMINI_3_1_PRO_PREVIEW_SERVICE_TIER_PRIORITY: Final = _gemini_case( + GEMINI_GEMINI_3_8_FLASH_SERVICE_TIER_PRIORITY, + "gemini-gemini-3.1-pro-preview-service_tier_priority", + "gemini/gemini-3.1-pro-preview", + "gemini-3.1-pro-preview", + ExactExpected( + spend=0.02018016, input_cost=0.0086112, output_cost=0.01156896, prompt_tokens=1840, completion_tokens=412 + ), ) GEMINI_GEMINI_4_ARGON_TOOL_CALL: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_TOOL_CALL, "gemini-gemini-4-argon-tool_call", "gemini/gemini-4-argon", @@ -3296,28 +3377,60 @@ GEMINI_GEMINI_4_ARGON_TOOL_CALL: Final = _gemini_case( GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_INPUT_TEXT, "gemini-gemini-3.1-pro-preview-input_text", "gemini/gemini-3.1-pro-preview", "gemini-3.1-pro-preview", - ExactExpected(spend=0.0112112, input_cost=0.004784, output_cost=0.0064272, prompt_tokens=1840, completion_tokens=412) + ExactExpected(spend=0.0112112, input_cost=0.004784, output_cost=0.0064272, prompt_tokens=1840, completion_tokens=412), + usage_fields={"serviceTier": "standard"}, ) GEMINI_GEMINI_3_1_PRO_PREVIEW_STREAM: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_STREAM, "gemini-gemini-3.1-pro-preview-stream", "gemini/gemini-3.1-pro-preview", "gemini-3.1-pro-preview", - ExactExpected(spend=0.0112112, input_cost=0.004784, output_cost=0.0064272, prompt_tokens=1840, completion_tokens=412) + ExactExpected(spend=0.0112112, input_cost=0.004784, output_cost=0.0064272, prompt_tokens=1840, completion_tokens=412), + usage_fields={"serviceTier": "standard"}, ) GEMINI_GEMINI_3_1_PRO_PREVIEW_CACHE_READ: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_CACHE_READ, "gemini-gemini-3.1-pro-preview-cache_read", "gemini/gemini-3.1-pro-preview", "gemini-3.1-pro-preview", - ExactExpected(spend=0.01078688, input_cost=0.00485888, output_cost=0.005928, prompt_tokens=12928, completion_tokens=380) + ExactExpected(spend=0.01078688, input_cost=0.00485888, output_cost=0.005928, prompt_tokens=12928, completion_tokens=380), + usage_fields={"serviceTier": "standard"}, ) GEMINI_GEMINI_3_1_PRO_PREVIEW_REASONING: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_REASONING, "gemini-gemini-3.1-pro-preview-reasoning", "gemini/gemini-3.1-pro-preview", "gemini-3.1-pro-preview", - ExactExpected(spend=0.066248, input_cost=0.003224, output_cost=0.063024, prompt_tokens=1240, completion_tokens=4040) + ExactExpected(spend=0.066248, input_cost=0.003224, output_cost=0.063024, prompt_tokens=1240, completion_tokens=4040), + usage_fields={"serviceTier": "standard"}, ) GEMINI_GEMINI_3_1_PRO_PREVIEW_TOOL_CALL: Final = _gemini_case( GEMINI_GEMINI_3_8_FLASH_TOOL_CALL, "gemini-gemini-3.1-pro-preview-tool_call", "gemini/gemini-3.1-pro-preview", "gemini-3.1-pro-preview", ExactExpected(spend=0.0112112, input_cost=0.004784, output_cost=0.0064272, prompt_tokens=1840, completion_tokens=412) ) +GEMINI_GEMINI_3_1_PRO_PREVIEW_TIERED_INPUT_ABOVE_200K: Final = _gemini_case( + GEMINI_GEMINI_3_1_PRO_TIERED_INPUT_ABOVE_200K, + "gemini-gemini-3.1-pro-preview-tiered_input_above_200k", + "gemini/gemini-3.1-pro-preview", + "gemini-3.1-pro-preview", + ExactExpected( + spend=1.079468, input_cost=1.06496, output_cost=0.014508, prompt_tokens=204800, completion_tokens=620 + ), + usage_fields={"serviceTier": "standard"}, +) +GEMINI_GEMINI_3_1_PRO_PREVIEW_TIERED_CACHE_READ_ABOVE_200K: Final = _gemini_case( + GEMINI_GEMINI_3_1_PRO_TIERED_CACHE_READ_ABOVE_200K, + "gemini-gemini-3.1-pro-preview-tiered_cache_read_above_200k", + "gemini/gemini-3.1-pro-preview", + "gemini-3.1-pro-preview", + ExactExpected( + spend=0.13742976, input_cost=0.12619776, output_cost=0.011232, prompt_tokens=205824, completion_tokens=480 + ), + usage_fields={"serviceTier": "standard"}, +) +GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES: Final = bridge_pairs( + (GEMINI_GEMINI_4_ARGON_INPUT_TEXT, GEMINI_GEMINI_4_ARGON_STREAM), + ("/v1/responses", "/v1/messages"), +) +GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES: Final = bridge_pairs( + (GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_GEMINI_3_1_PRO_PREVIEW_STREAM), + ("/v1/responses", "/v1/messages"), +) CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GEMINI_GEMINI_3_1_PRO_INPUT_TEXT, @@ -3379,12 +3492,19 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GEMINI_GEMINI_4_ARGON_STREAM, GEMINI_GEMINI_4_ARGON_CACHE_READ, GEMINI_GEMINI_4_ARGON_REASONING, + GEMINI_GEMINI_4_ARGON_SERVICE_TIER_FLEX, + GEMINI_GEMINI_4_ARGON_SERVICE_TIER_PRIORITY, GEMINI_GEMINI_4_ARGON_TOOL_CALL, GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_GEMINI_3_1_PRO_PREVIEW_STREAM, GEMINI_GEMINI_3_1_PRO_PREVIEW_CACHE_READ, + GEMINI_GEMINI_3_1_PRO_PREVIEW_TIERED_INPUT_ABOVE_200K, + GEMINI_GEMINI_3_1_PRO_PREVIEW_TIERED_CACHE_READ_ABOVE_200K, GEMINI_GEMINI_3_1_PRO_PREVIEW_REASONING, GEMINI_GEMINI_3_1_PRO_PREVIEW_TOOL_CALL, + GEMINI_GEMINI_3_1_PRO_PREVIEW_SERVICE_TIER_PRIORITY, + *(pair.bridged for pair in GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES), + *(pair.bridged for pair in GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES), ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -3410,6 +3530,22 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase( plain=GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, streamed=GEMINI_GEMINI_3_1_PRO_PREVIEW_STREAM ), + StreamParityTestCase( + plain=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[0].bridged, + streamed=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[2].bridged, + ), + StreamParityTestCase( + plain=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[1].bridged, + streamed=GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES[3].bridged, + ), + StreamParityTestCase( + plain=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[0].bridged, + streamed=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[2].bridged, + ), + StreamParityTestCase( + plain=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[1].bridged, + streamed=GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[3].bridged, + ), ) BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( @@ -3422,6 +3558,6 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( ), ("/v1/responses", "/v1/messages"), ), - *bridge_pairs((GEMINI_GEMINI_4_ARGON_INPUT_TEXT,), ("/v1/responses",)), - *bridge_pairs((GEMINI_GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT,), ("/v1/responses",)), + *GEMINI_GEMINI_4_ARGON_ROUTE_BRIDGES, + *GEMINI_GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES, ) diff --git a/tests/integration/cost_calculation/cases/misc.py b/tests/integration/cost_calculation/cases/misc.py index 1a8b2fcb66b..760f77f91cc 100644 --- a/tests/integration/cost_calculation/cases/misc.py +++ b/tests/integration/cost_calculation/cases/misc.py @@ -19,13 +19,17 @@ from integration.cost_calculation.cost_tracking_case import ( RecountRates, SseResponse, WavUpload, + with_usage, ) from integration.cost_calculation.stream_parity.case import StreamParityTestCase, sse_frames +from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.cases.anthropic import ( CLAUDE_FABLE_5_1_CACHE_READ, CLAUDE_FABLE_5_1_CACHE_WRITE_1H, CLAUDE_FABLE_5_1_CACHE_WRITE_5M, CLAUDE_FABLE_5_1_INPUT_TEXT, + CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + CLAUDE_FABLE_5_1_MESSAGES_STREAM, CLAUDE_FABLE_5_1_STREAM, CLAUDE_FABLE_5_1_THINKING, CLAUDE_FABLE_5_1_TOOL_CALL, @@ -33,12 +37,30 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_HAIKU_5_5_CACHE_WRITE_1H, CLAUDE_HAIKU_5_5_CACHE_WRITE_5M, CLAUDE_HAIKU_5_5_INPUT_TEXT, + CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + CLAUDE_HAIKU_5_5_MESSAGES_STREAM, CLAUDE_HAIKU_5_5_STREAM, CLAUDE_HAIKU_5_5_THINKING, CLAUDE_HAIKU_5_5_TOOL_CALL, ) +def _azure_ai_claude_messages_case( + response_case: CostTrackingTestCase, + request_case: CostTrackingTestCase, + name: str, + model: str, +) -> CostTrackingTestCase: + return response_case.model_copy( + update={ + "name": name, + "model": model, + "endpoint": "/v1/messages", + "request": request_case.request, + } + ) + + def _azure_ai_claude_usage( input_tokens: int, output_tokens: int, @@ -2027,7 +2049,66 @@ AZURE_AI_CLAUDE_HAIKU_5_5_TOOL_CALL: Final = _azure_ai_claude_shape_case( ), ) - +AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT: Final = _azure_ai_claude_messages_case( + AZURE_AI_CLAUDE_FABLE_5_1_INPUT_TEXT, + CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + "azure_ai/claude-fable-5-1-messages_input_text", + "azure_ai/claude-fable-5-1", +) +AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM: Final = _azure_ai_claude_messages_case( + AZURE_AI_CLAUDE_FABLE_5_1_STREAM, + CLAUDE_FABLE_5_1_MESSAGES_STREAM, + "azure_ai/claude-fable-5-1-messages_stream", + "azure_ai/claude-fable-5-1", +) +AZURE_AI_CLAUDE_FABLE_5_1_ROUTE_BRIDGES: Final = bridge_pairs( + (AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM), + ("/v1/responses",), +) +AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT: Final = _azure_ai_claude_messages_case( + AZURE_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, + CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + "azure_ai/claude-haiku-5-5-messages_input_text", + "azure_ai/claude-haiku-5-5", +) +AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM: Final = _azure_ai_claude_messages_case( + AZURE_AI_CLAUDE_HAIKU_5_5_STREAM, + CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + "azure_ai/claude-haiku-5-5-messages_stream", + "azure_ai/claude-haiku-5-5", +) +AZURE_AI_CLAUDE_HAIKU_5_5_ROUTE_BRIDGES: Final = bridge_pairs( + (AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM), + ("/v1/responses",), +) +AZURE_AI_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K: Final = with_usage( + AZURE_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, + "azure_ai/claude-haiku-5-5-tiered_input_above_100k", + { + "input_tokens": 150000, + "output_tokens": 480, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + }, + ExactExpected( + spend=0.14478, input_cost=0.1425, output_cost=0.00228, prompt_tokens=150000, completion_tokens=480 + ), +) +AZURE_AI_CLAUDE_HAIKU_5_5_TIERED_CACHE_READ_ABOVE_100K: Final = with_usage( + AZURE_AI_CLAUDE_HAIKU_5_5_CACHE_READ, + "azure_ai/claude-haiku-5-5-tiered_cache_read_above_100k", + { + "input_tokens": 4096, + "output_tokens": 480, + "cache_read_input_tokens": 110592, + "cache_creation_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + }, + ExactExpected( + spend=0.01667744, input_cost=0.01439744, output_cost=0.00228, prompt_tokens=114688, completion_tokens=480 + ), +) CASES: Final[tuple[CostTrackingTestCase, ...]] = ( PERPLEXITY_SONAR_NEXT_OFF_PEAK, NOVA_NEXT_TRANSCRIPTIONS_PER_SECOND, @@ -2088,13 +2169,21 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( AZURE_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_1H, AZURE_AI_CLAUDE_FABLE_5_1_THINKING, AZURE_AI_CLAUDE_FABLE_5_1_TOOL_CALL, + AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, + *(pair.bridged for pair in AZURE_AI_CLAUDE_FABLE_5_1_ROUTE_BRIDGES), AZURE_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, AZURE_AI_CLAUDE_HAIKU_5_5_STREAM, AZURE_AI_CLAUDE_HAIKU_5_5_CACHE_READ, + AZURE_AI_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K, + AZURE_AI_CLAUDE_HAIKU_5_5_TIERED_CACHE_READ_ABOVE_100K, AZURE_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_5M, AZURE_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_1H, AZURE_AI_CLAUDE_HAIKU_5_5_THINKING, AZURE_AI_CLAUDE_HAIKU_5_5_TOOL_CALL, + AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + *(pair.bridged for pair in AZURE_AI_CLAUDE_HAIKU_5_5_ROUTE_BRIDGES), ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -2105,4 +2194,25 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase( plain=AZURE_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, streamed=AZURE_AI_CLAUDE_HAIKU_5_5_STREAM ), + StreamParityTestCase( + plain=AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + streamed=AZURE_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=AZURE_AI_CLAUDE_FABLE_5_1_ROUTE_BRIDGES[0].bridged, + streamed=AZURE_AI_CLAUDE_FABLE_5_1_ROUTE_BRIDGES[1].bridged, + ), + StreamParityTestCase( + plain=AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + streamed=AZURE_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=AZURE_AI_CLAUDE_HAIKU_5_5_ROUTE_BRIDGES[0].bridged, + streamed=AZURE_AI_CLAUDE_HAIKU_5_5_ROUTE_BRIDGES[1].bridged, + ), +) + +BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( + *AZURE_AI_CLAUDE_FABLE_5_1_ROUTE_BRIDGES, + *AZURE_AI_CLAUDE_HAIKU_5_5_ROUTE_BRIDGES, ) diff --git a/tests/integration/cost_calculation/cases/openai.py b/tests/integration/cost_calculation/cases/openai.py index 85ac05dd69d..f610fd9591a 100644 --- a/tests/integration/cost_calculation/cases/openai.py +++ b/tests/integration/cost_calculation/cases/openai.py @@ -34,6 +34,256 @@ def _stream_for_model(response: SseResponse, model: str) -> SseResponse: return response.model_copy(update={"frames": tuple(frame.replace("gpt-6-luna", model) for frame in response.frames)}) +def _responses_json_body( + model: str, + *, + include_content_filters: bool = False, + content_filters: JsonValue = None, + include_reasoning: bool = True, +) -> dict[str, JsonValue]: + reasoning_item: Final[dict[str, JsonValue]] = { + "content": [], + "encrypted_content": "$ENCRYPTED_CONTENT", + "id": "rs_$REQUEST_ID", + "summary": [], + "type": "reasoning", + } + message_item: Final[dict[str, JsonValue]] = { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "scripted response", + "type": "output_text", + } + ], + "id": "msg_$REQUEST_ID", + "phase": "final_answer", + "role": "assistant", + "status": "completed", + "type": "message", + } + output: Final = [reasoning_item, message_item] if include_reasoning else [message_item] + body: Final[dict[str, JsonValue]] = { + "access_programs": [], + "background": False, + "completed_at": 1791855453, + "created_at": 1791855452, + "error": None, + "frequency_penalty": None, + "id": "resp_$REQUEST_ID", + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "metadata": {}, + "moderation": None, + "model": model, + "object": "response", + "output": output, + "parallel_tool_calls": True, + "presence_penalty": None, + "previous_response_id": None, + "prompt_cache_key": None, + "prompt_cache_retention": None, + "reasoning": {"effort": "medium"}, + "safety_identifier": None, + "service_tier": "default", + "status": "completed", + "store": True, + "temperature": 1.0, + "text": None, + "tool_choice": "auto", + "tool_usage": None, + "tools": [], + "top_logprobs": None, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 1840, + "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": 0}, + "output_tokens": 412, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 2252, + }, + "user": None, + } + return {**body, "content_filters": content_filters} if include_content_filters else body + + +def _responses_stream_response( + model: str, + delta_count: int, + *, + include_content_filters: bool = False, + content_filters: JsonValue = None, + include_reasoning: bool = True, +) -> SseResponse: + response_body: Final = _responses_json_body( + model, + include_content_filters=include_content_filters, + content_filters=content_filters, + include_reasoning=include_reasoning, + ) + in_progress_body: Final = { + **response_body, + "output": [], + "status": "in_progress", + "usage": None, + } + reasoning_item: Final = { + "content": [], + "encrypted_content": "$ENCRYPTED_CONTENT", + "id": "rs_$REQUEST_ID", + "summary": [], + "type": "reasoning", + } + message_item: Final = { + "content": [], + "id": "msg_$REQUEST_ID", + "role": "assistant", + "status": "in_progress", + "type": "message", + } + delta_events: Final = tuple( + { + "content_index": 0, + "delta": "scripted response" if index == 0 else "", + "item_id": "msg_$REQUEST_ID", + "logprobs": [], + "obfuscation": None, + "output_index": 1 if include_reasoning else 0, + "sequence_number": (6 if include_reasoning else 4) + index, + "type": "response.output_text.delta", + } + for index in range(delta_count) + ) + delta_start: Final = 6 if include_reasoning else 4 + message_output_index: Final = 1 if include_reasoning else 0 + reasoning_events: Final = ( + ( + { + "item": reasoning_item, + "output_index": 0, + "sequence_number": 2, + "type": "response.output_item.added", + }, + { + "item": reasoning_item, + "output_index": 0, + "sequence_number": 3, + "type": "response.output_item.done", + }, + ) + if include_reasoning + else () + ) + completed_message_item: Final = { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "scripted response", + "type": "output_text", + } + ], + "id": "msg_$REQUEST_ID", + "phase": "final_answer", + "role": "assistant", + "status": "completed", + "type": "message", + } + return SseResponse( + content_type="text/event-stream", + frames=sse_frames( + { + "response": in_progress_body, + "sequence_number": 0, + "type": "response.created", + }, + { + "response": in_progress_body, + "sequence_number": 1, + "type": "response.in_progress", + }, + *reasoning_events, + { + "item": message_item, + "output_index": message_output_index, + "sequence_number": 4 if include_reasoning else 2, + "type": "response.output_item.added", + }, + { + "content_index": 0, + "item_id": "msg_$REQUEST_ID", + "output_index": message_output_index, + "part": {"annotations": [], "logprobs": [], "text": "", "type": "output_text"}, + "sequence_number": 5 if include_reasoning else 3, + "type": "response.content_part.added", + }, + *delta_events, + { + "content_index": 0, + "item_id": "msg_$REQUEST_ID", + "logprobs": [], + "output_index": message_output_index, + "sequence_number": delta_start + delta_count, + "text": "scripted response", + "type": "response.output_text.done", + }, + { + "content_index": 0, + "item_id": "msg_$REQUEST_ID", + "output_index": message_output_index, + "part": { + "annotations": [], + "logprobs": [], + "text": "scripted response", + "type": "output_text", + }, + "sequence_number": delta_start + delta_count + 1, + "type": "response.content_part.done", + }, + { + "item": completed_message_item, + "output_index": message_output_index, + "sequence_number": delta_start + delta_count + 2, + "type": "response.output_item.done", + }, + { + "response": response_body, + "sequence_number": delta_start + delta_count + 3, + "type": "response.completed", + }, + ), + ) + + +def _responses_stream_case( + base: CostTrackingTestCase, + name: str, + response_model: str, + delta_count: int, + *, + include_content_filters: bool = False, + content_filters: JsonValue = None, + include_reasoning: bool = True, +) -> CostTrackingTestCase: + return base.model_copy( + update={ + "name": name, + "request": {**base.request, "stream": True}, + "response": _responses_stream_response( + response_model, + delta_count, + include_content_filters=include_content_filters, + content_filters=content_filters, + include_reasoning=include_reasoning, + ), + } + ) + + # gpt-5.3-codex GPT_5_3_CODEX_INPUT_TEXT: Final = CostTrackingTestCase( name="gpt-5.3-codex-input_text", @@ -11356,6 +11606,31 @@ GPT_5_6_TERRA_TOOL_CALL: Final = GPT_6_LUNA_TOOL_CALL.model_copy( } ) +GPT_5_6_TERRA_TIERED_INPUT_ABOVE_272K: Final = with_usage( + GPT_5_6_TERRA_INPUT_TEXT, + "gpt-5.6-terra-tiered_input_above_272k", + { + "prompt_tokens": 300000, + "total_tokens": 300412, + "prompt_tokens_details": {"audio_tokens": 0, "cache_write_tokens": 0, "cached_tokens": 0}, + }, + ExactExpected( + spend=1.5696408, input_cost=1.56, output_cost=0.0096408, prompt_tokens=300000, completion_tokens=412 + ), +) +GPT_5_6_TERRA_TIERED_CACHE_READ_ABOVE_272K: Final = with_usage( + GPT_5_6_TERRA_INPUT_TEXT, + "gpt-5.6-terra-tiered_cache_read_above_272k", + { + "prompt_tokens": 300000, + "total_tokens": 300412, + "prompt_tokens_details": {"audio_tokens": 0, "cache_write_tokens": 0, "cached_tokens": 200000}, + }, + ExactExpected( + spend=0.6336408, input_cost=0.624, output_cost=0.0096408, prompt_tokens=300000, completion_tokens=412 + ), +) + GPT_5_6_SLASH_ALIAS: Final = GPT_5_6_INPUT_TEXT.model_copy( update={"name": "gpt-5.6-slash_alias", "alias": "openai/custom/gpt-5.6-$MARKER"} @@ -11446,6 +11721,63 @@ GPT_5_6_RESPONSES_IMAGE_GENERATION_TOOL_TOKENS: Final = CostTrackingTestCase( ), ) +GPT_6_1_SOL_RESPONSES_NATIVE_STREAM: Final = _responses_stream_case( + GPT_6_1_SOL_RESPONSES_NATIVE_JSON, + "gpt-6.1-sol-responses_native_stream", + "gpt-6.1-sol", + 1, + include_reasoning=False, +) +GPT_6_LUNA_RESPONSES_NATIVE_STREAM: Final = _responses_stream_case( + GPT_6_LUNA_RESPONSES_NATIVE_JSON, + "gpt-6-luna-responses_native_stream", + "gpt-6-luna", + 45, +) +GPT_6_ASTRA_RESPONSES_NATIVE_STREAM: Final = _responses_stream_case( + GPT_6_ASTRA_RESPONSES_NATIVE_JSON, + "gpt-6-astra-responses_native_stream", + "gpt-6-astra", + 36, +) +GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM: Final = _responses_stream_case( + GPT_5_6_TERRA_RESPONSES_NATIVE_JSON, + "gpt-5.6-terra-responses_native_stream", + "gpt-5.6-terra", + 95, +) +GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON: Final = bridge_pairs( + (GPT_5_6_TERRA_RESPONSES_NATIVE_JSON,), ("/v1/messages",) +)[0].bridged.model_copy(update={"name": "gpt-5.6-terra-messages_bridged_json"}) +GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM: Final = GPT_6_1_SOL_MESSAGES_BRIDGED_JSON.model_copy( + update={ + "name": "gpt-6.1-sol-messages_bridged_stream", + "request": {**GPT_6_1_SOL_MESSAGES_BRIDGED_JSON.request, "stream": True}, + "response": GPT_6_1_SOL_RESPONSES_NATIVE_STREAM.response, + } +) +GPT_6_LUNA_MESSAGES_BRIDGED_STREAM: Final = GPT_6_LUNA_MESSAGES_BRIDGED_JSON.model_copy( + update={ + "name": "gpt-6-luna-messages_bridged_stream", + "request": {**GPT_6_LUNA_MESSAGES_BRIDGED_JSON.request, "stream": True}, + "response": GPT_6_LUNA_RESPONSES_NATIVE_STREAM.response, + } +) +GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM: Final = GPT_6_ASTRA_MESSAGES_BRIDGED_JSON.model_copy( + update={ + "name": "gpt-6-astra-messages_bridged_stream", + "request": {**GPT_6_ASTRA_MESSAGES_BRIDGED_JSON.request, "stream": True}, + "response": GPT_6_ASTRA_RESPONSES_NATIVE_STREAM.response, + } +) +GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM: Final = GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON.model_copy( + update={ + "name": "gpt-5.6-terra-messages_bridged_stream", + "request": {**GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON.request, "stream": True}, + "response": GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM.response, + } +) + CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GPT_5_6_SLASH_ALIAS, GPT_5_6_SLASH_ALIAS_STREAM, @@ -11659,6 +11991,8 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GPT_6_LUNA_CACHE_WRITE_5M, GPT_6_ASTRA_CACHE_WRITE_5M, GPT_5_6_TERRA_INPUT_TEXT, + GPT_5_6_TERRA_TIERED_INPUT_ABOVE_272K, + GPT_5_6_TERRA_TIERED_CACHE_READ_ABOVE_272K, GPT_5_6_TERRA_STREAM, GPT_5_6_TERRA_RESPONSES_NATIVE_JSON, GPT_5_6_TERRA_RESPONSES_DEPLOYMENT_JSON, @@ -11669,6 +12003,15 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GPT_5_6_TERRA_SERVICE_TIER_FLEX, GPT_5_6_TERRA_SERVICE_TIER_PRIORITY, GPT_5_6_TERRA_TOOL_CALL, + GPT_6_1_SOL_RESPONSES_NATIVE_STREAM, + GPT_6_LUNA_RESPONSES_NATIVE_STREAM, + GPT_6_ASTRA_RESPONSES_NATIVE_STREAM, + GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM, + GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON, + GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM, + GPT_6_LUNA_MESSAGES_BRIDGED_STREAM, + GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM, + GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM, ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -11698,6 +12041,16 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase(plain=GPT_6_ASTRA_INPUT_TEXT, streamed=GPT_6_ASTRA_STREAM), StreamParityTestCase(plain=GPT_6_1_SOL_INPUT_TEXT, streamed=GPT_6_1_SOL_STREAM), StreamParityTestCase(plain=GPT_5_6_TERRA_INPUT_TEXT, streamed=GPT_5_6_TERRA_STREAM), + StreamParityTestCase(plain=GPT_6_1_SOL_RESPONSES_NATIVE_JSON, streamed=GPT_6_1_SOL_RESPONSES_NATIVE_STREAM), + StreamParityTestCase(plain=GPT_6_LUNA_RESPONSES_NATIVE_JSON, streamed=GPT_6_LUNA_RESPONSES_NATIVE_STREAM), + StreamParityTestCase(plain=GPT_6_ASTRA_RESPONSES_NATIVE_JSON, streamed=GPT_6_ASTRA_RESPONSES_NATIVE_STREAM), + StreamParityTestCase(plain=GPT_5_6_TERRA_RESPONSES_NATIVE_JSON, streamed=GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM), + StreamParityTestCase(plain=GPT_6_1_SOL_MESSAGES_BRIDGED_JSON, streamed=GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM), + StreamParityTestCase(plain=GPT_6_LUNA_MESSAGES_BRIDGED_JSON, streamed=GPT_6_LUNA_MESSAGES_BRIDGED_STREAM), + StreamParityTestCase(plain=GPT_6_ASTRA_MESSAGES_BRIDGED_JSON, streamed=GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM), + StreamParityTestCase( + plain=GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON, streamed=GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM + ), ) BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( @@ -11726,6 +12079,13 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( BridgeCostParityTestCase(native=GPT_6_LUNA_RESPONSES_NATIVE_JSON, bridged=GPT_6_LUNA_MESSAGES_BRIDGED_JSON), BridgeCostParityTestCase(native=GPT_6_ASTRA_RESPONSES_NATIVE_JSON, bridged=GPT_6_ASTRA_MESSAGES_BRIDGED_JSON), BridgeCostParityTestCase(native=GPT_6_1_SOL_RESPONSES_NATIVE_JSON, bridged=GPT_6_1_SOL_MESSAGES_BRIDGED_JSON), + BridgeCostParityTestCase(native=GPT_5_6_TERRA_RESPONSES_NATIVE_JSON, bridged=GPT_5_6_TERRA_MESSAGES_BRIDGED_JSON), + BridgeCostParityTestCase(native=GPT_6_1_SOL_RESPONSES_NATIVE_STREAM, bridged=GPT_6_1_SOL_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase(native=GPT_6_LUNA_RESPONSES_NATIVE_STREAM, bridged=GPT_6_LUNA_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase(native=GPT_6_ASTRA_RESPONSES_NATIVE_STREAM, bridged=GPT_6_ASTRA_MESSAGES_BRIDGED_STREAM), + BridgeCostParityTestCase( + native=GPT_5_6_TERRA_RESPONSES_NATIVE_STREAM, bridged=GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM + ), BridgeCostParityTestCase(native=GPT_6_SOL_RESPONSES_DEPLOYMENT_JSON, bridged=GPT_6_SOL_CHAT_BRIDGED_JSON), BridgeCostParityTestCase(native=GPT_6_LUNA_RESPONSES_DEPLOYMENT_JSON, bridged=GPT_6_LUNA_CHAT_BRIDGED_JSON), BridgeCostParityTestCase(native=GPT_6_ASTRA_RESPONSES_DEPLOYMENT_JSON, bridged=GPT_6_ASTRA_CHAT_BRIDGED_JSON), diff --git a/tests/integration/cost_calculation/cases/vertex_ai.py b/tests/integration/cost_calculation/cases/vertex_ai.py index 761078ef593..0e95eb865bf 100644 --- a/tests/integration/cost_calculation/cases/vertex_ai.py +++ b/tests/integration/cost_calculation/cases/vertex_ai.py @@ -2,6 +2,7 @@ PARITY pairs the streamed case with its plain twin where both bill the same row.""" +import json from typing import Final, cast from pydantic import JsonValue @@ -11,13 +12,19 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_FABLE_5_1_CACHE_WRITE_1H, CLAUDE_FABLE_5_1_CACHE_WRITE_5M, CLAUDE_FABLE_5_1_INPUT_TEXT, + CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + CLAUDE_FABLE_5_1_MESSAGES_STREAM, CLAUDE_FABLE_5_1_STREAM, CLAUDE_FABLE_5_1_THINKING, CLAUDE_FABLE_5_1_TOOL_CALL, + CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + CLAUDE_HAIKU_5_5_MESSAGES_STREAM, CLAUDE_OPUS_5_5_CACHE_READ, CLAUDE_OPUS_5_5_CACHE_WRITE_1H, CLAUDE_OPUS_5_5_CACHE_WRITE_5M, CLAUDE_OPUS_5_5_INPUT_TEXT, + CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, + CLAUDE_OPUS_5_5_MESSAGES_STREAM, CLAUDE_OPUS_5_5_STREAM, CLAUDE_OPUS_5_5_THINKING, CLAUDE_OPUS_5_5_TOOL_CALL, @@ -25,6 +32,8 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_SONNET_5_5_CACHE_WRITE_1H, CLAUDE_SONNET_5_5_CACHE_WRITE_5M, CLAUDE_SONNET_5_5_INPUT_TEXT, + CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT, + CLAUDE_SONNET_5_5_MESSAGES_STREAM, CLAUDE_SONNET_5_5_STREAM, CLAUDE_SONNET_5_5_THINKING, CLAUDE_SONNET_5_5_TOOL_CALL, @@ -41,26 +50,72 @@ from integration.cost_calculation.cost_tracking_case import ( from integration.cost_calculation.stream_parity.case import StreamParityTestCase, sse_frames -def _gemini_case_response(response: JsonResponse | SseResponse, model_version: str) -> JsonResponse | SseResponse: +def _gemini_case_response( + response: JsonResponse | SseResponse, + model_version: str, + usage_fields: dict[str, JsonValue] | None = None, +) -> JsonResponse | SseResponse: match response: case JsonResponse(): - return response.model_copy(update={"body": {**response.body, "modelVersion": model_version}}) + usage: Final = response.body.get("usageMetadata") + body: Final = {**response.body, "modelVersion": model_version} + return response.model_copy( + update={ + "body": { + **body, + **( + { + "usageMetadata": { + **cast(dict[str, JsonValue], usage), + **usage_fields, + } + } + if usage_fields is not None and isinstance(usage, dict) + else {} + ), + } + } + ) case SseResponse(): return response.model_copy( update={ - "frames": tuple(frame.replace("gemini-3.8-flash", model_version) for frame in response.frames) + "frames": tuple( + _gemini_case_frame(frame, model_version, usage_fields) for frame in response.frames + ) } ) +def _gemini_case_frame(frame: str, model_version: str, usage_fields: dict[str, JsonValue] | None) -> str: + replaced: Final = frame.replace("gemini-3.8-flash", model_version) + if usage_fields is None: + return replaced + prefix, separator, payload = replaced.partition("data: ") + if not separator or payload == "[DONE]": + return replaced + event: Final = cast(dict[str, JsonValue], json.loads(payload)) + usage: Final = event.get("usageMetadata") + if not isinstance(usage, dict): + return replaced + return f"{prefix}data: {json.dumps({**event, 'usageMetadata': {**usage, **usage_fields}})}" + + def _gemini_case( - sibling: CostTrackingTestCase, name: str, model: str, expected: ExactExpected + sibling: CostTrackingTestCase, + name: str, + model: str, + expected: ExactExpected, + *, + usage_fields: dict[str, JsonValue] | None = None, + model_version: str = "gemini-3.1-pro-preview", ) -> CostTrackingTestCase: return sibling.model_copy( update={ "name": name, "model": model, - "response": _gemini_case_response(cast(JsonResponse | SseResponse, sibling.response), "gemini-3.1-pro-preview"), + "response": _gemini_case_response( + cast(JsonResponse | SseResponse, sibling.response), model_version, usage_fields + ), "expected": expected, } ) @@ -97,6 +152,7 @@ def _vertex_claude_shape_case( expected: ExactExpected, response_content: list[dict[str, JsonValue]] | None = None, adaptive_thinking: bool = False, + thinking_effort: str = "medium", ) -> CostTrackingTestCase: response: Final = cast(JsonResponse, sibling.response) request: Final = ( @@ -107,7 +163,7 @@ def _vertex_claude_shape_case( if key not in {"reasoning_effort", "allowed_openai_params"} }, "thinking": {"type": "adaptive"}, - "output_config": {"effort": "medium"}, + "output_config": {"effort": thinking_effort}, "allowed_openai_params": ["thinking", "output_config"], } if adaptive_thinking @@ -1716,6 +1772,8 @@ GEMINI_3_8_FLASH_INPUT_TEXT: Final = CostTrackingTestCase( "candidatesTokenCount": 412, "totalTokenCount": 2252, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1840}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}], + "trafficType": "ON_DEMAND", }, "modelVersion": "gemini-3.8-flash", }, @@ -1770,6 +1828,8 @@ GEMINI_3_8_FLASH_CACHE_READ: Final = CostTrackingTestCase( "totalTokenCount": 13308, "cachedContentTokenCount": 12288, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12928}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 380}], + "trafficType": "ON_DEMAND", }, "modelVersion": "gemini-3.8-flash", }, @@ -2117,6 +2177,8 @@ GEMINI_3_8_FLASH_STREAM: Final = CostTrackingTestCase( "candidatesTokenCount": 412, "totalTokenCount": 2252, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1840}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}], + "trafficType": "ON_DEMAND", }, "modelVersion": "gemini-3.8-flash", }, @@ -3122,28 +3184,258 @@ VERTEX_AI_CLAUDE_FABLE_5_1_TOOL_CALL: Final = _vertex_claude_shape_case( ), ) +VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_INPUT_TEXT, + "vertex_ai/claude-haiku-5-5-input_text", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage(input_tokens=1840, output_tokens=412), + ExactExpected(spend=0.00507, input_cost=0.002392, output_cost=0.002678, prompt_tokens=1840, completion_tokens=412), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM: Final = VERTEX_AI_CLAUDE_SONNET_5_5_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-haiku-5-5-stream", + "model": "vertex_ai/claude-haiku-5-5", + "response": _vertex_claude_stream_response("claude-haiku-5-5", "end_turn", ("scripted answer",)), + "expected": VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_READ, + "vertex_ai/claude-haiku-5-5-cache_read", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage(input_tokens=14, output_tokens=6, cache_read_input_tokens=27003), + ExactExpected( + spend=0.00356759, + input_cost=0.00352859, + output_cost=0.000039, + prompt_tokens=27017, + completion_tokens=6, + cache_read_cost=0.00351039, + ), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_5M: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_WRITE_5M, + "vertex_ai/claude-haiku-5-5-cache_write_5m", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage( + input_tokens=14, output_tokens=6, cache_creation_input_tokens=27011, ephemeral_5m_input_tokens=27011 + ), + ExactExpected( + spend=0.043950075, + input_cost=0.043911075, + output_cost=0.000039, + prompt_tokens=27025, + completion_tokens=6, + cache_creation_cost=0.043892875, + ), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_1H: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_WRITE_1H, + "vertex_ai/claude-haiku-5-5-cache_write_1h", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage( + input_tokens=14, output_tokens=6, cache_creation_input_tokens=27011, ephemeral_1h_input_tokens=27011 + ), + ExactExpected( + spend=0.0702858, + input_cost=0.0702468, + output_cost=0.000039, + prompt_tokens=27025, + completion_tokens=6, + cache_creation_cost=0.0702286, + ), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_THINKING: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_THINKING, + "vertex_ai/claude-haiku-5-5-thinking", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage(input_tokens=18, output_tokens=144, thinking_tokens=37), + ExactExpected( + spend=0.0009594, input_cost=0.0000234, output_cost=0.000936, prompt_tokens=18, completion_tokens=144 + ), + adaptive_thinking=True, + thinking_effort="low", +) +VERTEX_AI_CLAUDE_HAIKU_5_5_TOOL_CALL: Final = _vertex_claude_shape_case( + VERTEX_AI_CLAUDE_SONNET_5_5_TOOL_CALL, + "vertex_ai/claude-haiku-5-5-tool_call", + "vertex_ai/claude-haiku-5-5", + _vertex_claude_usage(input_tokens=385, output_tokens=50), + ExactExpected( + spend=0.0008255, input_cost=0.0005005, output_cost=0.000325, prompt_tokens=385, completion_tokens=50 + ), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT: Final = CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-haiku-5-5-messages_input_text", + "model": "vertex_ai/claude-haiku-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT.response, + "expected": VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM: Final = CLAUDE_HAIKU_5_5_MESSAGES_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-haiku-5-5-messages_stream", + "model": "vertex_ai/claude-haiku-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM.response, + "expected": VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT.expected, + } +) + +VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT: Final = CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-1-messages_input_text", + "model": "vertex_ai/claude-fable-5-1", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.response, + "expected": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM: Final = CLAUDE_FABLE_5_1_MESSAGES_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-1-messages_stream", + "model": "vertex_ai/claude-fable-5-1", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_FABLE_5_1_STREAM.response, + "expected": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS: Final = bridge_pairs( + (VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM), + ("/v1/responses",), +) +VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT: Final = CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-opus-5-5-messages_input_text", + "model": "vertex_ai/claude-opus-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT.response, + "expected": VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM: Final = CLAUDE_OPUS_5_5_MESSAGES_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-opus-5-5-messages_stream", + "model": "vertex_ai/claude-opus-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_OPUS_5_5_STREAM.response, + "expected": VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS: Final = bridge_pairs( + (VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM), + ("/v1/responses",), +) +VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT: Final = CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-sonnet-5-5-messages_input_text", + "model": "vertex_ai/claude-sonnet-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_SONNET_5_5_INPUT_TEXT.response, + "expected": VERTEX_AI_CLAUDE_SONNET_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_STREAM: Final = CLAUDE_SONNET_5_5_MESSAGES_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-sonnet-5-5-messages_stream", + "model": "vertex_ai/claude-sonnet-5-5", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_SONNET_5_5_STREAM.response, + "expected": VERTEX_AI_CLAUDE_SONNET_5_5_INPUT_TEXT.expected, + } +) +VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS: Final = bridge_pairs( + (VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_STREAM), + ("/v1/responses",), +) +VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS: Final = bridge_pairs( + (VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM), + ("/v1/responses",), +) + GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT: Final = _gemini_case( GEMINI_3_8_FLASH_INPUT_TEXT, "vertex_ai/gemini-3.1-pro-preview-input_text", "vertex_ai/gemini-3.1-pro-preview", - ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412) + ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}], + "trafficType": "ON_DEMAND", + }, ) GEMINI_3_1_PRO_PREVIEW_STREAM: Final = _gemini_case( GEMINI_3_8_FLASH_STREAM, "vertex_ai/gemini-3.1-pro-preview-stream", "vertex_ai/gemini-3.1-pro-preview", - ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412) + ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 412}], + "trafficType": "ON_DEMAND", + }, ) GEMINI_3_1_PRO_PREVIEW_CACHE_READ: Final = _gemini_case( GEMINI_3_8_FLASH_CACHE_READ, "vertex_ai/gemini-3.1-pro-preview-cache_read", "vertex_ai/gemini-3.1-pro-preview", - ExactExpected(spend=0.00912736, input_cost=0.00411136, output_cost=0.005016, prompt_tokens=12928, completion_tokens=380) + ExactExpected(spend=0.00912736, input_cost=0.00411136, output_cost=0.005016, prompt_tokens=12928, completion_tokens=380), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 380}], + "trafficType": "ON_DEMAND", + }, ) GEMINI_3_1_PRO_PREVIEW_REASONING: Final = _gemini_case( GEMINI_3_1_PRO_REASONING, "vertex_ai/gemini-3.1-pro-preview-reasoning", "vertex_ai/gemini-3.1-pro-preview", - ExactExpected(spend=0.056056, input_cost=0.002728, output_cost=0.053328, prompt_tokens=1240, completion_tokens=4040) + ExactExpected(spend=0.056056, input_cost=0.002728, output_cost=0.053328, prompt_tokens=1240, completion_tokens=4040), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 560}], + "trafficType": "ON_DEMAND", + }, +) +GEMINI_3_8_FLASH_REASONING: Final = _gemini_case( + GEMINI_3_1_PRO_REASONING, + "vertex_ai/gemini-3.8-flash-reasoning", + "vertex_ai/gemini-3.8-flash", + ExactExpected( + spend=0.01144, + input_cost=0.0006448, + output_cost=0.0107952, + prompt_tokens=1240, + completion_tokens=4040, + ), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 560}], + "trafficType": "ON_DEMAND", + }, + model_version="gemini-3.8-flash", ) GEMINI_3_1_PRO_PREVIEW_TOOL_CALL: Final = _gemini_case( GEMINI_3_8_FLASH_TOOL_CALL, "vertex_ai/gemini-3.1-pro-preview-tool_call", "vertex_ai/gemini-3.1-pro-preview", ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412) ) +GEMINI_3_1_PRO_PREVIEW_TIERED_INPUT_ABOVE_200K: Final = _gemini_case( + GEMINI_3_1_PRO_TIERED_INPUT_ABOVE_200K, + "vertex_ai/gemini-3.1-pro-preview-tiered_input_above_200k", + "vertex_ai/gemini-3.1-pro-preview", + ExactExpected( + spend=0.913396, input_cost=0.90112, output_cost=0.012276, prompt_tokens=204800, completion_tokens=620 + ), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 620}], + "trafficType": "ON_DEMAND", + }, +) +GEMINI_3_1_PRO_PREVIEW_TIERED_CACHE_READ_ABOVE_200K: Final = _gemini_case( + GEMINI_3_1_PRO_TIERED_CACHE_READ_ABOVE_200K, + "vertex_ai/gemini-3.1-pro-preview-tiered_cache_read_above_200k", + "vertex_ai/gemini-3.1-pro-preview", + ExactExpected( + spend=0.11628672, input_cost=0.10678272, output_cost=0.009504, prompt_tokens=205824, completion_tokens=480 + ), + usage_fields={ + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 480}], + "trafficType": "ON_DEMAND", + "cacheTokensDetails": [{"modality": "TEXT", "tokenCount": 201728}], + }, +) + GEMINI_4_ARGON_INPUT_TEXT: Final = GEMINI_3_8_FLASH_INPUT_TEXT.model_copy( update={ "name": "vertex_ai/gemini-4-argon-input_text", @@ -3301,6 +3593,15 @@ GEMINI_4_ARGON_TOOL_CALL: Final = GEMINI_3_8_FLASH_TOOL_CALL.model_copy( } ) +GEMINI_4_ARGON_ROUTE_BRIDGES: Final = bridge_pairs( + (GEMINI_4_ARGON_INPUT_TEXT, GEMINI_4_ARGON_STREAM), + ("/v1/responses", "/v1/messages"), +) +GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES: Final = bridge_pairs( + (GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_3_1_PRO_PREVIEW_STREAM), + ("/v1/responses", "/v1/messages"), +) + CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GEMINI_3_1_PRO_INPUT_TEXT, GEMINI_3_1_PRO_CACHE_READ, @@ -3326,6 +3627,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GEMINI_3_1_PRO_STREAM_TOOL_CALL, GEMINI_3_1_PRO_STREAM_FULL_USAGE, GEMINI_3_8_FLASH_INPUT_TEXT, + GEMINI_3_8_FLASH_REASONING, GEMINI_3_8_FLASH_CACHE_READ, GEMINI_3_8_FLASH_SERVICE_TIER_FLEX, GEMINI_3_8_FLASH_SERVICE_TIER_PRIORITY, @@ -3367,9 +3669,30 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_1H, VERTEX_AI_CLAUDE_FABLE_5_1_THINKING, VERTEX_AI_CLAUDE_FABLE_5_1_TOOL_CALL, + VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, + VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM, + VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ, + VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_5M, + VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_WRITE_1H, + VERTEX_AI_CLAUDE_HAIKU_5_5_THINKING, + VERTEX_AI_CLAUDE_HAIKU_5_5_TOOL_CALL, + VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + *(pair.bridged for pair in VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS), + VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, + *(pair.bridged for pair in VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS), + VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, + VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM, + *(pair.bridged for pair in VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS), + VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT, + VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_STREAM, + *(pair.bridged for pair in VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS), GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, GEMINI_3_1_PRO_PREVIEW_STREAM, GEMINI_3_1_PRO_PREVIEW_CACHE_READ, + GEMINI_3_1_PRO_PREVIEW_TIERED_INPUT_ABOVE_200K, + GEMINI_3_1_PRO_PREVIEW_TIERED_CACHE_READ_ABOVE_200K, GEMINI_3_1_PRO_PREVIEW_REASONING, GEMINI_3_1_PRO_PREVIEW_TOOL_CALL, GEMINI_4_ARGON_INPUT_TEXT, @@ -3377,6 +3700,8 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GEMINI_4_ARGON_CACHE_READ, GEMINI_4_ARGON_REASONING, GEMINI_4_ARGON_TOOL_CALL, + *(pair.bridged for pair in GEMINI_4_ARGON_ROUTE_BRIDGES), + *(pair.bridged for pair in GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES), ) PARITY: Final[tuple[StreamParityTestCase, ...]] = ( @@ -3397,8 +3722,59 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( ), StreamParityTestCase(plain=VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_OPUS_5_5_STREAM), StreamParityTestCase(plain=VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_FABLE_5_1_STREAM), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, + streamed=VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS[0].bridged, + streamed=VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + streamed=VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS[0].bridged, + streamed=VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, + streamed=VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS[0].bridged, + streamed=VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS[1].bridged, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT, + streamed=VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_STREAM, + ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS[0].bridged, + streamed=VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS[1].bridged, + ), StreamParityTestCase(plain=GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT, streamed=GEMINI_3_1_PRO_PREVIEW_STREAM), StreamParityTestCase(plain=GEMINI_4_ARGON_INPUT_TEXT, streamed=GEMINI_4_ARGON_STREAM), + StreamParityTestCase( + plain=GEMINI_4_ARGON_ROUTE_BRIDGES[0].bridged, + streamed=GEMINI_4_ARGON_ROUTE_BRIDGES[2].bridged, + ), + StreamParityTestCase( + plain=GEMINI_4_ARGON_ROUTE_BRIDGES[1].bridged, + streamed=GEMINI_4_ARGON_ROUTE_BRIDGES[3].bridged, + ), + StreamParityTestCase( + plain=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[0].bridged, + streamed=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[2].bridged, + ), + StreamParityTestCase( + plain=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[1].bridged, + streamed=GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES[3].bridged, + ), ) BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( @@ -3406,6 +3782,10 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( (GEMINI_3_8_FLASH_INPUT_TEXT, GEMINI_3_8_FLASH_CACHE_READ, GEMINI_3_8_FLASH_STREAM), ("/v1/responses", "/v1/messages"), ), - *bridge_pairs((GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT,), ("/v1/responses",)), - *bridge_pairs((GEMINI_4_ARGON_INPUT_TEXT,), ("/v1/responses",)), + *GEMINI_3_1_PRO_PREVIEW_ROUTE_BRIDGES, + *GEMINI_4_ARGON_ROUTE_BRIDGES, + *VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS, + *VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS, + *VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS, + *VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS, ) diff --git a/tests/integration/cost_calculation/cost_map.py b/tests/integration/cost_calculation/cost_map.py index 2ef42249a7e..ba7be7647da 100644 --- a/tests/integration/cost_calculation/cost_map.py +++ b/tests/integration/cost_calculation/cost_map.py @@ -889,6 +889,26 @@ COST_MAP_ENTRIES: Final[dict[str, dict[str, JsonValue]]] = { "max_output_tokens": 128000, "max_tokens": 128000, }, + "vertex_ai/claude-haiku-5-5": { + "litellm_provider": "vertex_ai-anthropic_models", + "mode": "chat", + "input_cost_per_token": 1.3e-06, + "input_cost_per_token_above_100k_tokens": 6.5e-06, + "output_cost_per_token": 6.5e-06, + "output_cost_per_token_above_100k_tokens": 3.25e-05, + "cache_creation_input_token_cost": 1.625e-06, + "cache_creation_input_token_cost_above_1hr": 2.6e-06, + "cache_creation_input_token_cost_above_100k_tokens": 8.125e-06, + "cache_creation_input_token_cost_above_1hr_above_100k_tokens": 1.3e-05, + "cache_read_input_token_cost": 1.3e-07, + "cache_read_input_token_cost_above_100k_tokens": 6.5e-07, + "supports_function_calling": True, + "supports_prompt_caching": True, + "supports_reasoning": True, + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + }, "groq/qwen/qwen3.8-27b": { "litellm_provider": "groq", "mode": "chat", diff --git a/tests/integration/cost_calculation/test_responses_websocket_guardrail_spend.py b/tests/integration/cost_calculation/test_responses_websocket_guardrail_spend.py new file mode 100644 index 00000000000..8af70d39186 --- /dev/null +++ b/tests/integration/cost_calculation/test_responses_websocket_guardrail_spend.py @@ -0,0 +1,168 @@ +"""A native Responses WebSocket runs the proxy's pre_call guardrails on every ``response.create`` frame, bills each +turn's spend row with the guardrail cost of that turn alone, and bills a blocked frame's guardrail scan as a failed +turn (LIT-9744)""" + +import asyncio +import hashlib +import json +import uuid +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import pytest +import websockets +import yaml +from integration._support.client import Gateway, eventually, gateway_from_environment +from integration._support.database import read_rows +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.responses_ws_peer import PROVIDER_MODEL, USAGE, create, responses_peer, turn +from integration._support.tls import write_self_signed_cert +from pydantic import JsonValue + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +_INPUT_COST_PER_TOKEN: Final = 0.002 +_OUTPUT_COST_PER_TOKEN: Final = 0.004 +_COST_PER_WORD: Final = 0.1 +_MARKER: Final = " [guarded]" +_BLOCKED_WORD: Final = "forbidden" +_GUARDRAIL_MODULE: Final = f""" +from litellm.integrations.custom_guardrail import CustomGuardrail + + +class TurnGuardrail(CustomGuardrail): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + blocks = [block for item in data.get("input") or [] for block in item.get("content") or []] + words = sum(len(block["text"].split()) for block in blocks) + blocked = any({_BLOCKED_WORD!r} in block["text"] for block in blocks) + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={{"words": words}}, + request_data=data, + guardrail_status="guardrail_intervened" if blocked else "success", + tracing_detail={{"guardrail_cost": round({_COST_PER_WORD} * words, 10)}}, + ) + if blocked: + raise ValueError("turn guardrail blocked the request") + for block in blocks: + block["text"] = block["text"] + {_MARKER!r} + return data +""" + + +@dataclass(frozen=True, slots=True) +class _Rig: + gateway: Gateway + cert: tuple[Path, Path] + + +def _config(directory: Path) -> Path: + (directory / "turn_guardrail.py").write_text(_GUARDRAIL_MODULE) + configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + configuration["guardrails"] = [ + { + "guardrail_name": "turn-guardrail", + "litellm_params": {"guardrail": "turn_guardrail.TurnGuardrail", "mode": "pre_call", "default_on": True}, + } + ] + path: Final = directory / "responses-ws-guardrail.yaml" + path.write_text(yaml.safe_dump(configuration)) + return path + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("responses-ws-guardrail") + cert: Final = write_self_signed_cert(directory) + with ( + gateway_from_environment() as shared, + owned_proxy(shared, directory, {"SSL_CERT_FILE": str(cert[0])}, config=_config(directory)) as owned, + ): + yield _Rig(owned, cert) + + +def _nested(frame: str) -> str: + """The ``{"type": "response.create", "response": {...}}`` wire shape the WS layer accepts next to the flat one.""" + flat: Final = json.loads(frame) + return json.dumps({"type": flat["type"], "response": {key: value for key, value in flat.items() if key != "type"}}) + + +async def _session( + proxy_url: str, key: str, model: str, frames: tuple[str, ...] +) -> tuple[tuple[dict[str, JsonValue], ...], ...]: + proxy: Final = proxy_url.rstrip("/").replace("http://", "ws://") + async with websockets.connect( + f"{proxy}/v1/responses?model={model}", + additional_headers={"Authorization": f"Bearer {key}"}, + open_timeout=10, + ) as connection: + return tuple([await turn(connection, frame) for frame in frames]) + + +def _spend_rows(key: str) -> list[dict[str, JsonValue]]: + return read_rows( + 'SELECT spend, status FROM "LiteLLM_SpendLogs" WHERE api_key = %s ORDER BY "startTime"', + (hashlib.sha256(key.encode()).hexdigest(),), + ) + + +def _key_spend(key: str) -> float: + (row,) = read_rows( + 'SELECT spend FROM "LiteLLM_VerificationToken" WHERE token = %s', (hashlib.sha256(key.encode()).hexdigest(),) + ) + return float(row["spend"]) + + +def _llm_cost() -> float: + return USAGE["input_tokens"] * _INPUT_COST_PER_TOKEN + USAGE["output_tokens"] * _OUTPUT_COST_PER_TOKEN + + +def test_every_turn_runs_the_pre_call_guardrail_and_bills_only_its_own_guardrail_cost(rig: _Rig) -> None: + with responses_peer(rig.cert) as peer, rig.gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"openai/{PROVIDER_MODEL}", + api_base=peer.url, + input_cost_per_token=_INPUT_COST_PER_TOKEN, + output_cost_per_token=_OUTPUT_COST_PER_TOKEN, + ) + key: Final = scenario.key(models=[model]) + tag: Final = uuid.uuid4().hex + texts: Final = ( + f"one {tag}", + f"one two {tag}", + f"one two three {tag}", + f"{_BLOCKED_WORD} {tag}", + f"one {tag}", + f"{_BLOCKED_WORD} again {tag}", + ) + frames: Final = tuple(create(model, text) for text in texts[:-1]) + (_nested(create(model, texts[-1])),) + turns: Final = asyncio.run(_session(str(rig.gateway.client.base_url), key, model, frames)) + + assert [events[-1]["type"] for events in turns] == [ + "response.completed", + "response.completed", + "response.completed", + "error", + "response.completed", + "error", + ], [[event["type"] for event in events] for events in turns] + assert "blocked" in str(turns[3][-1]["error"]), turns[3] + + forwarded: Final = tuple(peer.frames.get_nowait() for _ in range(peer.frames.qsize())) + assert [frame["input"][0]["content"][0]["text"] for frame in forwarded] == [ + f"{text}{_MARKER}" for text in texts if _BLOCKED_WORD not in text + ], forwarded + + rows: Final = eventually(lambda: _spend_rows(key), lambda values: len(values) == 6, seconds=70) + assert [row["status"] for row in rows] == ["success", "success", "success", "failure", "success", "failure"], ( + rows + ) + expected: Final = [ + (0.0 if _BLOCKED_WORD in text else _llm_cost()) + _COST_PER_WORD * len(text.split()) for text in texts + ] + assert [float(row["spend"]) for row in rows] == pytest.approx(expected), rows + total: Final = sum(expected) + assert eventually( + lambda: _key_spend(key), lambda spend: abs(spend - total) < 1e-6, seconds=70 + ) == pytest.approx(total) diff --git a/tests/integration/database/test_harness_lifecycle.py b/tests/integration/database/test_harness_lifecycle.py index 60bd7ae72a3..d600a92c5a2 100644 --- a/tests/integration/database/test_harness_lifecycle.py +++ b/tests/integration/database/test_harness_lifecycle.py @@ -784,9 +784,16 @@ def test_worker_crash_keeps_the_original_junit_identity_and_an_incomplete_execut assert cases[0].find("error") is not None -@pytest.mark.parametrize("assignment", ("absent", "missing", "foreign")) +@pytest.mark.parametrize( + ("assignment", "exit_code"), + ( + ("absent", pytest.ExitCode.USAGE_ERROR), + ("missing", pytest.ExitCode.USAGE_ERROR), + ("foreign", pytest.ExitCode.INTERRUPTED), + ), +) def test_invalid_native_selections_fail_before_any_case_runs( - tmp_path: Path, assignment: Literal["absent", "missing", "foreign"] + tmp_path: Path, assignment: Literal["absent", "missing", "foreign"], exit_code: pytest.ExitCode ) -> None: root: Final = Path(__file__).resolve().parents[3] events: Final = tmp_path / "executed.txt" @@ -823,7 +830,7 @@ def test_invalid_native_selections_fail_before_any_case_runs( timeout=15, check=False, ) - assert result.returncode == pytest.ExitCode.USAGE_ERROR, result.stdout + result.stderr + assert result.returncode == exit_code, result.stdout + result.stderr assert not events.exists() diff --git a/tests/integration/management/test_scim_admin_group.py b/tests/integration/management/test_scim_admin_group.py index 1c0cf42ad13..b7bf3cfe968 100644 --- a/tests/integration/management/test_scim_admin_group.py +++ b/tests/integration/management/test_scim_admin_group.py @@ -661,10 +661,6 @@ def test_scim_admin_group_put_members_promotes_and_demotes(gateway: Gateway, tmp @pytest.mark.timeout(300) def test_scim_group_put_returns_the_updated_members(gateway: Gateway, tmp_path: Path) -> None: - pytest.skip( - "BUG: PUT /scim/v2/Groups/{id} returns the members list from before the update, so the 200 body omits " - "the members it just added" - ) group_name: Final = f"scim-admin-put-response-{uuid.uuid4().hex}" with _scim_admin_group_fixture(gateway, tmp_path, group_name) as fixture: expected_member: Final = ScimGroupMember( diff --git a/tests/integration/mcp/test_mcp_oauth_flows.py b/tests/integration/mcp/test_mcp_oauth_flows.py index 6f59f694352..563d35bf794 100644 --- a/tests/integration/mcp/test_mcp_oauth_flows.py +++ b/tests/integration/mcp/test_mcp_oauth_flows.py @@ -697,6 +697,54 @@ def test_per_user_authorization_code_with_pkce_binds_the_token_to_the_authorizin ) +@pytest.mark.timeout(600) +def test_per_user_authorization_code_token_is_read_back_from_the_database_by_a_fresh_proxy_without_redis( + gateway: Gateway, tmp_path: Path +) -> None: + with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario: + alias: Final = "cold" + uuid.uuid4().hex[:8] + identity: Final = _register_oauth( + scenario, + peer, + auth, + alias, + auth_type="oauth2", + oauth2_flow="authorization_code", + credentials={"client_id": "cold-client", "client_secret": "cold-secret", "scopes": ["tools.call"]}, + ) + owner_id: Final = scenario.user() + owner: Final = scenario.key(user_id=owner_id, object_permission={"mcp_servers": [identity]}) + registered: Final = gateway.client.post( + f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"} + ) + assert registered.status_code in (200, 201), registered.text + client_id: Final = str(_response_object(registered)["client_id"]) + pkce: Final = _Pkce(secrets.token_urlsafe(32)) + code, _ = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce) + redeemed: Final = _redeem(gateway, alias, owner, client_id, code, pkce) + assert redeemed.status_code == 200, redeemed.text + token: Final = _issued_token(redeemed.json()) + stored: Final = read_rows( + 'SELECT credential_b64 FROM "LiteLLM_MCPUserCredentials" WHERE user_id = %s AND server_id = %s', + (owner_id, identity), + ) + assert len(stored) == 1, stored + stored_bytes: Final = base64.urlsafe_b64decode(str(stored[0]["credential_b64"])) + assert token.encode() not in stored_bytes, "stored token is plaintext" + config: Final = tmp_path / "no_redis.yaml" + config.write_text( + "general_settings:\n" + " master_key: os.environ/LITELLM_MASTER_KEY\n" + " database_url: os.environ/DATABASE_URL\n" + " store_model_in_db: true\n" + ) + peer.drain() + with owned_proxy(gateway, tmp_path, {}, config=config, remove_environment=("REDIS_HOST", "REDIS_PORT")) as cold: + called: Final = call_tool(cold, owner, identity, f"{alias}-add", ADD) + assert called.status_code == 200, called.text + assert _authorizations(peer) == (f"Bearer {token}".encode(),) + + def test_authorization_request_without_pkce_is_refused_before_reaching_the_authorization_server( gateway: Gateway, ) -> None: diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_count_tokens_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_count_tokens_wire.py index 92512a304bc..1009a954bee 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_count_tokens_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_count_tokens_wire.py @@ -104,7 +104,7 @@ def test_anthropic_count_tokens_forwards_body_and_refuses_a_key_without_model_ac client: Final = _client(gateway, gateway.key) result: Final = _sdk_count(client, model, spelling, _COUNT_BETA) - assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS} + assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS, "litellm_count_source": "provider"} requests: Final = wire.drain() assert len(requests) == 1, requests @@ -236,5 +236,5 @@ def test_anthropic_count_tokens_merges_the_client_beta_with_the_token_counting_b num_retries=0, ) result: Final = _sdk_count(_client(gateway, gateway.key), model, spelling, _CLIENT_BETA) - assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS} + assert result.model_dump() == {"input_tokens": _COUNTED_INPUT_TOKENS, "litellm_count_source": "provider"} assert len(wire.drain()) == 1 diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py index 8a79d5c6a7a..b13c34fe2d6 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_gateway_wire.py @@ -245,7 +245,10 @@ def test_enabled_gateway_count_tokens_alias_matches_native_count_tokens_route(en "/claude_code_gateway/v1/messages/count_tokens?beta=true", json=body, headers=headers ) assert gateway_response.status_code == 200, gateway_response.text - assert _JSON_OBJECT.validate_json(gateway_response.content) == {"input_tokens": 41}, gateway_response.text + assert _JSON_OBJECT.validate_json(gateway_response.content) == { + "input_tokens": 41, + "litellm_count_source": "provider", + }, gateway_response.text native_response: Final = enabled_gateway.client.post( "/v1/messages/count_tokens?beta=true", json=body, headers=headers ) diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_chaos.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_chaos.py new file mode 100644 index 00000000000..e2f23b7558f --- /dev/null +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_chaos.py @@ -0,0 +1,348 @@ +import asyncio +import json +import re +import threading +import uuid +from dataclasses import dataclass +from pathlib import Path +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final +from urllib.parse import urlsplit + +import httpx +import psutil +import pytest +import yaml +from integration._support.client import Gateway, eventually +from integration._support.database import read_rows +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from integration.messages_endpoint.providers.anthropic.test_anthropic_safeguards_beta_wire import ( + ANTHROPIC_KEY, + ANTHROPIC_MODEL, + BETA, + OMITTED, + anthropic_peer, + marker_of, + messages_body, + stream_frames, +) +from pydantic import JsonValue, TypeAdapter + +_CONFIG_MODEL: Final = "anthropic-safeguards-chaos" +_FOUNDRY_CONFIG_MODEL: Final = "azure-ai-safeguards-chaos" +_YAML_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})") +_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") +_MODEL_LIST: Final = json.dumps( + {"object": "list", "data": [{"id": ANTHROPIC_MODEL, "object": "model", "owned_by": "anthropic"}]} +).encode() + + +@dataclass(frozen=True, slots=True) +class _Call: + stream: bool + marker: str + safeguards: bool = True + + +@dataclass(frozen=True, slots=True) +class _Served: + call: _Call + status: int + text: str + + +def _calls(count: int, stream: bool, safeguards: bool = True) -> tuple[_Call, ...]: + return tuple(_Call(stream=stream, marker=uuid.uuid4().hex, safeguards=safeguards) for _ in range(count)) + + +def _streamed_reply(marker: str, abort_after: int | None = None, pause: float = 0) -> Reply: + return Reply( + content_type="text/event-stream", + chunks=stream_frames(marker), + abort_after=abort_after, + pause_between_chunks=pause, + ) + + +async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served: + async with client.stream( + "POST", + "/v1/messages", + json=( + messages_body(model, call.marker, stream=call.stream) + if call.safeguards + else messages_body(model, call.marker, stream=call.stream, safeguards=OMITTED) + ), + headers={"Authorization": f"Bearer {key}", "anthropic-version": "2023-06-01"}, + ) as response: + raw: Final = await response.aread() + return _Served(call=call, status=response.status_code, text=raw.decode()) + + +async def _burst( + base_url: str, key: str, model: str, calls: tuple[_Call, ...], *, tolerate_transport_errors: bool = False +) -> tuple[_Served, ...]: + async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client: + results: Final = await asyncio.gather( + *(_send(client, key, model, call) for call in calls), return_exceptions=tolerate_transport_errors + ) + for result in results: + assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result) + return tuple(result for result in results if isinstance(result, _Served)) + + +def _assert_answered_with_its_own_marker(served: _Served) -> None: + assert served.status == 200, served.text + assert set(_MARKER.findall(served.text)) == {served.call.marker}, served.text + assert f"msg_{served.call.marker}" in served.text, served.text + if served.call.stream: + assert "event: message_stop" in served.text, served.text + + +def _assert_every_request_carried_the_beta(received: tuple[Request, ...], markers: frozenset[str]) -> None: + assert sorted(marker_of(request) for request in received) == sorted(markers) + missing: Final = [ + frozenset(request.headers) for request in received if request.headers.get("anthropic-beta") != BETA + ] + assert missing == [], missing + + +def _spend_success_ids(model: str, expected: frozenset[str]) -> None: + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE model_group=%s AND status=%s', (model, "success") + ), + lambda found: expected <= {str(row["request_id"]) for row in found}, + seconds=70, + ) + identities: Final = [row["request_id"] for row in rows] + assert len(set(identities)) == len(identities), identities + + +async def test_burst_with_aborted_streams_keeps_the_beta_on_every_forwarded_request(gateway: Gateway) -> None: + calls: Final = tuple(_Call(stream=index % 2 == 0, marker=uuid.uuid4().hex) for index in range(30)) + aborted: Final = frozenset(call.marker for index, call in enumerate(calls) if call.stream and index % 3 == 0) + + def respond(request: Request) -> Reply: + marker: Final = marker_of(request) + if marker in aborted: + return _streamed_reply(marker, abort_after=0) + return anthropic_peer(request) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls) + assert len(served) == 30 + for item in served: + if item.call.marker in aborted: + assert item.status == 500, item.text + assert '"type":"api_error"' in item.text and "marker-" not in item.text, item.text + else: + _assert_answered_with_its_own_marker(item) + recovery: Final = _Call(stream=True, marker=uuid.uuid4().hex) + (recovered,) = await _burst(str(gateway.client.base_url), gateway.key, model, (recovery,)) + _assert_answered_with_its_own_marker(recovered) + _assert_every_request_carried_the_beta(wire.drain(), frozenset(call.marker for call in (*calls, recovery))) + answered: Final = frozenset(f"msg_{call.marker}" for call in (*calls, recovery) if call.marker not in aborted) + _spend_success_ids(model, answered) + + +async def test_slow_streams_complete_with_the_beta_on_every_request(gateway: Gateway) -> None: + calls: Final = _calls(10, stream=True) + with ( + wire_server(lambda request: _streamed_reply(marker_of(request), pause=0.3)) as wire, + gateway.scenario() as scenario, + ): + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls) + assert len(served) == 10 + for item in served: + _assert_answered_with_its_own_marker(item) + _assert_every_request_carried_the_beta(wire.drain(), frozenset(call.marker for call in calls)) + _spend_success_ids(model, frozenset(f"msg_{call.marker}" for call in calls)) + + +def _anthropic_deployment(wire: Wire) -> dict[str, JsonValue]: + return { + "model_name": _CONFIG_MODEL, + "litellm_params": {"model": f"anthropic/{ANTHROPIC_MODEL}", "api_base": wire.url, "api_key": ANTHROPIC_KEY}, + } + + +def _foundry_deployment(wire: Wire) -> dict[str, JsonValue]: + return { + "model_name": _FOUNDRY_CONFIG_MODEL, + "litellm_params": { + "model": "azure_ai/claude-sonnet-4-6", + "api_base": wire.url, + "api_key": "synthetic-foundry-key", + }, + } + + +def _chaos_config(tmp_path: Path, *deployments: dict[str, JsonValue]) -> Path: + base: Final = _YAML_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + config: Final = {**base, "model_list": list(deployments)} + path: Final = tmp_path / "anthropic-safeguards-chaos.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _open_upstream_connections(pid: int, upstream: str) -> int: + port: Final = urlsplit(upstream).port + return sum( + 1 + for connection in psutil.Process(pid).net_connections(kind="tcp") + if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port + ) + + +@pytest.mark.timeout(180) +async def test_worker_sigkill_mid_burst_leaves_the_sibling_sending_the_beta(gateway: Gateway, tmp_path: Path) -> None: + calls: Final = _calls(20, stream=False) + release: Final = threading.Event() + held_markers: Final[SimpleQueue[str]] = SimpleQueue() + + def held(request: Request) -> Reply: + if (request.method, request.target) == ("GET", "/v1/models"): + return Reply(body=_MODEL_LIST) + held_markers.put(marker_of(request)) + assert release.wait(timeout=60), "The burst was never released" + return anthropic_peer(request) + + with wire_server(held) as wire: + path: Final = _chaos_config(tmp_path, _anthropic_deployment(wire)) + with owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned: + candidate: Final = owned.gateway + workers: Final = eventually( + lambda: tuple(int(found.group(1)) for found in _STARTED_WORKER.finditer(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + burst: Final = asyncio.create_task( + _burst( + str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, calls, tolerate_transport_errors=True + ) + ) + await asyncio.to_thread(eventually, held_markers.qsize, lambda size: size == 20, 60) + held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers}) + assert sum(held_by.values()) == 20, held_by + victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__) + with owned.replacing_worker(victim_pid) as kill: + kill.freeze() + await asyncio.to_thread(kill) + release.set() + served: Final = await burst + assert held_by[survivor_pid] >= 10, held_by + assert len(served) == held_by[survivor_pid], (held_by, len(served)) + for item in served: + _assert_answered_with_its_own_marker(item) + follow_up: Final = _Call(stream=False, marker=uuid.uuid4().hex) + (answered,) = await _burst(str(candidate.client.base_url), candidate.key, _CONFIG_MODEL, (follow_up,)) + _assert_answered_with_its_own_marker(answered) + received: Final = wire.drain() + posts: Final = tuple(request for request in received if request.method == "POST") + probes: Final[list[tuple[str, str]]] = [ + (request.method, request.target) for request in received if request.method != "POST" + ] + assert set(probes) <= {("GET", "/v1/models")}, probes + _assert_every_request_carried_the_beta(posts, frozenset(call.marker for call in (*calls, follow_up))) + + +def _beta_map_gets(received: tuple[Request, ...]) -> int: + return sum(1 for request in received if (request.method, request.target) == ("GET", "/beta.json")) + + +async def _chat(base_url: str, key: str, marker: str) -> httpx.Response: + async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client: + return await client.post( + "/v1/chat/completions", + json={ + "model": _CONFIG_MODEL, + "max_tokens": 16, + "messages": [{"role": "user", "content": f"Question marker-{marker}"}], + }, + headers={"Authorization": f"Bearer {key}"}, + ) + + +async def _answered_while_the_beta_map_is_held( + base_url: str, key: str, guarded: tuple[_Call, ...], chat_markers: tuple[str, ...] +) -> None: + served: Final = await _burst(base_url, key, _CONFIG_MODEL, guarded) + chats: Final = await asyncio.gather(*(_chat(base_url, key, marker) for marker in chat_markers)) + for item in served: + _assert_answered_with_its_own_marker(item) + for marker, chat in zip(chat_markers, chats, strict=True): + assert chat.status_code == 200, chat.text + assert f"marker-{marker}" in chat.text, chat.text + + +@pytest.mark.timeout(240) +async def test_cold_workers_fetch_the_beta_map_only_for_filtered_safeguards_requests_off_the_shared_threads( + gateway: Gateway, tmp_path: Path +) -> None: + beta_map: Final = Path("litellm/anthropic_beta_headers_config.json").read_bytes() + release: Final = threading.Event() + + def held_beta_map(_: Request) -> Reply: + assert release.wait(timeout=180), "The beta map was never released" + return Reply(body=beta_map) + + def upstream(request: Request) -> Reply: + if (request.method, request.target) == ("GET", "/v1/models"): + return Reply(body=_MODEL_LIST) + return anthropic_peer(request) + + plain: Final = _calls(8, stream=False, safeguards=False) + plain_foundry: Final = _calls(8, stream=False, safeguards=False) + direct: Final = _calls(8, stream=False) + foundry: Final = _calls(80, stream=False) + direct_while_held: Final = _calls(8, stream=False) + chat_markers: Final = tuple(uuid.uuid4().hex for _ in range(4)) + with wire_server(held_beta_map) as beta_map_wire, wire_server(upstream) as wire: + path: Final = _chaos_config(tmp_path, _anthropic_deployment(wire), _foundry_deployment(wire)) + with owned_proxy_process( + gateway, + tmp_path, + {"LITELLM_ANTHROPIC_BETA_HEADERS_URL": f"{beta_map_wire.url}/beta.json"}, + remove_environment=("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS",), + config=path, + workers=2, + ) as owned: + base_url: Final = str(owned.gateway.client.base_url) + key: Final = owned.gateway.key + cold: Final = ( + *await _burst(base_url, key, _CONFIG_MODEL, plain), + *await _burst(base_url, key, _FOUNDRY_CONFIG_MODEL, plain_foundry), + *await _burst(base_url, key, _CONFIG_MODEL, direct), + ) + for served_cold in cold: + _assert_answered_with_its_own_marker(served_cold) + assert beta_map_wire.received.qsize() == 0, "a request that never filters betas fetched the beta map" + + held: Final = asyncio.create_task(_burst(base_url, key, _FOUNDRY_CONFIG_MODEL, foundry)) + try: + await asyncio.to_thread(eventually, beta_map_wire.received.qsize, lambda size: size >= 1, 60) + await asyncio.wait_for( + _answered_while_the_beta_map_is_held(base_url, key, direct_while_held, chat_markers), timeout=45 + ) + assert not held.done(), "Foundry safeguards requests were answered before the beta map loaded" + finally: + release.set() + for served_foundry in await held: + _assert_answered_with_its_own_marker(served_foundry) + + received: Final = tuple(request for request in wire.drain() if request.method == "POST") + beta_by_marker: Final = MappingProxyType( + {marker_of(request): request.headers.get("anthropic-beta") for request in received} + ) + assert {beta_by_marker[call.marker] for call in (*direct, *direct_while_held, *foundry)} == {BETA}, ( + beta_by_marker + ) + assert {beta_by_marker[call.marker] for call in (*plain, *plain_foundry)} == {None}, beta_by_marker + fetches: Final = _beta_map_gets(beta_map_wire.drain()) + assert 1 <= fetches <= 2, fetches diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py new file mode 100644 index 00000000000..0c41efffe73 --- /dev/null +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py @@ -0,0 +1,809 @@ +import itertools +import json +import re +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import anthropic +import httpx +import openai +import pytest +from integration._support.client import Gateway, eventually +from integration._support.database import read_rows +from integration._support.process import owned_proxy +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +BETA: Final = "dangerous-tool-use-2026-09-03" +SAFEGUARDS: Final[list[JsonValue]] = [ + {"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}} +] +ANTHROPIC_MODEL: Final = "claude-sonnet-4-5-20250929" +ANTHROPIC_KEY: Final = "synthetic-anthropic-key" +_CLAUDE_CODE_BETAS: Final = ( + "claude-code-20250219", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", + "context-management-2025-06-27", +) +_CLIENT_BETAS: Final = ",".join(_CLAUDE_CODE_BETAS) +_CLIENT_BETAS_WITH_DANGEROUS: Final = ",".join((*_CLAUDE_CODE_BETAS, BETA)) +_MERGED_BETAS: Final = ",".join(sorted((*_CLAUDE_CODE_BETAS, BETA))) +_FOUNDRY_MODEL: Final = "azure_ai/claude-sonnet-4-6" +_FOUNDRY_KEY: Final = "synthetic-foundry-key" +_FOUNDRY_TARGET: Final = "/anthropic/v1/messages" +_FOUNDRY_ARRAY_ERROR: Final = "safeguards: Input should be a valid array" +_FOUNDRY_EXTRA_INPUT_ERROR: Final = "safeguards: Extra inputs are not permitted" +_ANTHROPIC_VERSION: Final = MappingProxyType({"anthropic-version": "2023-06-01"}) +_MESSAGES_KEYS: Final = frozenset({"max_tokens", "messages", "model", "stream"}) +_RESPONSES_KEYS: Final = frozenset({"include", "input", "max_output_tokens", "model"}) +_SDK_EXTRA: Final[dict[str, JsonValue]] = {"safeguards": SAFEGUARDS, "cache": {"no-cache": True}, "num_retries": 0} +_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})") +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) + + +@dataclass(frozen=True, slots=True) +class _Omitted: + pass + + +OMITTED: Final = _Omitted() + + +@dataclass(frozen=True, slots=True) +class _Observed: + method: str + target: str + marker: str + headers: Mapping[str, str] + body: Mapping[str, JsonValue] + + @property + def beta(self) -> str | None: + return self.headers.get("anthropic-beta") + + @property + def keys(self) -> frozenset[str]: + return frozenset(self.body) + + @property + def header_names(self) -> frozenset[str]: + return frozenset(self.headers) + + +def marker_of(request: Request) -> str: + found: Final = _MARKER.search(request.body.decode()) + assert found is not None, request.body + return found.group(1) + + +def _message(marker: str) -> dict[str, JsonValue]: + return { + "id": f"msg_{marker}", + "type": "message", + "role": "assistant", + "model": ANTHROPIC_MODEL, + "content": [{"type": "text", "text": f"answer marker-{marker}"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 5}, + } + + +def stream_frames(marker: str) -> tuple[bytes, ...]: + opening: Final[dict[str, JsonValue]] = { + **_message(marker), + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 12, "output_tokens": 0}, + } + events: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = ( + ("message_start", {"message": opening}), + ("content_block_start", {"index": 0, "content_block": {"type": "text", "text": ""}}), + ("content_block_delta", {"index": 0, "delta": {"type": "text_delta", "text": f"answer marker-{marker}"}}), + ("content_block_stop", {"index": 0}), + ("message_delta", {"delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 5}}), + ("message_stop", {}), + ) + return tuple( + f"event: {kind}\ndata: {json.dumps({'type': kind, **payload})}\n\n".encode() for kind, payload in events + ) + + +def anthropic_peer(request: Request) -> Reply: + marker: Final = marker_of(request) + if _JSON_OBJECT.validate_json(request.body).get("stream") is True: + return Reply(content_type="text/event-stream", chunks=stream_frames(marker)) + return Reply(body=json.dumps(_message(marker)).encode()) + + +def _responses_peer(request: Request) -> Reply: + marker: Final = marker_of(request) + return Reply( + body=json.dumps( + { + "id": f"resp_{marker}", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": f"msg_{marker}", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": f"answer marker-{marker}", "annotations": []}], + } + ], + "usage": {"input_tokens": 12, "output_tokens": 5, "total_tokens": 17}, + } + ).encode() + ) + + +def _error_peer(status: int, kind: str, message: str) -> Callable[[Request], Reply]: + body: Final = json.dumps({"type": "error", "error": {"type": kind, "message": message}}).encode() + return lambda request: Reply(status=status, body=body) + + +def messages_body( + model: str, + marker: str, + *, + safeguards: JsonValue | _Omitted = SAFEGUARDS, + stream: bool = False, + no_cache: bool = True, +) -> dict[str, JsonValue]: + base: Final[dict[str, JsonValue]] = { + "model": model, + "max_tokens": 16, + "messages": [{"role": "user", "content": f"Question marker-{marker}"}], + "num_retries": 0, + **({"stream": True} if stream else {}), + **({"cache": {"no-cache": True}} if no_cache else {}), + } + if isinstance(safeguards, _Omitted): + return base + return {**base, "safeguards": safeguards} + + +def _observe(request: Request) -> _Observed: + return _Observed( + request.method, request.target, marker_of(request), request.headers, _JSON_OBJECT.validate_json(request.body) + ) + + +def _only(wire: Wire) -> _Observed: + (request,) = wire.drain() + return _observe(request) + + +def _post(gateway: Gateway, body: Mapping[str, JsonValue], *, beta: str | None = None) -> httpx.Response: + headers: Final = {**_ANTHROPIC_VERSION, **({"anthropic-beta": beta} if beta else {})} + return gateway.request("POST", "/v1/messages", body, headers=headers) + + +def _assert_answered(response: httpx.Response, marker: str) -> None: + assert response.status_code == 200, response.text + assert response.json()["id"] == f"msg_{marker}", response.text + + +def _assert_streamed(response: httpx.Response, marker: str) -> None: + assert response.status_code == 200, response.text + assert f"msg_{marker}" in response.text and f"answer marker-{marker}" in response.text, response.text + assert "event: message_stop" in response.text, response.text + + +def _spend_row(identity: str) -> None: + rows: Final = eventually( + lambda: read_rows('SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (identity,)), + lambda found: len(found) == 1, + seconds=70, + ) + assert rows[0]["status"] == "success", rows + + +def _cache_hit_spend_row(identity: str) -> None: + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id, status, cache_hit FROM "LiteLLM_SpendLogs" WHERE request_id LIKE %s', + (f"{identity}\\_cache\\_hit%",), + ), + lambda found: len(found) == 1, + seconds=70, + ) + assert (rows[0]["status"], str(rows[0]["cache_hit"])) == ("success", "True"), rows + + +def _responses_spend_row(model: str) -> None: + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE model_group = %s AND call_type = %s', + (model, "aresponses"), + ), + lambda found: len(found) == 1, + seconds=70, + ) + assert rows[0]["status"] == "success", rows + + +def _expect_messages( + observed: _Observed, + *, + target: str, + auth: tuple[str, str] | None, + beta: str | None, + safeguards: JsonValue | _Omitted = SAFEGUARDS, + keys: frozenset[str] = _MESSAGES_KEYS, +) -> None: + assert (observed.method, observed.target) == ("POST", target) + if auth is not None: + credential_matches: Final = observed.headers.get(auth[0]) == auth[1] + assert credential_matches, f"{auth[0]} differs from the deployment credential; headers {observed.header_names}" + assert observed.beta == beta, observed.header_names + if isinstance(safeguards, _Omitted): + assert observed.keys == keys + return + assert observed.keys == keys | {"safeguards"} + assert observed.body["safeguards"] == safeguards + + +def _expect_anthropic_version(observed: _Observed) -> None: + assert observed.headers.get("anthropic-version") == "2023-06-01", observed.header_names + + +@dataclass(frozen=True, slots=True) +class _ShapeCase: + sent: JsonValue | _Omitted + forwarded: JsonValue | _Omitted + client_beta: str | None + expected_beta: str | None + stream: bool = False + + +_ANTHROPIC_SHAPES: Final = MappingProxyType( + { + "D1_safeguards_without_client_header": _ShapeCase(SAFEGUARDS, SAFEGUARDS, None, BETA), + "D2_safeguards_with_claude_code_header": _ShapeCase( + SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS_WITH_DANGEROUS, _MERGED_BETAS + ), + "D3_safeguards_with_claude_code_header_lacking_the_beta": _ShapeCase( + SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS, _MERGED_BETAS + ), + "D4_empty_safeguards": _ShapeCase([], [], None, BETA), + "D5_null_safeguards": _ShapeCase(None, OMITTED, None, None), + "D6_missing_safeguards": _ShapeCase(OMITTED, OMITTED, None, None), + } +) +_FOUNDRY_SHAPES: Final = MappingProxyType( + { + "D9_streamed_safeguards_with_claude_code_header": _ShapeCase( + SAFEGUARDS, SAFEGUARDS, _CLIENT_BETAS_WITH_DANGEROUS, BETA, stream=True + ), + "D11_empty_safeguards": _ShapeCase([], [], None, BETA), + "D12_null_safeguards": _ShapeCase(None, OMITTED, None, None), + "D13_missing_safeguards_with_claude_code_header_lacking_the_beta": _ShapeCase( + OMITTED, OMITTED, _CLIENT_BETAS, None + ), + } +) + + +@pytest.mark.parametrize("row", tuple(_ANTHROPIC_SHAPES)) +def test_anthropic_messages_derive_the_dangerous_tool_use_beta_from_safeguards(gateway: Gateway, row: str) -> None: + case: Final = _ANTHROPIC_SHAPES[row] + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + response: Final = _post(gateway, messages_body(model, marker, safeguards=case.sent), beta=case.client_beta) + _assert_answered(response, marker) + observed: Final = _only(wire) + _expect_messages( + observed, + target="/v1/messages", + auth=("x-api-key", ANTHROPIC_KEY), + beta=case.expected_beta, + safeguards=case.forwarded, + ) + _expect_anthropic_version(observed) + _spend_row(f"msg_{marker}") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _DropCase: + deployment: Callable[[Gateway], Mapping[str, JsonValue]] + target: str + auth: tuple[str, str] | None + keys: frozenset[str] = _MESSAGES_KEYS + + +_VERTEX_PROJECT: Final = "scripted-project" +_VERTEX_LOCATION: Final = "us-east5" +_VERTEX_MODEL: Final = "claude-opus-5-5" + +_DROP_CASES: Final = MappingProxyType( + { + "anthropic": _DropCase( + deployment=lambda _: {"model": f"anthropic/{ANTHROPIC_MODEL}", "api_key": ANTHROPIC_KEY}, + target="/v1/messages", + auth=("x-api-key", ANTHROPIC_KEY), + ), + "azure_ai": _DropCase( + deployment=lambda _: {"model": _FOUNDRY_MODEL, "api_key": _FOUNDRY_KEY}, + target=_FOUNDRY_TARGET, + auth=("x-api-key", _FOUNDRY_KEY), + ), + "bedrock_claude_platform": _DropCase( + deployment=lambda _: { + "model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}", + "api_key": "synthetic-platform-key", + "workspace_id": "ws-synthetic", + "aws_region_name": "us-east-1", + }, + target="/v1/messages", + auth=("x-api-key", "synthetic-platform-key"), + ), + "vertex_ai": _DropCase( + deployment=lambda gateway: { + "model": f"vertex_ai/{_VERTEX_MODEL}", + "api_key": None, + "vertex_project": _VERTEX_PROJECT, + "vertex_location": _VERTEX_LOCATION, + "vertex_credentials": service_account_json(_VERTEX_PROJECT, gateway.upstream_url.rstrip("/")), + }, + target=( + f"/v1/projects/{_VERTEX_PROJECT}/locations/{_VERTEX_LOCATION}" + f"/publishers/anthropic/models/{_VERTEX_MODEL}:rawPredict" + ), + auth=None, + keys=frozenset({"anthropic_version", "max_tokens", "messages", "stream"}), + ), + } +) + + +@pytest.mark.parametrize("row", tuple(_DROP_CASES)) +def test_a_deployment_dropping_safeguards_sends_neither_the_field_nor_the_beta(gateway: Gateway, row: str) -> None: + case: Final = _DROP_CASES[row] + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + api_base=wire.url, additional_drop_params=["safeguards"], **case.deployment(gateway) + ) + response: Final = _post(gateway, messages_body(model, marker)) + _assert_answered(response, marker) + _expect_messages(_only(wire), target=case.target, auth=case.auth, beta=None, safeguards=OMITTED, keys=case.keys) + _spend_row(f"msg_{marker}") + + +def test_a_deployment_dropping_context_management_sends_neither_the_field_nor_its_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"anthropic/{ANTHROPIC_MODEL}", + api_base=wire.url, + api_key=ANTHROPIC_KEY, + additional_drop_params=["context_management"], + ) + body: Final = { + **messages_body(model, marker, safeguards=OMITTED), + "context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]}, + } + response: Final = _post(gateway, body) + _assert_answered(response, marker) + _expect_messages( + _only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=None, safeguards=OMITTED + ) + _spend_row(f"msg_{marker}") + + +@pytest.mark.parametrize("row", tuple(_FOUNDRY_SHAPES)) +def test_azure_ai_messages_derive_the_beta_instead_of_forwarding_the_client_header(gateway: Gateway, row: str) -> None: + case: Final = _FOUNDRY_SHAPES[row] + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + response: Final = _post( + gateway, messages_body(model, marker, safeguards=case.sent, stream=case.stream), beta=case.client_beta + ) + if case.stream: + _assert_streamed(response, marker) + else: + _assert_answered(response, marker) + observed: Final = _only(wire) + _expect_messages( + observed, + target=_FOUNDRY_TARGET, + auth=("x-api-key", _FOUNDRY_KEY), + beta=case.expected_beta, + safeguards=case.forwarded, + ) + _expect_anthropic_version(observed) + _spend_row(f"msg_{marker}") + + +def test_anthropic_sdk_sync_safeguards_derive_the_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0) + reply: Final = client.messages.create( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": f"Question marker-{marker}"}], + extra_body=_SDK_EXTRA, + ) + assert reply.id == f"msg_{marker}", reply + _expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=BETA) + _spend_row(f"msg_{marker}") + + +async def test_anthropic_sdk_async_stream_safeguards_derive_the_beta_and_log_spend(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + client: Final = anthropic.AsyncAnthropic( + base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0 + ) + async with client.messages.with_streaming_response.create( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": f"Question marker-{marker}"}], + stream=True, + extra_body=_SDK_EXTRA, + ) as streamed: + pieces: Final = [ + event.delta.text + async for event in await streamed.parse() + if event.type == "content_block_delta" and event.delta.type == "text_delta" + ] + assert "".join(pieces) == f"answer marker-{marker}", pieces + _expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=BETA) + _spend_row(f"msg_{marker}") + + +def test_azure_ai_sdk_sync_safeguards_derive_the_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0) + reply: Final = client.messages.create( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": f"Question marker-{marker}"}], + extra_body=_SDK_EXTRA, + ) + assert reply.id == f"msg_{marker}", reply + _expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA) + _spend_row(f"msg_{marker}") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _ProviderCase: + parameters: Mapping[str, JsonValue] + auth: tuple[str, str] | None + expected_beta: str | None + target: str = "/v1/messages" + api_base_suffix: str = "" + model_info: Mapping[str, JsonValue] | None = None + client_beta: str | None = None + keys: frozenset[str] = _MESSAGES_KEYS + extra_headers: Mapping[str, str] = MappingProxyType({}) + body_beta: list[str] | None = None + + +_PROVIDERS: Final = MappingProxyType( + { + "D14_deepseek_with_claude_code_header": _ProviderCase( + parameters={"model": "deepseek/deepseek-chat", "api_key": "synthetic-deepseek-key"}, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + target="/anthropic/v1/messages", + auth=("x-api-key", "synthetic-deepseek-key"), + expected_beta=None, + ), + "D15_tencent_with_claude_code_header": _ProviderCase( + parameters={"model": "tencent/deepseek-v4-flash", "api_key": "synthetic-tencent-key"}, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + auth=("x-api-key", "synthetic-tencent-key"), + expected_beta=None, + ), + "D16_minimax_with_claude_code_header": _ProviderCase( + parameters={"model": "minimax/MiniMax-M2", "api_key": "synthetic-minimax-key"}, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + auth=("x-api-key", "synthetic-minimax-key"), + expected_beta=None, + ), + "D17_openai_compatible_native_passthrough": _ProviderCase( + parameters={"model": "openai/claude-sonnet-4-5", "api_key": "synthetic-openai-like-key"}, + api_base_suffix="/v1", + model_info={"supported_endpoints": ["/v1/messages"]}, + auth=("authorization", "Bearer synthetic-openai-like-key"), + expected_beta=None, + ), + "D18_sail_with_claude_code_header": _ProviderCase( + parameters={"model": "sail/sail-claude", "api_key": "synthetic-sail-key"}, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + auth=("authorization", "Bearer synthetic-sail-key"), + expected_beta=None, + ), + "D19_edenai": _ProviderCase( + parameters={"model": "edenai/claude-sonnet-4-5", "api_key": "synthetic-edenai-key"}, + auth=("authorization", "Bearer synthetic-edenai-key"), + expected_beta=None, + ), + "D21_bedrock_claude_platform": _ProviderCase( + parameters={ + "model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}", + "api_key": "synthetic-platform-key", + "workspace_id": "ws-synthetic", + "aws_region_name": "us-east-1", + }, + auth=("x-api-key", "synthetic-platform-key"), + expected_beta=BETA, + extra_headers={"anthropic-workspace-id": "ws-synthetic"}, + ), + "D22_bedrock_claude_platform_with_claude_code_header": _ProviderCase( + parameters={ + "model": f"bedrock/claude_platform/{ANTHROPIC_MODEL}", + "api_key": "synthetic-platform-key", + "workspace_id": "ws-synthetic", + "aws_region_name": "us-east-1", + }, + client_beta=_CLIENT_BETAS_WITH_DANGEROUS, + auth=("x-api-key", "synthetic-platform-key"), + expected_beta=_MERGED_BETAS, + extra_headers={"anthropic-workspace-id": "ws-synthetic"}, + ), + "D23_bedrock_invoke": _ProviderCase( + parameters={ + "model": "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", + "api_key": "synthetic-bedrock-bearer", + "aws_region_name": "us-east-1", + }, + target="/model/anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", + auth=("authorization", "Bearer synthetic-bedrock-bearer"), + expected_beta=None, + keys=frozenset({"anthropic_beta", "anthropic_version", "max_tokens", "messages"}), + body_beta=[BETA], + ), + "D24_bedrock_mantle": _ProviderCase( + parameters={ + "model": "bedrock_mantle/anthropic.claude-sonnet-5-v1:0", + "api_key": "synthetic-mantle-bearer", + "aws_region_name": "us-east-1", + }, + target="/anthropic/v1/messages", + auth=("authorization", "Bearer synthetic-mantle-bearer"), + expected_beta=BETA, + keys=frozenset({"max_tokens", "messages", "model"}), + ), + } +) + + +@pytest.mark.parametrize("row", tuple(_PROVIDERS)) +def test_anthropic_compatible_providers_receive_the_beta_only_where_the_proxy_derives_it( + gateway: Gateway, row: str +) -> None: + case: Final = _PROVIDERS[row] + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model_info=case.model_info, api_base=wire.url + case.api_base_suffix, **case.parameters + ) + response: Final = _post(gateway, messages_body(model, marker), beta=case.client_beta) + _assert_answered(response, marker) + observed: Final = _only(wire) + _expect_messages(observed, target=case.target, auth=case.auth, beta=case.expected_beta, keys=case.keys) + for name, value in case.extra_headers.items(): + assert observed.headers.get(name) == value, observed.header_names + assert observed.body.get("anthropic_beta") == case.body_beta, observed.body + _spend_row(f"msg_{marker}") + + +def test_github_copilot_messages_forward_safeguards_without_deriving_the_beta( + gateway: Gateway, tmp_path: Path +) -> None: + marker: Final = uuid.uuid4().hex + token_dir: Final = tmp_path / "copilot" + token_dir.mkdir() + with wire_server(anthropic_peer) as wire: + (token_dir / "api-key.json").write_text( + json.dumps({"token": "synthetic-copilot-token", "expires_at": 4102444800, "endpoints": {"api": wire.url}}) + ) + with ( + owned_proxy(gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir)}) as owned, + owned.scenario() as scenario, + ): + model: Final = scenario.model(model="github_copilot/claude-sonnet-4-5", api_base=wire.url) + response: Final = _post(owned, messages_body(model, marker), beta=_CLIENT_BETAS_WITH_DANGEROUS) + _assert_answered(response, marker) + observed: Final = _only(wire) + _expect_messages( + observed, target="/v1/messages", auth=("authorization", "Bearer synthetic-copilot-token"), beta=None + ) + assert observed.headers.get("openai-intent") == "messages-proxy", observed.header_names + _spend_row(f"msg_{marker}") + + +def test_non_native_deployments_bridge_to_the_responses_api_without_safeguards_or_the_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_responses_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini", api_base=wire.url + "/v1", api_key="synthetic-openai-key" + ) + response: Final = _post(gateway, messages_body(model, marker), beta=_CLIENT_BETAS_WITH_DANGEROUS) + assert response.status_code == 200 and f"answer marker-{marker}" in response.text, response.text + observed: Final = _only(wire) + assert (observed.method, observed.target) == ("POST", "/v1/responses") + assert observed.headers.get("authorization") == "Bearer synthetic-openai-key", observed.header_names + assert observed.beta is None, observed.header_names + assert observed.keys == _RESPONSES_KEYS, observed.keys + _spend_row(str(_JSON_OBJECT.validate_json(response.content)["id"])) + + +def test_chat_completions_forward_safeguards_verbatim_without_deriving_the_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + client: Final = openai.OpenAI( + base_url=str(gateway.client.base_url).rstrip("/") + "/v1", api_key=gateway.key, max_retries=0 + ) + completion: Final = client.chat.completions.create( + model=model, messages=[{"role": "user", "content": f"Question marker-{marker}"}], extra_body=_SDK_EXTRA + ) + assert completion.choices[0].message.content == f"answer marker-{marker}", completion + _expect_messages( + _only(wire), + target="/v1/messages", + auth=("x-api-key", ANTHROPIC_KEY), + beta=None, + keys=frozenset({"max_tokens", "messages", "model"}), + ) + _spend_row(completion.id) + + +async def test_responses_stream_forwards_safeguards_verbatim_without_deriving_the_beta(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{ANTHROPIC_MODEL}", api_base=wire.url, api_key=ANTHROPIC_KEY) + client: Final = openai.AsyncOpenAI( + base_url=str(gateway.client.base_url).rstrip("/") + "/v1", api_key=gateway.key, max_retries=0 + ) + async with client.responses.with_streaming_response.create( + model=model, input=f"Question marker-{marker}", stream=True, extra_body=_SDK_EXTRA + ) as streamed: + events: Final = [event async for event in await streamed.parse()] + deltas: Final = [event.delta for event in events if event.type == "response.output_text.delta"] + assert "".join(deltas) == f"answer marker-{marker}", events + assert any(event.type == "response.completed" for event in events), events + _expect_messages(_only(wire), target="/v1/messages", auth=("x-api-key", ANTHROPIC_KEY), beta=None) + _responses_spend_row(model) + + +_FOUNDRY_REJECTED_SHAPES: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"S1_int": 1, "S2_empty_string": "", "S3_five_kb_string": "x" * 5000} +) + + +@pytest.mark.parametrize("row", tuple(_FOUNDRY_REJECTED_SHAPES)) +def test_foundry_rejecting_a_non_array_safeguards_value_reaches_the_caller_after_one_attempt( + gateway: Gateway, row: str +) -> None: + value: Final = _FOUNDRY_REJECTED_SHAPES[row] + marker: Final = uuid.uuid4().hex + with ( + wire_server(_error_peer(400, "invalid_request_error", _FOUNDRY_ARRAY_ERROR)) as wire, + gateway.scenario() as scenario, + ): + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + response: Final = _post(gateway, messages_body(model, marker, safeguards=value)) + assert response.status_code == 400 and _FOUNDRY_ARRAY_ERROR in response.text, response.text + _expect_messages( + _only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA, safeguards=value + ) + + +def test_duplicate_safeguards_keys_resolve_to_the_last_value(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + raw: Final = ( + f'{{"model": "{model}", "max_tokens": 16, "num_retries": 0, "cache": {{"no-cache": true}}, ' + f'"messages": [{{"role": "user", "content": "Question marker-{marker}"}}], ' + f'"safeguards": {json.dumps(SAFEGUARDS)}, "safeguards": null}}' + ) + response: Final = gateway.client.post( + "/v1/messages", + content=raw.encode(), + headers={ + "Authorization": f"Bearer {gateway.key}", + "content-type": "application/json", + **_ANTHROPIC_VERSION, + }, + ) + _assert_answered(response, marker) + _expect_messages( + _only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=None, safeguards=OMITTED + ) + _spend_row(f"msg_{marker}") + + +def test_the_same_safeguards_sent_twice_are_two_upstream_calls_with_two_spend_rows(gateway: Gateway) -> None: + markers: Final = (uuid.uuid4().hex, uuid.uuid4().hex) + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + responses: Final = tuple(_post(gateway, messages_body(model, marker)) for marker in markers) + for marker, response in zip(markers, responses, strict=True): + _assert_answered(response, marker) + received: Final = tuple(_observe(request) for request in wire.drain()) + assert len(received) == 2, received + for observed in received: + _expect_messages(observed, target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA) + assert sorted(item.marker for item in received) == sorted(markers) + for marker in markers: + _spend_row(f"msg_{marker}") + + +_UPSTREAM_ERRORS: Final = MappingProxyType( + { + "S6_unauthorized_deployment": (401, "authentication_error", "invalid x-api-key"), + "S7_foundry_extra_inputs_rejection": (400, "invalid_request_error", _FOUNDRY_EXTRA_INPUT_ERROR), + } +) + + +@pytest.mark.parametrize("row", tuple(_UPSTREAM_ERRORS)) +def test_upstream_errors_reach_the_caller_with_the_beta_sent_once(gateway: Gateway, row: str) -> None: + status, kind, message = _UPSTREAM_ERRORS[row] + marker: Final = uuid.uuid4().hex + with wire_server(_error_peer(status, kind, message)) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + response: Final = _post(gateway, messages_body(model, marker)) + assert response.status_code == status and message in response.text, response.text + _expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA) + + +def _post_noting_whether_upstream_was_called( + gateway: Gateway, body: Mapping[str, JsonValue], wire: Wire +) -> tuple[httpx.Response, bool]: + before: Final = wire.received.qsize() + response: Final = _post(gateway, body) + assert response.status_code == 200, response.text + return response, wire.received.qsize() > before + + +def _until_served_from_cache(gateway: Gateway, body: Mapping[str, JsonValue], wire: Wire) -> tuple[httpx.Response, int]: + deadline: Final = time.monotonic() + 20 + for attempt in itertools.count(1): + response, called_upstream = _post_noting_whether_upstream_was_called(gateway, body, wire) + if not called_upstream: + return response, attempt + assert time.monotonic() < deadline, "the response cache never served the repeated request" + raise AssertionError("unreachable") + + +def test_the_response_cache_serves_the_repeated_request_without_an_upstream_call(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(anthropic_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=_FOUNDRY_MODEL, api_base=wire.url, api_key=_FOUNDRY_KEY) + body: Final = messages_body(model, marker, no_cache=False) + first: Final = _post(gateway, body) + _assert_answered(first, marker) + _expect_messages(_only(wire), target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA) + _spend_row(f"msg_{marker}") + hit, attempts = _until_served_from_cache(gateway, body, wire) + assert hit.json()["id"] == first.json()["id"], hit.text + assert hit.json()["content"] == first.json()["content"], hit.text + _cache_hit_spend_row(f"msg_{marker}") + misses: Final = tuple(_observe(request) for request in wire.drain()) + assert len(misses) == attempts - 1, (attempts, misses) + for observed in misses: + _expect_messages(observed, target=_FOUNDRY_TARGET, auth=("x-api-key", _FOUNDRY_KEY), beta=BETA) diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py index dcfd1d19ac2..a448a8cd2da 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_wire.py @@ -10,6 +10,7 @@ from anthropic.types import Message from integration._support import claude_code as cc from integration._support.client import Gateway, eventually, object_value from integration._support.database import read_rows +from integration._support.safeguards_rig import DANGEROUS_TOOL_USE_BETA from integration._support.wire import Reply, Request, wire_server from pydantic import JsonValue, TypeAdapter @@ -280,7 +281,9 @@ def test_anthropic_messages_preserves_cli_beta_safeguards_and_result(gateway: Ga def respond(request: Request) -> Reply: assert request.method == "POST" and request.target == "/v1/messages", request.target assert request.headers["x-api-key"] == "synthetic-anthropic-key", request.headers - assert sorted(request.headers["anthropic-beta"].split(",")) == sorted(beta.split(",")), request.headers + assert sorted(request.headers["anthropic-beta"].split(",")) == sorted( + (*beta.split(","), DANGEROUS_TOOL_USE_BETA) + ), request.headers expected_body: Final = { "model": "claude-sonnet-4-6", "max_tokens": 32, diff --git a/tests/integration/messages_endpoint/safeguards/test_safeguard_results_mcp_auto_execute.py b/tests/integration/messages_endpoint/safeguards/test_safeguard_results_mcp_auto_execute.py index c9da24484e2..57ab6e0e745 100644 --- a/tests/integration/messages_endpoint/safeguards/test_safeguard_results_mcp_auto_execute.py +++ b/tests/integration/messages_endpoint/safeguards/test_safeguard_results_mcp_auto_execute.py @@ -4,28 +4,35 @@ with no verdicts.""" from __future__ import annotations +import json from collections.abc import Callable, Iterator, Mapping +from itertools import chain from typing import Final import pytest -from integration._support.client import object_value +from integration._support.anthropic_refusal import DOCS_STOP_DETAILS +from integration._support.client import list_value, object_value from integration._support.mcp import McpPeer, mcp_peer, tool_calls from integration._support.process import graceful_stop_seconds from integration._support.safeguards_rig import ( BASH_TOOL, CHAT_MODEL, + CLAUDE_MODEL, Responder, Rig, auto_mode_body, available, chat_backend, json_body, + native_message, nonce, request_json, safeguards_rig, + sse_events, stream_results, stream_tool_use_ids, tool_id, + tool_use_block, tool_use_ids, ) from integration._support.wire import Reply, Request @@ -115,3 +122,45 @@ def test_auto_executed_mcp_tool_costs_no_classifier_call(rig: Rig, peer: McpPeer received: Final = rig.received(request_nonce) assert rig.classifier_calls(received) == (), [request.target for request in received] assert len(rig.backend_calls(received)) == 2, [request.target for request in received] + + +def carries_a_tool_result_block(request: Request) -> bool: + messages: Final = list_value(request_json(request).get("messages", [])) + contents: Final = tuple(object_value(message).get("content") for message in messages) + blocks: Final = chain.from_iterable(content for content in contents if isinstance(content, list)) + return any(object_value(block).get("type") == "tool_result" for block in blocks) + + +def claude_mcp_tool_then_refusal(request_nonce: str) -> Responder: + identity: Final = tool_id(request_nonce) + tool_turn: Final = native_message( + identity, + content=(tool_use_block(f"{identity}m", name=f"{MCP_ALIAS}-add", arguments={"a": 2, "b": 3}),), + model="claude-sonnet-4-6", + ) + refusal_turn: Final = { + **native_message(f"{identity}r", content=(), stop_reason="refusal", model="claude-sonnet-4-6"), + "stop_details": DOCS_STOP_DETAILS, + } + + def respond(request: Request) -> Reply: + turn: Final = refusal_turn if carries_a_tool_result_block(request) else tool_turn + return Reply(body=json.dumps(turn).encode()) + + return respond + + +def test_auto_executed_mcp_loop_streams_the_final_turn_refusal_stop_details(rig: Rig, peer: McpPeer) -> None: + request_nonce: Final = nonce() + rig.register(request_nonce, claude_mcp_tool_then_refusal(request_nonce)) + peer.drain() + status, raw = rig.stream(auto_mode_body(CLAUDE_MODEL, request_nonce, tools=[dict(AUTO_EXECUTED), dict(BASH_TOOL)])) + assert status == 200, raw + assert executed_tools(peer) == ("add",) + deltas: Final = tuple( + object_value(event["delta"]) for event in sse_events(raw) if event.get("type") == "message_delta" + ) + assert len(deltas) == 1, raw + assert deltas[0]["stop_reason"] == "refusal", raw + assert deltas[0].get("stop_details") == DOCS_STOP_DETAILS, raw + assert len(rig.backend_calls(rig.received(request_nonce))) == 2 diff --git a/tests/integration/messages_endpoint/safeguards/test_safeguard_results_synthesized_streams.py b/tests/integration/messages_endpoint/safeguards/test_safeguard_results_synthesized_streams.py new file mode 100644 index 00000000000..99a6dd18d48 --- /dev/null +++ b/tests/integration/messages_endpoint/safeguards/test_safeguard_results_synthesized_streams.py @@ -0,0 +1,470 @@ +"""A /v1/messages stream LiteLLM builds from a complete reply (web search interception, a fallback's answer, a guardrail +rewrite) carries the ``stop_details`` and ``safeguard_results`` a real Anthropic stream puts on its ``message_delta``.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator, Mapping, Sequence +from typing import Final +from urllib.parse import urlsplit + +import pytest +from integration._support.client import eventually, object_value +from integration._support.process import graceful_stop_seconds +from integration._support.safeguards_rig import ( + BASH_TOOL, + CLAUDE_MODEL, + CLAUDE_TARGET, + TAVILY_TARGET, + Responder, + Rig, + auto_mode_body, + available, + deployment, + native_message, + native_stream_frames, + nonce, + request_json, + safeguards_rig, + sse_events, + stream_results, + stream_tool_use_ids, + text_block, + tool_id, + tool_use_block, + unavailable, +) +from integration._support.wire import Reply, Request +from pydantic import JsonValue + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 240) + +CLAUDE: Final = "anthropic/claude-sonnet-4-6" +PRIMARY_MODEL: Final = "synthesized-primary" +FALLBACK_MODEL: Final = "synthesized-fallback" +REFUSED_MODEL: Final = "synthesized-refused" +MOCK_MODEL: Final = "synthesized-mock" +PIPELINE_MODEL: Final = "synthesized-pipeline" +AZURE_REFUSED_MODEL: Final = "synthesized-azure-refused" +CACHED_FALLBACK_MODEL: Final = "synthesized-cached-fallback" +PRIMARY_TARGET: Final = "/primary/v1/messages" +FALLBACK_TARGET: Final = "/fallback/v1/messages" +REFUSED_TARGET: Final = "/refused/v1/messages" +PIPELINE_TARGET: Final = "/pipeline/v1/messages" +AZURE_REFUSED_TARGET: Final = "/azure/anthropic/v1/messages" +CACHED_FALLBACK_TARGET: Final = "/cached-fallback/v1/messages" +MOCK_ANSWER: Final = "mocked fallback answer" +TOOL_PERMISSION: Final = "synthesized-tool-permission" +PIPELINE_REWRITE: Final = "synthesized-pipeline-rewrite" +SECRET: Final = "SECRET" +MASKED: Final = "[MASKED]" +REWRITE_CODE: Final = ( + "def apply_guardrail(inputs, request_data, input_type):\n" + " if input_type != 'response':\n" + " return allow()\n" + f" texts = [text.replace({SECRET!r}, {MASKED!r}) for text in inputs.get('texts') or []]\n" + " calls = [\n" + " {'id': call['id'], 'type': 'function', 'function': {'name': call['function']['name'],\n" + f" 'arguments': call['function']['arguments'].replace({SECRET!r}, {MASKED!r})}}}}\n" + " for call in inputs.get('tool_calls') or []\n" + " ]\n" + " return modify(texts=texts, tool_calls=calls)\n" +) +WEB_SEARCH_TOOL: Final[Mapping[str, JsonValue]] = {"type": "web_search_20250305", "name": "web_search", "max_uses": 1} +READ_TOOL: Final[Mapping[str, JsonValue]] = { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}, +} +NO_CACHE: Final[Mapping[str, JsonValue]] = {"cache": {"no-cache": True}} +REFUSAL_STOP_DETAILS: Final[Mapping[str, JsonValue]] = { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm.", +} +_FRAME_BOUNDARY: Final = re.compile(rb"(?<=\n\n)") + +LITELLM_SETTINGS: Final[Mapping[str, JsonValue]] = { + "callbacks": ["websearch_interception"], + "websearch_interception_params": {"enabled_providers": ["anthropic"], "search_tool_name": "tavily-search"}, +} + + +def extra_models(wire_url: str) -> Sequence[Mapping[str, JsonValue]]: + return ( + deployment(PRIMARY_MODEL, CLAUDE, f"{wire_url}/primary"), + deployment(FALLBACK_MODEL, CLAUDE, f"{wire_url}/fallback"), + deployment(REFUSED_MODEL, CLAUDE, f"{wire_url}/refused"), + deployment(MOCK_MODEL, CLAUDE, f"{wire_url}/mock", mock_response=MOCK_ANSWER), + deployment(PIPELINE_MODEL, CLAUDE, f"{wire_url}/pipeline"), + deployment(AZURE_REFUSED_MODEL, "azure_ai/claude-sonnet-4-6", f"{wire_url}/azure/anthropic"), + deployment(CACHED_FALLBACK_MODEL, CLAUDE, f"{wire_url}/cached-fallback"), + ) + + +def top_level(wire_url: str) -> Mapping[str, JsonValue]: + tavily: Final[Mapping[str, JsonValue]] = { + "search_tool_name": "tavily-search", + "litellm_params": { + "search_provider": "tavily", + "api_key": "synthetic-tavily-key", + "api_base": f"{wire_url}/tavily", + }, + } + tool_permission: Final[Mapping[str, JsonValue]] = { + "guardrail_name": TOOL_PERMISSION, + "litellm_params": { + "guardrail": "tool_permission", + "mode": "post_call", + "default_on": False, + "default_action": "allow", + "on_disallowed_action": "rewrite", + "rules": [{"id": "deny_bash", "tool_name": "^Bash$", "decision": "deny"}], + }, + } + pipeline_rewrite: Final[Mapping[str, JsonValue]] = { + "guardrail_name": PIPELINE_REWRITE, + "litellm_params": { + "guardrail": "custom_code", + "mode": "post_call", + "default_on": False, + "custom_code": REWRITE_CODE, + }, + } + masking_policy: Final[Mapping[str, JsonValue]] = { + "guardrails": {"add": [PIPELINE_REWRITE]}, + "pipeline": { + "mode": "post_call", + "steps": [{"guardrail": PIPELINE_REWRITE, "on_pass": "allow", "on_fail": "block"}], + }, + } + return { + "search_tools": [dict(tavily)], + "guardrails": [dict(tool_permission), dict(pipeline_rewrite)], + "policies": {"synthesized-masking": dict(masking_policy)}, + "policy_attachments": [{"policy": "synthesized-masking", "models": [PIPELINE_MODEL]}], + "router_settings": { + "disable_cooldowns": True, + "content_policy_fallbacks": [ + {PRIMARY_MODEL: [FALLBACK_MODEL]}, + {REFUSED_MODEL: [MOCK_MODEL]}, + {AZURE_REFUSED_MODEL: [CACHED_FALLBACK_MODEL]}, + ], + }, + } + + +@pytest.fixture(scope="module") +def synthesizing(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("synthesized-streams") + with safeguards_rig( + directory, litellm_settings=LITELLM_SETTINGS, top_level=top_level, extra_models=extra_models + ) as rig: + yield rig + + +def claude_backend(message: Mapping[str, JsonValue], delta_fields: Mapping[str, JsonValue]) -> Responder: + def respond(request: Request) -> Reply: + if request_json(request).get("stream") is True: + return Reply( + content_type="text/event-stream", chunks=native_stream_frames(message, delta_extra=delta_fields) + ) + return Reply(body=json.dumps({**message, **delta_fields}).encode()) + + return respond + + +def by_target(backends: Mapping[str, Responder]) -> Responder: + def respond(request: Request) -> Reply: + return backends[urlsplit(request.target).path](request) + + return respond + + +def refusal(identity: str) -> Mapping[str, JsonValue]: + return native_message(identity, content=(), stop_reason="refusal", model="claude-sonnet-4-6") + + +def answer(identity: str, text: str) -> Mapping[str, JsonValue]: + return native_message(identity, content=(text_block(text),), stop_reason="end_turn", model="claude-sonnet-4-6") + + +def refusal_fields() -> Mapping[str, JsonValue]: + return {"stop_details": dict(REFUSAL_STOP_DETAILS), "safeguard_results": list(available({}))} + + +def search_body(model: str, request_nonce: str) -> Mapping[str, JsonValue]: + return auto_mode_body(model, request_nonce, tools=[dict(WEB_SEARCH_TOOL)], prompt="search the web for", **NO_CACHE) + + +def message_delta(raw: bytes) -> Mapping[str, JsonValue]: + deltas: Final = tuple( + object_value(event["delta"]) for event in sse_events(raw) if event.get("type") == "message_delta" + ) + assert len(deltas) == 1, raw + return deltas[0] + + +def message_start(raw: bytes) -> Mapping[str, JsonValue]: + starts: Final = tuple( + object_value(event["message"]) for event in sse_events(raw) if event.get("type") == "message_start" + ) + assert len(starts) == 1, raw + return starts[0] + + +def streamed_text(raw: bytes) -> str: + deltas: Final = tuple( + object_value(event["delta"]) for event in sse_events(raw) if event.get("type") == "content_block_delta" + ) + return "".join(str(delta["text"]) for delta in deltas if delta.get("type") == "text_delta") + + +def delta_frame(raw: bytes) -> bytes: + frames: Final = tuple(frame for frame in _FRAME_BOUNDARY.split(raw) if b'"message_delta"' in frame) + assert len(frames) == 1, raw + return frames[0] + + +def targets(rig: Rig, request_nonce: str) -> tuple[str, ...]: + return tuple(urlsplit(request.target).path for request in rig.received(request_nonce)) + + +def test_intercepted_refusal_stream_delivers_the_upstream_stop_details(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + synthesizing.register(request_nonce, claude_backend(refusal(tool_id(request_nonce)), refusal_fields())) + status, raw = synthesizing.stream(search_body(CLAUDE_MODEL, request_nonce)) + assert status == 200, raw + assert message_delta(raw) == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": dict(REFUSAL_STOP_DETAILS), + "safeguard_results": list(available({})), + }, raw + assert targets(synthesizing, request_nonce) == (CLAUDE_TARGET,) + + +def test_intercepted_refusal_stream_with_a_content_policy_fallback_delivers_the_refusal_without_falling_back( + synthesizing: Rig, +) -> None: + request_nonce: Final = nonce() + identity: Final = tool_id(request_nonce) + synthesizing.register( + request_nonce, + by_target( + { + PRIMARY_TARGET: claude_backend(refusal(identity), refusal_fields()), + FALLBACK_TARGET: claude_backend(answer(f"{identity}f", f"fallback answer {request_nonce}"), {}), + } + ), + ) + status, raw = synthesizing.stream(search_body(PRIMARY_MODEL, request_nonce)) + assert status == 200, raw + assert message_delta(raw) == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": dict(REFUSAL_STOP_DETAILS), + "safeguard_results": list(available({})), + }, raw + assert targets(synthesizing, request_nonce) == (PRIMARY_TARGET,) + + +def test_tool_permission_rewrite_keeps_only_the_delivered_tool_verdict_with_its_explanation(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + denied: Final = tool_id(request_nonce) + allowed: Final = f"{denied}r" + allowed_verdict: Final[Mapping[str, JsonValue]] = { + "type": "evaluated", + "outcome": "flagged", + "explanation": f"reads a secret {allowed}", + } + verdicts: Final[Mapping[str, JsonValue]] = { + denied: {"type": "evaluated", "outcome": "flagged", "explanation": f"destructive {denied}"}, + allowed: dict(allowed_verdict), + } + message: Final = native_message( + denied, + content=( + text_block("Cleaning up."), + tool_use_block(denied), + tool_use_block(allowed, name="Read", arguments={"path": "/work/repo/.env"}), + ), + model="claude-sonnet-4-6", + ) + synthesizing.register(request_nonce, claude_backend(message, {"safeguard_results": list(available(verdicts))})) + body: Final = auto_mode_body( + CLAUDE_MODEL, request_nonce, tools=[dict(BASH_TOOL), dict(READ_TOOL)], guardrails=[TOOL_PERMISSION], **NO_CACHE + ) + status, raw = synthesizing.stream(body) + assert status == 200, raw + assert stream_tool_use_ids(raw) == (allowed,), raw + assert stream_results(raw) == available({allowed: dict(allowed_verdict)}), raw + assert targets(synthesizing, request_nonce) == (CLAUDE_TARGET,) + + +def test_intercepted_answer_stream_keeps_its_safeguard_results(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + text: Final = f"found it {request_nonce}" + results: Final = list(available({})) + synthesizing.register( + request_nonce, + claude_backend(answer(tool_id(request_nonce), text), {"stop_details": None, "safeguard_results": results}), + ) + status, raw = synthesizing.stream(search_body(CLAUDE_MODEL, request_nonce)) + assert status == 200, raw + assert streamed_text(raw) == text, raw + assert message_delta(raw) == {"stop_reason": "end_turn", "stop_sequence": None, "safeguard_results": results}, raw + received: Final = targets(synthesizing, request_nonce) + assert received == (CLAUDE_TARGET,) and TAVILY_TARGET not in received, received + + +def test_native_refusal_stream_without_a_guardrail_keeps_the_upstream_message_delta_bytes(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + identity: Final = tool_id(request_nonce) + message: Final = native_message( + identity, content=(text_block("Partial."),), stop_reason="refusal", model="claude-sonnet-4-6" + ) + fields: Final = {**refusal_fields(), "safeguard_results": list(unavailable((identity,), "error"))} + sent: Final = native_stream_frames(message, delta_extra=fields) + synthesizing.register(request_nonce, claude_backend(message, fields)) + status, raw = synthesizing.stream(auto_mode_body(CLAUDE_MODEL, request_nonce, **NO_CACHE)) + assert status == 200, raw + assert delta_frame(raw) == delta_frame(b"".join(sent)), raw + assert targets(synthesizing, request_nonce) == (CLAUDE_TARGET,) + + +def test_native_refusal_falling_back_to_a_mock_reply_renders_a_fresh_message_lifecycle(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + synthesizing.register(request_nonce, claude_backend(refusal(tool_id(request_nonce)), refusal_fields())) + status, raw = synthesizing.stream(auto_mode_body(REFUSED_MODEL, request_nonce, **NO_CACHE)) + assert status == 200, raw + started: Final = message_start(raw) + assert started["stop_reason"] is None, raw + assert started.get("stop_details") is None and "safeguard_results" not in started, raw + assert message_delta(raw) == {"stop_reason": "end_turn", "stop_sequence": None}, raw + assert streamed_text(raw) == MOCK_ANSWER, raw + assert b'"refusal"' not in raw, raw + assert targets(synthesizing, request_nonce) == (REFUSED_TARGET,) + + +def test_native_refusal_falling_back_to_a_cached_intercepted_reply_streams_its_fields_in_message_delta( + synthesizing: Rig, +) -> None: + request_nonce: Final = nonce() + identity: Final = tool_id(request_nonce) + text: Final = f"cached answer {request_nonce}" + results: Final = list(available({})) + synthesizing.register( + request_nonce, + by_target( + { + AZURE_REFUSED_TARGET: claude_backend(refusal(identity), refusal_fields()), + CACHED_FALLBACK_TARGET: claude_backend( + answer(f"{identity}c", text), {"stop_details": None, "safeguard_results": results} + ), + } + ), + ) + cacheable: Final = auto_mode_body( + CACHED_FALLBACK_MODEL, request_nonce, tools=[dict(WEB_SEARCH_TOOL)], prompt="search the web for" + ) + + def prime() -> tuple[str, ...]: + primed: Final = synthesizing.post({**cacheable, "stream": False}) + assert primed.status_code == 200, primed.text + return targets(synthesizing, request_nonce) + + first_prime: Final = prime() + assert first_prime == (CACHED_FALLBACK_TARGET,), first_prime + eventually(prime, lambda received: received == ()) + status, raw = synthesizing.stream({**cacheable, "model": AZURE_REFUSED_MODEL}) + assert status == 200, raw + assert streamed_text(raw) == text, raw + started: Final = message_start(raw) + assert started["stop_details"] is None and "safeguard_results" not in started, raw + assert message_delta(raw) == {"stop_reason": "end_turn", "stop_sequence": None, "safeguard_results": results}, raw + assert targets(synthesizing, request_nonce) == (AZURE_REFUSED_TARGET,) + + +def flagged_verdict(explanation: str) -> Mapping[str, JsonValue]: + return {"type": "evaluated", "outcome": "flagged", "explanation": explanation} + + +FLAGGED_WITHOUT_EXPLANATION: Final[Mapping[str, JsonValue]] = {"type": "evaluated", "outcome": "flagged"} + + +def masked_refusal(identity: str) -> tuple[Mapping[str, JsonValue], Mapping[str, JsonValue], Mapping[str, JsonValue]]: + message: Final = native_message( + identity, content=(text_block(f"Partial {SECRET}."),), stop_reason="refusal", model="claude-sonnet-4-6" + ) + expected: Final[Mapping[str, JsonValue]] = { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": {**REFUSAL_STOP_DETAILS, "explanation": None}, + "safeguard_results": list(available({})), + } + return message, refusal_fields(), expected + + +def masked_tool_turn( + identity: str, +) -> tuple[Mapping[str, JsonValue], Mapping[str, JsonValue], Mapping[str, JsonValue]]: + message: Final = native_message( + identity, + content=(text_block(f"Reading {SECRET}."), tool_use_block(identity, name="Read", arguments={"path": "/work"})), + model="claude-sonnet-4-6", + ) + sent: Final[Mapping[str, JsonValue]] = { + "safeguard_results": list(available({identity: dict(flagged_verdict(f"reads {identity}"))})) + } + expected: Final[Mapping[str, JsonValue]] = { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": list(available({identity: dict(FLAGGED_WITHOUT_EXPLANATION)})), + } + return message, sent, expected + + +MASKED_STREAMS: Final = {"refusal_stop_details": masked_refusal, "verdict_explanation": masked_tool_turn} + + +@pytest.mark.parametrize("shape", tuple(MASKED_STREAMS)) +def test_pipeline_text_rewrite_drops_explanations_from_the_upstream_message_delta( + synthesizing: Rig, shape: str +) -> None: + request_nonce: Final = nonce() + message, sent, expected = MASKED_STREAMS[shape](tool_id(request_nonce)) + synthesizing.register(request_nonce, claude_backend(message, sent)) + body: Final = auto_mode_body(PIPELINE_MODEL, request_nonce, tools=[dict(READ_TOOL)], **NO_CACHE) + status, raw = synthesizing.stream(body) + assert status == 200, raw + assert MASKED in streamed_text(raw) and SECRET not in streamed_text(raw), raw + assert message_delta(raw) == expected, raw + assert targets(synthesizing, request_nonce) == (PIPELINE_TARGET,) + + +def test_pipeline_tool_call_rewrite_keeps_only_the_untouched_tool_verdict(synthesizing: Rig) -> None: + request_nonce: Final = nonce() + rewritten: Final = tool_id(request_nonce) + untouched: Final = f"{rewritten}r" + message: Final = native_message( + rewritten, + content=( + tool_use_block(rewritten, arguments={"command": f"cat {SECRET}"}), + tool_use_block(untouched, name="Read", arguments={"path": "/work"}), + ), + model="claude-sonnet-4-6", + ) + verdicts: Final[Mapping[str, JsonValue]] = { + rewritten: dict(flagged_verdict(f"reads a file {rewritten}")), + untouched: dict(flagged_verdict(f"reads a directory {untouched}")), + } + synthesizing.register(request_nonce, claude_backend(message, {"safeguard_results": list(available(verdicts))})) + body: Final = auto_mode_body(PIPELINE_MODEL, request_nonce, tools=[dict(BASH_TOOL), dict(READ_TOOL)], **NO_CACHE) + status, raw = synthesizing.stream(body) + assert status == 200, raw + assert stream_tool_use_ids(raw) == (rewritten, untouched), raw + assert SECRET.encode() not in raw and MASKED.encode() in raw, raw + assert stream_results(raw) == available({untouched: dict(FLAGGED_WITHOUT_EXPLANATION)}), raw + assert targets(synthesizing, request_nonce) == (PIPELINE_TARGET,) diff --git a/tests/integration/observability/test_generic_guardrail_num_retries.py b/tests/integration/observability/test_generic_guardrail_num_retries.py new file mode 100644 index 00000000000..df761d25968 --- /dev/null +++ b/tests/integration/observability/test_generic_guardrail_num_retries.py @@ -0,0 +1,179 @@ +"""generic_guardrail_api `num_retries` resends a check that timed out waiting for its answer, through a real proxy. + +Each guardrail points at its own owned stub. A stub that holds a check never answers it before the 1s guardrail +timeout, and holding lasts until teardown so a resent check can only be answered by a later arrival. +""" + +import itertools +import json +import threading +from collections.abc import Callable, Iterator, Mapping +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway +from integration._support.openai_wire import chat_reply +from integration._support.process import OwnedProxy, owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +MODEL: Final = "guarded-chat" +UPSTREAM_MODEL: Final = "openai/gpt-6.1-sol" +GUARDRAIL_TIMEOUT_SECONDS: Final = 1 +HOLDS_FIRST_RETRIED: Final = "holds-first-check-retried-once" +HOLDS_FIRST_UNSET: Final = "holds-first-check-num-retries-unset" +HOLDS_ALL_RETRIED_TWICE: Final = "holds-every-check-retried-twice" +UNAVAILABLE_RETRIED_TWICE: Final = "unavailable-retried-twice" +ANSWER: Final = "upstream answer" +NO_ACTION: Final = Reply(body=json.dumps({"action": "NONE"}).encode()) +_CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml" +_JSON: Final = TypeAdapter(dict[str, JsonValue]) + + +@dataclass(frozen=True, slots=True) +class Rig: + owned: OwnedProxy + upstream: Wire + guardrails: Mapping[str, Wire] + + +def _holds_first(release: threading.Event) -> Callable[[Request], Reply]: + arrivals: Final = itertools.count() + + def respond(_request: Request) -> Reply: + if next(arrivals) == 0: + release.wait() + return NO_ACTION + + return respond + + +def _holds_all(release: threading.Event) -> Callable[[Request], Reply]: + def respond(_request: Request) -> Reply: + release.wait() + return NO_ACTION + + return respond + + +def _unavailable(_request: Request) -> Reply: + return Reply(status=503, body=b'{"error": "guardrail unavailable"}') + + +def _upstream(_request: Request) -> Reply: + return chat_reply("chatcmpl-num-retries", "gpt-6.1-sol", ANSWER, stream=False) + + +def _guardrail(name: str, wire: Wire, num_retries: int | None) -> dict[str, JsonValue]: + return { + "guardrail_name": name, + "litellm_params": { + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "default_on": False, + "api_base": wire.url, + "api_key": "synthetic-guardrail-key", + "timeout": GUARDRAIL_TIMEOUT_SECONDS, + **({} if num_retries is None else {"num_retries": num_retries}), + }, + } + + +@pytest.fixture(scope="module") +def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("generic-guardrail-num-retries") + release: Final = threading.Event() + with ExitStack() as stack: + upstream: Final = stack.enter_context(wire_server(_upstream)) + guardrails: Final = MappingProxyType( + { + HOLDS_FIRST_RETRIED: stack.enter_context(wire_server(_holds_first(release), keep_alive=True)), + HOLDS_FIRST_UNSET: stack.enter_context(wire_server(_holds_first(release), keep_alive=True)), + HOLDS_ALL_RETRIED_TWICE: stack.enter_context(wire_server(_holds_all(release), keep_alive=True)), + UNAVAILABLE_RETRIED_TWICE: stack.enter_context(wire_server(_unavailable, keep_alive=True)), + } + ) + stack.callback(release.set) + retries: Final = MappingProxyType( + {HOLDS_FIRST_RETRIED: 1, HOLDS_FIRST_UNSET: None, HOLDS_ALL_RETRIED_TWICE: 2, UNAVAILABLE_RETRIED_TWICE: 2} + ) + base: Final = _JSON.validate_python(yaml.safe_load(_CONFIG.read_text())) + path: Final = directory / "generic-guardrail-num-retries.yaml" + path.write_text( + yaml.safe_dump( + { + **base, + "model_list": [ + { + "model_name": MODEL, + "litellm_params": { + "model": UPSTREAM_MODEL, + "api_base": f"{upstream.url}/v1", + "api_key": "synthetic-openai-key", + }, + } + ], + "guardrails": [_guardrail(name, wire, retries[name]) for name, wire in guardrails.items()], + } + ) + ) + owned: Final = stack.enter_context(owned_proxy_process(session_gateway, directory, {}, config=path)) + yield Rig(owned, upstream, guardrails) + + +def _chat(rig: Rig, guardrail: str) -> httpx.Response: + return rig.owned.gateway.request( + "POST", + "/v1/chat/completions", + {"model": MODEL, "messages": [{"role": "user", "content": "is this allowed?"}], "guardrails": [guardrail]}, + ) + + +def _checks(wire: Wire) -> tuple[bytes, ...]: + return tuple(request.body for request in wire.drain()) + + +def test_check_held_past_the_timeout_is_resent_on_a_fresh_connection_and_the_request_is_served(rig: Rig) -> None: + response: Final = _chat(rig, HOLDS_FIRST_RETRIED) + + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["content"] == ANSWER + stub: Final = rig.guardrails[HOLDS_FIRST_RETRIED] + checks: Final = _checks(stub) + assert len(checks) == 2, checks + assert checks[0] == checks[1] + assert stub.connections() == 2 + assert len(rig.upstream.drain()) == 1 + + +def test_check_held_past_the_timeout_is_sent_once_when_num_retries_is_unset(rig: Rig) -> None: + response: Final = _chat(rig, HOLDS_FIRST_UNSET) + + assert response.status_code != 200, response.text + assert "Generic Guardrail API failed" in response.text + assert len(_checks(rig.guardrails[HOLDS_FIRST_UNSET])) == 1 + assert rig.upstream.drain() == () + + +def test_num_retries_bounds_how_many_times_a_held_check_is_sent(rig: Rig) -> None: + response: Final = _chat(rig, HOLDS_ALL_RETRIED_TWICE) + + assert response.status_code != 200, response.text + assert "Generic Guardrail API failed" in response.text + assert len(_checks(rig.guardrails[HOLDS_ALL_RETRIED_TWICE])) == 3 + assert rig.upstream.drain() == () + + +def test_error_status_from_the_guardrail_is_not_resent(rig: Rig) -> None: + response: Final = _chat(rig, UNAVAILABLE_RETRIED_TWICE) + + assert response.status_code != 200, response.text + assert "Generic Guardrail API failed" in response.text + assert len(_checks(rig.guardrails[UNAVAILABLE_RETRIED_TWICE])) == 1 + assert rig.upstream.drain() == () diff --git a/tests/integration/observability/test_guardrail_effects.py b/tests/integration/observability/test_guardrail_effects.py index f267cff04f3..4e0c3d45cdf 100644 --- a/tests/integration/observability/test_guardrail_effects.py +++ b/tests/integration/observability/test_guardrail_effects.py @@ -8,6 +8,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from contextlib import contextmanager from dataclasses import dataclass +from itertools import chain from pathlib import Path from types import MappingProxyType from typing import Final @@ -22,7 +23,8 @@ from integration._support.mcp import mcp_peer, register_mcp, tool_names from integration._support.process import OwnedProxy, owned_proxy, owned_proxy_process from integration._support.wire import Reply, Request, Wire, wire_server from integration._support.ports import allocate_port -from openai import AsyncOpenAI, OpenAI +from openai import APIError, AsyncOpenAI, OpenAI +from openai.types.chat import ChatCompletionChunk from pydantic import BaseModel, JsonValue @@ -2858,6 +2860,281 @@ def test_a_fully_read_stream_is_scanned_exactly_once( assert len(rig.scans.response_scans(rig.secret())) == 1, rig.scans.seen +def _pre_call_progress_config(tmp_path: Path, identity: str, policy_url: str, *, store_prompts: bool) -> Path: + path: Final = _post_call_config(tmp_path, identity, policy_url, {**_END_OF_STREAM_ONLY, "mode": "pre_call"}, True) + if store_prompts: + config: Final = yaml.safe_load(path.read_text()) + settings: Final = {**config["general_settings"], "store_prompts_in_spend_logs": True} + path.write_text(yaml.safe_dump({**config, "general_settings": settings})) + return path + + +def _openai_chat_chunks(candidate: Gateway, model: str, prompt: str) -> tuple[ChatCompletionChunk, ...]: + with OpenAI( + base_url=str(candidate.client.base_url) + "/v1", api_key=candidate.key, max_retries=0, timeout=30 + ) as client: + return tuple( + client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + stream=True, + extra_headers={"x-litellm-call-id": prompt}, + ) + ) + + +def _async_openai_chat_chunks(candidate: Gateway, model: str, prompt: str) -> tuple[ChatCompletionChunk, ...]: + async def consume() -> tuple[ChatCompletionChunk, ...]: + async with AsyncOpenAI( + base_url=str(candidate.client.base_url) + "/v1", api_key=candidate.key, max_retries=0, timeout=30 + ) as client: + stream: Final = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + stream=True, + extra_headers={"x-litellm-call-id": prompt}, + ) + return tuple([chunk async for chunk in stream]) + + return asyncio.run(consume()) + + +_PROGRESS_STREAM_READERS: Final = ( + pytest.param(_openai_chat_chunks, id="sync-openai-sdk"), + pytest.param(_async_openai_chat_chunks, id="async-openai-sdk"), +) + + +@pytest.mark.parametrize("read_stream", _PROGRESS_STREAM_READERS) +@pytest.mark.parametrize("index_fields", ({}, {"index": None}), ids=("omitted-index", "null-index")) +@pytest.mark.parametrize("progress_content", (None, "progress "), ids=("null-content", "text-content")) +def test_progress_chunk_without_choice_index_streams_and_logs_served_answer( + gateway: Gateway, + tmp_path: Path, + record_property: Callable[[str, object], None], + index_fields: dict[str, JsonValue], + progress_content: str | None, + read_stream: Callable[[Gateway, str, str], tuple[ChatCompletionChunk, ...]], +) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + record_property("litellm_call_id", identity) + answer: Final = "synthetic answer " + identity + reasoning: Final = "backend progress " + identity + expected: Final = (progress_content or "") + answer + frames: Final = ( + _chat_frame( + identity, + ( + { + **index_fields, + "delta": {"reasoning_content": reasoning, "content": progress_content}, + "finish_reason": None, + }, + ), + ), + _chat_frame(identity, (_chat_choice(0, {"content": answer}),)), + _chat_frame(identity, (_chat_choice(0, {}, "stop"),)), + b"data: [DONE]\n\n", + ) + + def provider(request: Request) -> Reply: + assert request.target == "/v1/chat/completions", request.target + body: Final = json.loads(request.body) + assert body["stream"] is True, body + assert body["messages"] == [{"role": "user", "content": identity}], body + return Reply(content_type="text/event-stream", chunks=frames) + + with wire_server(_allowing_guardrail) as policy, wire_server(provider) as upstream: + path: Final = _pre_call_progress_config(tmp_path, identity, policy.url, store_prompts=True) + with ( + owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned, + owned.gateway.scenario() as scenario, + ): + candidate: Final = owned.gateway + model: Final = scenario.model(model_info={"id": identity}, api_base=upstream.url + "/v1") + eventually(lambda: owned.serves_only_on_every_worker(model, identity), bool, seconds=70) + chunks: Final = read_stream(candidate, model, identity) + choices: Final = tuple(chain.from_iterable(chunk.choices for chunk in chunks)) + actual: Final = "".join(choice.delta.content or "" for choice in choices) + assert actual == expected, chunks + assert tuple(choice.index for choice in choices if choice.finish_reason == "stop") == (0,), chunks + assert reasoning in json.dumps([chunk.model_dump() for chunk in chunks]), chunks + assert len(upstream.drain()) == 1 + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id, status, response, metadata FROM "LiteLLM_SpendLogs" ' + "WHERE model_group=%s AND (request_id=%s OR metadata->>'litellm_call_id'=%s)", + (model, identity, identity), + ), + lambda values: len(values) == 1, + seconds=70, + ) + record_property("spend_request_id", rows[0]["request_id"]) + assert rows[0]["status"] == "success", rows + logged_choices: Final = object_value(rows[0]["response"])["choices"] + assert isinstance(logged_choices, list), rows + assert tuple(object_value(object_value(choice)["message"])["content"] for choice in logged_choices) == ( + expected, + ), rows + entries: Final = object_value(rows[0]["metadata"])["guardrail_information"] + assert isinstance(entries, list), rows + assert tuple( + (entry["guardrail_name"], entry["guardrail_mode"], entry["guardrail_status"]) + for entry in map(object_value, entries) + ) == ((identity, "pre_call", "success"),), rows + scans: Final = tuple(json.loads(request.body) for request in policy.drain()) + assert len(scans) == 1 and scans[0]["input_type"] == "request", scans + assert scans[0]["texts"] == [identity], scans + + +@pytest.mark.parametrize("read_stream", _PROGRESS_STREAM_READERS) +def test_progress_chunk_without_choice_index_preserves_upstream_stream_error( + gateway: Gateway, + tmp_path: Path, + record_property: Callable[[str, object], None], + read_stream: Callable[[Gateway, str, str], tuple[ChatCompletionChunk, ...]], +) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + record_property("litellm_call_id", identity) + message: Final = "synthetic upstream failure marker " + identity + frames: Final = ( + _chat_frame( + identity, + ({"delta": {"reasoning_content": "backend progress", "content": None}, "finish_reason": None},), + ), + b"data: " + + json.dumps({"error": {"message": message, "type": "server_error", "code": "upstream_failed"}}).encode() + + b"\n\n", + b"data: [DONE]\n\n", + ) + + def provider(request: Request) -> Reply: + assert request.target == "/v1/chat/completions", request.target + body: Final = json.loads(request.body) + assert body["stream"] is True, body + assert body["messages"] == [{"role": "user", "content": identity}], body + return Reply(content_type="text/event-stream", chunks=frames) + + with wire_server(_allowing_guardrail) as policy, wire_server(provider) as upstream: + path: Final = _pre_call_progress_config(tmp_path, identity, policy.url, store_prompts=False) + with ( + owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned, + owned.gateway.scenario() as scenario, + ): + candidate: Final = owned.gateway + model: Final = scenario.model(model_info={"id": identity}, api_base=upstream.url + "/v1", num_retries=0) + eventually(lambda: owned.serves_only_on_every_worker(model, identity), bool, seconds=70) + with pytest.raises(APIError, match=message) as raised: + read_stream(candidate, model, identity) + assert "NoneType" not in str(raised.value), raised.value + assert len(upstream.drain()) == 1 + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id, status, metadata FROM "LiteLLM_SpendLogs" ' + "WHERE model_group=%s AND (request_id=%s OR metadata->>'litellm_call_id'=%s)", + (model, identity, identity), + ), + lambda values: len(values) == 1, + seconds=70, + ) + record_property("spend_request_id", rows[0]["request_id"]) + assert rows[0]["status"] == "failure", rows + error: Final = object_value(rows[0]["metadata"])["error_information"] + assert message in str(object_value(error)["error_message"]), rows + scans: Final = tuple(json.loads(request.body) for request in policy.drain()) + assert len(scans) == 1 and scans[0]["texts"] == [identity], scans + + +@pytest.mark.parametrize("index_fields", ({}, {"index": None}), ids=("omitted-index", "null-index")) +def test_disconnect_after_progress_without_choice_index_logs_only_the_received_prefix( + gateway: Gateway, + tmp_path: Path, + index_fields: dict[str, JsonValue], + record_property: Callable[[str, object], None], +) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + record_property("litellm_call_id", identity) + prefix: Final = "synthetic received prefix " + identity + tail: Final = "synthetic withheld tail " + identity + gate: Final = threading.Event() + frames: Final = ( + _chat_frame( + identity, + ( + { + **index_fields, + "delta": {"reasoning_content": "backend progress", "content": prefix}, + "finish_reason": None, + }, + ), + ), + _chat_frame(identity, (_chat_choice(0, {"content": tail}, "stop"),)), + b"data: [DONE]\n\n", + ) + + def provider(request: Request) -> Reply: + assert request.target == "/v1/chat/completions", request.target + body: Final = json.loads(request.body) + assert body["stream"] is True, body + assert body["messages"] == [{"role": "user", "content": identity}], body + return Reply(content_type="text/event-stream", chunks=frames, gate_after_first=gate) + + with wire_server(_allowing_guardrail) as policy, wire_server(provider) as upstream: + path: Final = _pre_call_progress_config(tmp_path, identity, policy.url, store_prompts=True) + with ( + owned_proxy_process(gateway, tmp_path, {}, config=path, workers=2) as owned, + owned.gateway.scenario() as scenario, + ): + candidate: Final = owned.gateway + model: Final = scenario.model(model_info={"id": identity}, api_base=upstream.url + "/v1", num_retries=0) + eventually(lambda: owned.serves_only_on_every_worker(model, identity), bool, seconds=70) + + try: + + async def disconnect() -> str: + async with AsyncOpenAI( + base_url=str(candidate.client.base_url) + "/v1", + api_key=candidate.key, + max_retries=0, + timeout=30, + ) as client: + stream: Final = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": identity}], + stream=True, + extra_headers={"x-litellm-call-id": identity}, + ) + async for chunk in stream: + for choice in chunk.choices: + if choice.delta.content == prefix: + await stream.close() + return choice.delta.content + raise AssertionError("The stream ended before the SDK received the provider prefix") + + assert asyncio.run(disconnect()) == prefix + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_id, status, response FROM "LiteLLM_SpendLogs" ' + "WHERE model_group=%s AND (request_id=%s OR metadata->>'litellm_call_id'=%s)", + (model, identity, identity), + ), + lambda values: len(values) == 1, + seconds=70, + ) + record_property("spend_request_id", rows[0]["request_id"]) + assert rows[0]["status"] == "success", rows + logged_choices: Final = object_value(rows[0]["response"])["choices"] + assert isinstance(logged_choices, list), rows + assert tuple(object_value(object_value(choice)["message"])["content"] for choice in logged_choices) == ( + prefix, + ), rows + assert len(upstream.drain()) == 1 + scans: Final = tuple(json.loads(request.body) for request in policy.drain()) + assert len(scans) == 1 and scans[0]["texts"] == [identity], scans + finally: + gate.set() + + def _cached_twin_rows(rig: _DisconnectRig) -> tuple[tuple[str, ...], ...]: first: Final = rig.candidate.request("POST", "/v1/chat/completions", _chat_body(rig, 0, stream=False)) second: Final = rig.candidate.request("POST", "/v1/chat/completions", _chat_body(rig, 0, stream=False)) diff --git a/tests/integration/observability/test_otel_cache_hit_cost.py b/tests/integration/observability/test_otel_cache_hit_cost.py new file mode 100644 index 00000000000..76c21b529ef --- /dev/null +++ b/tests/integration/observability/test_otel_cache_hit_cost.py @@ -0,0 +1,370 @@ +import json +import os +import re +import uuid +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, eventually, gateway_from_environment, list_value, object_value +from integration._support.database import read_rows +from integration._support.otlp_sink import Span, owned_sinks, recorded_spans +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter +from redis import Redis + +INPUT_PRICE: Final = 0.001 +OUTPUT_PRICE: Final = 0.002 +PROMPT_TOKENS: Final = 7 +COMPLETION_TOKENS: Final = 2 +CHAT_PRICE: Final = PROMPT_TOKENS * INPUT_PRICE + COMPLETION_TOKENS * OUTPUT_PRICE +EMBEDDING_PRICE: Final = PROMPT_TOKENS * INPUT_PRICE +CLIENT: Final = 3 +COST_HEADERS: Final = ("x-litellm-response-cost", "x-litellm-response-cost-input", "x-litellm-response-cost-output") +JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +MARKER: Final = re.compile(rb"cache-hit-cost-[0-9a-f]{32}") + + +def _sse(frames: tuple[Mapping[str, JsonValue], ...]) -> Reply: + return Reply( + content_type="text/event-stream", + chunks=(*(b"data: " + json.dumps(frame).encode() + b"\n\n" for frame in frames), b"data: [DONE]\n\n"), + ) + + +def _answer(marker: str) -> str: + return "answer to " + marker + + +def _embedding_value(marker: str) -> float: + return float(int(marker[-12:], 16)) + + +def _chat_reply(stream: bool, marker: str) -> Reply: + identity: Final = "chatcmpl-" + uuid.uuid4().hex + message: Final = {"role": "assistant", "content": _answer(marker)} + usage: Final = { + "prompt_tokens": PROMPT_TOKENS, + "completion_tokens": COMPLETION_TOKENS, + "total_tokens": PROMPT_TOKENS + COMPLETION_TOKENS, + } + if not stream: + return Reply( + body=json.dumps( + { + "id": identity, + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": usage, + } + ).encode() + ) + chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini"} + return _sse( + ( + {**chunk, "choices": [{"index": 0, "delta": message}]}, + {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}, + {**chunk, "choices": [], "usage": usage}, + ) + ) + + +def _responses_reply(marker: str) -> Reply: + identity: Final = "resp_" + uuid.uuid4().hex + return Reply( + body=json.dumps( + { + "id": identity, + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "id": "msg_" + identity, + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": _answer(marker), "annotations": []}], + } + ], + "usage": { + "input_tokens": PROMPT_TOKENS, + "output_tokens": COMPLETION_TOKENS, + "total_tokens": PROMPT_TOKENS + COMPLETION_TOKENS, + }, + } + ).encode() + ) + + +def _embedding_reply(marker: str) -> Reply: + return Reply( + body=json.dumps( + { + "object": "list", + "data": [{"object": "embedding", "index": 0, "embedding": [_embedding_value(marker), 0.5, 0.75]}], + "model": "text-embedding-3-small", + "usage": {"prompt_tokens": PROMPT_TOKENS, "total_tokens": PROMPT_TOKENS}, + } + ).encode() + ) + + +def _provider(request: Request) -> Reply: + found: Final = MARKER.search(request.body) + assert found is not None, request.body + marker: Final = found.group().decode() + if request.target.endswith("/embeddings"): + return _embedding_reply(marker) + if request.target.endswith("/responses"): + return _responses_reply(marker) + return _chat_reply(JSON_OBJECT.validate_json(request.body).get("stream") is True, marker) + + +@dataclass(frozen=True, slots=True) +class Route: + path: str + stream: bool + price: float + body: Callable[[str, str], Mapping[str, JsonValue]] + cached: Callable[[str], str] = _answer + + +ROUTES: Final[Mapping[str, Route]] = MappingProxyType( + { + "chat": Route( + "/v1/chat/completions", + False, + CHAT_PRICE, + lambda model, marker: {"model": model, "messages": [{"role": "user", "content": marker}]}, + ), + "chat_stream": Route( + "/v1/chat/completions", + True, + CHAT_PRICE, + lambda model, marker: { + "model": model, + "messages": [{"role": "user", "content": marker}], + "stream": True, + "stream_options": {"include_usage": True}, + }, + ), + "messages": Route( + "/v1/messages", + False, + CHAT_PRICE, + lambda model, marker: {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": marker}]}, + ), + "responses": Route("/v1/responses", False, CHAT_PRICE, lambda model, marker: {"model": model, "input": marker}), + "embeddings": Route( + "/v1/embeddings", + False, + EMBEDDING_PRICE, + lambda model, marker: {"model": model, "input": marker}, + lambda marker: repr(_embedding_value(marker)), + ), + } +) + + +@dataclass(frozen=True, slots=True) +class Served: + call_id: str + headers: httpx.Headers + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + sink: str + chat_model: str + embedding_model: str + upstream: Wire + otel_v2: bool + + def send(self, route: str, marker: str) -> Served: + chosen: Final = ROUTES[route] + model: Final = self.embedding_model if route == "embeddings" else self.chat_model + with self.proxy.client.stream( + "POST", + chosen.path, + json=dict(chosen.body(model, marker)), + headers={"Authorization": f"Bearer {self.proxy.key}"}, + ) as response: + body: Final = response.read() + assert response.status_code == 200, body + return Served(response.headers["x-litellm-call-id"], response.headers) + + def upstream_calls(self, marker: str) -> int: + return sum(1 for request in self.upstream.drain() if marker.encode() in request.body) + + def cost_span(self, call_id: str) -> Span: + def matching() -> tuple[Span, ...]: + return tuple(span for span in recorded_spans(self.sink)[1] if self._is_cost_span(span, call_id)) + + found: Final = eventually(matching, lambda spans: len(spans) >= 1, seconds=30) + assert len(found) == 1, found + return found[0] + + def _is_cost_span(self, span: Span, call_id: str) -> bool: + if span["attributes"].get("litellm.call_id") != call_id: + return False + if self.otel_v2: + return span["kind"] == CLIENT and "gen_ai.operation.name" in span["attributes"] + return span["name"] == "litellm_request" + + def total_cost(self, span: Span) -> float: + return _number(span["attributes"].get("litellm.cost.total" if self.otel_v2 else "gen_ai.cost.total_cost")) + + +def _number(value: JsonValue) -> float: + assert isinstance(value, (int, float, str)) and not isinstance(value, bool), value + return float(value) + + +def _config(provider: Wire, sink: str, chat_model: str, embedding_model: str) -> dict[str, JsonValue]: + config: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + priced: Final = {"input_cost_per_token": INPUT_PRICE, "output_cost_per_token": OUTPUT_PRICE} + return { + **config, + "model_list": [ + *list_value(config.get("model_list") or []), + { + "model_name": chat_model, + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "integration-provider-key", + "api_base": provider.url + "/v1", + **priced, + }, + }, + { + "model_name": embedding_model, + "litellm_params": { + "model": "openai/text-embedding-3-small", + "api_key": "integration-provider-key", + "api_base": provider.url + "/v1", + **priced, + }, + }, + ], + "litellm_settings": {**object_value(config["litellm_settings"]), "callbacks": ["otel"]}, + "callback_settings": {"otel": {"exporter": "http/json", "endpoint": sink, "use_simple_processor": True}}, + } + + +@pytest.fixture(scope="module") +def provider() -> Iterator[Wire]: + with wire_server(_provider) as wire: + yield wire + + +def _rig(provider: Wire, directory: Path, otel_v2: bool) -> Iterator[Rig]: + chat_model: Final = "cachecost-chat-" + uuid.uuid4().hex[:12] + embedding_model: Final = "cachecost-embed-" + uuid.uuid4().hex[:12] + with owned_sinks(directory / "sinks") as sinks: + path: Final = directory / f"cache-hit-cost-{uuid.uuid4().hex}.yaml" + path.write_text(yaml.safe_dump(_config(provider, sinks.operator, chat_model, embedding_model))) + v1_exporter: Final = {"OTEL_EXPORTER": "http/json", "OTEL_ENDPOINT": sinks.operator} + exporter: Final = {"LITELLM_OTEL_V2": "1"} if otel_v2 else v1_exporter + with ( + gateway_from_environment() as gateway, + owned_proxy_process( + gateway, + directory, + exporter, + config=path, + remove_environment=() if otel_v2 else ("LITELLM_OTEL_V2",), + workers=2, + ) as owned, + ): + yield Rig(owned.gateway, sinks.operator, chat_model, embedding_model, provider, otel_v2) + + +@pytest.fixture(scope="module") +def v1(provider: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + yield from _rig(provider, tmp_path_factory.mktemp("cache-hit-cost-v1"), otel_v2=False) + + +@pytest.fixture(scope="module") +def v2(provider: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + yield from _rig(provider, tmp_path_factory.mktemp("cache-hit-cost-v2"), otel_v2=True) + + +def _spend_row(call_id: str) -> Mapping[str, JsonValue]: + rows: Final = eventually( + lambda: read_rows( + 'SELECT spend, cache_hit, metadata FROM "LiteLLM_SpendLogs" WHERE litellm_call_id=%s', (call_id,) + ), + lambda found: len(found) == 1, + seconds=70, + ) + return rows[0] + + +def _breakdown_total(row: Mapping[str, JsonValue]) -> float: + return _number(object_value(object_value(row["metadata"])["cost_breakdown"])["total_cost"]) + + +def _cache_holds(needle: str) -> bool: + with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as store: + return any(needle.encode() in _bytes(store.get(key)) for key in store.scan_iter(count=1000, _type="STRING")) + + +def _bytes(value: object) -> bytes: + return value if isinstance(value, bytes) else b"" + + +def _miss_then_hit(rig: Rig, route: str) -> tuple[Served, Served]: + marker: Final = "cache-hit-cost-" + uuid.uuid4().hex + miss: Final = rig.send(route, marker) + eventually(lambda: _cache_holds(ROUTES[route].cached(marker)), bool, seconds=30) + hit: Final = rig.send(route, marker) + assert rig.upstream_calls(marker) == 1 + return miss, hit + + +@pytest.mark.parametrize("route", tuple(ROUTES)) +def test_a_v1_cache_hit_exports_zero_cost_and_answers_zero_cost_headers(v1: Rig, route: str) -> None: + price: Final = ROUTES[route].price + miss, hit = _miss_then_hit(v1, route) + + hit_span: Final = v1.cost_span(hit.call_id) + assert v1.total_cost(hit_span) == 0, hit_span + assert hit_span["attributes"].get("litellm.cache_hit") is True, hit_span + assert _number(hit_span["attributes"].get("litellm.saved_cache_cost")) == pytest.approx(price), hit_span + + assert {name: hit.headers.get(name) for name in COST_HEADERS} == dict.fromkeys(COST_HEADERS, "0.0"), hit.headers + + hit_row: Final = _spend_row(hit.call_id) + assert (_number(hit_row["spend"]), hit_row["cache_hit"]) == (0, "True"), hit_row + assert _breakdown_total(hit_row) == 0, hit_row + assert _number(_spend_row(miss.call_id)["spend"]) == pytest.approx(price) + + miss_span: Final = v1.cost_span(miss.call_id) + assert v1.total_cost(miss_span) == pytest.approx(price), miss_span + assert miss_span["attributes"].get("litellm.cache_hit") is False, miss_span + + +@pytest.mark.parametrize("route", tuple(ROUTES)) +def test_a_v2_cache_hit_exports_an_llm_span_at_zero_cost(v2: Rig, route: str) -> None: + price: Final = ROUTES[route].price + miss, hit = _miss_then_hit(v2, route) + + hit_span: Final = v2.cost_span(hit.call_id) + assert v2.total_cost(hit_span) == 0, hit_span + assert hit_span["attributes"].get("litellm.cache_hit") is True, hit_span + assert _number(hit_span["attributes"].get("litellm.saved_cache_cost")) == pytest.approx(price), hit_span + + miss_span: Final = v2.cost_span(miss.call_id) + assert v2.total_cost(miss_span) == pytest.approx(price), miss_span + assert miss_span["attributes"].get("litellm.cache_hit") is False, miss_span diff --git a/tests/integration/observability/test_presidio_overlapping_spans.py b/tests/integration/observability/test_presidio_overlapping_spans.py new file mode 100644 index 00000000000..a2d92b15330 --- /dev/null +++ b/tests/integration/observability/test_presidio_overlapping_spans.py @@ -0,0 +1,251 @@ +import json +import re +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from types import MappingProxyType +from typing import Final, Literal + +import pytest +import yaml +from integration._support.client import Gateway, gateway_from_environment +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server + +MODEL: Final = "gpt-6.1-sol" +PHONE: Final = "4251234567" +EMAIL: Final = "john.smith@example.com" +INTERNATIONAL_PHONE: Final = "+33 1 42 68 53 00" +US_PHONE: Final = "+1 (206) 555-0199" +SAME_SPAN: Final = f"Repeat exactly: my number is {PHONE}" +NESTED: Final = f"contact {EMAIL} today" +CONTAINED_FRAGMENT: Final = f"her phone is {INTERNATIONAL_PHONE}." +SEPARATE_PARTS: Final = (f"My number is {PHONE}.", f"Also call {US_PHONE}.") +# Spans, scores and anonymized texts recorded from presidio-analyzer and presidio-anonymizer 2.2.364 on 2026-10-16 +DETECTIONS: Final = MappingProxyType( + { + PHONE: (("PHONE_NUMBER", 0, 10, 0.75), ("US_BANK_NUMBER", 0, 10, 0.05), ("US_DRIVER_LICENSE", 0, 10, 0.01)), + EMAIL: (("EMAIL_ADDRESS", 0, 22, 1.0), ("URL", 0, 7, 0.5), ("URL", 11, 22, 0.5)), + "today": (("DATE_TIME", 0, 5, 0.85),), + INTERNATIONAL_PHONE: (("DATE_TIME", 12, 17, 0.85), ("PHONE_NUMBER", 0, 17, 0.75)), + US_PHONE: (("PHONE_NUMBER", 0, 17, 0.4),), + } +) +ANONYMIZED: Final = MappingProxyType( + { + SAME_SPAN: "Repeat exactly: my number is ", + NESTED: "contact ", + CONTAINED_FRAGMENT: "her phone is .", + SEPARATE_PARTS[0]: "My number is .", + SEPARATE_PARTS[1]: "Also call .", + } +) +CASES: Final = ( + pytest.param(SAME_SPAN, "Repeat exactly: my number is ", id="same_span"), + pytest.param(NESTED, "contact ", id="nested"), + pytest.param(CONTAINED_FRAGMENT, "her phone is .", id="container_outranks_higher_scored_fragment"), +) +STREAM: Final = pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) + + +def _detections(text: str, value: str) -> Iterator[dict[str, str | int | float]]: + for match in re.finditer(rf"(? list[dict[str, str | int | float]]: + findings: Final = chain.from_iterable(_detections(text, value) for value in DETECTIONS) + return sorted(findings, key=lambda item: -item["score"]) + + +def _spans(results: list[dict[str, str | int | float]]) -> list[tuple[str | int | float, ...]]: + return sorted(tuple(result[key] for key in ("entity_type", "start", "end", "score")) for result in results) + + +def _analyze(request: Request) -> Reply: + assert request.target == "/analyze", request.target + return Reply(body=json.dumps(_findings(json.loads(request.body)["text"])).encode()) + + +def _anonymize(request: Request) -> Reply: + assert request.target == "/anonymize", request.target + body: Final = json.loads(request.body) + text: Final = body["text"] + if text not in ANONYMIZED: + return Reply(status=500, body=json.dumps({"error": f"no recorded anonymization for {text!r}"}).encode()) + if _spans(body["analyzer_results"]) != _spans(_findings(text)): + return Reply( + status=500, body=json.dumps({"error": f"analyzer_results differ from the detections for {text!r}"}).encode() + ) + return Reply(body=json.dumps({"text": ANONYMIZED[text], "items": []}).encode()) + + +def _texts(messages: list[dict]) -> tuple[str, ...]: + return tuple( + chain.from_iterable( + (message["content"],) + if isinstance(message["content"], str) + else (part["text"] for part in message["content"]) + for message in messages + ) + ) + + +def _chunk(text: str, finish: str | None) -> bytes: + delta: Final = {"role": "assistant", "content": text} if finish is None else {} + payload: Final = { + "id": "chatcmpl-echo", + "object": "chat.completion.chunk", + "created": 1, + "model": MODEL, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return b"data: " + json.dumps(payload).encode() + b"\n\n" + + +def _echo(request: Request) -> Reply: + assert request.target == "/v1/chat/completions", request.target + body: Final = json.loads(request.body) + text: Final = " | ".join(_texts(body["messages"])) + if body.get("stream"): + pieces: Final = tuple(text[index : index + 6] for index in range(0, len(text), 6)) + return Reply( + content_type="text/event-stream", + chunks=(*(_chunk(piece, None) for piece in pieces), _chunk("", "stop"), b"data: [DONE]\n\n"), + ) + reply: Final = { + "id": "chatcmpl-echo", + "object": "chat.completion", + "created": 1, + "model": MODEL, + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 9, "completion_tokens": 9, "total_tokens": 18}, + } + return Reply(body=json.dumps(reply).encode()) + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + provider: Wire + + +def _config(root: Path, provider: Wire, analyzer: Wire, anonymizer: Wire, scope: Literal["input"] | None) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["litellm_settings"]["cache"] = False + config["model_list"].append( + { + "model_name": MODEL, + "litellm_params": {"model": f"openai/{MODEL}", "api_base": provider.url + "/v1", "api_key": "synthetic"}, + } + ) + guardrail: Final = { + "guardrail": "presidio", + "mode": "pre_call", + "output_parse_pii": True, + "default_on": True, + "presidio_analyzer_api_base": analyzer.url + "/", + "presidio_anonymizer_api_base": anonymizer.url + "/", + } + scoped: Final = guardrail if scope is None else {**guardrail, "presidio_filter_scope": scope} + config["guardrails"] = [{"guardrail_name": "presidio-pii", "litellm_params": scoped}] + path: Final = root / "presidio-overlapping-spans.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +@contextmanager +def _rig(root: Path, scope: Literal["input"] | None) -> Iterator[Rig]: + with ( + gateway_from_environment() as gateway, + wire_server(_echo) as provider, + wire_server(_analyze) as analyzer, + wire_server(_anonymize) as anonymizer, + owned_proxy_process( + gateway, root, {}, config=_config(root, provider, analyzer, anonymizer, scope), workers=2 + ) as owned, + ): + yield Rig(owned.gateway, provider) + + +@pytest.fixture(scope="module") +def default_scope_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + with _rig(tmp_path_factory.mktemp("presidio-default-scope"), None) as rig: + yield rig + + +@pytest.fixture(scope="module") +def input_scope_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + with _rig(tmp_path_factory.mktemp("presidio-input-scope"), "input") as rig: + yield rig + + +def _send(rig: Rig, messages: list[dict], stream: bool) -> tuple[int, str]: + rig.provider.drain() + body: Final = {"model": MODEL, "messages": messages, "stream": stream} + headers: Final = {"Authorization": f"Bearer {rig.proxy.key}"} + if not stream: + response: Final = rig.proxy.client.post("/v1/chat/completions", json=body, headers=headers) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json()["choices"][0]["message"]["content"] + with rig.proxy.client.stream("POST", "/v1/chat/completions", json=body, headers=headers) as streamed: + if streamed.status_code != 200: + return streamed.status_code, streamed.read().decode() + frames: Final = tuple( + json.loads(line.removeprefix("data: ")) + for line in streamed.iter_lines() + if line.startswith("data: ") and line != "data: [DONE]" + ) + return 200, "".join(frame["choices"][0]["delta"].get("content") or "" for frame in frames if frame["choices"]) + + +def _forwarded(rig: Rig) -> tuple[str, ...]: + received: Final = rig.provider.drain() + assert len(received) == 1, received + return _texts(json.loads(received[0].body)["messages"]) + + +@STREAM +@pytest.mark.parametrize(("text", "forwarded"), CASES) +def test_overlapping_detections_reach_the_provider_as_one_token_and_come_back_unmasked( + input_scope_rig: Rig, text: str, forwarded: str, stream: bool +) -> None: + status, replied = _send(input_scope_rig, [{"role": "user", "content": text}], stream) + assert status == 200, replied + assert _forwarded(input_scope_rig) == (forwarded,) + assert replied == text + + +@STREAM +@pytest.mark.parametrize(("text", "forwarded"), CASES) +def test_default_scope_masks_the_cleanly_restored_reply_like_presidio_masks_the_original( + default_scope_rig: Rig, text: str, forwarded: str, stream: bool +) -> None: + status, replied = _send(default_scope_rig, [{"role": "user", "content": text}], stream) + assert status == 200, replied + assert _forwarded(default_scope_rig) == (forwarded,) + assert replied == ANONYMIZED[text] + + +@STREAM +@pytest.mark.parametrize( + "messages", + [ + pytest.param( + [{"role": "user", "content": [{"type": "text", "text": part} for part in SEPARATE_PARTS]}], + id="content_parts", + ), + pytest.param([{"role": "user", "content": part} for part in SEPARATE_PARTS], id="messages"), + ], +) +def test_one_type_in_separate_parts_gets_distinct_tokens_and_each_part_comes_back_as_itself( + input_scope_rig: Rig, messages: list[dict], stream: bool +) -> None: + status, replied = _send(input_scope_rig, messages, stream) + assert status == 200, replied + assert _forwarded(input_scope_rig) == ("My number is .", "Also call .") + assert replied == " | ".join(SEPARATE_PARTS) diff --git a/tests/integration/observability/test_presidio_streaming_output.py b/tests/integration/observability/test_presidio_streaming_output.py index d784fecd146..d69866ec43d 100644 --- a/tests/integration/observability/test_presidio_streaming_output.py +++ b/tests/integration/observability/test_presidio_streaming_output.py @@ -2,7 +2,7 @@ import json import re import threading import uuid -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack, contextmanager from dataclasses import dataclass @@ -10,6 +10,7 @@ from pathlib import Path from typing import Final import httpx +import pytest import yaml from integration._support.client import Gateway, eventually from integration._support.process import OwnedProxy, owned_proxy_process @@ -457,6 +458,96 @@ def test_anthropic_messages_per_request_guardrails_selects_masking(gateway: Gate assert len(rig.analyzer.drain()) == 1 +READ_TOOL_USE_ID: Final = "toolu_presidio_read" +READ_INPUT: Final = {"path": "/work/report.txt"} + + +def anthropic_tool_turn_stream(identity: str, text: str, delta: Mapping[str, object]) -> tuple[bytes, ...]: + opening, text_start, text_delta, text_stop, _, closing = anthropic_stream(identity, text) + tool_use: Final = {"type": "tool_use", "id": READ_TOOL_USE_ID, "name": "Read", "input": {}} + return ( + opening, + text_start, + text_delta, + text_stop, + anthropic_frame("content_block_start", {"type": "content_block_start", "index": 1, "content_block": tool_use}), + anthropic_frame( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": json.dumps(READ_INPUT)}, + }, + ), + anthropic_frame("content_block_stop", {"type": "content_block_stop", "index": 1}), + anthropic_frame("message_delta", {"type": "message_delta", "delta": delta, "usage": {"output_tokens": 4}}), + closing, + ) + + +def read_verdicts(verdict: Mapping[str, object]) -> Sequence[Mapping[str, object]]: + return [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {READ_TOOL_USE_ID: verdict}}}] + + +def anthropic_deltas(received: Received, event_type: str) -> tuple[Mapping[str, object], ...]: + events: Final = tuple( + json.loads(line.removeprefix("data: ")) for line in received.text.split("\n") if line.startswith("data: ") + ) + return tuple(event["delta"] for event in events if event.get("type") == event_type) + + +def anthropic_message_delta(received: Received) -> Mapping[str, object]: + deltas: Final = anthropic_deltas(received, "message_delta") + assert len(deltas) == 1, received.text + return deltas[0] + + +def anthropic_text_deltas(received: Received) -> str: + deltas: Final = anthropic_deltas(received, "content_block_delta") + return "".join(str(delta["text"]) for delta in deltas if delta.get("type") == "text_delta") + + +@pytest.mark.parametrize( + ("delta", "field", "kept"), + [ + ( + { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": read_verdicts( + {"type": "evaluated", "outcome": "flagged", "explanation": f"{PERSON} asked for the report"} + ), + }, + "safeguard_results", + read_verdicts({"type": "evaluated", "outcome": "flagged"}), + ), + ( + { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": {"type": "refusal", "category": "cyber", "explanation": f"declined for {PERSON}"}, + }, + "stop_details", + {"type": "refusal", "category": "cyber", "explanation": None}, + ), + ], + ids=["verdict_explanation", "stop_details_explanation"], +) +def test_anthropic_messages_masked_stream_keeps_upstream_safeguard_fields_without_explanations( + gateway: Gateway, tmp_path: Path, delta: Mapping[str, object], field: str, kept: object +) -> None: + identity: Final = "msg_" + uuid.uuid4().hex + provider: Final = anthropic_provider(anthropic_tool_turn_stream(identity, f"{PERSON} designed it.", delta)) + with presidio_rig(gateway, tmp_path, provider) as rig: + received: Final = rig.stream("/v1/messages", rig.messages_body()) + assert received.status == 200, received.text + assert anthropic_text_deltas(received) == f"{MASK} designed it.", received.text + assert anthropic_message_id(received) == identity, received.text + assert anthropic_message_delta(received).get(field) == kept, received.text + assert PERSON not in received.text, received.text + assert len(rig.upstream.drain()) == 1 + + def openai_provider(identity: str) -> Callable[[Request], Reply]: def provider(request: Request) -> Reply: assert request.target == "/v1/chat/completions", request.target diff --git a/tests/integration/observability/test_stream_error_alert_api_base.py b/tests/integration/observability/test_stream_error_alert_api_base.py new file mode 100644 index 00000000000..449e378dfc7 --- /dev/null +++ b/tests/integration/observability/test_stream_error_alert_api_base.py @@ -0,0 +1,482 @@ +import asyncio +import hashlib +import json +import uuid +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import anthropic +import openai +import pytest +import yaml +from anthropic.types import RawContentBlockDeltaEvent, TextDelta +from integration._support.anthropic_sse import DROP_PAUSE, dropping_reply, stream_reply +from integration._support.client import ( + JSON_OBJECT, + Gateway, + Scenario, + eventually, + gateway_from_environment, + object_value, + string_value, +) +from integration._support.database import read_rows +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.responses_stream import created, delta, failed, frame +from integration._support.responses_ws_peer import PROVIDER_MODEL, Script, events, responses_peer, session +from integration._support.tls import write_self_signed_cert +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire, wire_server +from openai.types.chat import ChatCompletionChunk +from openai.types.responses import ResponseFailedEvent, ResponseTextDeltaEvent +from pydantic import JsonValue + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +AZURE_KEY: Final = "synthetic-azure-key" +API_VERSION: Final = "2025-04-01-preview" +AZURE_CHAT_BACKEND: Final = "azure/gpt-4o-mini" +PARTIAL_TEXT: Final = "partial answer" +ALERT_SECONDS: Final = 40 +NO_RETRY_NO_CACHE: Final[Mapping[str, JsonValue]] = MappingProxyType({"num_retries": 0, "cache": {"no-cache": True}}) +EMPTY_MODEL_LIST: Final = Reply(body=b'{"object": "list", "data": []}') +VERTEX_PROJECT: Final = "alert-vertex-project" +VERTEX_LOCATION: Final = "us-east5" +VERTEX_PARTNER_BACKEND: Final = "claude-opus-5-5@default" +VERTEX_PARTNER_URL: Final = ( + f"https://{VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/{VERTEX_PROJECT}" + f"/locations/{VERTEX_LOCATION}/publishers/anthropic/models/{VERTEX_PARTNER_BACKEND}" +) +VERTEX_OVERLOADED: Final = Reply( + status=500, body=b'{"error": {"code": 500, "message": "vertex partner overloaded", "status": "INTERNAL"}}' +) + +API_BASES: Final[Mapping[str, Callable[[str, str], str]]] = MappingProxyType( + { + "plain": lambda url, _: f"{url}/gateway", + "code": lambda url, secret: f"{url}/gateway?code={secret}", + "subscription-key": lambda url, secret: f"{url}/gateway?subscription-key={secret}", + "userinfo": lambda url, secret: url.replace("://", f"://alice:{secret}@", 1) + "/gateway", + } +) + + +@dataclass(frozen=True, slots=True) +class AlertingProxy: + gateway: Gateway + alerts: Wire + vertex: Wire + cert: tuple[Path, Path] + + +@dataclass(frozen=True, slots=True) +class Caller: + key: str + key_alias: str + team_alias: str + + +@dataclass(frozen=True, slots=True) +class Outcome: + text: str + failure: str + + +def accept_alert(request: Request) -> Reply: + return Reply(body=b"ok", content_type="text/plain") + + +def overloaded_vertex(request: Request) -> Reply: + return VERTEX_OVERLOADED + + +def alerting_config(directory: Path) -> Path: + base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + config: Final = { + **base, + "general_settings": { + **object_value(base["general_settings"]), + "alerting": ["slack"], + "alert_types": ["llm_exceptions"], + }, + } + path: Final = directory / "alerting.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +@pytest.fixture(scope="module") +def alerting(tmp_path_factory: pytest.TempPathFactory) -> Iterator[AlertingProxy]: + directory: Final = tmp_path_factory.mktemp("stream-error-alerts") + cert: Final = write_self_signed_cert(directory) + with ( + gateway_from_environment() as shared, + wire_server(accept_alert) as alerts, + wire_server(overloaded_vertex) as vertex, + ): + with owned_proxy( + shared, + directory, + { + "SLACK_WEBHOOK_URL": alerts.url, + "DEFAULT_FLUSH_INTERVAL_SECONDS": "1", + "SSL_CERT_FILE": str(cert[0]), + "VERTEXAI_API_BASE": vertex.url, + }, + config=alerting_config(directory), + ) as gateway: + yield AlertingProxy(gateway, alerts, vertex, cert) + + +def alert_naming(alerts: Wire, marker: str) -> str: + def matching() -> tuple[str, ...]: + texts: Final = (string_value(JSON_OBJECT.validate_json(request.body)["text"]) for request in alerts.drain()) + return tuple(text for text in texts if marker in text) + + return eventually(matching, lambda found: len(found) > 0, seconds=ALERT_SECONDS)[0] + + +def api_base_line(alert: str) -> str | None: + return next((line for line in alert.splitlines() if line.startswith("API Base:")), None) + + +def assert_names_deployment(alert: str, api_base: str, model_group: str, backend: str, caller: Caller) -> None: + assert api_base_line(alert) == f"API Base: `{api_base}`", alert + assert f"model_group: `{model_group}`" in alert, alert + assert f"deployment: `{backend}`" in alert, alert + assert f"Key Name: `{caller.key_alias}`\nTeam: `{caller.team_alias}`" in alert, alert + + +def keyed_caller(scenario: Scenario) -> Caller: + team_alias: Final = f"alert-team-{uuid.uuid4().hex[:12]}" + key_alias: Final = f"alert-key-{uuid.uuid4().hex[:12]}" + team: Final = scenario.team(team_alias=team_alias) + return Caller(scenario.key(key_alias=key_alias, team_id=team), key_alias, team_alias) + + +def azure_chat(scenario: Scenario, api_base: str, *, bridged: bool = False) -> str: + return scenario.model( + model=AZURE_CHAT_BACKEND, + api_base=api_base, + api_key=AZURE_KEY, + api_version=API_VERSION, + num_retries=0, + use_chat_completions_api=bridged, + ) + + +def provider_message(marker: str) -> str: + return f"upstream overloaded {marker}" + + +def chat_chunk(marker: str, content: Mapping[str, JsonValue]) -> bytes: + body: Final = { + "id": f"chatcmpl-{marker}", + "object": "chat.completion.chunk", + "created": 1, + "model": f"gpt-4o-mini-{marker}", + "choices": [{"index": 0, "delta": dict(content), "finish_reason": None}], + } + return b"data: " + json.dumps(body).encode() + b"\n\n" + + +def first_text(marker: str) -> bytes: + return chat_chunk(marker, {"role": "assistant", "content": PARTIAL_TEXT}) + + +def cut_after_text(marker: str) -> Reply: + return dropping_reply((first_text(marker), chat_chunk(marker, {"content": " never sent"})), abort_after=1) + + +def error_frame_after_text(marker: str) -> Reply: + error: Final = {"error": {"message": provider_message(marker), "type": "server_error", "code": "server_error"}} + return stream_reply((first_text(marker), b"data: " + json.dumps(error).encode() + b"\n\n"), pause=DROP_PAUSE) + + +def rejected_before_stream(marker: str) -> Reply: + return Reply( + status=500, body=json.dumps({"error": {"message": provider_message(marker), "type": "server_error"}}).encode() + ) + + +def chat_upstream(reply: Callable[[str], Reply], marker: str) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.method != "POST": + return EMPTY_MODEL_LIST + return reply(marker) + + return respond + + +def items_or_error(open_stream: Callable[[], Iterable[object]], error_type: type[Exception]) -> Iterator[object]: + try: + yield from open_stream() + except error_type as error: + yield error + + +def chunk_text(item: object) -> str: + if not isinstance(item, ChatCompletionChunk): + return "" + return "".join(choice.delta.content or "" for choice in item.choices) + + +def message_text(item: object) -> str: + if isinstance(item, RawContentBlockDeltaEvent) and isinstance(item.delta, TextDelta): + return item.delta.text + return "" + + +def responses_text(item: object) -> str: + return item.delta if isinstance(item, ResponseTextDeltaEvent) else "" + + +def failure_of(item: object) -> str: + if isinstance(item, Exception): + return str(item) + if isinstance(item, ResponseFailedEvent) and item.response.error is not None: + return item.response.error.message + return "" + + +def outcome(items: tuple[object, ...], text_of: Callable[[object], str]) -> Outcome: + return Outcome("".join(text_of(item) for item in items), "".join(failure_of(item) for item in items)) + + +def openai_client(gateway: Gateway, key: str) -> openai.OpenAI: + return openai.OpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=key, max_retries=0, timeout=60) + + +def chat_outcome(gateway: Gateway, key: str, model: str) -> Outcome: + with openai_client(gateway, key) as client: + items: Final = tuple( + items_or_error( + lambda: client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": "stream an answer"}], + stream=True, + extra_body=dict(NO_RETRY_NO_CACHE), + ), + openai.APIError, + ) + ) + return outcome(items, chunk_text) + + +def messages_outcome(gateway: Gateway, key: str, model: str) -> Outcome: + with anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=key, max_retries=0, timeout=60) as client: + items: Final = tuple( + items_or_error( + lambda: client.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": "stream an answer"}], + stream=True, + extra_body=dict(NO_RETRY_NO_CACHE), + ), + anthropic.APIError, + ) + ) + return outcome(items, message_text) + + +def responses_outcome(gateway: Gateway, key: str, model: str) -> Outcome: + with openai_client(gateway, key) as client: + items: Final = tuple( + items_or_error( + lambda: client.responses.create( + model=model, input="stream an answer", stream=True, extra_body=dict(NO_RETRY_NO_CACHE) + ), + openai.APIError, + ) + ) + return outcome(items, responses_text) + + +ENTRYPOINTS: Final[Mapping[str, Callable[[Gateway, str, str], Outcome]]] = MappingProxyType( + {"chat": chat_outcome, "messages": messages_outcome, "responses-bridge": responses_outcome} +) +FAILURES: Final[Mapping[str, Callable[[str], Reply]]] = MappingProxyType( + {"cut": cut_after_text, "error-frame": error_frame_after_text} +) + + +@pytest.mark.parametrize( + ("entrypoint", "failure"), + [("chat", "cut"), ("chat", "error-frame"), ("messages", "error-frame"), ("responses-bridge", "error-frame")], +) +def test_mid_stream_failure_alert_names_the_deployment(alerting: AlertingProxy, entrypoint: str, failure: str) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(chat_upstream(FAILURES[failure], marker)) as wire, alerting.gateway.scenario() as scenario: + model: Final = azure_chat(scenario, wire.url, bridged=entrypoint == "responses-bridge") + caller: Final = keyed_caller(scenario) + seen: Final = ENTRYPOINTS[entrypoint](alerting.gateway, caller.key, model) + assert seen.text == PARTIAL_TEXT and seen.failure, seen + assert failure == "cut" or provider_message(marker) in seen.failure, seen + assert_names_deployment(alert_naming(alerting.alerts, marker), wire.url, model, AZURE_CHAT_BACKEND, caller) + + +def test_native_responses_failed_event_alert_names_the_deployment(alerting: AlertingProxy) -> None: + marker: Final = uuid.uuid4().hex + identity: Final = f"resp_{marker}" + stream: Final = ( + frame(created(identity)), + frame(delta(identity, PARTIAL_TEXT)), + frame(failed(identity, "server_error", provider_message(marker))), + ) + + def respond(request: Request) -> Reply: + if request.method != "POST": + return EMPTY_MODEL_LIST + assert "/responses" in request.target, request.target + return stream_reply(stream) + + with wire_server(respond) as wire, alerting.gateway.scenario() as scenario: + model: Final = scenario.model(model=AZURE_CHAT_BACKEND, api_base=wire.url, api_key=AZURE_KEY, num_retries=0) + caller: Final = keyed_caller(scenario) + seen: Final = responses_outcome(alerting.gateway, caller.key, model) + assert seen.text == PARTIAL_TEXT and provider_message(marker) in seen.failure, seen + assert_names_deployment(alert_naming(alerting.alerts, marker), wire.url, model, AZURE_CHAT_BACKEND, caller) + + +def failing_turn(marker: str) -> Script: + def script( + response_id: str, + ) -> tuple[dict[str, JsonValue], ...]: # mutable-ok: responses_peer's Script returns dicts + opened, text_delta, completed_event = events(response_id, PARTIAL_TEXT) + response: Final = object_value(completed_event["response"]) + return ( + opened, + text_delta, + { + "type": "response.failed", + "response": { + **response, + "status": "failed", + "output": [], + "error": {"code": "server_error", "message": provider_message(marker)}, + }, + }, + ) + + return script + + +def test_responses_websocket_turn_failure_alert_names_the_deployment(alerting: AlertingProxy) -> None: + marker: Final = uuid.uuid4().hex + backend: Final = f"openai/{PROVIDER_MODEL}" + with responses_peer(alerting.cert, failing_turn(marker)) as peer, alerting.gateway.scenario() as scenario: + model: Final = scenario.model(model=backend, api_base=peer.url, num_retries=0) + caller: Final = keyed_caller(scenario) + (received,) = asyncio.run(session(str(alerting.gateway.client.base_url), caller.key, model, (marker,))) + assert [event["type"] for event in received] == [ + "response.created", + "response.output_text.delta", + "response.failed", + ], received + assert_names_deployment(alert_naming(alerting.alerts, marker), peer.url, model, backend, caller) + (row,) = eventually( + lambda: read_rows( + 'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE api_key = %s', + (hashlib.sha256(caller.key.encode()).hexdigest(),), + ), + lambda rows: len(rows) >= 1, + seconds=70, + ) + assert row["status"] == "failure", row + error_information: Final = object_value(object_value(row["metadata"])["error_information"]) + assert provider_message(marker) in string_value(error_information["error_message"]), error_information + + +@pytest.mark.parametrize( + ("phase", "placement"), + [ + ("pre-stream", "plain"), + ("pre-stream", "code"), + ("pre-stream", "subscription-key"), + ("pre-stream", "userinfo"), + ("mid-stream", "code"), + ], +) +def test_alert_api_base_line_keeps_credentials_out(alerting: AlertingProxy, phase: str, placement: str) -> None: + marker: Final = uuid.uuid4().hex + secret: Final = f"credential{uuid.uuid4().hex}" + pre_stream: Final = phase == "pre-stream" + reply: Final = rejected_before_stream if pre_stream else error_frame_after_text + with wire_server(chat_upstream(reply, marker)) as wire, alerting.gateway.scenario() as scenario: + model: Final = azure_chat(scenario, API_BASES[placement](wire.url, secret)) + caller: Final = keyed_caller(scenario) + seen: Final = chat_outcome(alerting.gateway, caller.key, model) + assert seen.failure and seen.text == ("" if pre_stream else PARTIAL_TEXT), seen + alert: Final = alert_naming(alerting.alerts, model if pre_stream else marker) + assert secret not in alert, alert + assert_names_deployment(alert, f"{wire.url}/gateway", model, AZURE_CHAT_BACKEND, caller) + + +def test_alert_api_base_line_keeps_a_malformed_password_out(alerting: AlertingProxy) -> None: + password_start: Final = f"credential{uuid.uuid4().hex[:8]}" + password_end: Final = uuid.uuid4().hex + with alerting.gateway.scenario() as scenario: + model: Final = azure_chat(scenario, f"http://alice:{password_start}?{password_end}@127.0.0.1:9/gateway") + caller: Final = keyed_caller(scenario) + seen: Final = chat_outcome(alerting.gateway, caller.key, model) + assert seen.failure and not seen.text, seen + line: Final = api_base_line(alert_naming(alerting.alerts, model)) or "" + assert not any(part in line for part in ("alice", password_start, password_end)), line + + +def test_alert_api_base_line_names_a_vertex_partner_model_version(alerting: AlertingProxy) -> None: + with alerting.gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"vertex_ai/{VERTEX_PARTNER_BACKEND}", + api_base=None, + api_key=None, + vertex_project=VERTEX_PROJECT, + vertex_location=VERTEX_LOCATION, + vertex_credentials=service_account_json(VERTEX_PROJECT, alerting.gateway.upstream_url.rstrip("/")), + num_retries=0, + ) + caller: Final = keyed_caller(scenario) + seen: Final = chat_outcome(alerting.gateway, caller.key, model) + assert seen.failure and not seen.text, seen + assert any( + f"/publishers/anthropic/models/{VERTEX_PARTNER_BACKEND}:" in request.target + for request in alerting.vertex.drain() + ) + line: Final = api_base_line(alert_naming(alerting.alerts, model)) or "" + assert line.startswith(f"API Base: `{VERTEX_PARTNER_URL}:"), line + + +def test_fallback_mid_stream_failure_alert_names_the_fallback_deployment(alerting: AlertingProxy) -> None: + marker: Final = uuid.uuid4().hex + primary_reply: Final = Reply(status=500, body=b'{"error": {"message": "primary is down", "type": "server_error"}}') + with ( + wire_server(chat_upstream(lambda _: primary_reply, marker)) as primary_wire, + wire_server(chat_upstream(cut_after_text, marker)) as backup_wire, + alerting.gateway.scenario() as scenario, + ): + primary: Final = azure_chat(scenario, primary_wire.url) + backup: Final = azure_chat(scenario, backup_wire.url) + caller: Final = keyed_caller(scenario) + with openai_client(alerting.gateway, caller.key) as client: + items: Final = tuple( + items_or_error( + lambda: client.chat.completions.create( + model=primary, + messages=[{"role": "user", "content": "stream an answer"}], + stream=True, + extra_body={**NO_RETRY_NO_CACHE, "fallbacks": [backup]}, + ), + openai.APIError, + ) + ) + seen: Final = outcome(items, chunk_text) + assert seen.text == PARTIAL_TEXT and seen.failure, seen + assert [request.method for request in primary_wire.drain()].count("POST") == 1 + assert [request.method for request in backup_wire.drain()].count("POST") == 1 + alert: Final = alert_naming(alerting.alerts, marker) + assert_names_deployment(alert, backup_wire.url, backup, AZURE_CHAT_BACKEND, caller) + assert primary_wire.url not in alert, alert diff --git a/tests/integration/pricing/test_price_data_reload_prices.py b/tests/integration/pricing/test_price_data_reload_prices.py new file mode 100644 index 00000000000..bb24130aa6c --- /dev/null +++ b/tests/integration/pricing/test_price_data_reload_prices.py @@ -0,0 +1,433 @@ +import itertools +import json +import re +import threading +import uuid +from collections.abc import Callable, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import httpx +import pytest +from integration._support.client import Gateway, eventually, object_value, string_value +from integration._support.database import read_rows +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue + +_ROW: Final = "anthropic.claude-audit-reload-v1:0" +_GEO: Final = f"au.{_ROW}" +_FINETUNE: Final = "ft:gpt-audit-reload-2025-01-01" +_CLAUDE: Final = "claude-audit-reload-sonnet" +_MINI: Final = "gpt-audit-reload-mini" +_USAGE: Final = re.compile(r"bill (\d+) (\d+) (\d+)") +_CUSTOM: Final = MappingProxyType({"input_cost_per_token": 1.1e-06, "output_cost_per_token": 2.3e-06}) +_COST_MAP_LIMITS: Final = MappingProxyType({"MODEL_COST_MAP_MIN_MODEL_COUNT": "1", "MODEL_COST_MAP_MAX_SHRINK_RATIO": "0"}) +_PUBLISHED_SCALE: Final = 2.0 +_CATALOG: Final[Mapping[str, Mapping[str, JsonValue]]] = MappingProxyType( + { + _ROW: { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + }, + _GEO: { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + }, + _FINETUNE: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 3.75e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 1.875e-06, + }, + _CLAUDE: { + "litellm_provider": "anthropic", + "mode": "chat", + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + }, + _MINI: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2e-06, + }, + } +) + + +@dataclass(frozen=True, slots=True) +class _Usage: + prompt: int + cached: int + completion: int + + def text(self) -> str: + return f"bill {self.prompt} {self.cached} {self.completion}" + + +@dataclass(frozen=True, slots=True) +class _Rates: + input: float + output: float + cache_read: float + + def cost(self, usage: _Usage) -> float: + return ( + (usage.prompt - usage.cached) * self.input + usage.cached * self.cache_read + usage.completion * self.output + ) + + +@dataclass(frozen=True, slots=True) +class _Deployment: + name: str + model: str + prices: Mapping[str, float] + usage: _Usage + rates: Callable[[float], _Rates | None] + + +@dataclass(frozen=True, slots=True) +class _Charge: + request_id: str + expected: float + + +def _catalog(scale: float) -> dict[str, dict[str, JsonValue]]: + return { + model: {name: value * scale if isinstance(value, float) else value for name, value in row.items()} + for model, row in _CATALOG.items() + } + + +def _rate(model: str, field: str, scale: float) -> float: + rate: Final = _CATALOG[model][field] + assert isinstance(rate, float), rate + return rate * scale + + +def _catalog_rates(model: str) -> Callable[[float], _Rates]: + return lambda scale: _Rates( + input=_rate(model, "input_cost_per_token", scale), + output=_rate(model, "output_cost_per_token", scale), + cache_read=_rate(model, "cache_read_input_token_cost", scale) + if "cache_read_input_token_cost" in _CATALOG[model] + else _rate(model, "input_cost_per_token", scale), + ) + + +def _custom_rates(_scale: float) -> _Rates: + return _Rates( + input=_CUSTOM["input_cost_per_token"], + output=_CUSTOM["output_cost_per_token"], + cache_read=_CUSTOM["input_cost_per_token"], + ) + + +def _unpriced(_scale: float) -> None: + return None + + +def _expected_cost(rates: _Rates | None, usage: _Usage) -> float: + return 0.0 if rates is None else rates.cost(usage) + + +def _expected_listing(rates: _Rates | None) -> tuple[object, object]: + if rates is None: + return None, None + return pytest.approx(rates.input), pytest.approx(rates.output) + + +def _connection(model: str, wire_url: str) -> dict[str, JsonValue]: + if model.startswith("bedrock/"): + return {"api_base": wire_url, "api_key": "synthetic-bedrock-bearer", "aws_region_name": "ap-northeast-1"} + if model.startswith("azure/"): + return {"api_base": wire_url, "api_key": "synthetic-azure-key", "api_version": "2025-01-01-preview"} + return {"api_base": f"{wire_url}/v1", "api_key": "synthetic-openai-key"} + + +def _config(directory: Path, deployments: tuple[_Deployment, ...], wire_url: str) -> Path: + config: Final = directory / "price_data_reload_config.yaml" + config.write_text( + json.dumps( + { + "model_list": [ + { + "model_name": deployment.name, + "litellm_params": { + "model": deployment.model, + **_connection(deployment.model, wire_url), + **deployment.prices, + }, + } + for deployment in deployments + ], + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "database_url": "os.environ/DATABASE_URL", + }, + "router_settings": {"disable_cooldowns": True}, + } + ) + ) + return config + + +def _usage_in(request: Request) -> _Usage: + found: Final = _USAGE.search(request.body.decode()) + assert found is not None, request + return _Usage(prompt=int(found.group(1)), cached=int(found.group(2)), completion=int(found.group(3))) + + +def _reply(request: Request) -> Reply: + usage: Final = _usage_in(request) + if "/converse" in request.target: + return Reply( + body=json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "priced"}]}}, + "stopReason": "end_turn", + "usage": { + "inputTokens": usage.prompt, + "outputTokens": usage.completion, + "totalTokens": usage.prompt + usage.completion, + }, + "metrics": {"latencyMs": 1}, + } + ).encode() + ) + if "/invoke" in request.target: + return Reply( + body=json.dumps( + { + "id": f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "model": "claude-scripted", + "content": [{"type": "text", "text": "priced"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": usage.prompt, "output_tokens": usage.completion}, + } + ).encode() + ) + return Reply( + body=json.dumps( + { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": 1, + "model": "scripted", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "priced"}, "finish_reason": "stop"} + ], + "usage": { + "prompt_tokens": usage.prompt, + "completion_tokens": usage.completion, + "total_tokens": usage.prompt + usage.completion, + "prompt_tokens_details": {"cached_tokens": usage.cached}, + }, + } + ).encode() + ) + + +def _responder(published: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.target == "/model_prices.json": + return Reply(body=json.dumps(_catalog(_PUBLISHED_SCALE if published.is_set() else 1.0)).encode()) + if request.method == "GET": + return Reply(body=json.dumps({"object": "list", "data": []}).encode()) + return _reply(request) + + return respond + + +def _listed_rates(proxy: Gateway, name: str) -> tuple[JsonValue, JsonValue]: + entries: Final = proxy.get("/model/info")["data"] + assert isinstance(entries, list), entries + info: Final = next( + object_value(object_value(entry)["model_info"]) + for entry in entries + if object_value(entry)["model_name"] == name + ) + return info.get("input_cost_per_token"), info.get("output_cost_per_token") + + +def _billed(client: httpx.Client, key: str, deployment: _Deployment) -> tuple[str, float | None]: + response: Final = client.post( + "/v1/chat/completions", + json={"model": deployment.name, "messages": [{"role": "user", "content": deployment.usage.text()}]}, + headers={"Authorization": f"Bearer {key}"}, + ) + assert response.status_code == 200, response.text + reported: Final = response.headers.get("x-litellm-response-cost") + return string_value(object_value(response.json())["id"]), None if reported is None else float(reported) + + +def _charged_as_listed(proxy: Gateway, deployment: _Deployment, scale: float) -> _Charge: + rates: Final = deployment.rates(scale) + assert _listed_rates(proxy, deployment.name) == _expected_listing(rates), deployment.name + request_id, charged = _billed(proxy.client, proxy.key, deployment) + expected: Final = _expected_cost(rates, deployment.usage) + assert charged == (None if rates is None else pytest.approx(expected, rel=1e-6)), deployment.name + return _Charge(request_id=request_id, expected=expected) + + +def _spend(request_id: str) -> float: + rows: Final = eventually( + lambda: read_rows('SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (request_id,)), + lambda values: len(values) == 1, + seconds=70, + ) + spend: Final = rows[0]["spend"] + assert isinstance(spend, (int, float)), rows + return float(spend) + + +def _proxy_environment(wire_url: str) -> dict[str, str]: + return {**_COST_MAP_LIMITS, "LITELLM_MODEL_COST_MAP_URL": f"{wire_url}/model_prices.json"} + + +def _assert_priced_across_a_reload(gateway: Gateway, tmp_path: Path, deployments: tuple[_Deployment, ...]) -> None: + published: Final = threading.Event() + with ( + wire_server(_responder(published)) as wire, + owned_proxy( + gateway, + tmp_path, + _proxy_environment(wire.url), + config=_config(tmp_path, deployments, wire.url), + remove_environment=("LITELLM_LOCAL_MODEL_COST_MAP",), + ) as proxy, + ): + before: Final = tuple(_charged_as_listed(proxy, deployment, 1.0) for deployment in deployments) + published.set() + reload: Final = proxy.request("POST", "/reload/model_cost_map") + assert reload.status_code == 200, reload.text + after: Final = tuple(_charged_as_listed(proxy, deployment, _PUBLISHED_SCALE) for deployment in deployments) + for charge in (*before, *after): + assert _spend(charge.request_id) == pytest.approx(charge.expected, rel=1e-6) + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +def test_price_data_reload_reprices_catalog_deployments_and_keeps_custom_prices( + gateway: Gateway, tmp_path: Path +) -> None: + _assert_priced_across_a_reload( + gateway, + tmp_path, + ( + _Deployment("geo-unpriced", f"bedrock/{_GEO}", {}, _Usage(1000, 0, 500), _catalog_rates(_GEO)), + _Deployment("mini-custom", f"openai/{_MINI}", _CUSTOM, _Usage(1000, 0, 500), _custom_rates), + ), + ) + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +@pytest.mark.parametrize( + "deployment", + ( + _Deployment( + "region-unpriced", f"bedrock/ap-northeast-1.{_ROW}", {}, _Usage(1000, 0, 500), _catalog_rates(_ROW) + ), + _Deployment( + "finetune-unpriced", + f"openai/{_FINETUNE}:org:suffix:y", + {}, + _Usage(2000, 1000, 1000), + _catalog_rates(_FINETUNE), + ), + _Deployment("finetune-custom", f"openai/{_FINETUNE}:org:suffix:z", _CUSTOM, _Usage(2000, 0, 1000), _custom_rates), + _Deployment("cross-provider-unpriced", f"azure/{_CLAUDE}", {}, _Usage(2000, 1000, 1000), _unpriced), + _Deployment( + "region-custom-above-threshold", + f"bedrock/ap-northeast-1.{_ROW}", + _CUSTOM, + _Usage(250_000, 0, 1000), + _custom_rates, + ), + ), + ids=lambda deployment: deployment.name, +) +def test_deployment_bills_the_catalog_row_its_registration_resolves_to_across_a_reload( + gateway: Gateway, tmp_path: Path, deployment: _Deployment +) -> None: + _assert_priced_across_a_reload(gateway, tmp_path, (deployment,)) + + +def _storm_deployment(index: int) -> _Deployment: + prices: Final = MappingProxyType( + {"input_cost_per_token": (index + 1) * 1e-07, "output_cost_per_token": (index + 1) * 3e-07} + ) + return _Deployment( + f"storm-{index}", + f"openai/gpt-audit-reload-storm-{index}", + prices, + _Usage(100, 0, 50), + lambda _scale: _Rates( + input=prices["input_cost_per_token"], + output=prices["output_cost_per_token"], + cache_read=prices["input_cost_per_token"], + ), + ) + + +def _storm_traffic( + base_url: str, key: str, deployments: tuple[_Deployment, ...], offset: int, finished: threading.Event +) -> tuple[tuple[float, float], ...]: + rotation: Final = ( + deployments[(offset + turn) % len(deployments)] for turn, _ in enumerate(iter(finished.is_set, True)) + ) + with httpx.Client(base_url=base_url, timeout=60, trust_env=False) as client: + return tuple( + (_billed(client, key, deployment)[1], _expected_cost(deployment.rates(1.0), deployment.usage)) + for deployment in rotation + ) + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +def test_price_data_reloads_under_custom_priced_traffic_keep_every_deployment_priced( + gateway: Gateway, tmp_path: Path +) -> None: + storm: Final = tuple(_storm_deployment(index) for index in range(24)) + geo: Final = _Deployment("geo-unpriced", f"bedrock/{_GEO}", {}, _Usage(1000, 0, 500), _catalog_rates(_GEO)) + finished: Final = threading.Event() + with ( + wire_server(_responder(threading.Event())) as wire, + owned_proxy( + gateway, + tmp_path, + _proxy_environment(wire.url), + config=_config(tmp_path, (geo, *storm), wire.url), + remove_environment=("LITELLM_LOCAL_MODEL_COST_MAP",), + ) as proxy, + ThreadPoolExecutor(max_workers=8) as pool, + ): + traffic: Final = tuple( + pool.submit(_storm_traffic, str(proxy.client.base_url), proxy.key, storm, offset, finished) + for offset in range(8) + ) + reloads: Final = tuple(proxy.request("POST", "/reload/model_cost_map") for _ in range(40)) + finished.set() + charges: Final = tuple(itertools.chain.from_iterable(calls.result() for calls in traffic)) + assert tuple(reload.status_code for reload in reloads) == (200,) * 40, tuple( + reload.text for reload in reloads if reload.status_code != 200 + ) + assert len(charges) >= 8 + assert tuple(charged for charged, _ in charges) == pytest.approx(tuple(expected for _, expected in charges)) + settled: Final = tuple(_charged_as_listed(proxy, deployment, 1.0) for deployment in (geo, *storm)) + for charge in settled: + assert _spend(charge.request_id) == pytest.approx(charge.expected, rel=1e-6) diff --git a/tests/integration/providers/_anthropic_safeguards_beta_support.py b/tests/integration/providers/_anthropic_safeguards_beta_support.py new file mode 100644 index 00000000000..0307ea11724 --- /dev/null +++ b/tests/integration/providers/_anthropic_safeguards_beta_support.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import json +import re +import threading +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final + +import yaml +from integration._support.anthropic_sse import ( + CONTENT_BLOCK_START, + error_body, + message_start, + sse, + text_delta, +) +from integration._support.client import Gateway, object_value +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire +from pydantic import JsonValue, TypeAdapter + +DANGEROUS_TOOL_USE: Final = "dangerous-tool-use-2026-09-03" +WEB_SEARCH: Final = "web-search-2025-03-05" +CLIENT_BETAS: Final = "interleaved-thinking-2025-05-14,claude-code-20250219,interleaved-thinking-2025-05-14" +MERGED_BETAS: Final = f"claude-code-20250219,{DANGEROUS_TOOL_USE},interleaved-thinking-2025-05-14" +BACKEND: Final = "claude-sonnet-4-6" +ANTHROPIC_DEPLOYMENT: Final = "safeguarded-claude" +VERTEX_DEPLOYMENT: Final = "safeguarded-vertex-claude" +DEEPSEEK_DEPLOYMENT: Final = "safeguarded-deepseek" +ANTHROPIC_KEY: Final = "synthetic-anthropic-key" +DEEPSEEK_KEY: Final = "synthetic-deepseek-key" +PROJECT: Final = "scripted-project" +LOCATION: Final = "us-east5" +_VERTEX_MODEL_PATH: Final = f"/v1/projects/{PROJECT}/locations/{LOCATION}/publishers/anthropic/models/{BACKEND}" +VERTEX_TARGET: Final = f"{_VERTEX_MODEL_PATH}:rawPredict" +VERTEX_STREAM_TARGET: Final = f"{_VERTEX_MODEL_PATH}:streamRawPredict?alt=sse" +ANTHROPIC_TARGET: Final = "/v1/messages" +DEEPSEEK_TARGET: Final = "/anthropic/v1/messages" +SCRIPTED_TARGETS: Final = frozenset({ANTHROPIC_TARGET, DEEPSEEK_TARGET, VERTEX_TARGET, VERTEX_STREAM_TARGET}) +REPLY_TEXT: Final = "safeguards beta control" +EXTRA_INPUTS_MESSAGE: Final = "safeguards: Extra inputs are not permitted" +NOT_A_LIST_MESSAGE: Final = "safeguards: Input should be a valid array" +OVERLOADED_MESSAGE: Final = "Overloaded" +SAFEGUARDS: Final[tuple[JsonValue, ...]] = ( + { + "type": "dangerous_tool_use", + "classifier_context": {"v": 1, "permission_mode": "auto"}, + }, +) +SAFEGUARD_RESULTS: Final[tuple[JsonValue, ...]] = ( + {"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {}}}, +) +BASH_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "name": "Bash", + "description": "Run a shell command", + "input_schema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + } +) +WEB_SEARCH_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"type": "web_search_20250305", "name": "web_search", "max_uses": 1} +) +RUST_ROUTE_HEADER: Final = "x-litellm-rust" +_MARKER: Final = re.compile(rb"marker-[0-9a-f]{32}") +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_STARTED_WORKER: Final = "Started server process [" + + +@dataclass(frozen=True, slots=True) +class Delivery: + target: str + beta: str | None + safeguards: JsonValue + marker: str + authorization: str | None + api_key: str | None + + +def new_marker() -> str: + return f"marker-{uuid.uuid4().hex}" + + +def marker_of(request: Request) -> str: + found: Final = tuple(match.group(0) for match in _MARKER.finditer(request.body)) + return found[-1].decode() if found else "unmarked" + + +def body_of(request: Request) -> dict[str, JsonValue]: + return _JSON_OBJECT.validate_json(request.body) + + +def json_object(text: str) -> dict[str, JsonValue]: + return _JSON_OBJECT.validate_json(text) + + +def betas_of(request: Request) -> frozenset[str]: + return frozenset(part.strip() for part in request.headers.get("anthropic-beta", "").split(",") if part.strip()) + + +def started_workers(log: Path) -> tuple[int, ...]: + return tuple( + int(line.rsplit("[", 1)[1].strip(" ]\r")) for line in log.read_text().splitlines() if _STARTED_WORKER in line + ) + + +def _streams(request: Request) -> bool: + return request.target == VERTEX_STREAM_TARGET or body_of(request).get("stream") is True + + +def _results(safeguards: JsonValue) -> list[JsonValue] | None: + if safeguards is None: + return None + return list(SAFEGUARD_RESULTS) if safeguards else [] + + +def _message(identity: str, results: list[JsonValue] | None) -> dict[str, JsonValue]: + verdict: Final[dict[str, JsonValue]] = {} if results is None else {"safeguard_results": results} + return { + "id": identity, + "type": "message", + "role": "assistant", + "model": BACKEND, + "content": [{"type": "text", "text": REPLY_TEXT}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 3}, + **verdict, + } + + +def _stream(identity: str, results: list[JsonValue] | None) -> tuple[bytes, ...]: + verdict: Final[dict[str, JsonValue]] = {} if results is None else {"safeguard_results": results} + return ( + message_start(identity, BACKEND), + CONTENT_BLOCK_START, + text_delta(REPLY_TEXT), + sse("content_block_stop", {"type": "content_block_stop", "index": 0}), + sse( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None, **verdict}, + "usage": {"output_tokens": 3}, + }, + ), + sse("message_stop", {"type": "message_stop"}), + ) + + +def anthropic_peer(request: Request) -> Reply: + if request.target not in SCRIPTED_TARGETS: + return Reply( + status=404, + body=json.dumps({"error": f"unscripted target {request.target}"}).encode(), + ) + if request.target == DEEPSEEK_TARGET: + return Reply(body=json.dumps(_message(f"msg_{marker_of(request)}", None)).encode()) + body: Final = body_of(request) + safeguards: Final = body.get("safeguards") + if "safeguards" in body and DANGEROUS_TOOL_USE not in betas_of(request): + return Reply(status=400, body=error_body(400, EXTRA_INPUTS_MESSAGE)) + if safeguards is not None and not isinstance(safeguards, list): + return Reply(status=400, body=error_body(400, NOT_A_LIST_MESSAGE)) + identity: Final = f"msg_{marker_of(request)}" + results: Final = _results(safeguards) + if _streams(request): + return Reply(content_type="text/event-stream", chunks=_stream(identity, results)) + return Reply(body=json.dumps(_message(identity, results)).encode()) + + +def outage_peer(outage: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if outage.is_set(): + return Reply(status=529, body=error_body(529, OVERLOADED_MESSAGE)) + return anthropic_peer(request) + + return respond + + +def holding_peer(held: SimpleQueue[str], release: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + held.put(marker_of(request)) + assert release.wait(timeout=60), "Held request was never released" + return anthropic_peer(request) + + return respond + + +def deliveries(wire: Wire) -> tuple[Delivery, ...]: + return tuple( + Delivery( + request.target, + request.headers.get("anthropic-beta"), + body_of(request).get("safeguards"), + marker_of(request), + request.headers.get("authorization"), + request.headers.get("x-api-key"), + ) + for request in wire.drain() + ) + + +def one_delivery(wire: Wire, marker: str) -> Delivery: + (sent,) = deliveries(wire) + assert sent.marker == marker, sent + return sent + + +def rust_routed(headers: Mapping[str, str]) -> bool: + return headers.get(RUST_ROUTE_HEADER) == "true" + + +def messages_body(model: str, marker: str, *, stream: bool = False) -> dict[str, JsonValue]: + return { + "model": model, + "max_tokens": 64, + "stream": stream, + "messages": [{"role": "user", "content": marker}], + "tools": [dict(BASH_TOOL)], + } + + +def safeguarded(body: Mapping[str, JsonValue], safeguards: JsonValue) -> dict[str, JsonValue]: + return {**body, "safeguards": safeguards} + + +def chat_body(model: str, marker: str) -> dict[str, JsonValue]: + return {"model": model, "messages": [{"role": "user", "content": marker}]} + + +def responses_body(model: str, marker: str) -> dict[str, JsonValue]: + return {"model": model, "input": marker} + + +def owned_config(directory: Path, gateway: Gateway, api_base: str) -> Path: + config: Final = _JSON_OBJECT.validate_python( + yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + ) + merged: Final = { + **config, + "model_list": [ + { + "model_name": ANTHROPIC_DEPLOYMENT, + "litellm_params": { + "model": f"anthropic/{BACKEND}", + "api_base": api_base, + "api_key": ANTHROPIC_KEY, + }, + }, + { + "model_name": VERTEX_DEPLOYMENT, + "litellm_params": { + "model": f"vertex_ai/{BACKEND}", + "api_base": api_base, + "vertex_project": PROJECT, + "vertex_location": LOCATION, + "vertex_credentials": service_account_json(PROJECT, gateway.upstream_url.rstrip("/")), + }, + }, + { + "model_name": DEEPSEEK_DEPLOYMENT, + "litellm_params": { + "model": "deepseek/deepseek-chat", + "api_base": api_base, + "api_key": DEEPSEEK_KEY, + }, + }, + ], + "router_settings": { + **object_value(config["router_settings"]), + "num_retries": 0, + }, + } + path: Final = directory / f"safeguards-beta-{uuid.uuid4().hex}.yaml" + path.write_text(yaml.safe_dump(merged)) + return path diff --git a/tests/integration/providers/_idle_upstream_support.py b/tests/integration/providers/_idle_upstream_support.py index ca808abc008..5fccd9804a6 100644 --- a/tests/integration/providers/_idle_upstream_support.py +++ b/tests/integration/providers/_idle_upstream_support.py @@ -294,11 +294,11 @@ def chat_httpx(target: ProxyTarget, model: str, marker: str) -> Answer: ) -def chat_with_guardrail(target: ProxyTarget, model: str, marker: str) -> Answer: +def chat_with_guardrail(target: ProxyTarget, model: str, marker: str, guardrail: str = GUARDRAIL) -> Answer: return _post( target, "/v1/chat/completions", - {"model": model, "messages": [{"role": "user", "content": marker}], "guardrails": [GUARDRAIL]}, + {"model": model, "messages": [{"role": "user", "content": marker}], "guardrails": [guardrail]}, _chat_body, ) @@ -517,18 +517,21 @@ def pass_through_deployment(_model: str, url: str, _steady: str) -> ConfigPart: return ConfigPart(pass_through=({"path": PASS_THROUGH, "target": f"{url}/passthrough"},)) -def guardrail_deployment(model: str, url: str, steady: str) -> ConfigPart: +def guardrail_deployment( + model: str, url: str, steady: str, guardrail: str = GUARDRAIL, num_retries: int | None = None +) -> ConfigPart: return ConfigPart( models=openai_deployment(model, steady, steady).models, guardrails=( { - "guardrail_name": GUARDRAIL, + "guardrail_name": guardrail, "litellm_params": { "guardrail": "generic_guardrail_api", "mode": "pre_call", "default_on": False, "api_base": url, "api_key": PROVIDER_KEY, + **({} if num_retries is None else {"num_retries": num_retries}), }, }, ), diff --git a/tests/integration/providers/test_anthropic_count_tokens_system_lift_wire.py b/tests/integration/providers/test_anthropic_count_tokens_system_lift_wire.py index c3cd4c5b0e7..afd25fcfa04 100644 --- a/tests/integration/providers/test_anthropic_count_tokens_system_lift_wire.py +++ b/tests/integration/providers/test_anthropic_count_tokens_system_lift_wire.py @@ -274,7 +274,7 @@ def test_messages_count_tokens_lifts_the_leading_system_run( ) -> None: with _listening(deployment) as peer: response: Final = _count(gateway, count_request(deployment.model, case)) - assert _payload(response) == {"input_tokens": _COUNT}, response.text + assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text assert _bodies(peer, deployment.provider) == (expected_count_body(_MODEL, case),) @@ -329,7 +329,11 @@ def test_responses_input_tokens_lifts_instructions(deployment: _Deployment, gate "/v1/responses/input_tokens", {"model": deployment.model, "input": USER_TEXT, "instructions": INSTRUCTION}, ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _COUNT, + "litellm_count_source": "provider", + }, response.text assert _bodies(peer, deployment.provider) == (_STRING_BODY,) @@ -342,7 +346,11 @@ def test_responses_input_tokens_lifts_instructions_ahead_of_a_leading_system_ite "/v1/responses/input_tokens", {"model": deployment.model, "input": [MID_SYSTEM, USER], "instructions": INSTRUCTION}, ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _COUNT, + "litellm_count_source": "provider", + }, response.text assert _bodies(peer, deployment.provider) == ( {"model": _MODEL, "messages": [USER], "system": [LIFTED, {"type": "text", "text": REMINDER}]}, ) @@ -373,7 +381,7 @@ def test_messages_count_tokens_forwards_tools_beside_the_lifted_system( ) -> None: with _listening(deployment) as peer: response: Final = _count(gateway, {**count_request(deployment.model, STRING_CASE), "tools": TOOLS}) - assert _payload(response) == {"input_tokens": _COUNT}, response.text + assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text assert _bodies(peer, deployment.provider) == ({**_STRING_BODY, "tools": TOOLS},) @@ -386,7 +394,7 @@ def test_messages_count_tokens_keeps_a_mid_conversation_system_in_place( "messages": [LEADING, USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], } response: Final = _count(gateway, body) - assert _payload(response) == {"input_tokens": _COUNT}, response.text + assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text assert _bodies(peer, deployment.provider) == ( {"model": _MODEL, "messages": [USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], "system": [LIFTED]}, ) @@ -418,7 +426,7 @@ def test_messages_count_tokens_repeated_request_lifts_each_time(deployment: _Dep answers: Final = tuple( _payload(_count(gateway, count_request(deployment.model, STRING_CASE))) for _ in range(2) ) - assert answers == ({"input_tokens": _COUNT},) * 2 + assert answers == ({"input_tokens": _COUNT, "litellm_count_source": "provider"},) * 2 assert _bodies(peer, deployment.provider) == (_STRING_BODY,) * 2 @@ -432,7 +440,7 @@ def test_messages_count_tokens_surfaces_the_counting_error_for_a_leading_system_ assert "input_tokens" not in response.text, response.text assert peer.drain() == () follow_up: Final = _count(gateway, count_request(deployment.model, STRING_CASE)) - assert _payload(follow_up) == {"input_tokens": _COUNT}, follow_up.text + assert _payload(follow_up) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, follow_up.text assert _bodies(peer, deployment.provider) == (_STRING_BODY,) @@ -447,7 +455,7 @@ def test_messages_count_tokens_duplicate_messages_key_lifts_the_last_value( content=f'{{"model": "{deployment.model}", "messages": {first}, "messages": {last}}}', headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"}, ) - assert _payload(response) == {"input_tokens": _COUNT}, response.text + assert _payload(response) == {"input_tokens": _COUNT, "litellm_count_source": "provider"}, response.text assert _bodies(peer, deployment.provider) == (_STRING_BODY,) diff --git a/tests/integration/providers/test_bedrock_batch_files_wire.py b/tests/integration/providers/test_bedrock_batch_files_wire.py index 1a834fc2f8c..823bcda7e06 100644 --- a/tests/integration/providers/test_bedrock_batch_files_wire.py +++ b/tests/integration/providers/test_bedrock_batch_files_wire.py @@ -2,7 +2,7 @@ import json from typing import Final import pytest -from integration._support.client import Gateway +from integration._support.client import JSON_OBJECT, Gateway, list_value, object_value from integration._support.wire import Reply, Request, wire_server MODEL: Final = "bedrock/anthropic.claude-3-haiku-20240307-v1:0" @@ -76,3 +76,56 @@ def test_completions_and_responses_batch_records_upload_as_anthropic_user_messag assert len(uploads) == 1, f"Expected exactly one S3 PUT, saw {[upload.target for upload in uploads]}" stored: Final = tuple(json.loads(line) for line in uploads[0].body.decode().splitlines() if line.strip()) assert stored == EXPECTED_S3_OBJECT + + +_JSON_TOOL: Final = "json_tool_call" +_SCHEMA: Final = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, +} + + +@pytest.mark.parametrize( + "backend", ["us.anthropic.claude-haiku-5-5", "us.anthropic.claude-opus-4-8"], ids=["haiku_5_5", "opus_4_8"] +) +def test_json_schema_batch_record_with_thinking_uploads_the_json_tool_and_no_native_output( + gateway: Gateway, backend: str +) -> None: + record: Final = { + "custom_id": "schema-record", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": f"bedrock/{backend}", + "messages": [{"role": "user", "content": "Name a city."}], + "max_tokens": 2048, + "thinking": {"type": "adaptive"}, + "response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": _SCHEMA}}, + }, + } + with wire_server(s3_peer) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"bedrock/{backend}", + api_key=None, + api_base=None, + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + aws_region_name="us-east-1", + s3_bucket_name=BUCKET, + s3_endpoint_url=wire.url, + ) + response: Final = gateway.request_multipart( + "/v1/files", + {"purpose": "batch", "model": model}, + {"file": ("in.jsonl", (json.dumps(record) + "\n").encode(), "application/jsonl")}, + ) + assert response.status_code == 200, response.text + (upload,) = wire.drain() + (stored,) = tuple(JSON_OBJECT.validate_json(line) for line in upload.body.decode().splitlines() if line.strip()) + model_input: Final = object_value(stored["modelInput"]) + assert "format" not in object_value(model_input.get("output_config", {})), model_input + (tool,) = (object_value(entry) for entry in list_value(model_input["tools"])) + assert tool["name"] == _JSON_TOOL, model_input + assert object_value(tool["input_schema"])["properties"] == _SCHEMA["properties"], model_input diff --git a/tests/integration/providers/test_bedrock_mantle_count_tokens_wire.py b/tests/integration/providers/test_bedrock_mantle_count_tokens_wire.py index 701d185b241..ab0c35d0ca4 100644 --- a/tests/integration/providers/test_bedrock_mantle_count_tokens_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_count_tokens_wire.py @@ -378,7 +378,7 @@ def test_messages_count_tokens_counts_through_mantle_when_the_runtime_cannot( ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS}) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",) assert _mantle_bodies(mantle) == (_mantle_body(system=system, tools=_TOOLS),) @@ -394,7 +394,7 @@ def test_messages_count_tokens_without_system_or_tools_sends_a_bare_body_to_mant ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, _bare(model)) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_MANTLE_BARE,) @@ -429,7 +429,11 @@ def test_gemini_count_tokens_route_reports_the_mantle_total(counting_proxy: Owne ): model: Final = _deployment(scenario, runtime.url) response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", _GEMINI_BODY) - assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text + assert _payload(response) == { + "totalTokens": _MANTLE_COUNT, + "promptTokensDetails": [], + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == ({"model": _OPUS_BASE, "messages": _GEMINI_MESSAGES},) @@ -445,7 +449,11 @@ def test_gemini_count_tokens_route_reports_the_runtime_total_for_a_model_bedrock ): model: Final = _deployment(scenario, runtime.url, _SONNET) response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", _GEMINI_BODY) - assert _payload(response) == {"totalTokens": _RUNTIME_COUNT, "promptTokensDetails": []}, response.text + assert _payload(response) == { + "totalTokens": _RUNTIME_COUNT, + "promptTokensDetails": [], + "litellm_count_source": "provider", + }, response.text assert _runtime_count_targets(runtime) == (f"/model/{_SONNET}/count-tokens",) assert mantle.drain() == () @@ -460,7 +468,7 @@ def test_runtime_404_on_claude_falls_back_to_mantle(counting_proxy: OwnedProxy, ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS}) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_mantle_body(system=system, tools=_TOOLS),) @@ -520,7 +528,11 @@ def test_google_count_tokens_forwards_system_and_tools_to_mantle( ): model: Final = _deployment(scenario, runtime.url) response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", body) - assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text + assert _payload(response) == { + "totalTokens": _MANTLE_COUNT, + "promptTokensDetails": [], + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == ( _mantle_body( @@ -565,7 +577,11 @@ def test_google_count_tokens_normalizes_uppercase_parameters_for_mantle( ): model: Final = _deployment(scenario, runtime.url) response: Final = gateway.request("POST", f"/v1beta/models/{model}:countTokens", body) - assert _payload(response) == {"totalTokens": _MANTLE_COUNT, "promptTokensDetails": []}, response.text + assert _payload(response) == { + "totalTokens": _MANTLE_COUNT, + "promptTokensDetails": [], + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == ( _mantle_body( @@ -595,7 +611,11 @@ def test_responses_input_tokens_counts_through_mantle(counting_proxy: OwnedProxy response: Final = gateway.request( "POST", "/v1/responses/input_tokens", {"model": model, "input": "Count this message"} ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _MANTLE_COUNT, + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_MANTLE_BARE,) @@ -640,7 +660,10 @@ def test_model_the_runtime_counts_never_reaches_mantle(counting_proxy: OwnedProx gateway.scenario() as scenario, ): model: Final = _deployment(scenario, runtime.url, _SONNET) - assert _payload(_count(gateway, _full(model))) == {"input_tokens": _RUNTIME_COUNT} + assert _payload(_count(gateway, _full(model))) == { + "input_tokens": _RUNTIME_COUNT, + "litellm_count_source": "provider", + } detailed: Final = gateway.request( "POST", "/utils/token_counter", _full(model), params={"call_endpoint": "true"} ) @@ -728,7 +751,11 @@ def test_responses_input_tokens_with_instructions_counts_through_mantle( "/v1/responses/input_tokens", {"model": model, "input": "Count this message", "instructions": "Be terse"}, ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _MANTLE_COUNT, + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_mantle_body(system=[{"type": "text", "text": "Be terse"}]),) @@ -745,7 +772,7 @@ def test_messages_count_tokens_lifts_the_leading_system_run_for_mantle( ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, count_request(model, case)) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (expected_count_body(_OPUS_BASE, case),) @@ -858,7 +885,11 @@ def test_responses_input_tokens_lifts_instructions_ahead_of_a_leading_system_ite "/v1/responses/input_tokens", {"model": model, "input": [MID_SYSTEM, USER], "instructions": INSTRUCTION}, ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _MANTLE_COUNT, + "litellm_count_source": "provider", + }, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_mantle_body(system=[LIFTED, {"type": "text", "text": REMINDER}]),) @@ -874,7 +905,7 @@ def test_messages_count_tokens_forwards_tools_beside_the_lifted_system_to_mantle ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, {**count_request(model, STRING_CASE), "tools": _TOOLS}) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_mantle_body(system=[LIFTED], tools=_TOOLS),) @@ -894,7 +925,7 @@ def test_messages_count_tokens_keeps_a_mid_conversation_system_in_place_for_mant "messages": [LEADING, USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], } response: Final = _count(gateway, body) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == ( _mantle_body(messages=[USER, MID_SYSTEM, ASSISTANT, FOLLOW_UP], system=[LIFTED]), @@ -947,7 +978,7 @@ def test_messages_count_tokens_repeated_request_lifts_the_leading_system_each_ti ): model: Final = _deployment(scenario, runtime.url) answers: Final = tuple(_payload(_count(gateway, count_request(model, STRING_CASE))) for _ in range(2)) - assert answers == ({"input_tokens": _MANTLE_COUNT},) * 2 + assert answers == ({"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"},) * 2 assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",) * 2 assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,) * 2 @@ -968,7 +999,9 @@ def test_messages_count_tokens_surfaces_the_counting_error_for_a_leading_system_ assert "input_tokens" not in response.text, response.text assert mantle.drain() == () follow_up: Final = _count(gateway, count_request(model, STRING_CASE)) - assert _payload(follow_up) == {"input_tokens": _MANTLE_COUNT}, follow_up.text + assert _payload(follow_up) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, ( + follow_up.text + ) assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,) @@ -990,7 +1023,7 @@ def test_messages_count_tokens_duplicate_messages_key_lifts_the_last_value_for_m content=f'{{"model": "{model}", "messages": {first}, "messages": {last}}}', headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"}, ) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_MANTLE_LIFTED,) @@ -1015,7 +1048,9 @@ def test_disabled_token_counter_counts_a_leading_system_through_mantle( ) with wire_server(_mantle(_strict), port=mantle_port) as strict: counted: Final = _count(owned.gateway, count_request(_OWNED_OPUS, STRING_CASE)) - assert _payload(counted) == {"input_tokens": _MANTLE_COUNT}, counted.text + assert _payload(counted) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, ( + counted.text + ) assert _mantle_bodies(strict) == (_MANTLE_LIFTED,) assert len(_runtime_count_targets(runtime)) == 1 @@ -1220,7 +1255,7 @@ def test_messages_count_tokens_forwards_a_5kb_system_verbatim_to_mantle( ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, {**_bare(model), "system": system}) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_mantle_body(system=system),) @@ -1241,7 +1276,7 @@ def test_messages_count_tokens_duplicate_system_and_tools_keys_forward_one_value content=f'{{"model": "{model}", "messages": {json.dumps(_MESSAGES)}, {fields}, {fields}}}', headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"}, ) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 (sent,) = mantle.drain() assert _mantle_requests((sent,)) == (_MANTLE_FULL,) @@ -1259,7 +1294,7 @@ def test_messages_count_tokens_leaves_empty_tools_and_system_out_of_the_mantle_b ): model: Final = _deployment(scenario, runtime.url) response: Final = _count(gateway, {**_bare(model), "tools": [], "system": ""}) - assert _payload(response) == {"input_tokens": _MANTLE_COUNT}, response.text + assert _payload(response) == {"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"}, response.text assert len(_runtime_count_targets(runtime)) == 1 assert _mantle_bodies(mantle) == (_MANTLE_BARE,) @@ -1275,7 +1310,7 @@ def test_messages_count_tokens_repeated_request_reaches_both_peers_each_time( ): model: Final = _deployment(scenario, runtime.url) answers: Final = tuple(_payload(_count(gateway, _bare(model))) for _ in range(2)) - assert answers == ({"input_tokens": _MANTLE_COUNT},) * 2 + assert answers == ({"input_tokens": _MANTLE_COUNT, "litellm_count_source": "provider"},) * 2 assert _runtime_count_targets(runtime) == (f"/model/{_OPUS_BASE}/count-tokens",) * 2 assert _mantle_bodies(mantle) == (_MANTLE_BARE,) * 2 @@ -1304,9 +1339,15 @@ def test_disabled_token_counter_surfaces_the_mantle_error_instead_of_counting_lo assert _REJECTION in refused.text and "input_tokens" not in refused.text, refused.text assert _mantle_bodies(refusing) == (_MANTLE_FULL,) with wire_server(_mantle(), port=mantle_port) as counting: - assert _payload(_count(owned.gateway, _full(_OWNED_OPUS))) == {"input_tokens": _MANTLE_COUNT} + assert _payload(_count(owned.gateway, _full(_OWNED_OPUS))) == { + "input_tokens": _MANTLE_COUNT, + "litellm_count_source": "provider", + } assert _mantle_bodies(counting) == (_MANTLE_FULL,) - assert _payload(_count(owned.gateway, _full(_OWNED_SONNET))) == {"input_tokens": _RUNTIME_COUNT} + assert _payload(_count(owned.gateway, _full(_OWNED_SONNET))) == { + "input_tokens": _RUNTIME_COUNT, + "litellm_count_source": "provider", + } rejected: Final = _count(owned.gateway, _full(_OWNED_NOVA)) assert rejected.status_code == 400, rejected.text assert _UNSUPPORTED in rejected.text and "input_tokens" not in rejected.text, rejected.text diff --git a/tests/integration/providers/test_bedrock_mantle_gpt_prompt_cache_breakpoint_wire.py b/tests/integration/providers/test_bedrock_mantle_gpt_prompt_cache_breakpoint_wire.py index 2af41c2b683..bc46cf5cde1 100644 --- a/tests/integration/providers/test_bedrock_mantle_gpt_prompt_cache_breakpoint_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_gpt_prompt_cache_breakpoint_wire.py @@ -1,10 +1,12 @@ import asyncio import json +from collections.abc import Iterator from typing import Final from urllib.parse import urlsplit import pytest from integration._support.client import Gateway, eventually +from integration._support.process import isolated_gateway from integration._support.wire import Request, wire_server from integration.providers._mantle_gpt_prompt_cache_support import ( ANTHROPIC_PATH, @@ -68,6 +70,15 @@ from pydantic import JsonValue pytestmark = pytest.mark.timeout(600) +@pytest.fixture(scope="module") +def gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]: + with ( + pytest.MonkeyPatch.context() as environment, + isolated_gateway(tmp_path_factory.mktemp("gateway"), environment) as owned, + ): + yield owned + + @pytest.mark.parametrize("stream", [False, True], ids=["json", "stream"]) @pytest.mark.parametrize("endpoint", ENDPOINTS) def test_a_configured_system_point_reaches_mantle_as_a_breakpoint_over_httpx( diff --git a/tests/integration/providers/test_bedrock_mantle_output_limit_wire.py b/tests/integration/providers/test_bedrock_mantle_output_limit_wire.py index 343d5253900..1e599103a27 100644 --- a/tests/integration/providers/test_bedrock_mantle_output_limit_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_output_limit_wire.py @@ -5,7 +5,7 @@ import os import re import threading import uuid -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from pathlib import Path from queue import SimpleQueue @@ -21,7 +21,7 @@ import pytest import yaml from integration._support.client import Gateway, Scenario, eventually, object_value, string_value from integration._support.database import read_rows -from integration._support.process import graceful_stop_seconds, owned_proxy_process +from integration._support.process import graceful_stop_seconds, isolated_gateway, owned_proxy_process from integration._support.wire import Reply, Request, Wire, wire_server from pydantic import JsonValue, TypeAdapter @@ -250,6 +250,7 @@ def _deployment( *, base: str = _OPENAI_BASE, api_key: str = _API_KEY, + model_info: Mapping[str, JsonValue] | None = _CHAT_INFO, **extra: JsonValue, ) -> str: reasoning: Final[Mapping[str, JsonValue]] = ( @@ -260,7 +261,7 @@ def _deployment( custom_llm_provider="bedrock_mantle", api_base=wire.url + base, api_key=api_key, - model_info=_CHAT_INFO, + model_info=model_info, **reasoning, **extra, ) @@ -318,6 +319,15 @@ def _spend_statuses(model: str, expected: int) -> list[JsonValue]: return [row["status"] for row in rows] +@pytest.fixture(scope="module") +def gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]: + with ( + pytest.MonkeyPatch.context() as environment, + isolated_gateway(tmp_path_factory.mktemp("gateway"), environment) as owned, + ): + yield owned + + def _proxy_url(gateway: Gateway) -> str: return str(gateway.client.base_url).rstrip("/") @@ -420,7 +430,7 @@ def test_stream_with_tools_carries_the_deployment_limit_as_max_completion_tokens text: Final = _stream_text( gateway, "/v1/chat/completions", - _chat_body(model, marker, stream=True, stream_options={"include_usage": True}, tools=_TOOLS), + _chat_body(model, marker, stream=True, stream_options={"include_usage": True}, tools=list(_TOOLS)), ) assert f"marker-{marker}" in text and text.rstrip().endswith("data: [DONE]"), text (attempt,) = _attempts(wire) @@ -462,6 +472,22 @@ async def test_async_anthropic_sdk_messages_stream_reaches_mantle_as_max_complet _assert_logged(message.id, model, "success") +def test_a_chat_mode_deployment_leaves_its_unpinned_sibling_on_the_responses_api(gateway: Gateway) -> None: + pinned_marker: Final = uuid.uuid4().hex + sibling_marker: Final = uuid.uuid4().hex + with wire_server(_mantle) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _GPT56) + sibling: Final = _deployment(scenario, wire, _GPT56, model_info=None) + for model, marker in ((pinned, pinned_marker), (sibling, sibling_marker)): + response = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker)) + assert response.status_code == 200, response.text + assert _answer(marker) in response.text, response.text + pinned_attempt, sibling_attempt = _attempts(wire) + assert (pinned_attempt.target, _marker_in(pinned_attempt)) == (_CHAT_TARGET, pinned_marker), pinned_attempt + assert sibling_attempt.target in _RESPONSES_TARGETS, sibling_attempt + assert _marker_in(sibling_attempt) == sibling_marker, sibling_attempt + + def test_responses_max_output_tokens_is_forwarded_natively_on_both_legs(gateway: Gateway) -> None: unary: Final = uuid.uuid4().hex streamed: Final = uuid.uuid4().hex @@ -491,7 +517,6 @@ def test_responses_max_output_tokens_is_forwarded_natively_on_both_legs(gateway: _assert_logged(_managed_response_id(streamed_issued), model, "success") -@pytest.mark.skip(reason="unrealistic input") def test_region_prefixed_deployment_on_a_v1_base_sends_the_bare_model_with_max_completion_tokens( gateway: Gateway, ) -> None: diff --git a/tests/integration/providers/test_bedrock_native_structured_output_wire.py b/tests/integration/providers/test_bedrock_native_structured_output_wire.py index f389e53d132..5803d1dee44 100644 --- a/tests/integration/providers/test_bedrock_native_structured_output_wire.py +++ b/tests/integration/providers/test_bedrock_native_structured_output_wire.py @@ -1,14 +1,16 @@ +import base64 import json import re import uuid -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping from itertools import chain from typing import Final from urllib.parse import unquote import httpx import pytest -from integration._support.client import Gateway, Scenario, list_value, object_value, string_value +from integration._support.client import Gateway, Scenario, held, list_value, object_value, string_value +from integration._support.database import read_rows from integration._support.upstream import aws_event_stream_frame from integration._support.wire import Reply, Request, Wire, wire_server from pydantic import JsonValue, TypeAdapter @@ -17,7 +19,8 @@ _HAIKU: Final = "us.anthropic.claude-haiku-5-5" _FABLE: Final = "us.anthropic.claude-fable-5" _SONNET: Final = "us.anthropic.claude-sonnet-4-6" _OPUS: Final = "us.anthropic.claude-opus-5-5" -_OPT_IN: Final = "au.anthropic.claude-opus-4-8" +_OPT_IN: Final = _OPUS +_OPUS_4_8: Final = "us.anthropic.claude-opus-4-8" _DEEPSEEK: Final = "deepseek.v3.2" _AWS: Final[dict[str, JsonValue]] = { "aws_access_key_id": "AKIASCRIPTEDPROVIDER", @@ -38,6 +41,20 @@ _JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) _FABLE_REJECTION: Final = ( "The model returned the following errors: output_config.format: Extra inputs are not permitted" ) +_SCHEMA_TOO_COMPLEX: Final = ( + "The model returned the following errors: compilation exceeded limits: Schema is too complex." +) +_PATTERN_TOO_COMPLEX: Final = ( + "The model returned the following errors: output_config.format.schema: Unsupported regex feature in pattern " + "field: pattern is too complex for structured output: reduce the {n,m} upper bound, narrow the character class " + "range, or avoid nesting quantified groups" +) +_MAX_TOKENS_400: Final = ( + "The model returned the following errors: max_tokens: 2048 > 1024, the maximum for this request" +) +_SIGNATURE_400: Final = ( + "The model returned the following errors: messages.1.content.0: Invalid `signature` in `thinking` block" +) _UNSUPPORTED_KEYWORDS: Final = ( "minimum", "maximum", @@ -327,6 +344,54 @@ def _converse_frames(blocks: tuple[dict[str, JsonValue], ...], stop_reason: str) ) +def _invoke_chunk(event: Mapping[str, JsonValue]) -> bytes: + return aws_event_stream_frame( + {":event-type": "chunk", ":content-type": "application/json", ":message-type": "event"}, + json.dumps({"bytes": base64.b64encode(json.dumps(event).encode()).decode()}).encode(), + ) + + +def _invoke_block_events(index: int, block: Mapping[str, JsonValue]) -> tuple[dict[str, JsonValue], ...]: + def start(content_block: dict[str, JsonValue]) -> dict[str, JsonValue]: + return {"type": "content_block_start", "index": index, "content_block": content_block} + + def delta(payload: dict[str, JsonValue]) -> dict[str, JsonValue]: + return {"type": "content_block_delta", "index": index, "delta": payload} + + stop: Final[dict[str, JsonValue]] = {"type": "content_block_stop", "index": index} + match block.get("type"): + case "thinking": + return ( + start({"type": "thinking", "thinking": ""}), + delta({"type": "thinking_delta", "thinking": block["thinking"]}), + delta({"type": "signature_delta", "signature": block["signature"]}), + stop, + ) + case "tool_use": + return ( + start({"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}), + delta({"type": "input_json_delta", "partial_json": json.dumps(block["input"])}), + stop, + ) + case _: + return (start({"type": "text", "text": ""}), delta({"type": "text_delta", "text": block["text"]}), stop) + + +def _invoke_frames(message: Mapping[str, JsonValue]) -> bytes: + blocks: Final = tuple(object_value(block) for block in list_value(message["content"])) + events: Final[tuple[Mapping[str, JsonValue], ...]] = ( + {"type": "message_start", "message": {**message, "content": [], "stop_reason": None}}, + *chain.from_iterable(_invoke_block_events(index, block) for index, block in enumerate(blocks)), + { + "type": "message_delta", + "delta": {"stop_reason": message["stop_reason"], "stop_sequence": None}, + "usage": {"output_tokens": 7}, + }, + {"type": "message_stop"}, + ) + return b"".join(_invoke_chunk(event) for event in events) + + def _called_tool(tools: Iterable[str], native: bool) -> str | None: names: Final = frozenset(tools) if _USER_TOOL in names: @@ -365,6 +430,8 @@ def _bedrock(request: Request) -> Reply: "stop_sequence": None, "usage": {"input_tokens": 11, "output_tokens": 7}, } + if _is_stream(request): + return Reply(body=_invoke_frames(message), content_type=_EVENT_STREAM) return Reply(body=json.dumps(message).encode()) if _is_stream(request): return Reply(body=_converse_frames(blocks, stop_reason), content_type=_EVENT_STREAM) @@ -446,6 +513,20 @@ def _messages_text(blocks: tuple[dict[str, JsonValue], ...]) -> JsonValue: return _JSON_VALUE.validate_json(string_value(text["text"])) +def _messages_stream_text(response: httpx.Response) -> JsonValue: + assert response.status_code == 200, response.text + events: Final = tuple( + _JSON.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: {") + ) + deltas: Final = tuple( + object_value(event["delta"]) for event in events if event.get("type") == "content_block_delta" + ) + text: Final = "".join(string_value(delta["text"]) for delta in deltas if delta.get("type") == "text_delta") + return _JSON_VALUE.validate_json(text) + + def _refs(schema: JsonValue) -> frozenset[str]: return frozenset(string_value(node["$ref"]) for node in _schema_nodes(schema) if "$ref" in node) @@ -551,15 +632,18 @@ def test_bedrock_native_model_without_thinking_keeps_the_forced_json_tool(gatewa @pytest.mark.parametrize( - ("backend", "model_info"), - [(_OPUS, None), (_OPT_IN, {"supports_bedrock_native_structured_output": True})], - ids=["neither_key", "deployment_opt_in"], + ("backend", "model_info", "native"), + [ + (_OPUS, None, False), + (_OPT_IN, {"supports_bedrock_native_structured_output": True}, True), + (_OPUS_4_8, {"supports_bedrock_native_structured_output": False}, False), + ], + ids=["neither_key", "deployment_opt_in", "deployment_opt_out"], ) def test_thinking_request_follows_the_bedrock_native_key_the_model_resolves_to( - gateway: Gateway, backend: str, model_info: Mapping[str, JsonValue] | None + gateway: Gateway, backend: str, model_info: Mapping[str, JsonValue] | None, native: bool ) -> None: marker, marker_hex = _marker() - native: Final = model_info is not None with wire_server(_bedrock) as wire, gateway.scenario() as scenario: model: Final = _deployment(scenario, wire, "converse", backend, model_info) response: Final = gateway.request( @@ -818,3 +902,293 @@ def test_numeric_formats_and_wide_schemas_follow_the_bedrock_native_key( tools: Final = _tools(body, invoke) assert (_JSON_TOOL in tools) is not native, body assert native or tools[_JSON_TOOL]["properties"] == schema["properties"], body + + +def _rejecting_native_output(message: str) -> Callable[[Request], Reply]: + def reply(request: Request) -> Reply: + native: Final = _native_schema(_JSON.validate_json(request.body), _is_invoke(request)) + return _bedrock(request) if native is None else _error_reply(message) + + return reply + + +def _fallback_answer(gateway: Gateway, entry: str, model: str, marker: str) -> JsonValue: + match entry: + case "converse_chat_stream" | "invoke_chat_stream": + streamed: Final = _chat_body( + model, marker, stream=True, thinking=_ADAPTIVE, response_format=_response_format(_SCHEMA) + ) + return _content_json(_stream_message(gateway.request("POST", "/v1/chat/completions", streamed))) + case "invoke_messages_stream": + streamed_messages: Final = _messages_body( + model, marker, stream=True, output_config={"format": {"type": "json_schema", "schema": _SCHEMA}} + ) + return _messages_stream_text(gateway.request("POST", "/v1/messages", streamed_messages)) + case "converse_output_config": + chat: Final = _chat_body( + model, marker, output_config={"format": {"type": "json_schema", "schema": _SCHEMA}} + ) + return _content_json(_chat_message(gateway.request("POST", "/v1/chat/completions", chat))) + case _: + return _opt_in_answer(gateway, entry, model, marker) + + +@pytest.mark.parametrize( + ("entry", "rejection"), + [ + ("converse_chat", _FABLE_REJECTION), + ("converse_chat", _PATTERN_TOO_COMPLEX), + ("converse_chat_stream", _SCHEMA_TOO_COMPLEX), + ("converse_responses", _FABLE_REJECTION), + ("converse_messages", _FABLE_REJECTION), + ("converse_output_config", _FABLE_REJECTION), + ("invoke_chat", _FABLE_REJECTION), + ("invoke_chat_stream", _FABLE_REJECTION), + ("invoke_messages", _SCHEMA_TOO_COMPLEX), + ("invoke_messages_stream", _FABLE_REJECTION), + ], +) +def test_bedrock_rejecting_opt_in_native_output_gets_one_retry_without_it( + gateway: Gateway, entry: str, rejection: str +) -> None: + marker, marker_hex = _marker() + route: Final = entry.split("_", 1)[0] + invoke: Final = route == "invoke" + with wire_server(_rejecting_native_output(rejection)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, route, _OPUS_4_8) + assert _fallback_answer(gateway, entry, model, marker) == _payload(marker_hex) + rejected, retried = (_JSON.validate_json(request.body) for request in wire.drain()) + assert _native_schema(rejected, invoke) is not None, rejected + assert _native_schema(retried, invoke) is None, retried + tools: Final = _tools(retried, invoke) + match entry: + case "converse_output_config": + assert not tools, retried + case "invoke_messages" | "invoke_messages_stream": + assert not tools, retried + prompt: Final = json.dumps(retried["messages"]) + assert all(name in prompt for name in object_value(_SCHEMA["properties"])), retried + case _: + assert tools[_JSON_TOOL]["properties"] == _SCHEMA["properties"], retried + + +@pytest.mark.parametrize( + ("backend", "rejection"), + [(_OPUS_4_8, _MAX_TOKENS_400), (_SONNET, _FABLE_REJECTION)], + ids=["other_400", "native_model"], +) +@pytest.mark.parametrize("route", ["converse", "invoke"]) +def test_bedrock_400_is_not_retried_unless_it_rejects_opt_in_native_output( + gateway: Gateway, route: str, backend: str, rejection: str +) -> None: + marker, marker_hex = _marker() + with wire_server(_rejecting_native_output(rejection)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, route, backend) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + _chat_body(model, marker, thinking=_ADAPTIVE, response_format=_response_format(_SCHEMA)), + ) + assert response.status_code == 400, response.text + assert rejection.split(": ", 1)[1] in response.text, response.text + _, body = _one_call(wire, marker_hex) + assert _native_schema(body, route == "invoke") == _SCHEMA, body + + +def _always_rejecting(message: str) -> Callable[[Request], Reply]: + def reply(_: Request) -> Reply: + return _error_reply(message) + + return reply + + +@pytest.mark.parametrize( + ("backend", "rejection"), + [(_OPUS_4_8, _MAX_TOKENS_400), (_SONNET, _FABLE_REJECTION)], + ids=["other_400", "native_model"], +) +def test_messages_400_on_bedrock_invoke_reaches_the_caller_after_one_call_unless_it_rejects_opt_in_native_output( + gateway: Gateway, backend: str, rejection: str +) -> None: + marker, marker_hex = _marker() + with wire_server(_always_rejecting(rejection)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, "invoke", backend) + response: Final = gateway.request( + "POST", + "/v1/messages", + _messages_body(model, marker, output_config={"format": {"type": "json_schema", "schema": _SCHEMA}}), + ) + assert response.status_code == 400, response.text + assert rejection.split(": ", 1)[1] in response.text, response.text + _one_call(wire, marker_hex) + + +def test_a_messages_retry_without_native_output_still_omits_the_deployments_dropped_params(gateway: Gateway) -> None: + marker, marker_hex = _marker() + with wire_server(_rejecting_native_output(_FABLE_REJECTION)) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"bedrock/invoke/{_OPUS_4_8}", + api_base=wire.url, + model_info=None, + additional_drop_params=["stop_sequences"], + **_AWS, + ) + request: Final = _messages_body( + model, marker, stop_sequences=["END"], output_config={"format": {"type": "json_schema", "schema": _SCHEMA}} + ) + assert _messages_text(_messages_blocks(gateway.request("POST", "/v1/messages", request))) == _payload( + marker_hex + ) + rejected, retried = (_JSON.validate_json(call.body) for call in wire.drain()) + assert _native_schema(rejected, True) is not None, rejected + assert _native_schema(retried, True) is None, retried + assert "stop_sequences" not in rejected, rejected + assert "stop_sequences" not in retried, retried + + +def _carries_thinking_block(message: JsonValue) -> bool: + content: Final = object_value(message).get("content") + return isinstance(content, list) and any(object_value(block).get("type") == "thinking" for block in content) + + +def _replays_thinking(body: Mapping[str, JsonValue]) -> bool: + return any(_carries_thinking_block(message) for message in list_value(body["messages"])) + + +def _rejecting_native_output_and_foreign_signatures(signature_first: bool) -> Callable[[Request], Reply]: + def reply(request: Request) -> Reply: + body: Final = _JSON.validate_json(request.body) + checks: Final = ( + (_replays_thinking(body), _SIGNATURE_400), + (_native_schema(body, True) is not None, _FABLE_REJECTION), + ) + failing: Final = tuple(message for failed, message in (checks if signature_first else checks[::-1]) if failed) + return _error_reply(failing[0]) if failing else _bedrock(request) + + return reply + + +@pytest.mark.parametrize("signature_first", [False, True], ids=["rejection_first", "signature_first"]) +def test_messages_retry_without_native_output_keeps_the_thinking_signature_fix( + gateway: Gateway, signature_first: bool +) -> None: + marker, marker_hex = _marker() + history: Final[list[JsonValue]] = [ + {"role": "user", "content": [{"type": "text", "text": f"first question {marker}"}]}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "from another deployment", "signature": _SIGNATURE}, + {"type": "text", "text": "first answer"}, + ], + }, + {"role": "user", "content": [{"type": "text", "text": "second question"}]}, + ] + with ( + wire_server(_rejecting_native_output_and_foreign_signatures(signature_first)) as wire, + gateway.scenario() as scenario, + ): + model: Final = _deployment(scenario, wire, "invoke", _OPUS_4_8) + request: Final = { + **_messages_body( + model, marker, thinking=_ADAPTIVE, output_config={"format": {"type": "json_schema", "schema": _SCHEMA}} + ), + "messages": history, + } + assert _messages_text(_messages_blocks(gateway.request("POST", "/v1/messages", request))) == _payload( + marker_hex + ) + sent: Final = tuple(_JSON.validate_json(call.body) for call in wire.drain()) + assert len(sent) == 3, sent + assert _native_schema(sent[0], True) is not None and _replays_thinking(sent[0]), sent[0] + assert _native_schema(sent[-1], True) is None and not _replays_thinking(sent[-1]), sent[-1] + assert all(name in json.dumps(sent[-1]["messages"]) for name in object_value(_SCHEMA["properties"])), sent[-1] + + +@pytest.mark.parametrize("entry", ["converse_chat", "invoke_messages"]) +def test_a_request_retried_without_native_output_lands_one_success_spend_row(gateway: Gateway, entry: str) -> None: + marker, marker_hex = _marker() + invoke: Final = entry.startswith("invoke") + with wire_server(_rejecting_native_output(_FABLE_REJECTION)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, entry.split("_", 1)[0], _OPUS_4_8) + assert _fallback_answer(gateway, entry, model, marker) == _payload(marker_hex) + sent: Final = tuple(_JSON.validate_json(call.body) for call in wire.drain()) + assert len(sent) == (1 if _native_schema(sent[0], invoke) is None else 2), sent + rows: Final = held( + lambda: read_rows('SELECT status FROM "LiteLLM_SpendLogs" WHERE model_group=%s', (model,)), + lambda found: len(found) == 1, + holding=2, + seconds=70, + ) + assert rows[0]["status"] == "success", rows + + +_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT: Final[dict[str, JsonValue]] = { + "textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "risk", "schema": {"type": "object"}}}} +} +_BEDROCK_SCHEMA_OBJECT_400: Final = "Start of structure or map found where not expected" + + +def _converse_json_schema_field(body: Mapping[str, JsonValue]) -> JsonValue: + text_format: Final = object_value(body.get("outputConfig") or {}).get("textFormat") or {} + structure: Final = object_value(text_format).get("structure") or {} + return object_value(object_value(structure).get("jsonSchema") or {}).get("schema") + + +def _deserializing_like_bedrock(request: Request) -> Reply: + schema: Final = _converse_json_schema_field(_JSON.validate_json(request.body)) + if schema is None or isinstance(schema, str): + return _bedrock(request) + return Reply(status=400, body=json.dumps({"Message": _BEDROCK_SCHEMA_OBJECT_400}).encode()) + + +def test_a_malformed_caller_output_config_on_an_opt_in_model_gets_bedrocks_own_400_after_one_call( + gateway: Gateway, +) -> None: + marker, marker_hex = _marker() + with wire_server(_deserializing_like_bedrock) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, "converse", _OPUS_4_8) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + _chat_body( + model, + marker, + thinking=_ADAPTIVE, + response_format=_response_format(_SCHEMA), + outputConfig=_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT, + ), + ) + assert response.status_code == 400, response.text + assert _BEDROCK_SCHEMA_OBJECT_400 in response.text, response.text + _, body = _one_call(wire, marker_hex) + assert body["outputConfig"] == _CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT, body + + +_CALLER_OUTPUT_CONFIG: Final[dict[str, JsonValue]] = { + "textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "risk", "schema": json.dumps(_SCHEMA)}}} +} + + +@pytest.mark.parametrize("thinking", [None, _ADAPTIVE], ids=["no_thinking", "adaptive_thinking"]) +def test_a_caller_output_config_bedrock_refuses_is_sent_once_on_an_opt_in_model( + gateway: Gateway, thinking: dict[str, JsonValue] | None +) -> None: + marker, marker_hex = _marker() + with wire_server(_rejecting_native_output(_PATTERN_TOO_COMPLEX)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, "converse", _OPUS_4_8) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + _chat_body( + model, + marker, + response_format=_response_format(_SCHEMA), + outputConfig=_CALLER_OUTPUT_CONFIG, + **({} if thinking is None else {"thinking": thinking}), + ), + ) + assert response.status_code == 400, response.text + assert "pattern is too complex for structured output" in response.text, response.text + _, body = _one_call(wire, marker_hex) + assert body["outputConfig"] == _CALLER_OUTPUT_CONFIG, body diff --git a/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py b/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py new file mode 100644 index 00000000000..5fd12405ab2 --- /dev/null +++ b/tests/integration/providers/test_bedrock_passthrough_request_metadata_wire.py @@ -0,0 +1,316 @@ +import base64 +import json +import uuid +from urllib.parse import unquote +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import boto3 +import pytest +import yaml +from botocore.config import Config +from botocore.exceptions import ClientError +from integration._support.budget import case_timeout_seconds +from integration._support.client import Gateway +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.upstream import _aws_event_frame +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +_MODEL_ID: Final = "anthropic.claude-sonnet-5-v1:0" +_EVENT_STREAM: Final = "application/vnd.amazon.eventstream" +_METADATA_HEADER: Final = "x-amzn-bedrock-request-metadata" +_HEADER_MAX_CHARS: Final = 8500 +_LONG_SPEND_LOGS_METADATA: Final = tuple((f"{index:02d}".ljust(256, "k"), "\n" * 256) for index in range(12)) +_CONVERSE_REPLY: Final[Mapping[str, JsonValue]] = { + "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 5, "outputTokens": 1, "totalTokens": 6}, + "metrics": {"latencyMs": 3}, +} +_INVOKE_REPLY: Final[Mapping[str, JsonValue]] = { + "id": "msg_bedrock", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 1}, +} +_CONVERSE_STREAM_FRAMES: Final = ( + _aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"), + _aws_event_frame("messageStop", {"stopReason": "end_turn"}, "sc", "u"), + _aws_event_frame("metadata", {"usage": {"inputTokens": 5, "outputTokens": 1, "totalTokens": 6}}, "sc", "u"), +) + + +def _bedrock_peer(request: Request) -> Reply: + if request.target.endswith("/converse-stream"): + return Reply(chunks=_CONVERSE_STREAM_FRAMES, content_type=_EVENT_STREAM) + if request.target.endswith("/invoke-with-response-stream"): + chunk: Final = {"bytes": base64.b64encode(json.dumps(_INVOKE_REPLY).encode()).decode()} + return Reply(chunks=(_aws_event_frame("chunk", chunk, "sc", "u"),), content_type=_EVENT_STREAM) + if request.target.endswith("/converse"): + return Reply(body=json.dumps(_CONVERSE_REPLY).encode()) + return Reply(body=json.dumps(_INVOKE_REPLY).encode()) + + +def _messages(marker: str) -> Sequence[JsonValue]: + return [{"role": "user", "content": [{"text": marker}]}] + + +def _invoke_body(marker: str) -> Mapping[str, JsonValue]: + return { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": marker}], + } + + +def _signed_header_names(request: Request) -> frozenset[str]: + return frozenset(request.headers["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")) + + +def _header_value(pairs: tuple[tuple[str, str], ...]) -> str: + return json.dumps(dict(pairs), separators=(",", ":")) + + +def _send_with_caller_metadata( + runtime: object, action: str, model: str, marker: str, caller_metadata: Mapping[str, JsonValue] +) -> None: + if action == "invoke": + getattr(runtime, "invoke_model")( + modelId=model, body=json.dumps({**_invoke_body(marker), "requestMetadata": caller_metadata}) + ) + return + getattr(runtime, action.replace("-", "_"))( + modelId=model, messages=_messages(marker), requestMetadata=caller_metadata + ) + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + wire: Wire + strict_model: str + dropping_model: str + chat_model: str + key: str + identity: Mapping[str, str] + long_key: str + long_identity: tuple[tuple[str, str], ...] + + def runtime(self, key: str, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 # boto3 clients are untyped + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", key) + return boto3.client( + "bedrock-runtime", + endpoint_url=f"{str(self.proxy.client.base_url).rstrip('/')}/bedrock", + region_name="us-east-1", + config=Config( + read_timeout=case_timeout_seconds(), + retries={"total_max_attempts": 1}, + parameter_validation=False, + ), + ) + + def received(self, marker: str) -> tuple[Request, ...]: + return tuple(request for request in self.wire.drain() if marker.encode() in request.body) + + +@pytest.fixture(scope="module") +def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + suffix: Final = uuid.uuid4().hex[:12] + strict_model: Final = f"bedrock-metadata-{suffix}" + dropping_model: Final = f"bedrock-metadata-dropping-{suffix}" + chat_model: Final = f"bedrock-metadata-chat-{suffix}" + directory: Final = tmp_path_factory.mktemp("bedrock-request-metadata") + base_config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + with wire_server(_bedrock_peer) as wire: + deployment: Final = { + "model": f"bedrock/{_MODEL_ID}", + "api_base": wire.url, + "aws_access_key_id": "AKIASCRIPTEDPROVIDER", + "aws_secret_access_key": "scripted-secret", + "aws_region_name": "us-east-1", + "use_in_pass_through": True, + } + config: Final = { + **base_config, + "model_list": [ + *base_config.get("model_list", []), + {"model_name": strict_model, "litellm_params": deployment}, + {"model_name": dropping_model, "litellm_params": {**deployment, "drop_params": True}}, + { + "model_name": chat_model, + "litellm_params": {**deployment, "model": f"bedrock/converse/{_MODEL_ID}", "drop_params": True}, + }, + ], + "litellm_settings": { + **base_config.get("litellm_settings", {}), + "drop_params": False, + "bedrock_request_metadata_fields": [ + "user_api_key_alias", + "user_api_key_team_alias", + "spend_logs_metadata", + ], + }, + } + config_path: Final = directory / "bedrock-request-metadata.yaml" + config_path.write_text(yaml.safe_dump(config)) + with ( + owned_proxy(session_gateway, directory, {}, config=config_path, workers=2) as proxy, + proxy.scenario() as scenario, + ): + team_alias: Final = f"platform-{suffix}" + team_id: Final = scenario.team(team_alias=team_alias) + key_alias: Final = f"prod-key-{suffix}" + long_key_alias: Final = f"long-key-{suffix}" + yield Rig( + proxy=proxy, + wire=wire, + strict_model=strict_model, + dropping_model=dropping_model, + chat_model=chat_model, + key=scenario.key( + key_alias=key_alias, + team_id=team_id, + metadata={"spend_logs_metadata": {"agent": "triage-bot"}}, + ), + identity={ + "user_api_key_alias": key_alias, + "user_api_key_team_alias": team_alias, + "agent": "triage-bot", + }, + long_key=scenario.key( + key_alias=long_key_alias, + team_id=team_id, + metadata={"spend_logs_metadata": dict(_LONG_SPEND_LOGS_METADATA)}, + ), + long_identity=( + ("user_api_key_alias", long_key_alias), + ("user_api_key_team_alias", team_alias), + *_LONG_SPEND_LOGS_METADATA, + ), + ) + + +def test_boto3_passthrough_calls_carry_the_key_identity_in_signed_request_metadata( + rig: Rig, monkeypatch: pytest.MonkeyPatch +) -> None: + marker: Final = f"who-is-calling-{uuid.uuid4().hex}" + forged: Final = {"user_api_key_team_alias": "forged", "ticket": "T-42", "bad!": "x"} + runtime: Final = rig.runtime(rig.key, monkeypatch) + model: Final = rig.dropping_model + + converse: Final = runtime.converse(modelId=model, messages=_messages(marker), requestMetadata=forged) + converse_stream: Final = [ + next(iter(event)) + for event in runtime.converse_stream(modelId=model, messages=_messages(marker), requestMetadata=forged)[ + "stream" + ] + ] + invoke: Final = json.loads( + runtime.invoke_model(modelId=model, body=json.dumps(_invoke_body(marker)))["body"].read() + ) + invoke_stream: Final = [ + json.loads(event["chunk"]["bytes"]) + for event in runtime.invoke_model_with_response_stream(modelId=model, body=json.dumps(_invoke_body(marker)))[ + "body" + ] + ] + assert converse["output"] == _CONVERSE_REPLY["output"], converse + assert converse_stream == ["messageStart", "messageStop", "metadata"], converse_stream + assert invoke == _INVOKE_REPLY, invoke + assert invoke_stream == [_INVOKE_REPLY], invoke_stream + + received: Final = rig.received(marker) + assert [request.target for request in received] == [ + f"/model/{_MODEL_ID}/converse", + f"/model/{_MODEL_ID}/converse-stream", + f"/model/{_MODEL_ID}/invoke", + f"/model/{_MODEL_ID}/invoke-with-response-stream", + ], received + converse_requests: Final = received[:2] + invoke_requests: Final = received[2:] + assert [json.loads(request.body) for request in converse_requests] == [ + {"messages": _messages(marker), "requestMetadata": {**rig.identity, "ticket": "T-42"}}, + {"messages": _messages(marker), "requestMetadata": {**rig.identity, "ticket": "T-42"}}, + ], [request.body for request in converse_requests] + assert [_METADATA_HEADER in request.headers for request in converse_requests] == [False, False] + assert [json.loads(request.body) for request in invoke_requests] == [_invoke_body(marker), _invoke_body(marker)] + assert [json.loads(request.headers[_METADATA_HEADER]) for request in invoke_requests] == [ + rig.identity, + rig.identity, + ], [request.headers for request in invoke_requests] + assert all(_METADATA_HEADER in _signed_header_names(request) for request in invoke_requests), [ + request.headers["authorization"] for request in invoke_requests + ] + + +def test_invoke_metadata_too_long_for_the_header_signs_the_leading_pairs_that_fit( + rig: Rig, monkeypatch: pytest.MonkeyPatch +) -> None: + marker: Final = f"long-key-{uuid.uuid4().hex}" + runtime: Final = rig.runtime(rig.long_key, monkeypatch) + + invoke: Final = json.loads( + runtime.invoke_model(modelId=rig.strict_model, body=json.dumps(_invoke_body(marker)))["body"].read() + ) + + assert invoke == _INVOKE_REPLY, invoke + (request,) = rig.received(marker) + assert json.loads(request.body) == _invoke_body(marker) + header: Final = request.headers[_METADATA_HEADER] + sent_pairs: Final = len(json.loads(header)) + assert tuple(json.loads(header).items()) == rig.long_identity[:sent_pairs], header + assert 2 <= sent_pairs < len(rig.long_identity), header + assert len(header) <= _HEADER_MAX_CHARS < len(_header_value(rig.long_identity[: sent_pairs + 1])) + assert _METADATA_HEADER in _signed_header_names(request), request.headers["authorization"] + + +@pytest.mark.parametrize( + ("action", "caller_metadata", "error_code"), + [ + pytest.param("converse", {"ticket": 42}, "SerializationException", id="converse-number-value"), + pytest.param("converse-stream", {"bad!": "x"}, "ValidationException", id="converse-stream-bad-key"), + pytest.param("invoke", {"bad!": "x"}, "ValidationException", id="invoke-bad-key"), + ], +) +def test_malformed_caller_request_metadata_reaches_boto3_as_the_bedrock_error_without_an_upstream_call( + rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str, caller_metadata: Mapping[str, JsonValue], error_code: str +) -> None: + marker: Final = f"malformed-{uuid.uuid4().hex}" + runtime: Final = rig.runtime(rig.key, monkeypatch) + + with pytest.raises(ClientError) as raised: + _send_with_caller_metadata(runtime, action, rig.strict_model, marker, caller_metadata) + + assert raised.value.response["Error"]["Code"] == error_code, raised.value.response + assert raised.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400, raised.value.response + assert rig.received(marker) == () + + +def test_chat_on_a_dropping_converse_deployment_sends_only_the_valid_caller_pairs(rig: Rig) -> None: + marker: Final = f"chat-{uuid.uuid4().hex}" + + response: Final = rig.proxy.client.post( + "/chat/completions", + json={ + "model": rig.chat_model, + "messages": [{"role": "user", "content": marker}], + "requestMetadata": {"ticket": "T-42", "bad!": "x"}, + }, + headers={"Authorization": f"Bearer {rig.key}"}, + timeout=case_timeout_seconds(), + ) + + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["content"] == "ok", response.text + (request,) = rig.received(marker) + assert unquote(request.target) == f"/model/{_MODEL_ID}/converse", request.target + assert json.loads(request.body)["requestMetadata"] == {**rig.identity, "ticket": "T-42"}, request.body diff --git a/tests/integration/providers/test_bedrock_stream_timeout_wire.py b/tests/integration/providers/test_bedrock_stream_timeout_wire.py index 631232a0420..66267ca80ce 100644 --- a/tests/integration/providers/test_bedrock_stream_timeout_wire.py +++ b/tests/integration/providers/test_bedrock_stream_timeout_wire.py @@ -22,6 +22,8 @@ _CONVERSE_MODEL_ID: Final = "global.moonshotai.kimi-k3" _CONVERSE_MODEL: Final = f"bedrock/converse/{_CONVERSE_MODEL_ID}" _INVOKE_MODEL_ID: Final = "anthropic.claude-3-haiku-20240307-v1:0" _INVOKE_MODEL: Final = f"bedrock/invoke/{_INVOKE_MODEL_ID}" +_FAKED_MODEL_ID: Final = "ai21.jamba-1-5-mini-v1:0" +_FAKED_MODEL: Final = f"bedrock/converse/{_FAKED_MODEL_ID}" _EVENT_STREAM: Final = "application/vnd.amazon.eventstream" _ANSWER_HEAD: Final = "scripted answer part one " _ANSWER_TAIL: Final = "scripted answer part two" @@ -31,6 +33,9 @@ _USER_TURN: Final[dict[str, JsonValue]] = {"role": "user", "content": _PROMPT} _TIMEOUT_SECONDS: Final = 1 _PAUSE_SECONDS: Final = 3.0 _HOLD_SECONDS: Final = 40.0 +_GENERATION_SECONDS: Final = 3.0 +_LONG_TIMEOUT_SECONDS: Final = 6 +_BOTH_LIMITS_TIMEOUT_SECONDS: Final = 2 _CLIENT_WINDOW: Final = 10.0 _RETRY_WINDOW: Final = 25.0 _SHORT_WINDOW: Final = 3.0 @@ -65,8 +70,8 @@ _INVOKE_RESPONSE: Final = json.dumps( } ).encode() -Endpoint = Literal["chat", "messages", "responses"] -Mode = Literal["fast", "stall", "pause", "cut"] +Endpoint = Literal["chat", "messages", "responses", "completions"] +Mode = Literal["fast", "stall", "pause", "cut", "slow"] def _frame(event_type: str, payload: Mapping[str, JsonValue]) -> bytes: @@ -136,6 +141,8 @@ class _Peer: target: Final = unquote(request.target) if self.mode == "stall": self.release.wait(timeout=_HOLD_SECONDS) + if self.mode == "slow": + self.release.wait(timeout=_GENERATION_SECONDS) if not _is_stream(target): return Reply(body=_json_for(target)) frames: Final = _frames_for(target) @@ -163,6 +170,10 @@ def _converse(scenario: Scenario, wire: Wire, **extra: JsonValue) -> str: return scenario.model(model=_CONVERSE_MODEL, api_base=wire.url, **_AWS, **extra) +def _faked(scenario: Scenario, wire: Wire, **extra: JsonValue) -> str: + return scenario.model(model=_FAKED_MODEL, api_base=wire.url, **_AWS, **extra) + + def _invoke(scenario: Scenario, wire: Wire, **extra: JsonValue) -> str: return scenario.model( model=_INVOKE_MODEL, api_base=wire.url, aws_bedrock_runtime_endpoint=wire.url, **_AWS, **extra @@ -205,6 +216,8 @@ def _path(endpoint: Endpoint) -> str: return "/v1/messages" case "responses": return "/v1/responses" + case "completions": + return "/v1/completions" def _body(endpoint: Endpoint, model: str, *, stream: bool = True, **extra: JsonValue) -> dict[str, JsonValue]: @@ -215,6 +228,8 @@ def _body(endpoint: Endpoint, model: str, *, stream: bool = True, **extra: JsonV return {"model": model, "messages": [_USER_TURN], "max_tokens": 16, "stream": stream, **_EXTRA, **extra} case "responses": return {"model": model, "input": _PROMPT, "stream": stream, **_EXTRA, **extra} + case "completions": + return {"model": model, "prompt": _PROMPT, "stream": stream, **_EXTRA, **extra} @dataclass(frozen=True, slots=True) @@ -318,6 +333,11 @@ def _received(wire: Wire, expected: int) -> None: assert len(wire.drain()) == expected +def _received_targets(wire: Wire, expected: int) -> tuple[str, ...]: + eventually(wire.received.qsize, lambda size: size == expected, 10) + return tuple(unquote(request.target) for request in wire.drain()) + + def _model_identity(gateway: Gateway, model: str) -> str: entries: Final = _MODELS.validate_python(gateway.get("/model/info")["data"]) (entry,) = tuple(item for item in entries if item["model_name"] == model) @@ -441,6 +461,39 @@ def test_h13_an_invoke_stream_cut_off_mid_answer_reports_the_timeout_in_band(gat _received(wire, 1) +@pytest.mark.parametrize("endpoint", ("chat", "messages", "responses", "completions")) +def test_f1_to_f4_a_faked_stream_slower_than_its_stream_timeout_is_answered_within_the_deployment_timeout( + gateway: Gateway, endpoint: Endpoint +) -> None: + with _peer("slow") as wire, gateway.scenario() as scenario: + model: Final = _faked(scenario, wire, timeout=_LONG_TIMEOUT_SECONDS, stream_timeout=_TIMEOUT_SECONDS) + _assert_answered(_streamed(gateway, endpoint, _body(endpoint, model))) + (target,) = _received_targets(wire, 1) + assert target.endswith("/converse"), target + + +def test_f5_a_faked_stream_slower_than_both_limits_times_out_at_the_deployment_timeout(gateway: Gateway) -> None: + with _peer("stall") as wire, gateway.scenario() as scenario: + model: Final = _faked(scenario, wire, timeout=_BOTH_LIMITS_TIMEOUT_SECONDS, stream_timeout=_TIMEOUT_SECONDS) + served: Final = _streamed(gateway, "chat", _body("chat", model)) + _assert_timed_out(served.status, served.text, _BOTH_LIMITS_TIMEOUT_SECONDS) + assert served.headers.get("x-litellm-timeout") == f"{_BOTH_LIMITS_TIMEOUT_SECONDS:.1f}", served.headers + _received(wire, 1) + _failure_rows(model, 1) + + +@pytest.mark.parametrize("endpoint", ("chat", "messages", "responses")) +def test_c11_to_c13_a_live_stream_slower_than_its_stream_timeout_still_fails_at_the_stream_timeout( + gateway: Gateway, endpoint: Endpoint +) -> None: + with _peer("slow") as wire, gateway.scenario() as scenario: + model: Final = _converse(scenario, wire, timeout=_LONG_TIMEOUT_SECONDS, stream_timeout=_TIMEOUT_SECONDS) + served: Final = _streamed(gateway, endpoint, _body(endpoint, model)) + _assert_timed_out(served.status, served.text) + (target,) = _received_targets(wire, 1) + assert _is_stream(target), target + + def test_c1_chat_without_streaming_already_fails_at_the_deployment_timeout(gateway: Gateway) -> None: with _peer("stall") as wire, gateway.scenario() as scenario: model: Final = _converse(scenario, wire, timeout=_TIMEOUT_SECONDS) diff --git a/tests/integration/providers/test_count_tokens_model_wire.py b/tests/integration/providers/test_count_tokens_model_wire.py new file mode 100644 index 00000000000..46e5396ef64 --- /dev/null +++ b/tests/integration/providers/test_count_tokens_model_wire.py @@ -0,0 +1,719 @@ +import json +import re +from collections.abc import Callable, Iterator, Mapping +from itertools import chain +from pathlib import Path +from typing import Final +from uuid import uuid4 + +import anthropic +import httpx +import pytest +from anthropic.types import MessageCountTokensToolParam, MessageTokensCount +from integration._support.bedrock_runtime_peer import target_of +from integration._support.client import Gateway, Scenario, gateway_from_environment +from integration._support.ports import allocate_port +from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +pytestmark = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +_SONNET: Final = "us.anthropic.claude-sonnet-5-5" +_SONNET_BASE: Final = "anthropic.claude-sonnet-5-5" +_OPUS: Final = "us.anthropic.claude-opus-5-5" +_OPUS_BASE: Final = "anthropic.claude-opus-5-5" +_NOVA: Final = "amazon.nova-lite-v1:0" +_KIMI: Final = "us.moonshotai.kimi-k3" +_KIMI_BASE: Final = "moonshotai.kimi-k3" +_PROFILE_ID: Final = "lit9563count" +_PROFILE_ARN: Final = f"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/{_PROFILE_ID}" +_COST_MAP_PATH: Final = Path(__file__).resolve().parents[3] / "model_prices_and_context_window.json" +_REGION: Final = "us-west-2" +_COUNT_REGION: Final = "us-east-1" +_ACCESS_KEY: Final = "AKIAINTEGRATIONCOUNT" +_SECRET_KEY: Final = "integration-count-secret" +_PROVIDER_COUNT: Final = 1345 +_COUNT_MODEL_COUNT: Final = 2718 +_FRAMING: Final = 50 +_UNSUPPORTED: Final = "The provided model doesn't support counting tokens." +_COUNT_TARGET: Final = re.compile(r"^/model/(.+)/count-tokens$") +_SCRIPTED_STATUS: Final = re.compile(r"status=(\d{3})") +_SCRIPTED_COUNT_MODEL_STATUS: Final = re.compile(r"count_model=(\d{3})") +_CREDENTIAL: Final = re.compile(r"Credential=([^/]+)/\d+/([^/]+)/") +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_COST_MAP: Final = TypeAdapter(dict[str, dict[str, JsonValue]]) + +_SYSTEM: Final = "You are a terse assistant that answers in one sentence" +_WEATHER_SCHEMA: Final[dict[str, JsonValue]] = { + "type": "object", + "properties": {"city": {"type": "string", "description": "City to look up"}}, + "required": ["city"], +} +_SDK_TOOLS: Final[list[MessageCountTokensToolParam]] = [ + { + "name": "get_weather", + "description": "Look up the current weather for a city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string", "description": "City to look up"}}, + "required": ["city"], + }, + } +] +_COUNT_MODEL_SOURCE: Final = {"litellm_count_source": "token_count_model", "litellm_count_model": _OPUS} +_OPENAI_PRIMARY: Final = "gpt-count-primary" +_OPENAI_COUNT_MODEL: Final = "gpt-count-model" +_OPENAI_COUNT_TARGET: Final = "/v1/responses/input_tokens" +_COUNTED_FIELDS: Final = ("input", "instructions", "tools", "text") +_ANSWER_SCHEMA: Final[dict[str, JsonValue]] = {"type": "object", "properties": {"answer": {"type": "string"}}} +_ANTHROPIC_TOOL: Final[dict[str, JsonValue]] = { + "name": "get_weather", + "description": "Look up the current weather for a city", + "input_schema": _WEATHER_SCHEMA, +} +_TOOL_TURNS: Final[list[JsonValue]] = [ + {"role": "user", "content": "What is the weather in Paris?"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Sunny"}]}, +] +_LOCAL_SOURCE: Final = {"litellm_count_source": "local_estimate"} + + +def _prompt(status: int, count_model_status: int | None = None) -> str: + scripted: Final = "" if count_model_status is None else f" count_model={count_model_status}" + return f"Count this message status={status}{scripted}" + + +def _converse_body(prompt: str) -> dict[str, JsonValue]: + return { + "input": { + "converse": { + "messages": [{"role": "user", "content": [{"text": prompt}]}], + "system": [{"text": _SYSTEM}], + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "get_weather", + "description": "Look up the current weather for a city", + "inputSchema": {"json": _WEATHER_SCHEMA}, + } + } + ] + }, + } + } + } + + +def _json_reply(status: int, payload: Mapping[str, JsonValue]) -> Reply: + return Reply(status=status, body=json.dumps(payload).encode()) + + +def _counted_model(request: Request) -> str: + counted: Final = _COUNT_TARGET.match(target_of(request)) + return target_of(request) if counted is None else counted.group(1) + + +def _scripted(pattern: re.Pattern[str], request: Request, default: int) -> int: + scripted: Final = pattern.search(request.body.decode(errors="replace")) + return default if scripted is None else int(scripted.group(1)) + + +def _runtime(request: Request) -> Reply: + model: Final = _counted_model(request) + if model == _OPUS_BASE: + count_model_status: Final = _scripted(_SCRIPTED_COUNT_MODEL_STATUS, request, 200) + if count_model_status == 200: + return _json_reply(200, {"inputTokens": _COUNT_MODEL_COUNT}) + return _json_reply(count_model_status, {"message": f"scripted runtime {count_model_status} for {model}"}) + status: Final = _scripted(_SCRIPTED_STATUS, request, 500) + if status == 200: + return _json_reply(200, {"inputTokens": _PROVIDER_COUNT}) + return _json_reply(status, {"message": _UNSUPPORTED if status == 400 else f"scripted runtime {status}"}) + + +def _mantle(request: Request) -> Reply: + model: Final = _JSON_OBJECT.validate_json(request.body).get("model") + return _json_reply( + 404, {"type": "error", "error": {"type": "not_found_error", "message": f"scripted mantle 404 for {model}"}} + ) + + +def _signer(request: Request) -> tuple[str, str]: + credential: Final = _CREDENTIAL.search(request.headers.get("authorization", "")) + return ("", "") if credential is None else (credential.group(1), credential.group(2)) + + +def _runtime_counts(wire: Wire) -> tuple[tuple[str, tuple[str, str], dict[str, JsonValue]], ...]: + return tuple( + (_counted_model(request), _signer(request), _JSON_OBJECT.validate_json(request.body)) + for request in wire.drain() + ) + + +def _runtime_models(wire: Wire) -> tuple[str, ...]: + return tuple(_counted_model(request) for request in wire.drain()) + + +def _mantle_models(wire: Wire) -> tuple[JsonValue, ...]: + return tuple(_JSON_OBJECT.validate_json(request.body).get("model") for request in wire.drain()) + + +def _url(port: int) -> str: + return f"http://127.0.0.1:{port}" + + +def _litellm_params(model: str, api_base: str) -> dict[str, JsonValue]: + return { + "model": f"bedrock/{model}", + "api_base": api_base, + "aws_access_key_id": _ACCESS_KEY, + "aws_secret_access_key": _SECRET_KEY, + "aws_region_name": _REGION, + } + + +@pytest.fixture(scope="module") +def runtime_port() -> int: + return allocate_port() + + +@pytest.fixture(scope="module") +def mantle_port() -> int: + return allocate_port() + + +@pytest.fixture(scope="module") +def counting_proxy(tmp_path_factory: pytest.TempPathFactory, mantle_port: int) -> Iterator[OwnedProxy]: + with ( + gateway_from_environment() as gateway, + owned_proxy_process( + gateway, + tmp_path_factory.mktemp("count-model"), + { + "BEDROCK_MANTLE_API_BASE": _url(mantle_port), + "LITELLM_LOCAL_MODEL_COST_MAP": "True", + "HF_HUB_OFFLINE": "1", + }, + workers=1, + remove_environment=("AWS_BEARER_TOKEN_BEDROCK",), + ) as owned, + ): + yield owned + + +def _deployment( + scenario: Scenario, runtime_port: int, model: str = _SONNET, model_info: Mapping[str, JsonValue] | None = None +) -> str: + return scenario.model(model_info=model_info, api_key=None, **_litellm_params(model, _url(runtime_port))) + + +def _count(gateway: Gateway, model: str, status: int, count_model_status: int | None = None) -> MessageTokensCount: + with anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0) as client: + return client.messages.count_tokens( + model=model, + messages=[{"role": "user", "content": _prompt(status, count_model_status)}], + system=_SYSTEM, + tools=_SDK_TOOLS, + ) + + +def _messages_estimate(gateway: Gateway, model: str) -> tuple[int, JsonValue]: + counted: Final = _count(gateway, model, 400) + return counted.input_tokens, (counted.model_extra or {}).get("litellm_count_source") + + +def _utils_estimate(gateway: Gateway, model: str) -> tuple[int, JsonValue]: + response: Final = gateway.request( + "POST", "/utils/token_counter", {"model": model, "messages": [{"role": "user", "content": _prompt(400)}]} + ) + assert response.status_code == 200, response.text + body: Final = _JSON_OBJECT.validate_json(response.content) + total: Final = body["total_tokens"] + assert isinstance(total, int), body + return total, body.get("count_source") + + +def _shipped_framing(model: str) -> JsonValue: + return _COST_MAP.validate_json(_COST_MAP_PATH.read_bytes())[model].get("token_count_framing") + + +def test_shipped_token_count_model_counts_the_request_in_its_region_without_trying_the_model( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + counted: Final = _count(gateway, _deployment(scenario, runtime_port), 404) + assert (counted.input_tokens, counted.model_extra) == (_COUNT_MODEL_COUNT, _COUNT_MODEL_SOURCE), counted + assert _runtime_counts(runtime) == ((_OPUS_BASE, (_ACCESS_KEY, _COUNT_REGION), _converse_body(_prompt(404))),) + assert _mantle_models(mantle) == () + + +@pytest.mark.parametrize( + ("count_model_status", "error", "message"), + [ + (401, anthropic.AuthenticationError, f"scripted runtime 401 for {_OPUS_BASE}"), + (429, anthropic.RateLimitError, f"scripted runtime 429 for {_OPUS_BASE}"), + (404, anthropic.NotFoundError, f"scripted mantle 404 for {_OPUS_BASE}"), + ], +) +def test_token_count_model_failure_answers_its_error_and_names_the_region_to_enable( + counting_proxy: OwnedProxy, + runtime_port: int, + mantle_port: int, + count_model_status: int, + error: type[anthropic.APIStatusError], + message: str, +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + with pytest.raises(error) as refused: + _count(gateway, _deployment(scenario, runtime_port), 200, count_model_status=count_model_status) + assert message in str(refused.value) and f"{_OPUS} in {_COUNT_REGION}" in str(refused.value), refused.value + assert _runtime_models(runtime) == (_OPUS_BASE,) + assert _mantle_models(mantle) == ((_OPUS_BASE,) if count_model_status == 404 else ()) + + +def test_framing_is_added_to_the_local_estimate_when_the_runtime_cannot_count( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + framed: Final = _count( + gateway, _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": _FRAMING}), 400 + ) + unframed: Final = _count(gateway, _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": 0}), 400) + assert (framed.model_extra, unframed.model_extra) == (_LOCAL_SOURCE, _LOCAL_SOURCE), (framed, unframed) + assert framed.input_tokens - unframed.input_tokens == _FRAMING, (framed, unframed) + assert _runtime_models(runtime) == (_NOVA, _NOVA) + assert _mantle_models(mantle) == () + + +def test_framing_on_one_deployment_leaves_a_sibling_of_the_same_model_raising_the_400( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + framed: Final = _deployment(scenario, runtime_port, _NOVA, {"token_count_framing": _FRAMING}) + sibling: Final = _deployment(scenario, runtime_port, _NOVA) + assert _count(gateway, framed, 400).model_extra == _LOCAL_SOURCE + with pytest.raises(anthropic.BadRequestError, match=re.escape(_UNSUPPORTED)): + _count(gateway, sibling, 400) + assert _runtime_models(runtime) == (_NOVA, _NOVA) + assert _mantle_models(mantle) == () + + +def test_empty_configured_token_count_model_counts_the_model_itself_in_the_deployment_region( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + counted: Final = _count(gateway, _deployment(scenario, runtime_port, model_info={"token_count_model": {}}), 200) + assert (counted.input_tokens, counted.model_extra) == (_PROVIDER_COUNT, {"litellm_count_source": "provider"}) + assert _runtime_counts(runtime) == ((_SONNET_BASE, (_ACCESS_KEY, _REGION), _converse_body(_prompt(200))),) + assert _mantle_models(mantle) == () + + +@pytest.mark.parametrize("base_model_in", ["model_info", "litellm_params"]) +def test_inference_profile_arn_counts_with_the_token_count_model_of_its_base_model( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int, base_model_in: str +) -> None: + gateway: Final = counting_proxy.gateway + base_model: Final[dict[str, JsonValue]] = {"base_model": f"bedrock/{_SONNET}"} + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + model: Final = scenario.model( + model_info=base_model if base_model_in == "model_info" else None, + api_key=None, + **_litellm_params(_PROFILE_ARN, _url(runtime_port)), + **(base_model if base_model_in == "litellm_params" else {}), + ) + counted: Final = _count(gateway, model, 404) + assert (counted.input_tokens, counted.model_extra) == (_COUNT_MODEL_COUNT, _COUNT_MODEL_SOURCE), counted + assert _runtime_models(runtime) == (_OPUS_BASE,) + assert _mantle_models(mantle) == () + + +@pytest.mark.parametrize( + ("estimate", "runtime_calls"), + [(_messages_estimate, (_KIMI_BASE, _KIMI_BASE)), (_utils_estimate, ())], + ids=["messages_count_tokens", "utils_token_counter"], +) +def test_shipped_framing_is_added_to_the_estimate_and_a_configured_zero_turns_it_off( + counting_proxy: OwnedProxy, + runtime_port: int, + mantle_port: int, + estimate: Callable[[Gateway, str], tuple[int, JsonValue]], + runtime_calls: tuple[str, ...], +) -> None: + gateway: Final = counting_proxy.gateway + with ( + wire_server(_runtime, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + shipped: Final = estimate(gateway, _deployment(scenario, runtime_port, _KIMI)) + unframed: Final = estimate(gateway, _deployment(scenario, runtime_port, _KIMI, {"token_count_framing": 0})) + assert (shipped[1], unframed[1]) == ("local_estimate", "local_estimate"), (shipped, unframed) + framing: Final = _shipped_framing(_KIMI) + assert isinstance(framing, int) and framing > 0, framing + assert shipped[0] - unframed[0] == framing, (shipped, unframed) + assert _runtime_models(runtime) == runtime_calls + assert _mantle_models(mantle) == () + + +def _openai_count(request: Request) -> Reply: + model: Final = _JSON_OBJECT.validate_json(request.body).get("model") + if request.target == _OPENAI_COUNT_TARGET and model == _OPENAI_COUNT_MODEL: + return _json_reply(200, {"object": "response.input_tokens", "input_tokens": _COUNT_MODEL_COUNT}) + return _json_reply( + 404, + {"error": {"message": f"scripted openai 404 for {model}", "type": "invalid_request_error", "code": None}}, + ) + + +def _counted_fields(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + return {key: body[key] for key in _COUNTED_FIELDS if key in body} + + +def _openai_counts(wire: Wire) -> tuple[tuple[str, JsonValue, dict[str, JsonValue]], ...]: + bodies: Final = tuple((request.target, _JSON_OBJECT.validate_json(request.body)) for request in wire.drain()) + return tuple((target, body.get("model"), _counted_fields(body)) for target, body in bodies) + + +def _openai_deployment(scenario: Scenario, wire: Wire, model_info: Mapping[str, JsonValue]) -> str: + return scenario.model(model=f"openai/{_OPENAI_PRIMARY}", api_base=f"{wire.url}/v1", model_info=model_info) + + +def test_token_count_model_counts_the_anthropic_body_converted_like_the_provider_count( + counting_proxy: OwnedProxy, +) -> None: + gateway: Final = counting_proxy.gateway + with wire_server(_openai_count) as openai, gateway.scenario() as scenario: + model: Final = _openai_deployment(scenario, openai, {"token_count_model": {"model": _OPENAI_COUNT_MODEL}}) + response: Final = gateway.request( + "POST", + "/v1/messages/count_tokens", + { + "model": model, + "system": _SYSTEM, + "tools": [_ANTHROPIC_TOOL], + "output_config": {"format": {"type": "json_schema", "schema": _ANSWER_SCHEMA}}, + "messages": _TOOL_TURNS, + }, + ) + assert response.status_code == 200, response.text + assert _JSON_OBJECT.validate_json(response.content) == { + "input_tokens": _COUNT_MODEL_COUNT, + "litellm_count_source": "token_count_model", + "litellm_count_model": _OPENAI_COUNT_MODEL, + } + counts: Final = _openai_counts(openai) + assert tuple((target, counted) for target, counted, _ in counts) == ( + (_OPENAI_COUNT_TARGET, _OPENAI_COUNT_MODEL), + ), counts + primary: Final = counts[0][2] + assert primary["instructions"] == _SYSTEM, primary + text: Final = primary.get("text") + assert isinstance(text, dict) and isinstance(text.get("format"), dict), primary + input_items: Final = primary["input"] + assert isinstance(input_items, list), primary + item_types: Final = {item.get("type") for item in input_items if isinstance(item, dict)} + assert {"function_call", "function_call_output"} <= item_types, input_items + + +@pytest.mark.parametrize( + ("body", "named"), + [ + ( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Summarize the notes."}, + { + "type": "search_result", + "source": "https://example.com/notes", + "title": "Notes", + "content": [], + }, + ], + } + ] + }, + "search_result", + ), + ( + {"messages": [{"role": "user", "content": "hello"}, {"content": "no role"}]}, + "messages.1.role: Field required", + ), + ], + ids=["unconvertible_block", "missing_role"], +) +def test_framing_keeps_the_400_for_a_body_the_openai_count_rejects_before_calling_openai( + counting_proxy: OwnedProxy, body: Mapping[str, JsonValue], named: str +) -> None: + gateway: Final = counting_proxy.gateway + with wire_server(_openai_count) as openai, gateway.scenario() as scenario: + model: Final = _openai_deployment(scenario, openai, {"token_count_framing": _FRAMING}) + rejected: Final = gateway.request("POST", "/v1/messages/count_tokens", {"model": model, **body}) + assert rejected.status_code == 400, rejected.text + error: Final = _JSON_OBJECT.validate_json(rejected.content)["error"] + assert isinstance(error, dict) and named in str(error.get("message")), error + assert openai.drain() == () + estimated: Final = gateway.request( + "POST", "/v1/messages/count_tokens", {"model": model, "messages": [{"role": "user", "content": "hello"}]} + ) + assert estimated.status_code == 200, estimated.text + assert _JSON_OBJECT.validate_json(estimated.content).get("litellm_count_source") == "local_estimate" + assert tuple(counted for _, counted, _ in _openai_counts(openai)) == (_OPENAI_PRIMARY,) + + +def _runtime_cannot_count(_request: Request) -> Reply: + return _json_reply(400, {"message": _UNSUPPORTED}) + + +_REPLAYED_WEB_SEARCH: Final[list[JsonValue]] = [ + {"role": "user", "content": "What is the newest LiteLLM release?"}, + { + "role": "assistant", + "content": [ + {"type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search", "input": {"query": "LiteLLM"}}, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01", + "content": [ + { + "type": "web_search_result", + "url": "https://docs.litellm.ai/release_notes", + "title": "Release notes", + "encrypted_content": "", + } + ], + }, + {"type": "text", "text": "The newest release is in the release notes."}, + ], + }, + {"role": "user", "content": "Thanks."}, +] +_BOOLEAN_SUBSCHEMA_TOOL: Final[dict[str, JsonValue]] = { + "name": "lookup", + "description": "Look up a value", + "input_schema": {"type": "object", "properties": {"query": True}}, +} +_UNREADABLE_BODIES: Final[dict[str, dict[str, JsonValue]]] = { + "web_search_replay": {"messages": _REPLAYED_WEB_SEARCH}, + "boolean_subschema_tool": { + "messages": [{"role": "user", "content": "Look up the value."}], + "tools": [_BOOLEAN_SUBSCHEMA_TOOL], + }, +} +_COUNT_ENDPOINTS: Final[dict[str, tuple[str, str]]] = { + "messages_count_tokens": ("/v1/messages/count_tokens", "litellm_count_source"), + "utils_token_counter": ("/utils/token_counter?call_endpoint=true", "count_source"), +} + + +def _count_answer(response: httpx.Response, source_key: str) -> tuple[int, str]: + if response.status_code not in (200, 400): + return response.status_code, response.text + body: Final = _JSON_OBJECT.validate_json(response.content) + error: Final = body.get("error") + if response.status_code == 400 and isinstance(error, dict): + return 400, str(error.get("message")) + return response.status_code, str(body.get(source_key)) + + +@pytest.mark.parametrize("endpoint", sorted(_COUNT_ENDPOINTS)) +@pytest.mark.parametrize("unreadable", sorted(_UNREADABLE_BODIES)) +def test_shipped_framing_never_answers_a_500_for_a_body_the_local_count_cannot_read( + counting_proxy: OwnedProxy, runtime_port: int, mantle_port: int, unreadable: str, endpoint: str +) -> None: + gateway: Final = counting_proxy.gateway + path, source_key = _COUNT_ENDPOINTS[endpoint] + with ( + wire_server(_runtime_cannot_count, port=runtime_port) as runtime, + wire_server(_mantle, port=mantle_port) as mantle, + gateway.scenario() as scenario, + ): + response: Final = gateway.request( + "POST", path, {"model": _deployment(scenario, runtime_port, _KIMI), **_UNREADABLE_BODIES[unreadable]} + ) + status, answer = _count_answer(response, source_key) + assert (status, answer) == (200, "local_estimate") or (status == 400 and _UNSUPPORTED in answer), ( + response.status_code, + response.text, + ) + assert _runtime_models(runtime) == (_KIMI_BASE,) + assert _mantle_models(mantle) == () + + +_GEMINI_MODEL: Final = "gemini-4-argon" +_GEMINI_COUNT_MODEL: Final = "gemini-3.8-flash" +_VERTEX_PROJECT: Final = "scripted-project" +_COMPACT_UNDER_THE_MINIMUM_TRIGGER: Final[dict[str, JsonValue]] = { + "edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 1000}}] +} +_CLEAR_OLDER_TOOL_RESULTS: Final[dict[str, JsonValue]] = { + "edits": [ + { + "type": "clear_tool_uses_20250919", + "trigger": {"type": "input_tokens", "value": 500}, + "keep": {"type": "tool_uses", "value": 1}, + } + ] +} +_READ_FILE_TOOL: Final[dict[str, JsonValue]] = { + "name": "read_file", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}, +} +_FILE_TEXT: Final = "line of a long file that the agent read earlier. " * 180 +_FILES: Final = 3 + + +def _google_cannot_count(_request: Request) -> Reply: + return _json_reply(400, {"error": {"code": 400, "message": "scripted count 400", "status": "INVALID_ARGUMENT"}}) + + +def _google_counts_only_the_count_model(request: Request) -> Reply: + if request.target.endswith(f"/{_GEMINI_COUNT_MODEL}:countTokens"): + return _json_reply(200, {"totalTokens": _COUNT_MODEL_COUNT}) + return _google_cannot_count(request) + + +def _google_deployment( + gateway: Gateway, scenario: Scenario, wire: Wire, provider: str, model_info: dict[str, JsonValue] +) -> str: + if provider == "gemini": + return scenario.model( + model=f"gemini/{_GEMINI_MODEL}", api_base=wire.url, api_key="scripted-gemini-key", model_info=model_info + ) + return scenario.model( + model=f"vertex_ai/{_GEMINI_MODEL}", + api_base=wire.url, + api_key=None, + vertex_project=_VERTEX_PROJECT, + vertex_location="us-east5", + vertex_credentials=service_account_json(_VERTEX_PROJECT, gateway.upstream_url.rstrip("/")), + model_info=model_info, + ) + + +def _google_count_targets(wire: Wire) -> tuple[bool, ...]: + return tuple(request.target.endswith(":countTokens") for request in wire.drain()) + + +def _read_file_round(marker: str, index: int) -> tuple[JsonValue, ...]: + return ( + {"role": "user", "content": f"Read file {index}."}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": f"toolu_{index}", "name": "read_file", "input": {"path": f"{index}.txt"}} + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": f"toolu_{index}", + "content": f"{_FILE_TEXT}", + } + ], + }, + ) + + +def _agent_messages(marker: str) -> list[JsonValue]: + rounds: Final = tuple(chain.from_iterable(_read_file_round(marker, index) for index in range(_FILES))) + return [*rounds, {"role": "user", "content": "Summarize what you read."}] + + +def _counted_files(request: Request, marker: str) -> tuple[bool, tuple[bool, ...]]: + sent: Final = json.dumps(_JSON_OBJECT.validate_json(request.body)) + return ( + request.target.endswith(f"/{_GEMINI_COUNT_MODEL}:countTokens"), + tuple(f"" in sent for index in range(_FILES)), + ) + + +@pytest.mark.parametrize("provider", ["gemini", "vertex_ai"]) +def test_framing_keeps_the_400_for_a_context_edit_the_gemini_count_rejects_before_calling_google( + counting_proxy: OwnedProxy, provider: str +) -> None: + gateway: Final = counting_proxy.gateway + hello: Final[list[JsonValue]] = [{"role": "user", "content": "hello"}] + with wire_server(_google_cannot_count) as google, gateway.scenario() as scenario: + model: Final = _google_deployment(gateway, scenario, google, provider, {"token_count_framing": _FRAMING}) + rejected: Final = gateway.request( + "POST", + "/v1/messages/count_tokens", + {"model": model, "messages": hello, "context_management": _COMPACT_UNDER_THE_MINIMUM_TRIGGER}, + ) + assert rejected.status_code == 400, rejected.text + assert _google_count_targets(google) == () + estimated: Final = gateway.request("POST", "/v1/messages/count_tokens", {"model": model, "messages": hello}) + assert estimated.status_code == 200, estimated.text + assert _JSON_OBJECT.validate_json(estimated.content).get("litellm_count_source") == "local_estimate" + assert _google_count_targets(google) == (True,) + + +@pytest.mark.parametrize("provider", ["gemini", "vertex_ai"]) +def test_token_count_model_counts_the_body_left_after_the_context_management_edits( + counting_proxy: OwnedProxy, provider: str +) -> None: + gateway: Final = counting_proxy.gateway + marker: Final = uuid4().hex + with wire_server(_google_counts_only_the_count_model) as google, gateway.scenario() as scenario: + model: Final = _google_deployment( + gateway, scenario, google, provider, {"token_count_model": {"model": _GEMINI_COUNT_MODEL}} + ) + counted: Final = gateway.request( + "POST", + "/v1/messages/count_tokens", + { + "model": model, + "tools": [_READ_FILE_TOOL], + "messages": _agent_messages(marker), + "context_management": _CLEAR_OLDER_TOOL_RESULTS, + }, + ) + assert counted.status_code == 200, counted.text + assert _JSON_OBJECT.validate_json(counted.content) == { + "input_tokens": _COUNT_MODEL_COUNT, + "litellm_count_source": "token_count_model", + "litellm_count_model": _GEMINI_COUNT_MODEL, + } + assert tuple(_counted_files(request, marker) for request in google.drain()) == ((True, (False, False, True)),) diff --git a/tests/integration/providers/test_google_genai_vertex_routes_wire.py b/tests/integration/providers/test_google_genai_vertex_routes_wire.py index d2c2f3bafb2..d7c2aa72c74 100644 --- a/tests/integration/providers/test_google_genai_vertex_routes_wire.py +++ b/tests/integration/providers/test_google_genai_vertex_routes_wire.py @@ -388,7 +388,7 @@ def test_adapter_route_upstream_error_reaches_the_caller_with_its_status( gateway: Gateway, status: int, stream: bool ) -> None: route: Final = _ROUTES["model_garden_publisher"] - message: Final = f"scripted vertex rejection {uuid.uuid4().hex}" + message: Final = f"scripted vertex rejection {uuid.uuid4().hex}f4039" with wire_server(_vertex_peer(status=status, error=message)) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire, route, num_retries=0) key: Final = scenario.key(models=[model]) diff --git a/tests/integration/providers/test_idle_upstream_guardrail_cut_wire.py b/tests/integration/providers/test_idle_upstream_guardrail_cut_wire.py new file mode 100644 index 00000000000..6b12e61aa4e --- /dev/null +++ b/tests/integration/providers/test_idle_upstream_guardrail_cut_wire.py @@ -0,0 +1,134 @@ +import threading +from collections.abc import Iterator, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from functools import partial +from itertools import chain +from types import MappingProxyType +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.idle_gateway import Arrival +from integration._support.process import graceful_stop_seconds +from integration._support.wire import Reply, Request +from integration.providers._idle_upstream_support import ( + REUSE_IDLE_SECONDS, + ProxyTarget, + Row, + Sent, + UpstreamProxy, + answer, + answered, + chat_with_guardrail, + expected_answers, + guardrail_deployment, + landed, + on_each_worker, + send, + spend_statuses, + stay_idle, + upstream_proxy, +) + +CUT_AFTER_SECONDS: Final = 3.0 +POOLED_PER_WORKER: Final = 2 +RETRIED: Final = "guardrail-cut-num-retries-1" +UNSET: Final = "guardrail-cut-num-retries-unset" + +pytestmark: Final = pytest.mark.timeout(int(2 * graceful_stop_seconds() + 180 + REUSE_IDLE_SECONDS)) + + +@dataclass(frozen=True, slots=True) +class PairedWarmup: + """While warming, each check waits for a second one so every worker pools two connections to the guardrail.""" + + warming: threading.Event = field(default_factory=threading.Event) + pair: threading.Barrier = field(default_factory=lambda: threading.Barrier(POOLED_PER_WORKER)) + + def respond(self, request: Request) -> Reply: + if self.warming.is_set(): + self.pair.wait(timeout=30) + return answer(request) + + +@dataclass(frozen=True, slots=True) +class StaleGuardrails: + proxy: UpstreamProxy + pooled: Mapping[str, frozenset[int]] + + +WARMUPS: Final = MappingProxyType({RETRIED: PairedWarmup(), UNSET: PairedWarmup()}) +ROWS: Final = ( + Row( + RETRIED, + partial(guardrail_deployment, guardrail=RETRIED, num_retries=1), + partial(chat_with_guardrail, guardrail=RETRIED), + respond=WARMUPS[RETRIED].respond, + ), + Row( + UNSET, + partial(guardrail_deployment, guardrail=UNSET), + partial(chat_with_guardrail, guardrail=UNSET), + respond=WARMUPS[UNSET].respond, + ), +) + + +def _pair(row: Row, target: ProxyTarget) -> tuple[Sent, ...]: + with ThreadPoolExecutor(max_workers=POOLED_PER_WORKER) as pool: + return tuple(pool.map(send, (row,) * POOLED_PER_WORKER, (target,) * POOLED_PER_WORKER)) + + +def _warm(proxy: UpstreamProxy, row: Row) -> frozenset[int]: + warmup: Final = WARMUPS[row.name] + warmup.warming.set() + sents: Final = on_each_worker(proxy.owned, proxy.workers, lambda: _pair(row, proxy.target)) + warmup.warming.clear() + assert answered(sents) == expected_answers(row, sents), sents + return frozenset(arrival.connection for arrival in proxy.gateways[row.name].drain()) + + +@pytest.fixture(scope="module") +def stale(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[StaleGuardrails]: + directory: Final = tmp_path_factory.mktemp("idle-upstream-guardrail-cut") + with upstream_proxy(session_gateway, directory, ROWS, {}, drop_after=CUT_AFTER_SECONDS) as proxy: + pooled: Final = MappingProxyType({row.name: _warm(proxy, row) for row in ROWS}) + assert {name: len(connections) for name, connections in pooled.items()} == { + row.name: 2 * POOLED_PER_WORKER for row in ROWS + }, pooled + stay_idle(tuple(proxy.gateways.values()), REUSE_IDLE_SECONDS) + yield StaleGuardrails(proxy, pooled) + + +def _cut_once(sents: tuple[Sent, ...]) -> tuple[tuple[str, bool], ...]: + return tuple(sorted((sent.marker, True) for sent in sents)) + + +def _cut_once_then_landed(sents: tuple[Sent, ...]) -> tuple[tuple[str, bool], ...]: + return tuple(sorted(chain.from_iterable(((sent.marker, False), (sent.marker, True)) for sent in sents))) + + +def _landed_on(arrivals: tuple[Arrival, ...]) -> frozenset[int]: + return frozenset(arrival.connection for arrival in arrivals if not arrival.dropped) + + +def test_retried_check_cut_on_a_stale_pooled_connection_lands_on_a_connection_that_was_never_pooled( + stale: StaleGuardrails, +) -> None: + sents, arrivals = stale.proxy.probe(RETRIED) + + assert answered(sents) == expected_answers(stale.proxy.rows[RETRIED], sents), (sents, arrivals) + assert landed(arrivals) == _cut_once_then_landed(sents), arrivals + assert _landed_on(arrivals).isdisjoint(stale.pooled[RETRIED]), (arrivals, stale.pooled) + assert spend_statuses(sents) == ("success",) * len(sents), sents + + +def test_check_cut_on_a_stale_pooled_connection_is_not_resent_when_num_retries_is_unset( + stale: StaleGuardrails, +) -> None: + sents, arrivals = stale.proxy.probe(UNSET) + + assert [sent.answer.status == 200 for sent in sents] == [False] * len(sents), sents + assert all("Generic Guardrail API failed" in sent.answer.text for sent in sents), sents + assert landed(arrivals) == _cut_once(sents), arrivals diff --git a/tests/integration/providers/test_malformed_tools_wire.py b/tests/integration/providers/test_malformed_tools_wire.py index 25a86f7fc7c..706babac398 100644 --- a/tests/integration/providers/test_malformed_tools_wire.py +++ b/tests/integration/providers/test_malformed_tools_wire.py @@ -46,6 +46,7 @@ _CLAUDE_BACKEND: Final = "claude-haiku-4-5" _GEMINI_BACKEND: Final = "gemini-2.5-flash" _PROVIDER_KEY: Final = "scripted-malformed-tools-key" _COUNT: Final = 37 +_PROVIDER_SOURCE: Final[Mapping[str, JsonValue]] = MappingProxyType({"litellm_count_source": "provider"}) _CONFIG_STORE: Final = "vs_integration_config_store" _OTHER_STORE: Final = "vs_malformed_tools_other_store" _CLAUDE_CODE_AGENT: Final = "claude-cli/2.1.0 (external, cli)" @@ -821,7 +822,7 @@ def test_count_tokens_forwards_well_formed_tools(rig: _Rig) -> None: "POST", "/v1/messages/count_tokens", _count_body(rig, marker, tools=[MESSAGES.tool]) ) assert response.status_code == 200, response.text - assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT}, response.text + assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT, **_PROVIDER_SOURCE}, response.text assert _one_delivery(rig, marker, "/v1/messages/count_tokens")["tools"] == [MESSAGES.tool] @@ -853,6 +854,7 @@ def test_responses_input_tokens_counts_well_formed_tools(rig: _Rig) -> None: assert _JSON_OBJECT.validate_json(response.content) == { "object": "response.input_tokens", "input_tokens": _COUNT, + **_PROVIDER_SOURCE, }, response.text sent: Final = _one_delivery(rig, marker, "/v1/messages/count_tokens") assert "get_weather" in json.dumps(sent["tools"]), sent @@ -1396,7 +1398,7 @@ def test_count_tokens_drops_the_bad_entry_a_deployment_list_names(rig: _Rig, fie {**_count_body(rig, marker, tools=[MESSAGES.tool, 5]), "model": field_groups.anthropic}, ) assert response.status_code == 200, response.text - assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT}, response.text + assert _JSON_OBJECT.validate_json(response.content) == {"input_tokens": _COUNT, **_PROVIDER_SOURCE}, response.text assert _one_delivery(rig, marker, "/v1/messages/count_tokens")["tools"] == [MESSAGES.tool] @@ -1656,7 +1658,7 @@ _CHECKED: Final[Mapping[str, _Checked]] = MappingProxyType( named_as=MESSAGES, send=_count_tokens, target="/v1/messages/count_tokens", - answered=_counted({"input_tokens": _COUNT}), + answered=_counted({"input_tokens": _COUNT, **_PROVIDER_SOURCE}), kept_by_a_tools_list=("get_weather",), bad_entry=_MESSAGES_BAD_ENTRY, ), @@ -1666,7 +1668,7 @@ _CHECKED: Final[Mapping[str, _Checked]] = MappingProxyType( named_as=MESSAGES, send=_input_tokens, target="/v1/messages/count_tokens", - answered=_counted({"object": "response.input_tokens", "input_tokens": _COUNT}), + answered=_counted({"object": "response.input_tokens", "input_tokens": _COUNT, **_PROVIDER_SOURCE}), kept_by_a_tools_list=("get_weather",), bad_entry=_RESPONSES_BAD_ENTRY, ), diff --git a/tests/integration/providers/test_openai_count_tokens_tools_wire.py b/tests/integration/providers/test_openai_count_tokens_tools_wire.py index 2b23ccdf4f2..16a5b8685ad 100644 --- a/tests/integration/providers/test_openai_count_tokens_tools_wire.py +++ b/tests/integration/providers/test_openai_count_tokens_tools_wire.py @@ -391,3 +391,28 @@ def test_google_count_tokens_sends_the_system_instruction_text(counting_proxy: O ) assert response.status_code == 200, response.text assert _count_body(wire)["instructions"] == "Be brief.\nAnswer in English." + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + {"message": "Invalid input", "type": "invalid_request_error", "param": "input", "code": "invalid_value"}, + id="full", + ), + pytest.param({"message": "Invalid input", "type": "invalid_request_error"}, id="bare"), + ], +) +def test_responses_input_tokens_passes_an_openai_count_error_through( + counting_proxy: OwnedProxy, error: dict[str, JsonValue] +) -> None: + def rejecting(_request: Request) -> Reply: + return Reply(status=400, body=json.dumps({"error": error}).encode()) + + gateway: Final = counting_proxy.gateway + with wire_server(rejecting) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request("POST", "/v1/responses/input_tokens", {"model": model, "input": "hello"}) + assert response.status_code == 400, response.text + assert response.json() == {"error": {"param": None, "code": None, **error}}, response.text + assert _count_body(wire)["input"] == [{"role": "user", "content": "hello"}] diff --git a/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py b/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py index ea55552c331..4b25275285e 100644 --- a/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py +++ b/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py @@ -1118,6 +1118,7 @@ def test_input_tokens_counts_marked_items_without_their_marks(gateway: Gateway) assert _JSON_OBJECT.validate_json(response.text) == { "object": "response.input_tokens", "input_tokens": _COUNTED_TOKENS, + "litellm_count_source": "provider", } assert counted.target.endswith("/count_tokens"), counted.target assert anthropic_labels(counted) == [] @@ -1199,6 +1200,7 @@ def test_input_tokens_reads_each_input_shape_without_item_marks(gateway: Gateway assert _JSON_OBJECT.validate_json(response.text) == { "object": "response.input_tokens", "input_tokens": _COUNTED_TOKENS, + "litellm_count_source": "provider", } assert [anthropic_labels(request) for request in counted] == [[]] diff --git a/tests/integration/providers/test_rust_anthropic_safeguards_beta_chaos.py b/tests/integration/providers/test_rust_anthropic_safeguards_beta_chaos.py new file mode 100644 index 00000000000..f90d68c1a63 --- /dev/null +++ b/tests/integration/providers/test_rust_anthropic_safeguards_beta_chaos.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from pathlib import Path +from queue import SimpleQueue +from typing import Final + +import httpcore +import httpx +import psutil +import pytest +from integration._support.client import Gateway, eventually +from integration._support.process import graceful_stop_seconds, owned_proxy_process +from integration._support.wire import wire_server +from integration.providers._anthropic_safeguards_beta_support import ( + ANTHROPIC_DEPLOYMENT, + DANGEROUS_TOOL_USE, + SAFEGUARDS, + deliveries, + holding_peer, + messages_body, + new_marker, + owned_config, + rust_routed, + safeguarded, + started_workers, +) +from pydantic import TypeAdapter + +_CELL_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120) +_RESTART_TIMEOUT: Final = int(4 * graceful_stop_seconds() + 240) +_BURST: Final = 12 +_SECOND_WAVE: Final = 6 +_CLIENT_ADDRESS: Final = TypeAdapter(tuple[str, int]) + + +def _clients(stack: ExitStack, base_url: str, count: int) -> tuple[httpx.Client, ...]: + return tuple( + stack.enter_context(httpx.Client(base_url=base_url, timeout=120, trust_env=False)) for _ in range(count) + ) + + +def _post(client: httpx.Client, key: str, marker: str) -> int: + body: Final = safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, marker), list(SAFEGUARDS)) + response: Final = client.post("/v1/messages", json=body, headers={"Authorization": f"Bearer {key}"}) + assert response.status_code != 200 or rust_routed(response.headers), dict(response.headers) + return response.status_code + + +def _post_or_dropped(client: httpx.Client, key: str, marker: str) -> int | None: + try: + return _post(client, key, marker) + except httpx.TransportError: + return None + + +def _local_port(client: httpx.Client) -> int: + with client.stream("GET", "/health/liveliness") as response: + stream: Final[object] = response.extensions["network_stream"] # pyright: ignore[reportAny] # httpx types extensions as Any + assert isinstance(stream, httpcore.NetworkStream), stream + port: Final = _CLIENT_ADDRESS.validate_python(stream.get_extra_info("client_addr"))[1] + response.read() + assert response.status_code == 200, response.text + return port + + +def _accepted_client_ports(pid: int, proxy_port: int) -> frozenset[int]: + return frozenset( + connection.raddr.port + for connection in psutil.Process(pid).net_connections(kind="tcp") + if connection.raddr and connection.laddr.port == proxy_port + ) + + +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_worker_sigkill_mid_safeguards_burst_leaves_the_replacement_adding_the_beta( + gateway: Gateway, tmp_path: Path +) -> None: + held: Final[SimpleQueue[str]] = SimpleQueue() + release: Final = threading.Event() + with ExitStack() as stack: + wire: Final = stack.enter_context(wire_server(holding_peer(held, release))) + config: Final = owned_config(tmp_path, gateway, wire.url) + owned: Final = stack.enter_context( + owned_proxy_process(gateway, tmp_path, {"LITELLM_RUST": "true"}, config=config, workers=2) + ) + proxy_url: Final = owned.gateway.client.base_url + workers: Final = eventually(lambda: started_workers(owned.log), lambda pids: len(pids) == 2, seconds=30) + clients: Final = _clients(stack, str(proxy_url), _BURST) + pool: Final = stack.enter_context(ThreadPoolExecutor(max_workers=len(clients))) + stack.callback(release.set) + ports: Final = tuple(_local_port(client) for client in clients) + markers: Final = tuple(new_marker() for _ in clients) + futures: Final = tuple( + pool.submit(_post_or_dropped, client, gateway.key, marker) + for client, marker in zip(clients, markers, strict=True) + ) + eventually(held.qsize, lambda size: size == len(clients), seconds=30) + shares: Final = {pid: _accepted_client_ports(pid, proxy_url.port or 0) & frozenset(ports) for pid in workers} + assert sum(map(len, shares.values())) == len(clients), shares + victim: Final = min((pid for pid in workers if shares[pid]), key=lambda pid: len(shares[pid])) + with owned.replacing_worker(victim) as kill: + kill() + release.set() + results: Final = tuple(future.result(timeout=60) for future in futures) + for port, result in zip(ports, results, strict=True): + assert result == (None if port in shares[victim] else 200), ( + port, + result, + shares, + ) + second_wave: Final = tuple(new_marker() for _ in range(_SECOND_WAVE)) + for marker in second_wave: + assert _post(owned.gateway.client, gateway.key, marker) == 200 + delivered: Final = deliveries(wire) + assert tuple(sent.beta for sent in delivered) == (DANGEROUS_TOOL_USE,) * (len(clients) + len(second_wave)), ( + delivered + ) + seen: Final = tuple(sum(sent.marker == marker for sent in delivered) for marker in markers + second_wave) + assert seen == (1,) * len(seen), seen + assert owned.process.poll() is None + + +@pytest.mark.timeout(_RESTART_TIMEOUT) +def test_proxy_shutdown_mid_safeguards_burst_then_a_fresh_boot_keeps_adding_the_beta( + gateway: Gateway, tmp_path: Path +) -> None: + held: Final[SimpleQueue[str]] = SimpleQueue() + release: Final = threading.Event() + with ExitStack() as stack: + wire: Final = stack.enter_context(wire_server(holding_peer(held, release))) + config: Final = owned_config(tmp_path, gateway, wire.url) + markers: Final = tuple(new_marker() for _ in range(_BURST)) + pool: Final = stack.enter_context(ThreadPoolExecutor(max_workers=len(markers))) + stack.callback(release.set) + with owned_proxy_process(gateway, tmp_path, {"LITELLM_RUST": "true"}, config=config, workers=2) as first: + clients: Final = _clients(stack, str(first.gateway.client.base_url), len(markers)) + futures: Final = tuple( + pool.submit(_post_or_dropped, client, gateway.key, marker) + for client, marker in zip(clients, markers, strict=True) + ) + eventually(held.qsize, lambda size: size == len(markers), seconds=30) + first.begin_shutdown() + release.set() + results: Final = tuple(future.result(timeout=120) for future in futures) + during: Final = deliveries(wire) + assert all(result in (None, 200) for result in results), results + assert tuple(sent.beta for sent in during) == (DANGEROUS_TOOL_USE,) * len(markers), during + assert sorted(sent.marker for sent in during) == sorted(markers), during + with owned_proxy_process(gateway, tmp_path, {"LITELLM_RUST": "true"}, config=config, workers=2) as second: + after: Final = tuple(new_marker() for _ in range(_SECOND_WAVE)) + statuses: Final = tuple(_post(second.gateway.client, gateway.key, marker) for marker in after) + assert statuses == (200,) * len(after), statuses + delivered: Final = deliveries(wire) + assert tuple(sent.beta for sent in delivered) == (DANGEROUS_TOOL_USE,) * len(after), delivered + assert sorted(sent.marker for sent in delivered) == sorted(after), delivered diff --git a/tests/integration/providers/test_rust_anthropic_safeguards_beta_wire.py b/tests/integration/providers/test_rust_anthropic_safeguards_beta_wire.py new file mode 100644 index 00000000000..75c5efc3aa0 --- /dev/null +++ b/tests/integration/providers/test_rust_anthropic_safeguards_beta_wire.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import json +import threading +from collections.abc import Iterator, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Final + +import anthropic +import httpx +import pytest +from integration._support.client import ( + Gateway, + Scenario, + eventually, + gateway_from_environment, +) +from integration._support.database import read_rows +from integration._support.process import ( + OwnedProxy, + graceful_stop_seconds, + owned_proxy_process, +) +from integration._support.wire import Wire, wire_server +from integration.providers._anthropic_safeguards_beta_support import ( + ANTHROPIC_DEPLOYMENT, + ANTHROPIC_KEY, + ANTHROPIC_TARGET, + BACKEND, + BASH_TOOL, + CLIENT_BETAS, + DANGEROUS_TOOL_USE, + DEEPSEEK_DEPLOYMENT, + DEEPSEEK_KEY, + DEEPSEEK_TARGET, + MERGED_BETAS, + NOT_A_LIST_MESSAGE, + OVERLOADED_MESSAGE, + REPLY_TEXT, + SAFEGUARD_RESULTS, + SAFEGUARDS, + VERTEX_DEPLOYMENT, + VERTEX_STREAM_TARGET, + VERTEX_TARGET, + WEB_SEARCH, + WEB_SEARCH_TOOL, + anthropic_peer, + chat_body, + deliveries, + json_object, + messages_body, + new_marker, + one_delivery, + outage_peer, + owned_config, + responses_body, + rust_routed, + safeguarded, + started_workers, +) +from pydantic import JsonValue + +pytestmark: Final = pytest.mark.xdist_group("anthropic_safeguards_beta") + +_CELL_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120) +_BURST: Final = 24 +_RECOVERY: Final = 6 +_LARGE_ENTRY: Final[JsonValue] = [ + { + "type": "dangerous_tool_use", + "classifier_context": {"v": 1, "permission_mode": "auto", "note": "x" * 5120}, + } +] + + +@dataclass(frozen=True, slots=True) +class _Rig: + owned: OwnedProxy + wire: Wire + outage: threading.Event + + @property + def gateway(self) -> Gateway: + return self.owned.gateway + + +@dataclass(frozen=True, slots=True) +class _Answer: + path: str + marker: str + status: int + text: str + rust: bool + + +@pytest.fixture(scope="module") +def shared_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("anthropic-safeguards-beta") + outage: Final = threading.Event() + with ( + gateway_from_environment() as environment, + wire_server(outage_peer(outage)) as wire, + ): + config: Final = owned_config(directory, environment, wire.url) + with owned_proxy_process(environment, directory, {"LITELLM_RUST": "true"}, config=config, workers=2) as owned: + eventually( + lambda: started_workers(owned.log), + lambda pids: len(pids) == 2, + seconds=60, + ) + wire.drain() + yield _Rig(owned, wire, outage) + + +@pytest.fixture +def rig(shared_rig: _Rig) -> _Rig: + shared_rig.outage.clear() + shared_rig.wire.drain() + return shared_rig + + +def _spend_rows(call_id: str) -> list[dict[str, JsonValue]]: + return read_rows( + 'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE litellm_call_id=%s', + (call_id,), + ) + + +def _post(gateway: Gateway, body: Mapping[str, JsonValue], *, beta: str | None = None) -> httpx.Response: + return gateway.request( + "POST", + "/v1/messages", + body, + headers={} if beta is None else {"anthropic-beta": beta}, + ) + + +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_sdk_messages_with_safeguards_go_upstream_with_the_dangerous_tool_use_beta( + rig: _Rig, +) -> None: + marker: Final = new_marker() + with httpx.Client(trust_env=False, timeout=60) as http: + client: Final = anthropic.Anthropic( + base_url=str(rig.gateway.client.base_url), + api_key=rig.gateway.key, + max_retries=0, + http_client=http, + ) + raw: Final = client.messages.with_raw_response.create( + model=ANTHROPIC_DEPLOYMENT, + max_tokens=64, + messages=[{"role": "user", "content": marker}], + extra_body={"safeguards": list(SAFEGUARDS), "tools": [dict(BASH_TOOL)]}, + ) + message: Final = raw.parse() + assert rust_routed(raw.headers), dict(raw.headers) + received: Final = json_object(message.model_dump_json()) + assert message.id == f"msg_{marker}", received + assert received.get("safeguard_results") == list(SAFEGUARD_RESULTS), received + sent: Final = one_delivery(rig.wire, marker) + assert (sent.target, sent.api_key, sent.beta) == ( + ANTHROPIC_TARGET, + ANTHROPIC_KEY, + DANGEROUS_TOOL_USE, + ), sent + assert sent.safeguards == list(SAFEGUARDS), sent + assert "x-litellm-call-id" in raw.headers, dict(raw.headers) + call_id: Final = raw.headers["x-litellm-call-id"] + rows: Final = eventually(lambda: _spend_rows(call_id), lambda found: len(found) == 1, seconds=70) + assert len(rows) == 1, rows + + +@pytest.mark.timeout(_CELL_TIMEOUT) +async def test_sdk_streamed_messages_with_safeguards_go_upstream_with_the_beta( + rig: _Rig, +) -> None: + marker: Final = new_marker() + async with httpx.AsyncClient(trust_env=False, timeout=60) as http: + client: Final = anthropic.AsyncAnthropic( + base_url=str(rig.gateway.client.base_url), + api_key=rig.gateway.key, + max_retries=0, + http_client=http, + ) + async with client.messages.stream( + model=ANTHROPIC_DEPLOYMENT, + max_tokens=64, + messages=[{"role": "user", "content": marker}], + extra_body={"safeguards": list(SAFEGUARDS), "tools": [dict(BASH_TOOL)]}, + ) as stream: + text: Final = "".join([piece async for piece in stream.text_stream]) + final: Final = await stream.get_final_message() + assert rust_routed(stream.response.headers), dict(stream.response.headers) + assert (final.id, text) == (f"msg_{marker}", REPLY_TEXT), (final, text) + sent: Final = one_delivery(rig.wire, marker) + assert (sent.target, sent.beta, sent.safeguards) == ( + ANTHROPIC_TARGET, + DANGEROUS_TOOL_USE, + list(SAFEGUARDS), + ), sent + + +@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"]) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_raw_messages_merge_the_safeguards_beta_into_the_client_betas(rig: _Rig, stream: bool) -> None: + marker: Final = new_marker() + body: Final = safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, marker, stream=stream), list(SAFEGUARDS)) + response: Final = _post(rig.gateway, body, beta=CLIENT_BETAS) + assert response.status_code == 200, response.text + assert rust_routed(response.headers), dict(response.headers) + assert f"msg_{marker}" in response.text and "safeguard_results" in response.text, response.text + sent: Final = one_delivery(rig.wire, marker) + assert (sent.target, sent.beta) == (ANTHROPIC_TARGET, MERGED_BETAS), sent + + +@pytest.mark.parametrize("extra", [{"safeguards": None}, {}], ids=["null", "absent"]) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_null_or_absent_safeguards_add_no_beta(rig: _Rig, extra: Mapping[str, JsonValue]) -> None: + marker: Final = new_marker() + response: Final = _post(rig.gateway, {**messages_body(ANTHROPIC_DEPLOYMENT, marker), **extra}) + assert response.status_code == 200, response.text + assert rust_routed(response.headers), dict(response.headers) + assert f"msg_{marker}" in response.text and "safeguard_results" not in response.text, response.text + sent: Final = one_delivery(rig.wire, marker) + assert (sent.beta, sent.safeguards) == (None, None), sent + + +@pytest.mark.parametrize( + ("safeguards", "status", "fragment"), + [ + (7, 400, NOT_A_LIST_MESSAGE), + ("dangerous_tool_use", 400, NOT_A_LIST_MESSAGE), + ({}, 400, NOT_A_LIST_MESSAGE), + ([], 200, "safeguard_results"), + (_LARGE_ENTRY, 200, "safeguard_results"), + ], + ids=["int", "string", "object", "empty_list", "5kb_entry"], +) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_malformed_safeguards_still_add_the_beta_and_relay_the_vendor_verdict( + rig: _Rig, safeguards: JsonValue, status: int, fragment: str +) -> None: + marker: Final = new_marker() + response: Final = _post( + rig.gateway, + safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, marker), safeguards), + ) + assert (response.status_code, fragment in response.text) == (status, True), response.text + sent: Final = one_delivery(rig.wire, marker) + assert (sent.beta, sent.safeguards) == (DANGEROUS_TOOL_USE, safeguards), sent + control: Final = new_marker() + after: Final = _post(rig.gateway, messages_body(ANTHROPIC_DEPLOYMENT, control)) + assert after.status_code == 200, after.text + assert rust_routed(after.headers), dict(after.headers) + assert one_delivery(rig.wire, control).beta is None + assert rig.gateway.request("GET", "/health/liveliness").status_code == 200 + + +@pytest.mark.parametrize("last", ["null", "list"]) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_duplicated_safeguards_keys_resolve_to_the_last_value(rig: _Rig, last: str) -> None: + marker: Final = new_marker() + opening: Final = json.dumps(messages_body(ANTHROPIC_DEPLOYMENT, marker))[:-1] + null_field: Final = '"safeguards": null' + list_field: Final = f'"safeguards": {json.dumps(list(SAFEGUARDS))}' + ordered: Final = (null_field, list_field) if last == "list" else (list_field, null_field) + raw: Final = f"{opening}, {ordered[0]}, {ordered[1]}}}".encode() + response: Final = rig.gateway.client.post( + "/v1/messages", + content=raw, + headers={ + "Authorization": f"Bearer {rig.gateway.key}", + "content-type": "application/json", + }, + ) + assert response.status_code == 200, response.text + assert rust_routed(response.headers), dict(response.headers) + assert ("safeguard_results" in response.text) == (last == "list"), response.text + sent: Final = one_delivery(rig.wire, marker) + assert sent.beta == (DANGEROUS_TOOL_USE if last == "list" else None), sent + + +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_safeguards_request_with_a_bad_key_never_reaches_the_peer(rig: _Rig) -> None: + body: Final = safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, new_marker()), list(SAFEGUARDS)) + response: Final = rig.gateway.request("POST", "/v1/messages", body, key="sk-not-a-key") + assert response.status_code == 401, response.text + assert deliveries(rig.wire) == () + + +@pytest.mark.parametrize("web_search", [False, True], ids=["bash_only", "with_web_search"]) +@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"]) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_vertex_messages_with_safeguards_keep_the_beta_from_the_shared_helper( + rig: _Rig, stream: bool, web_search: bool +) -> None: + marker: Final = new_marker() + tools: Final[list[JsonValue]] = [ + dict(BASH_TOOL), + *((dict(WEB_SEARCH_TOOL),) if web_search else ()), + ] + body: Final = safeguarded(messages_body(VERTEX_DEPLOYMENT, marker, stream=stream), list(SAFEGUARDS)) + response: Final = _post(rig.gateway, {**body, "tools": tools}) + assert response.status_code == 200, response.text + assert rust_routed(response.headers), dict(response.headers) + assert f"msg_{marker}" in response.text and "safeguard_results" in response.text, response.text + sent: Final = one_delivery(rig.wire, marker) + assert sent.target == (VERTEX_STREAM_TARGET if stream else VERTEX_TARGET), sent + assert sent.authorization == "Bearer scripted-token", sent + assert sent.beta == (f"{DANGEROUS_TOOL_USE},{WEB_SEARCH}" if web_search else DANGEROUS_TOOL_USE), sent + + +def _beta_set(header: str | None) -> frozenset[str]: + return frozenset(part.strip() for part in (header or "").split(",") if part.strip()) + + +@pytest.mark.parametrize("beta", [None, CLIENT_BETAS], ids=["no-client-betas", "client-betas"]) +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_deepseek_messages_forward_safeguards_without_adding_a_beta(rig: _Rig, beta: str | None) -> None: + marker: Final = new_marker() + response: Final = _post( + rig.gateway, + safeguarded(messages_body(DEEPSEEK_DEPLOYMENT, marker), list(SAFEGUARDS)), + beta=beta, + ) + assert response.status_code == 200, response.text + assert rust_routed(response.headers), dict(response.headers) + assert f"msg_{marker}" in response.text, response.text + sent: Final = one_delivery(rig.wire, marker) + assert (sent.target, sent.api_key, sent.safeguards) == (DEEPSEEK_TARGET, DEEPSEEK_KEY, list(SAFEGUARDS)), sent + assert _beta_set(sent.beta) <= _beta_set(beta) - {DANGEROUS_TOOL_USE}, sent + + +def test_python_route_adds_the_beta_for_safeguards(gateway: Gateway, scenario: Scenario) -> None: + with wire_server(anthropic_peer) as wire: + deployment: Final = scenario.model(model=f"anthropic/{BACKEND}", api_base=wire.url, api_key=ANTHROPIC_KEY) + marker: Final = new_marker() + response: Final = _post(gateway, safeguarded(messages_body(deployment, marker), list(SAFEGUARDS))) + assert response.status_code == 200, response.text + assert "safeguard_results" in response.text, response.text + (sent,) = deliveries(wire) + assert (sent.marker, sent.safeguards) == (marker, list(SAFEGUARDS)), sent + assert DANGEROUS_TOOL_USE in _beta_set(sent.beta), sent + assert not rust_routed(response.headers), dict(response.headers) + + +def _burst_request(index: int, marker: str) -> tuple[str, dict[str, JsonValue]]: + if index % 4 == 0: + return "/v1/messages", safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, marker), list(SAFEGUARDS)) + if index % 4 == 1: + return "/v1/messages", safeguarded(messages_body(ANTHROPIC_DEPLOYMENT, marker, stream=True), list(SAFEGUARDS)) + if index % 4 == 2: + return "/v1/chat/completions", chat_body(ANTHROPIC_DEPLOYMENT, marker) + return "/v1/responses", responses_body(ANTHROPIC_DEPLOYMENT, marker) + + +def _answer(gateway: Gateway, path: str, marker: str, body: Mapping[str, JsonValue]) -> _Answer: + response: Final = gateway.request("POST", path, body) + return _Answer(path, marker, response.status_code, response.text, rust_routed(response.headers)) + + +def _wave(pool: ThreadPoolExecutor, gateway: Gateway, markers: tuple[str, ...], offset: int) -> tuple[_Answer, ...]: + planned: Final = tuple((marker, *_burst_request(offset + index, marker)) for index, marker in enumerate(markers)) + submitted: Final = tuple(pool.submit(_answer, gateway, path, marker, body) for marker, path, body in planned) + return tuple(future.result(timeout=120) for future in submitted) + + +def _assert_served(answer: _Answer) -> None: + assert answer.status == 200, answer + assert REPLY_TEXT in answer.text, answer + assert answer.rust == (answer.path == "/v1/messages"), answer + if answer.path == "/v1/messages": + assert f"msg_{answer.marker}" in answer.text and "safeguard_results" in answer.text, answer + + +@pytest.mark.timeout(_CELL_TIMEOUT) +def test_peer_outage_mid_safeguards_burst_relays_each_verdict_once_and_recovers( + rig: _Rig, +) -> None: + healthy: Final = tuple(new_marker() for _ in range(_BURST // 2)) + outage: Final = tuple(new_marker() for _ in range(_BURST // 2)) + recovery: Final = tuple(new_marker() for _ in range(_RECOVERY)) + with ThreadPoolExecutor(max_workers=8) as pool: + before: Final = _wave(pool, rig.gateway, healthy, 0) + rig.outage.set() + assert rig.gateway.request("GET", "/health/liveliness").status_code == 200 + during: Final = _wave(pool, rig.gateway, outage, len(healthy)) + rig.outage.clear() + after: Final = _wave(pool, rig.gateway, recovery, len(healthy) + len(outage)) + for answer in (*before, *after): + _assert_served(answer) + for answer in during: + assert answer.status >= 500 and OVERLOADED_MESSAGE in answer.text, answer + markers: Final = (*healthy, *outage, *recovery) + safeguarded_markers: Final = frozenset( + marker for index, marker in enumerate(markers) if _burst_request(index, marker)[0] == "/v1/messages" + ) + delivered: Final = deliveries(rig.wire) + seen: Final = tuple(sum(sent.marker == marker for sent in delivered) for marker in markers) + assert seen == (1,) * len(seen), seen + expected: Final = { + marker: (True, DANGEROUS_TOOL_USE) if marker in safeguarded_markers else (False, None) for marker in markers + } + for sent in delivered: + assert (sent.safeguards is not None, sent.beta) == expected[sent.marker], sent diff --git a/tests/integration/providers/test_vector_store_team_model_provider_wire.py b/tests/integration/providers/test_vector_store_team_model_provider_wire.py new file mode 100644 index 00000000000..5b986a64ff6 --- /dev/null +++ b/tests/integration/providers/test_vector_store_team_model_provider_wire.py @@ -0,0 +1,192 @@ +import json +import uuid +from collections.abc import Callable +from typing import Final + +from integration._support.client import Gateway +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +QUERY: Final = "team provider vector search" +TEAM_ACCESS_KEY: Final = "AKIAINTEGRATIONTEAM01" +STORE_ACCESS_KEY: Final = "AKIASTOREKEY00000001" +_RETRIEVE_RESPONSE: Final = json.dumps( + { + "retrievalResults": [ + { + "content": {"text": "matching Bedrock content"}, + "score": 0.87, + "metadata": { + "x-amz-bedrock-kb-source-uri": "s3://synthetic-bucket/vector-store.txt", + "x-amz-bedrock-kb-chunk-id": "chunk-1", + }, + } + ] + } +).encode() +_OPENAI_RESULT: Final[dict[str, JsonValue]] = { + "file_id": "file-search-wire", + "filename": "search.txt", + "score": 0.91, + "attributes": {"tenant": "a"}, + "content": [{"type": "text", "text": "matching OpenAI content"}], +} +_BEDROCK_RESULT: Final[dict[str, JsonValue]] = { + "score": 0.87, + "content": [{"text": "matching Bedrock content", "type": "text"}], + "file_id": "s3://synthetic-bucket/vector-store.txt", + "filename": "vector-store.txt", + "attributes": { + "x-amz-bedrock-kb-source-uri": "s3://synthetic-bucket/vector-store.txt", + "x-amz-bedrock-kb-chunk-id": "chunk-1", + }, +} + + +def _bedrock_retrieve_peer(knowledge_base_id: str, access_key: str) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == f"/knowledgebases/{knowledge_base_id}/retrieve", request.target + assert request.headers["authorization"].startswith(f"AWS4-HMAC-SHA256 Credential={access_key}/") + assert JSON_OBJECT.validate_json(request.body) == { + "retrievalQuery": {"text": QUERY}, + "retrievalConfiguration": {"vectorSearchConfiguration": {"numberOfResults": 3}}, + } + return Reply(body=_RETRIEVE_RESPONSE) + + return respond + + +def _openai_search_peer(store_id: str) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == f"/v1/vector_stores/{store_id}/search", request.target + assert request.headers["authorization"] == "Bearer team-openai-key" + assert JSON_OBJECT.validate_json(request.body)["query"] == QUERY + return Reply( + body=json.dumps( + { + "object": "vector_store.search_results.page", + "search_query": QUERY, + "data": [_OPENAI_RESULT], + "has_more": False, + "next_page": None, + } + ).encode() + ) + + return respond + + +def test_bedrock_vector_store_search_uses_bedrock_team_model_credentials(gateway: Gateway) -> None: + knowledge_base_id: Final = f"KB{uuid.uuid4().hex[:8].upper()}" + with wire_server(_bedrock_retrieve_peer(knowledge_base_id, TEAM_ACCESS_KEY)) as wire, gateway.scenario() as scenario: + team: Final = scenario.team() + scenario.model( + model="langgraph/repro-agent", + api_base="http://127.0.0.1:1", + model_info={"team_id": team}, + ) + scenario.model( + model="bedrock/us.anthropic.claude-sonnet-5-5", + api_base=wire.url, + aws_access_key_id=TEAM_ACCESS_KEY, + aws_secret_access_key="synthetic-team-secret-key", + aws_region_name="us-east-1", + aws_bedrock_runtime_endpoint=wire.url, + model_info={"team_id": team}, + ) + team_key: Final = scenario.key(team_id=team) + gateway.post( + "/vector_store/new", + { + "vector_store_id": knowledge_base_id, + "custom_llm_provider": "bedrock", + "litellm_params": { + "aws_region_name": "us-east-1", + "aws_bedrock_runtime_endpoint": wire.url, + }, + }, + ) + scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": knowledge_base_id}) + + response: Final = gateway.request( + "POST", + f"/v1/vector_stores/{knowledge_base_id}/search", + {"query": QUERY, "max_num_results": 3}, + key=team_key, + ) + assert response.status_code == 200, response.text + assert JSON_OBJECT.validate_json(response.content)["data"] == [_BEDROCK_RESULT], response.text + assert len(wire.drain()) == 1 + + +def test_bedrock_vector_store_search_uses_store_credentials_without_matching_team_model( + gateway: Gateway, +) -> None: + knowledge_base_id: Final = f"KB{uuid.uuid4().hex[:8].upper()}" + with wire_server(_bedrock_retrieve_peer(knowledge_base_id, STORE_ACCESS_KEY)) as wire, gateway.scenario() as scenario: + team: Final = scenario.team() + scenario.model( + model="langgraph/repro-agent", + api_base="http://127.0.0.1:1", + model_info={"team_id": team}, + ) + team_key: Final = scenario.key(team_id=team) + gateway.post( + "/vector_store/new", + { + "vector_store_id": knowledge_base_id, + "custom_llm_provider": "bedrock", + "litellm_params": { + "aws_access_key_id": STORE_ACCESS_KEY, + "aws_secret_access_key": "synthetic-store-secret-key", + "aws_region_name": "us-east-1", + "aws_bedrock_runtime_endpoint": wire.url, + }, + }, + ) + scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": knowledge_base_id}) + + response: Final = gateway.request( + "POST", + f"/v1/vector_stores/{knowledge_base_id}/search", + {"query": QUERY, "max_num_results": 3}, + key=team_key, + ) + assert response.status_code == 200, response.text + assert JSON_OBJECT.validate_json(response.content)["data"] == [_BEDROCK_RESULT], response.text + assert len(wire.drain()) == 1 + + +def test_openai_vector_store_search_without_store_key_uses_team_byok_key(gateway: Gateway) -> None: + store_id: Final = f"vs_team_{uuid.uuid4().hex}" + with wire_server(_openai_search_peer(store_id)) as wire, gateway.scenario() as scenario: + team: Final = scenario.team() + scenario.model( + model="openai/gpt-4o-mini", + api_key="team-openai-key", + api_base=f"{wire.url}/v1", + model_info={"team_id": team}, + ) + team_key: Final = scenario.key(team_id=team) + gateway.post( + "/vector_store/new", + { + "vector_store_id": store_id, + "custom_llm_provider": "openai", + "litellm_params": {}, + }, + ) + scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": store_id}) + + response: Final = gateway.request( + "POST", + f"/v1/vector_stores/{store_id}/search", + {"query": QUERY}, + key=team_key, + ) + assert response.status_code == 200, response.text + assert JSON_OBJECT.validate_json(response.content)["data"] == [_OPENAI_RESULT], response.text + assert len(wire.drain()) == 1 diff --git a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py index b2aba7fb100..bba79049283 100644 --- a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py +++ b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py @@ -757,12 +757,15 @@ def _vertex_deployment(gateway: Gateway, scenario: Scenario, api_base: str, back ) -def _bedrock_invoke_deployment(scenario: Scenario, api_base: str, backend: str) -> str: +def _bedrock_invoke_deployment( + scenario: Scenario, api_base: str, backend: str, model_info: Mapping[str, JsonValue] | None = None +) -> str: return scenario.model( model=f"bedrock/invoke/{backend}", api_base=api_base, api_key=_BEDROCK_TOKEN, aws_region_name="us-east-1", + model_info=model_info, ) @@ -1521,7 +1524,9 @@ def _json_tool_deployment(gateway: Gateway, scenario: Scenario, api_base: str, p case "vertex_ai": return _vertex_deployment(gateway, scenario, api_base, _TOOL_MODEL) case "bedrock_invoke": - return _bedrock_invoke_deployment(scenario, api_base, _BEDROCK_JSON_TOOL_MODEL) + return _bedrock_invoke_deployment( + scenario, api_base, _BEDROCK_JSON_TOOL_MODEL, {"supports_bedrock_native_structured_output": False} + ) case _: raise AssertionError(provider) diff --git a/tests/integration/providers/test_vertex_partner_count_tokens_wire.py b/tests/integration/providers/test_vertex_partner_count_tokens_wire.py index 4cf9a3159fe..1956301f1be 100644 --- a/tests/integration/providers/test_vertex_partner_count_tokens_wire.py +++ b/tests/integration/providers/test_vertex_partner_count_tokens_wire.py @@ -5,8 +5,10 @@ import uuid from collections.abc import Callable, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack +from dataclasses import dataclass from pathlib import Path from queue import SimpleQueue +from types import MappingProxyType from typing import Final import httpx @@ -14,11 +16,12 @@ import psutil import pytest import yaml from integration._support.client import Gateway, Scenario, eventually -from integration._support.process import owned_proxy_process +from integration._support.process import graceful_stop_seconds, owned_proxy_process from integration._support.vertex import service_account_json from integration._support.wire import Reply, Request, Wire, wire_server from integration._support.ports import allocate_port from pydantic import JsonValue, TypeAdapter +from tokenizers import Regex, Tokenizer, models, pre_tokenizers _BACKEND: Final = "claude-sonnet-4-6" _PROJECT: Final = "scripted-project" @@ -34,9 +37,19 @@ _REPLY_TEXT: Final = "scripted reply" _OWNED_MODEL: Final = "partner-claude" _OWNED_UNREACHABLE_MODEL: Final = "partner-claude-unreachable" _JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_GLM_BACKEND: Final = "zai-org/glm-5.2-maas" +_MISTRAL_BACKEND: Final = "mistral-medium-3" +_LLAMA_BACKEND: Final = "meta/llama-4-maverick-17b-128e-instruct-maas" +_LLAMA_COUNT_TARGET: Final = ( + f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/meta/models/count-tokens:rawPredict" +) +_COUNTED_TEXT: Final = "Count this message" +_TOKENIZER_REPO: Final = "scripted/char-tokenizer" +_TOKENIZER_TARGET: Final = f"/{_TOKENIZER_REPO}/resolve/main/tokenizer.json" +_TOKENIZER_HEADERS: Final = MappingProxyType({"X-Repo-Commit": "0" * 40, "ETag": '"scripted-char-tokenizer"'}) _STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") -_MESSAGES: Final[list[JsonValue]] = [{"role": "user", "content": "Count this message"}] +_MESSAGES: Final[list[JsonValue]] = [{"role": "user", "content": _COUNTED_TEXT}] _SYSTEM: Final = "You are a terse assistant that answers in one sentence" _SYSTEM_BLOCKS: Final[list[JsonValue]] = [ {"type": "text", "text": "You are a terse assistant"}, @@ -114,16 +127,16 @@ def _counted(_request: Request) -> Reply: return Reply(body=json.dumps({"input_tokens": _PEER_COUNT}).encode()) -def _rejected(status: int) -> Reply: +def _rejected(status: int, error_type: str = "invalid_request_error") -> Reply: return Reply( status=status, - body=json.dumps({"type": "error", "error": {"type": "invalid_request_error", "message": _REJECTION}}).encode(), + body=json.dumps({"type": "error", "error": {"type": error_type, "message": _REJECTION}}).encode(), ) -def _rejecting(status: int) -> Callable[[Request], Reply]: +def _rejecting(status: int, error_type: str = "invalid_request_error") -> Callable[[Request], Reply]: def count(_request: Request) -> Reply: - return _rejected(status) + return _rejected(status, error_type) return count @@ -223,6 +236,78 @@ def _local_count(gateway: Gateway, body: Mapping[str, JsonValue]) -> int: return total +@dataclass(frozen=True, slots=True) +class _CountRoute: + path: Callable[[str], str] + body: Callable[[str], dict[str, JsonValue]] + rejection: Callable[[str], dict[str, JsonValue]] + count_field: str + params: Mapping[str, str] = MappingProxyType({}) + + def send(self, gateway: Gateway, model: str) -> httpx.Response: + return gateway.request("POST", self.path(model), self.body(model), params=self.params) + + +_MESSAGES_ROUTE: Final = _CountRoute( + path=lambda _model: "/v1/messages/count_tokens", + body=_bare, + rejection=lambda message: {"type": "error", "error": {"type": "invalid_request_error", "message": message}}, + count_field="input_tokens", +) +_RESPONSES_ROUTE: Final = _CountRoute( + path=lambda _model: "/v1/responses/input_tokens", + body=lambda model: {"model": model, "input": _COUNTED_TEXT}, + rejection=lambda message: { + "error": {"message": message, "type": "invalid_request_error", "param": None, "code": None} + }, + count_field="input_tokens", +) +_GOOGLE_ROUTE: Final = _CountRoute( + path=lambda model: f"/v1beta/models/{model}:countTokens", + body=lambda _model: {"contents": [{"role": "user", "parts": [{"text": _COUNTED_TEXT}]}]}, + rejection=lambda message: {"error": {"code": 400, "message": message, "status": "INVALID_ARGUMENT"}}, + count_field="totalTokens", +) +_UTILS_ROUTE: Final = _CountRoute( + path=lambda _model: "/utils/token_counter", + body=_bare, + rejection=lambda message: { + "error": {"message": message, "type": "token_counting_error", "param": "model", "code": "400"} + }, + count_field="total_tokens", + params=MappingProxyType({"call_endpoint": "true"}), +) +_COUNT_ROUTES: Final = (_MESSAGES_ROUTE, _RESPONSES_ROUTE, _GOOGLE_ROUTE, _UTILS_ROUTE) + + +def _error_message(payload: Mapping[str, JsonValue]) -> str: + error: Final = payload.get("error") + assert isinstance(error, dict), payload + message: Final = error.get("message") + assert isinstance(message, str), payload + return message + + +def _route_count(gateway: Gateway, route: _CountRoute, model: str) -> tuple[int, JsonValue]: + response: Final = route.send(gateway, model) + return response.status_code, _JSON_OBJECT.validate_json(response.content).get(route.count_field) + + +def _char_tokenizer() -> bytes: + tokenizer: Final = Tokenizer(models.WordLevel({"[UNK]": 0}, unk_token="[UNK]")) + tokenizer.pre_tokenizer = pre_tokenizers.Split(Regex("."), behavior="isolated") + return tokenizer.to_str().encode() + + +def _tokenizer_hub(tokenizer: bytes) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.target == _TOKENIZER_TARGET: + return Reply(body=tokenizer, headers=_TOKENIZER_HEADERS) + return Reply(status=404, body=json.dumps({"error": f"unscripted target {request.target}"}).encode()) + + return respond + + def _closed_port_url() -> str: return f"http://127.0.0.1:{allocate_port()}" @@ -311,7 +396,7 @@ def test_messages_count_tokens_forwards_system_and_tools(gateway: Gateway, syste with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, {**_bare(model), "system": system, "tools": _TOOLS}) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == ({**_PEER_BARE, "system": system, "tools": _TOOLS},) @@ -319,7 +404,7 @@ def test_messages_count_tokens_without_system_or_tools_sends_bare_body(gateway: with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, _bare(model)) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == (_PEER_BARE,) @@ -358,7 +443,11 @@ def test_responses_input_tokens_counts_through_the_partner_peer(gateway: Gateway response: Final = gateway.request( "POST", "/v1/responses/input_tokens", {"model": model, "input": "Count this message"} ) - assert _payload(response) == {"object": "response.input_tokens", "input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == { + "object": "response.input_tokens", + "input_tokens": _PEER_COUNT, + "litellm_count_source": "provider", + }, response.text assert _counted_bodies(wire) == (_PEER_BARE,) @@ -371,11 +460,89 @@ def test_responses_input_tokens_surfaces_the_peer_error(gateway: Gateway) -> Non {"model": model, "input": "Count this message", "instructions": "Be terse", "tools": _RESPONSES_TOOLS}, ) assert response.status_code == 400, response.text - assert "input_tokens" not in response.text, response.text + assert response.json() == _RESPONSES_ROUTE.rejection(_REJECTION), response.text (sent,) = _counted_bodies(wire) assert sent["tools"] == _RESPONSES_TOOLS, sent +def test_responses_input_tokens_types_a_peer_error_by_its_status(gateway: Gateway) -> None: + with wire_server(_peer(_rejecting(404, "not_found_error"))) as wire, gateway.scenario() as scenario: + model: Final = _deployment(gateway, scenario, wire.url) + response: Final = _RESPONSES_ROUTE.send(gateway, model) + assert response.status_code == 404, response.text + assert response.json() == _RESPONSES_ROUTE.rejection(_REJECTION), response.text + assert _counted_bodies(wire) == (_PEER_BARE,) + + +@pytest.mark.parametrize( + ("backend", "route"), + [ + pytest.param(_GLM_BACKEND, _MESSAGES_ROUTE, id="glm-messages"), + pytest.param(_GLM_BACKEND, _RESPONSES_ROUTE, id="glm-responses"), + pytest.param(_GLM_BACKEND, _GOOGLE_ROUTE, id="glm-google"), + pytest.param(_GLM_BACKEND, _UTILS_ROUTE, id="glm-utils"), + pytest.param(_MISTRAL_BACKEND, _MESSAGES_ROUTE, id="mistral-messages"), + ], +) +def test_partner_without_a_count_route_answers_400_without_calling_vertex( + gateway: Gateway, backend: str, route: _CountRoute +) -> None: + with wire_server(_peer()) as wire, gateway.scenario() as scenario: + model: Final = _deployment(gateway, scenario, wire.url, model=f"vertex_ai/{backend}") + response: Final = route.send(gateway, model) + assert response.status_code == 400, response.text + payload: Final = _JSON_OBJECT.validate_json(response.content) + message: Final = _error_message(payload) + assert backend in message, message + assert payload == route.rejection(message), response.text + assert wire.drain() == () + + +def test_llama_count_still_reaches_the_meta_count_route(gateway: Gateway) -> None: + with wire_server(_peer()) as wire, gateway.scenario() as scenario: + model: Final = _deployment(gateway, scenario, wire.url, model=f"vertex_ai/{_LLAMA_BACKEND}") + response: Final = _count(gateway, _bare(model)) + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text + (sent,) = _count_bodies(wire.drain(), _LLAMA_COUNT_TARGET) + assert sent["messages"] == _MESSAGES, sent + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +def test_custom_tokenizer_partner_counts_locally_on_every_count_route(gateway: Gateway, tmp_path: Path) -> None: + environment: Final = {"HF_HOME": str(tmp_path / "hf-home")} + with ( + wire_server(_tokenizer_hub(_char_tokenizer())) as hub, + wire_server(_peer()) as vertex, + owned_proxy_process(gateway, tmp_path, {**environment, "HF_ENDPOINT": hub.url}) as owned, + owned.gateway.scenario() as scenario, + ): + model: Final = _deployment( + owned.gateway, + scenario, + vertex.url, + model=f"vertex_ai/{_GLM_BACKEND}", + model_info={"custom_tokenizer": {"identifier": _TOKENIZER_REPO, "revision": "main", "auth_token": None}}, + ) + local: Final = _payload( + owned.gateway.request("POST", "/utils/token_counter", _bare(model), params={"call_endpoint": "false"}) + ) + assert local["tokenizer_type"] == "huggingface_tokenizer", local + total: Final = local["total_tokens"] + assert isinstance(total, int) and total >= len(_COUNTED_TEXT), local + counts: Final = tuple(_route_count(owned.gateway, route, model) for route in _COUNT_ROUTES) + assert counts == ((200, total),) * len(_COUNT_ROUTES), counts + contents_only: Final = owned.gateway.request( + "POST", + "/utils/token_counter", + {"model": model, "contents": [{"role": "user", "parts": [{"text": _COUNTED_TEXT}]}]}, + params={"call_endpoint": "true"}, + ) + assert contents_only.status_code == 400, contents_only.text + assert _GLM_BACKEND in _error_message(_JSON_OBJECT.validate_json(contents_only.content)), contents_only.text + assert vertex.drain() == () + assert {request.target for request in hub.drain()} >= {_TOKENIZER_TARGET} + + def test_gemini_count_tokens_route_reaches_the_partner_peer(gateway: Gateway) -> None: with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) @@ -453,7 +620,7 @@ def test_messages_count_tokens_forwards_an_empty_tools_list(gateway: Gateway) -> with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, {**_bare(model), "tools": []}) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == ({**_PEER_BARE, "tools": []},) @@ -461,7 +628,7 @@ def test_messages_count_tokens_forwards_an_empty_system_string(gateway: Gateway) with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, {**_bare(model), "system": ""}) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == ({**_PEER_BARE, "system": ""},) @@ -469,7 +636,7 @@ def test_messages_count_tokens_leaves_null_system_and_tools_out(gateway: Gateway with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, {**_bare(model), "system": None, "tools": None}) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == (_PEER_BARE,) @@ -487,7 +654,7 @@ def test_messages_count_tokens_forwards_a_5kb_system_verbatim(gateway: Gateway) with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) response: Final = _count(gateway, {**_bare(model), "system": system}) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text assert _counted_bodies(wire) == ({**_PEER_BARE, "system": system},) @@ -500,7 +667,7 @@ def test_messages_count_tokens_duplicate_system_and_tools_keys_forward_one_value content=f'{{"model": "{model}", "messages": {json.dumps(_MESSAGES)}, {fields}, {fields}}}', headers={"Authorization": f"Bearer {gateway.key}", "Content-Type": "application/json"}, ) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text (sent,) = _count_requests(wire.drain()) assert _count_bodies((sent,)) == (_PEER_FULL,) assert (sent.body.count(b'"system"'), sent.body.count(b'"tools"')) == (1, 1), sent.body @@ -533,7 +700,7 @@ def test_count_tokens_location_override_targets_the_count_region(gateway: Gatewa gateway, scenario, wire.url, vertex_location="global", vertex_count_tokens_location="europe-west1" ) response: Final = _count(gateway, _bare(model)) - assert _payload(response) == {"input_tokens": _PEER_COUNT}, response.text + assert _payload(response) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, response.text target: Final = _COUNT_TARGET.replace(f"/locations/{_LOCATION}/", "/locations/europe-west1/") assert _count_bodies(wire.drain(), target) == (_PEER_BARE,) @@ -542,7 +709,7 @@ def test_messages_count_tokens_repeated_request_reaches_the_peer_each_time(gatew with wire_server(_peer()) as wire, gateway.scenario() as scenario: model: Final = _deployment(gateway, scenario, wire.url) answers: Final = tuple(_payload(_count(gateway, _bare(model))) for _ in range(2)) - assert answers == ({"input_tokens": _PEER_COUNT},) * 2 + assert answers == ({"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"},) * 2 assert _counted_bodies(wire) == (_PEER_BARE,) * 2 @@ -559,7 +726,7 @@ def test_disabled_token_counter_surfaces_provider_failures_instead_of_counting_l ) with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned: counted: Final = _count(owned.gateway, _full(_OWNED_MODEL)) - assert _payload(counted) == {"input_tokens": _PEER_COUNT}, counted.text + assert _payload(counted) == {"input_tokens": _PEER_COUNT, "litellm_count_source": "provider"}, counted.text rejected: Final = _count( owned.gateway, {**_full(_OWNED_MODEL), "messages": [{"role": "user", "content": _REJECT_TEXT}]} ) diff --git a/tests/integration/proxy_config.yaml b/tests/integration/proxy_config.yaml index 37bcc4683dd..4e2cab56d27 100644 --- a/tests/integration/proxy_config.yaml +++ b/tests/integration/proxy_config.yaml @@ -473,6 +473,13 @@ model_list: vertex_project: synthetic-vertex-project vertex_location: global vertex_credentials: '{"type": "external_account", "audience": "synthetic-vertex-audience", "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", "token_url": "http://127.0.0.1:8190/_oauth/token", "credential_source": {"url": "http://127.0.0.1:8190/health"}}' + - model_name: vertex_ai/claude-haiku-5-5@default + litellm_params: + model: vertex_ai/claude-haiku-5-5@default + api_base: http://127.0.0.1:8191 + vertex_project: synthetic-vertex-project + vertex_location: global + vertex_credentials: '{"type": "external_account", "audience": "synthetic-vertex-audience", "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", "token_url": "http://127.0.0.1:8190/_oauth/token", "credential_source": {"url": "http://127.0.0.1:8190/health"}}' - model_name: vertex_ai/claude-opus-5-5@default litellm_params: model: vertex_ai/claude-opus-5-5@default diff --git a/tests/integration/routing/test_deployment_mode_routing_wire.py b/tests/integration/routing/test_deployment_mode_routing_wire.py new file mode 100644 index 00000000000..65e64883b18 --- /dev/null +++ b/tests/integration/routing/test_deployment_mode_routing_wire.py @@ -0,0 +1,1037 @@ +import asyncio +import itertools +import json +import re +import threading +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final, Literal +from urllib.parse import urlsplit + +import anthropic +import httpx +import openai +import psutil +import pytest +import yaml +from integration._support.client import Gateway, Scenario, eventually, object_value, string_value +from integration._support.database import read_rows +from integration._support.openai_wire import chat_reply, responses_reply +from integration._support.process import graceful_stop_seconds, owned_proxy_process +from integration._support.responses_vendor import same_response +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +_API_KEY: Final = "synthetic-routing-bearer" +_MANTLE_GPT: Final = "bedrock_mantle/openai.gpt-5.6-terra" +_MANTLE_OSS: Final = "bedrock_mantle/openai.gpt-oss-120b" +_OPENAI_GPT: Final = "openai/gpt-5.4" +_PINNED: Final = "routing-chat-pinned" +_SIBLING: Final = "routing-unpinned-sibling" +_NO_RETRIES: Final[Mapping[str, JsonValue]] = MappingProxyType({"num_retries": 0}) +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})") +_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") +_SPEND_BY_MODEL_GROUP: Final = 'SELECT request_id, status, model_group FROM "LiteLLM_SpendLogs" WHERE model_group = %s' +_N_REJECTED_BY_MANTLE: Final = "'n' is not supported for values greater than 1" +_FUNCTION_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Current weather for a city", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + } +) + +Api = Literal["chat", "responses"] +Client = Literal["openai-sdk", "openai-sdk-async-stream", "anthropic-sdk", "anthropic-sdk-async-stream", "httpx-stream"] +Endpoint = Literal["chat", "messages", "responses"] + + +@dataclass(frozen=True, slots=True) +class _Attempt: + api: Api + marker: str + body: Mapping[str, JsonValue] + + +@dataclass(frozen=True, slots=True) +class _Answer: + status: int + identity: str + call_id: str + text: str + + +@dataclass(frozen=True, slots=True) +class _Call: + model: str + endpoint: Endpoint + stream: bool + marker: str + + +def _question(marker: str) -> str: + return f"Question marker-{marker}" + + +def _answer(marker: str) -> str: + return f"answer marker-{marker}" + + +def _marker_in(text: str) -> str: + found: Final = _MARKER.search(text) + assert found is not None, text + return found.group(1) + + +def _api(target: str) -> Api: + if target.endswith("/chat/completions"): + return "chat" + assert target.endswith("/responses"), target + return "responses" + + +def _error(status: int, message: str, kind: str) -> Reply: + return Reply(status=status, body=json.dumps({"error": {"message": message, "type": kind, "code": None}}).encode()) + + +def _upstream(down: threading.Event | None = None) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.method != "POST": + return Reply(body=b'{"object": "list", "data": []}') + if request.headers.get("authorization") != f"Bearer {_API_KEY}": + return _error(401, "Invalid bearer token.", "authentication_error") + if down is not None and down.is_set(): + return _error(503, "The server is overloaded.", "server_error") + body: Final = _JSON_OBJECT.validate_json(request.body) + marker: Final = _marker_in(request.body.decode()) + stream: Final = body.get("stream") is True + backend: Final = string_value(body["model"]) + if _api(request.target) == "responses": + return responses_reply(f"resp_{marker}", backend, _answer(marker), stream=stream) + n: Final = body.get("n") + if isinstance(n, int) and n > 1: + return _error(400, _N_REJECTED_BY_MANTLE, "invalid_request_error") + return chat_reply(f"chatcmpl-{marker}", backend, _answer(marker), stream=stream) + + return respond + + +def _is_probe(request: Request) -> bool: + return request.method == "POST" and _MARKER.search(request.body.decode()) is None + + +def _attempts(wire: Wire, *, skip_probes: bool = False) -> tuple[_Attempt, ...]: + return tuple( + _Attempt(_api(request.target), _marker_in(request.body.decode()), _JSON_OBJECT.validate_json(request.body)) + for request in wire.drain() + if request.method == "POST" and not (skip_probes and _is_probe(request)) + ) + + +def _routes(attempts: tuple[_Attempt, ...]) -> Mapping[str, tuple[Api, ...]]: + markers: Final = frozenset(attempt.marker for attempt in attempts) + return {marker: tuple(attempt.api for attempt in attempts if attempt.marker == marker) for marker in markers} + + +def _api_base(wire: Wire, backend: str) -> str: + return wire.url + ("/openai/v1" if backend.startswith("bedrock_mantle/") else "/v1") + + +def _deployment( + scenario: Scenario, + wire: Wire, + backend: str, + mode: str | None, + *, + provider_pin: bool = True, + api_key: str = _API_KEY, +) -> str: + provider: Final[Mapping[str, JsonValue]] = {"custom_llm_provider": backend.split("/", 1)[0]} if provider_pin else {} + return scenario.model( + model=backend, + api_base=_api_base(wire, backend), + api_key=api_key, + model_info=None if mode is None else {"mode": mode}, + **provider, + ) + + +def _wildcard(scenario: Scenario, wire: Wire, mode: str | None) -> str: + prefix: Final = f"wild-{uuid.uuid4().hex}" + created: Final = scenario.gateway.post( + "/model/new", + { + "model_name": f"{prefix}/*", + "litellm_params": {"model": "bedrock_mantle/*", "api_base": wire.url + "/openai/v1", "api_key": _API_KEY}, + "model_info": {} if mode is None else {"mode": mode}, + }, + ) + scenario.cleanups.callback(scenario.delete_model, string_value(object_value(created["model_info"])["id"])) + return f"{prefix}/{_MANTLE_GPT.removeprefix('bedrock_mantle/')}" + + +def _raw_model_info_deployment(scenario: Scenario, wire: Wire, raw_model_info: str) -> str: + name: Final = f"integration-{uuid.uuid4().hex}" + params: Final = json.dumps( + { + "model": _MANTLE_GPT, + "custom_llm_provider": "bedrock_mantle", + "api_base": wire.url + "/openai/v1", + "api_key": _API_KEY, + } + ) + response: Final = scenario.gateway.client.post( + "/model/new", + content=f'{{"model_name": "{name}", "litellm_params": {params}, "model_info": {raw_model_info}}}', + headers={"Authorization": f"Bearer {scenario.gateway.key}", "Content-Type": "application/json"}, + ) + assert response.status_code == 200, response.text + identity: Final = string_value(object_value(_JSON_OBJECT.validate_json(response.content)["model_info"])["id"]) + scenario.cleanups.callback(scenario.delete_model, identity) + return name + + +def _proxy_url(gateway: Gateway) -> str: + return str(gateway.client.base_url).rstrip("/") + + +def _messages(marker: str) -> list[JsonValue]: + return [{"role": "user", "content": _question(marker)}] + + +def _identity(endpoint: Endpoint, stream: bool, text: str) -> str: + if not stream: + return string_value(_JSON_OBJECT.validate_json(text)["id"]) + first: Final = _JSON_OBJECT.validate_json( + next(line for line in text.splitlines() if line.startswith("data: {")).removeprefix("data: ") + ) + match endpoint: + case "chat": + return string_value(first["id"]) + case "messages": + return string_value(object_value(first["message"])["id"]) + case "responses": + return string_value(object_value(first["response"])["id"]) + + +def _body(call: _Call, extra: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + match call.endpoint: + case "chat": + return {"model": call.model, "messages": _messages(call.marker), "stream": call.stream, **extra} + case "messages": + return {"model": call.model, "max_tokens": 64, "messages": _messages(call.marker), "stream": call.stream} + case "responses": + return {"model": call.model, "input": _question(call.marker), "stream": call.stream, **extra} + + +def _path(endpoint: Endpoint) -> str: + match endpoint: + case "chat": + return "/v1/chat/completions" + case "messages": + return "/v1/messages" + case "responses": + return "/v1/responses" + + +async def _send(client: httpx.AsyncClient, key: str, call: _Call, extra: Mapping[str, JsonValue]) -> _Answer: + async with client.stream( + "POST", + _path(call.endpoint), + json=_body(call, extra), + headers={"Authorization": f"Bearer {key}", "anthropic-version": "2023-06-01"}, + ) as response: + text: Final = (await response.aread()).decode() + identity: Final = _identity(call.endpoint, call.stream, text) if response.status_code == 200 else "" + call_id: Final = response.headers["x-litellm-call-id"] if "x-litellm-call-id" in response.headers else "" + return _Answer(response.status_code, identity, call_id, text) + + +async def _http(gateway: Gateway, call: _Call, extra: Mapping[str, JsonValue] = _NO_RETRIES) -> _Answer: + async with httpx.AsyncClient(base_url=_proxy_url(gateway), timeout=60, trust_env=False) as client: + return await _send(client, gateway.key, call, extra) + + +async def _burst( + base_url: str, key: str, calls: tuple[_Call, ...], *, tolerate_drops: bool = False +) -> tuple[_Answer | None, ...]: + async with httpx.AsyncClient(base_url=base_url, timeout=90, trust_env=False) as client: + results: Final = await asyncio.gather( + *(_send(client, key, call, _NO_RETRIES) for call in calls), return_exceptions=tolerate_drops + ) + for result in results: + assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result) + return tuple(result if isinstance(result, _Answer) else None for result in results) + + +def _openai_sdk(gateway: Gateway, model: str, marker: str) -> _Answer: + with openai.OpenAI(base_url=f"{_proxy_url(gateway)}/v1", api_key=gateway.key, max_retries=0) as sdk: + completion: Final = sdk.chat.completions.create( + model=model, messages=[{"role": "user", "content": _question(marker)}] + ) + return _Answer(200, completion.id, "", completion.choices[0].message.content or "") + + +async def _openai_sdk_async_stream(gateway: Gateway, model: str, marker: str) -> _Answer: + async with openai.AsyncOpenAI(base_url=f"{_proxy_url(gateway)}/v1", api_key=gateway.key, max_retries=0) as sdk: + stream: Final = await sdk.chat.completions.create( + model=model, messages=[{"role": "user", "content": _question(marker)}], stream=True + ) + chunks: Final = [chunk async for chunk in stream] + (identity,) = {chunk.id for chunk in chunks} + return _Answer( + 200, identity, "", "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) + ) + + +def _anthropic_sdk(gateway: Gateway, model: str, marker: str) -> _Answer: + with anthropic.Anthropic(base_url=_proxy_url(gateway), api_key=gateway.key, max_retries=0) as sdk: + message: Final = sdk.messages.create( + model=model, max_tokens=64, messages=[{"role": "user", "content": _question(marker)}] + ) + return _Answer(200, message.id, "", "".join(block.text for block in message.content if block.type == "text")) + + +async def _anthropic_sdk_async_stream(gateway: Gateway, model: str, marker: str) -> _Answer: + async with anthropic.AsyncAnthropic(base_url=_proxy_url(gateway), api_key=gateway.key, max_retries=0) as sdk: + async with sdk.messages.stream( + model=model, max_tokens=64, messages=[{"role": "user", "content": _question(marker)}] + ) as stream: + pieces: Final = [piece async for piece in stream.text_stream] + message: Final = await stream.get_final_message() + return _Answer(200, message.id, "", "".join(pieces)) + + +async def _ask(gateway: Gateway, client: Client, model: str, marker: str) -> _Answer: + match client: + case "openai-sdk": + return _openai_sdk(gateway, model, marker) + case "openai-sdk-async-stream": + return await _openai_sdk_async_stream(gateway, model, marker) + case "anthropic-sdk": + return _anthropic_sdk(gateway, model, marker) + case "anthropic-sdk-async-stream": + return await _anthropic_sdk_async_stream(gateway, model, marker) + case "httpx-stream": + return await _http(gateway, _Call(model, "chat", True, marker)) + + +def _logged_ids(answer: _Answer) -> tuple[str, ...]: + markers: Final = frozenset(_MARKER.findall(answer.text)) + upstream: Final = (*(f"resp_{marker}" for marker in markers), *(f"chatcmpl-{marker}" for marker in markers)) + return tuple(shown for shown in (answer.identity, answer.call_id, *upstream) if shown) + + +def _logs_answer(request_id: JsonValue, answer: _Answer) -> bool: + return any(same_response(string_value(request_id), shown) for shown in _logged_ids(answer)) + + +def _logged_rows(answer: _Answer, model: str) -> list[dict[str, JsonValue]]: + return [row for row in read_rows(_SPEND_BY_MODEL_GROUP, (model,)) if _logs_answer(row["request_id"], answer)] + + +def _assert_logged(answer: _Answer, model: str, status: str) -> None: + rows: Final = eventually(lambda: _logged_rows(answer, model), lambda found: len(found) >= 1, seconds=70) + assert len(rows) == 1, rows + assert (rows[0]["status"], rows[0]["model_group"]) == (status, model), rows + + +def _assert_answered(answer: _Answer | None, marker: str) -> _Answer: + assert answer is not None, marker + assert answer.status == 200, answer.text + assert _answer(marker) in answer.text, answer.text + assert set(_MARKER.findall(answer.text)) == {marker}, answer.text + return answer + + +def _waves(model: str, count: int) -> tuple[_Call, ...]: + endpoints: Final[tuple[Endpoint, ...]] = ("chat", "messages", "responses") + return tuple( + _Call(model, endpoints[index % len(endpoints)], index % 2 == 0, uuid.uuid4().hex) for index in range(count) + ) + + +def _expected_route(call: _Call, pinned: str) -> Api: + return "chat" if call.model == pinned and call.endpoint != "responses" else "responses" + + +@pytest.mark.parametrize( + "client", ("openai-sdk", "openai-sdk-async-stream", "anthropic-sdk", "anthropic-sdk-async-stream", "httpx-stream") +) +async def test_a_chat_pin_leaves_its_unpinned_sibling_on_responses_on_every_client( + gateway: Gateway, client: Client +) -> None: + pinned_marker: Final = uuid.uuid4().hex + sibling_marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + pinned_answer: Final = _assert_answered(await _ask(gateway, client, pinned, pinned_marker), pinned_marker) + sibling_answer: Final = _assert_answered(await _ask(gateway, client, sibling, sibling_marker), sibling_marker) + assert _routes(_attempts(wire)) == {pinned_marker: ("chat",), sibling_marker: ("responses",)} + _assert_logged(pinned_answer, pinned, "success") + _assert_logged(sibling_answer, sibling, "success") + + +@pytest.mark.parametrize("backend", (_MANTLE_OSS, _OPENAI_GPT)) +async def test_a_responses_pin_leaves_its_unpinned_sibling_on_chat(gateway: Gateway, backend: str) -> None: + pinned_marker: Final = uuid.uuid4().hex + sibling_marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, backend, "responses") + sibling: Final = _deployment(scenario, wire, backend, None) + pinned_answer: Final = _assert_answered( + await _http(gateway, _Call(pinned, "chat", False, pinned_marker)), pinned_marker + ) + sibling_answer: Final = _assert_answered( + await _http(gateway, _Call(sibling, "chat", False, sibling_marker)), sibling_marker + ) + assert _routes(_attempts(wire)) == {pinned_marker: ("responses",), sibling_marker: ("chat",)} + _assert_logged(pinned_answer, pinned, "success") + _assert_logged(sibling_answer, sibling, "success") + + +@pytest.mark.parametrize("stream", (False, True)) +async def test_the_responses_endpoint_serves_a_chat_pinned_deployment_natively(gateway: Gateway, stream: bool) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + answer: Final = _assert_answered(await _http(gateway, _Call(pinned, "responses", stream, marker)), marker) + assert _routes(_attempts(wire)) == {marker: ("responses",)} + _assert_logged(answer, pinned, "success") + + +async def test_a_wildcard_route_keeps_the_registered_mode_beside_a_chat_pin(gateway: Gateway) -> None: + pinned_marker: Final = uuid.uuid4().hex + wildcard_marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + wildcard: Final = _wildcard(scenario, wire, None) + _assert_answered(await _http(gateway, _Call(pinned, "chat", False, pinned_marker)), pinned_marker) + _assert_answered(await _http(gateway, _Call(wildcard, "chat", False, wildcard_marker)), wildcard_marker) + assert _routes(_attempts(wire)) == {pinned_marker: ("chat",), wildcard_marker: ("responses",)} + + +@pytest.mark.parametrize("shape", ("wildcard", "no-provider-pin")) +async def test_a_chat_pin_without_a_provider_pin_gets_chat_completions(gateway: Gateway, shape: str) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + model: Final = ( + _wildcard(scenario, wire, "chat") + if shape == "wildcard" + else _deployment(scenario, wire, _MANTLE_GPT, "chat", provider_pin=False) + ) + _assert_answered(await _http(gateway, _Call(model, "chat", False, marker)), marker) + assert _routes(_attempts(wire)) == {marker: ("chat",)} + + +async def test_an_explicit_responses_prefix_wins_over_a_chat_pin(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/responses/gpt-5.4", api_base=wire.url + "/v1", api_key=_API_KEY, model_info={"mode": "chat"} + ) + answer: Final = _assert_answered(await _http(gateway, _Call(model, "chat", False, marker)), marker) + (attempt,) = _attempts(wire) + assert (attempt.api, attempt.body["model"]) == ("responses", "gpt-5.4"), attempt + _assert_logged(answer, model, "success") + + +_SUMMARY_REASONING: Final[Mapping[str, JsonValue]] = MappingProxyType({"effort": "low", "summary": "auto"}) + + +@pytest.mark.parametrize( + ("extra", "reasoning"), + ( + ({"tools": [dict(_FUNCTION_TOOL)]}, None), + ({"reasoning_effort": dict(_SUMMARY_REASONING)}, _SUMMARY_REASONING), + ({"extra_body": {"reasoning_effort": dict(_SUMMARY_REASONING)}}, _SUMMARY_REASONING), + ({"web_search_options": {"search_context_size": "low"}}, None), + ), + ids=("function-tools", "reasoning-summary", "extra-body-reasoning-summary", "web-search"), +) +async def test_a_mantle_chat_pin_sends_what_chat_cannot_serve_to_responses( + gateway: Gateway, extra: Mapping[str, JsonValue], reasoning: Mapping[str, JsonValue] | None +) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + answer: Final = _assert_answered( + await _http(gateway, _Call(pinned, "chat", False, marker), {**_NO_RETRIES, **extra}), marker + ) + (attempt,) = _attempts(wire) + assert attempt.api == "responses", attempt + assert reasoning is None or attempt.body["reasoning"] == reasoning, attempt.body + _assert_logged(answer, pinned, "success") + + +def _placed(placement: str, params: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]: + return params if placement == "top-level" else {"extra_body": dict(params)} + + +async def _messages_with_thinking( + gateway: Gateway, model: str, marker: str, thinking: Mapping[str, JsonValue] +) -> _Answer: + async with httpx.AsyncClient(base_url=_proxy_url(gateway), timeout=60, trust_env=False) as client: + response: Final = await client.post( + "/v1/messages", + json={"model": model, "max_tokens": 2048, "thinking": dict(thinking), "messages": _messages(marker)}, + headers={"Authorization": f"Bearer {gateway.key}", "anthropic-version": "2023-06-01"}, + ) + identity: Final = _identity("messages", False, response.text) if response.status_code == 200 else "" + return _Answer(response.status_code, identity, response.headers.get("x-litellm-call-id", ""), response.text) + + +@pytest.mark.parametrize(("summary", "route"), ((None, "chat"), ("auto", "responses")), ids=("plain", "summary")) +async def test_a_mantle_chat_pin_moves_messages_thinking_to_responses_only_when_it_asks_for_a_summary( + gateway: Gateway, summary: str | None, route: Api +) -> None: + marker: Final = uuid.uuid4().hex + thinking: Final[Mapping[str, JsonValue]] = {"type": "enabled", "budget_tokens": 1024} | ( + {} if summary is None else {"summary": summary} + ) + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + answer: Final = _assert_answered(await _messages_with_thinking(gateway, pinned, marker, thinking), marker) + (attempt,) = _attempts(wire) + assert attempt.api == route, attempt + match route: + case "chat": + assert "summary" not in json.dumps(attempt.body), attempt.body + case "responses": + assert object_value(attempt.body["reasoning"])["summary"] == summary, attempt.body + _assert_logged(answer, pinned, "success") + + +def _sent_effort(attempt: _Attempt) -> JsonValue: + match attempt.api: + case "chat": + return attempt.body.get("reasoning_effort") + case "responses": + return object_value(attempt.body["reasoning"]).get("effort") + + +@pytest.mark.parametrize( + ("backend", "mode", "effort", "route"), + ((_OPENAI_GPT, None, "low", "responses"), (_MANTLE_GPT, "chat", "none", "chat")), + ids=("openai-low", "mantle-chat-pin-none"), +) +@pytest.mark.parametrize("placement", ("top-level", "extra-body")) +async def test_an_effort_inside_extra_body_routes_function_tools_like_a_top_level_one( + gateway: Gateway, backend: str, mode: str | None, effort: str, route: Api, placement: str +) -> None: + marker: Final = uuid.uuid4().hex + extra: Final[Mapping[str, JsonValue]] = { + **_NO_RETRIES, + "tools": [dict(_FUNCTION_TOOL)], + **_placed(placement, {"reasoning_effort": effort}), + } + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, backend, mode) + answer: Final = _assert_answered(await _http(gateway, _Call(model, "chat", False, marker), extra), marker) + (attempt,) = _attempts(wire) + assert (attempt.api, _sent_effort(attempt)) == (route, effort), attempt + _assert_logged(answer, model, "success") + + +_AUTO_SUMMARY_LEVELS: Final[Mapping[str, str]] = MappingProxyType( + {"auto-summary-mantle": "auto", "auto-summary-openai": "detailed"} +) +_PLAIN_THINKING: Final[Mapping[str, JsonValue]] = MappingProxyType({"type": "enabled", "budget_tokens": 1024}) + + +def _auto_summary_config(wire: Wire, tmp_path: Path) -> Path: + base: Final = _JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + mantle: Final[dict[str, JsonValue]] = { + "model": _MANTLE_GPT, + "custom_llm_provider": "bedrock_mantle", + "api_base": wire.url + "/openai/v1", + "api_key": _API_KEY, + } + openai_codex: Final[dict[str, JsonValue]] = { + "model": "openai/gpt-5.3-codex", + "api_base": wire.url + "/v1", + "api_key": _API_KEY, + } + config: Final = { + **base, + "model_list": [ + {"model_name": "auto-summary-mantle", "litellm_params": mantle}, + {"model_name": "auto-summary-openai", "litellm_params": openai_codex}, + ], + "router_settings": {**object_value(base["router_settings"]), "num_retries": 0}, + } + path: Final = tmp_path / "deployment-mode-routing-auto-summary.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +async def _ask_for_reasoning(gateway: Gateway, call: _Call) -> _Answer: + if call.endpoint == "messages": + return await _messages_with_thinking(gateway, call.model, call.marker, _PLAIN_THINKING) + return await _http(gateway, call, {**_NO_RETRIES, "reasoning_effort": "low"}) + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +async def test_the_auto_summary_asks_each_target_for_a_level_it_accepts_on_every_endpoint( + gateway: Gateway, tmp_path: Path +) -> None: + endpoints: Final[tuple[Endpoint, ...]] = ("chat", "messages", "responses") + calls: Final = tuple( + _Call(model, endpoint, False, uuid.uuid4().hex) + for model, endpoint in itertools.product(_AUTO_SUMMARY_LEVELS, endpoints) + ) + with wire_server(_upstream()) as wire: + config: Final = _auto_summary_config(wire, tmp_path) + with owned_proxy_process(gateway, tmp_path, {"LITELLM_REASONING_AUTO_SUMMARY": "true"}, config=config) as owned: + for call in calls: + _assert_answered(await _ask_for_reasoning(owned.gateway, call), call.marker) + sent: Final = { + attempt.marker: (attempt.api, object_value(attempt.body["reasoning"]).get("summary")) + for attempt in _attempts(wire) + } + assert sent == {call.marker: ("responses", _AUTO_SUMMARY_LEVELS[call.model]) for call in calls} + + +def _health_upstream(request: Request) -> Reply: + if request.method != "POST": + return Reply(body=b'{"object": "list", "data": []}') + backend: Final = string_value(_JSON_OBJECT.validate_json(request.body)["model"]) + if _api(request.target) == "responses": + return responses_reply("resp_health", backend, "ok", stream=False) + return chat_reply("chatcmpl-health", backend, "ok", stream=False) + + +@pytest.mark.parametrize(("mode", "route"), (("chat", "chat"), (None, "responses")), ids=("chat-pin", "unpinned")) +def test_a_health_check_probes_the_api_its_deployment_sends_chat_to( + gateway: Gateway, mode: str | None, route: Api +) -> None: + with wire_server(_health_upstream) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, _MANTLE_GPT, mode) + response: Final = gateway.request("GET", "/health", params={"model": model}) + probed: Final = tuple(_api(request.target) for request in wire.drain() if request.method == "POST") + assert response.status_code == 200, response.text + assert _JSON_OBJECT.validate_json(response.content)["healthy_count"] == 1, response.text + assert probed == (route,), probed + + +_INPUT_PRICE: Final = 0.001 +_OUTPUT_PRICE: Final = 0.002 +_SPEND_OF_MODEL_GROUP: Final = 'SELECT request_id, spend FROM "LiteLLM_SpendLogs" WHERE model_group = %s' + + +def _priced_chat_pin(scenario: Scenario, wire: Wire) -> str: + return scenario.model( + model=_MANTLE_GPT, + custom_llm_provider="bedrock_mantle", + api_base=_api_base(wire, _MANTLE_GPT), + api_key=_API_KEY, + input_cost_per_token=_INPUT_PRICE, + output_cost_per_token=_OUTPUT_PRICE, + model_info={"mode": "chat"}, + ) + + +def _probe_or_marked(request: Request) -> Reply: + if _is_probe(request): + return _health_upstream(request) + return _upstream()(request) + + +async def _answered_burst(gateway: Gateway, model: str, count: int) -> tuple[tuple[_Call, _Answer], ...]: + calls: Final = tuple(_Call(model, "chat", False, uuid.uuid4().hex) for _ in range(count)) + answers: Final = await _burst(_proxy_url(gateway), gateway.key, calls) + return tuple((call, _assert_answered(answer, call.marker)) for call, answer in zip(calls, answers, strict=True)) + + +async def test_a_custom_priced_chat_pin_bills_its_price_and_leaves_its_unpinned_sibling_on_responses( + gateway: Gateway, +) -> None: + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _priced_chat_pin(scenario, wire) + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + pinned_calls: Final = await _answered_burst(gateway, pinned, 8) + sibling_calls: Final = await _answered_burst(gateway, sibling, 8) + assert _routes(_attempts(wire)) == {call.marker: ("chat",) for call, _ in pinned_calls} | { + call.marker: ("responses",) for call, _ in sibling_calls + } + _, answer = pinned_calls[0] + rows: Final = eventually( + lambda: [ + row for row in read_rows(_SPEND_OF_MODEL_GROUP, (pinned,)) if _logs_answer(row["request_id"], answer) + ], + lambda found: len(found) == 1, + seconds=70, + ) + assert float(str(rows[0]["spend"])) == pytest.approx(5 * _INPUT_PRICE + 3 * _OUTPUT_PRICE), rows + + +async def test_a_health_probe_of_a_custom_priced_chat_pin_leaves_its_unpinned_sibling_on_responses( + gateway: Gateway, +) -> None: + with wire_server(_probe_or_marked) as wire, gateway.scenario() as scenario: + pinned: Final = _priced_chat_pin(scenario, wire) + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + async with httpx.AsyncClient(base_url=_proxy_url(gateway), timeout=60, trust_env=False) as client: + probes: Final = await asyncio.gather( + *( + client.get("/health", params={"model": pinned}, headers={"Authorization": f"Bearer {gateway.key}"}) + for _ in range(6) + ) + ) + probed: Final = tuple( + _api(request.target) + for request in wire.drain() + if request.method == "POST" and _MARKER.search(request.body.decode()) is None + ) + sibling_calls: Final = await _answered_burst(gateway, sibling, 8) + assert _routes(_attempts(wire)) == {call.marker: ("responses",) for call, _ in sibling_calls} + assert [(probe.status_code, _JSON_OBJECT.validate_json(probe.content)["healthy_count"]) for probe in probes] == [ + (200, 1) + ] * 6, [probe.text for probe in probes] + assert probed == ("chat",) * 6, probed + + +_PROBED: Final = "routing-probed-deployment" +_BACKGROUND_HEALTH_CHECKS: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"background_health_checks": True, "health_check_interval": 1} +) + + +def _failing_probes(probes: SimpleQueue[Api]) -> Callable[[Request], Reply]: + respond: Final = _upstream() + + def fail_probe(request: Request) -> Reply: + if not _is_probe(request): + return respond(request) + probes.put(_api(request.target)) + return _error(404, "The model `openai.gpt-5.6-terra` does not exist.", "invalid_request_error") + + return fail_probe + + +def _probed_deployment_config(wire: Wire, tmp_path: Path, mode: str | None, general: Mapping[str, JsonValue]) -> Path: + base: Final = _JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + params: Final[dict[str, JsonValue]] = { + "model": _MANTLE_GPT, + "custom_llm_provider": "bedrock_mantle", + "api_base": wire.url + "/openai/v1", + "api_key": _API_KEY, + } + pin: Final[dict[str, JsonValue]] = {} if mode is None else {"model_info": {"mode": mode}} + config: Final = { + **base, + "model_list": [{"model_name": _PROBED, "litellm_params": params, **pin}], + "general_settings": {**object_value(base["general_settings"]), "store_model_in_db": False, **general}, + "router_settings": { + **object_value(base["router_settings"]), + "disable_cooldowns": False, + "cooldown_time": 60, + "num_retries": 0, + }, + } + path: Final = tmp_path / "deployment-mode-routing-failing-probe.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _drained(probes: SimpleQueue[Api]) -> tuple[Api, ...]: + return tuple(probes.get() for _ in range(probes.qsize())) + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +@pytest.mark.parametrize(("mode", "route"), (("chat", "chat"), (None, "responses")), ids=("chat-pin", "unpinned")) +async def test_a_failing_health_probe_leaves_its_deployment_serving_the_next_requests( + gateway: Gateway, tmp_path: Path, mode: str | None, route: Api +) -> None: + probes: Final[SimpleQueue[Api]] = SimpleQueue() + calls: Final = tuple(_Call(_PROBED, "chat", False, uuid.uuid4().hex) for _ in range(4)) + with wire_server(_failing_probes(probes)) as wire: + config: Final = _probed_deployment_config(wire, tmp_path, mode, {}) + with owned_proxy_process(gateway, tmp_path, {}, config=config) as owned: + health: Final = owned.gateway.request("GET", "/health", params={"model": _PROBED}) + answers: Final = [await _http(owned.gateway, call) for call in calls] + served: Final = _routes(_attempts(wire, skip_probes=True)) + counts: Final = _JSON_OBJECT.validate_json(health.content) + assert (counts["healthy_count"], counts["unhealthy_count"]) == (0, 1), health.text + assert _drained(probes) == (route,) + for call, answer in zip(calls, answers, strict=True): + _assert_answered(answer, call.marker) + assert served == {call.marker: (route,) for call in calls} + + +@pytest.mark.timeout(2 * graceful_stop_seconds() + 120) +async def test_failing_background_health_probes_leave_a_chat_pinned_deployment_serving_requests( + gateway: Gateway, tmp_path: Path +) -> None: + probes: Final[SimpleQueue[Api]] = SimpleQueue() + calls: Final = tuple(_Call(_PROBED, "chat", False, uuid.uuid4().hex) for _ in range(4)) + with wire_server(_failing_probes(probes)) as wire: + config: Final = _probed_deployment_config(wire, tmp_path, "chat", _BACKGROUND_HEALTH_CHECKS) + with owned_proxy_process(gateway, tmp_path, {}, config=config) as owned: + await asyncio.to_thread(eventually, probes.qsize, lambda size: size >= 2, 60) + answers: Final = [await _http(owned.gateway, call) for call in calls] + served: Final = _routes(_attempts(wire, skip_probes=True)) + probed: Final = _drained(probes) + assert len(probed) >= 2 and set(probed) == {"chat"}, probed + for call, answer in zip(calls, answers, strict=True): + _assert_answered(answer, call.marker) + assert served == {call.marker: ("chat",) for call in calls} + + +@pytest.mark.parametrize( + "extra_body", + ( + {"reasoning_effort": "low", "reasoningSummary": "auto"}, + {"reasoning_effort": {"effort": "low"}, "reasoning_summary": "auto"}, + ), + ids=("string-effort", "object-effort"), +) +@pytest.mark.parametrize( + ("backend", "mode"), ((_OPENAI_GPT, None), (_MANTLE_GPT, "chat")), ids=("openai", "mantle-chat-pin") +) +async def test_a_summary_alias_beside_an_extra_body_effort_reaches_the_responses_api( + gateway: Gateway, extra_body: Mapping[str, JsonValue], backend: str, mode: str | None +) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire, backend, mode) + answer: Final = _assert_answered( + await _http(gateway, _Call(model, "chat", False, marker), {**_NO_RETRIES, "extra_body": dict(extra_body)}), + marker, + ) + (attempt,) = _attempts(wire) + assert (attempt.api, attempt.body.get("reasoning")) == ("responses", {"effort": "low", "summary": "auto"}), ( + attempt + ) + _assert_logged(answer, model, "success") + + +@pytest.mark.parametrize("placement", ("top-level", "extra-body")) +async def test_a_mantle_chat_pin_rejects_n_above_one_before_the_upstream(gateway: Gateway, placement: str) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + answer: Final = await _http( + gateway, _Call(pinned, "chat", False, marker), {**_NO_RETRIES, **_placed(placement, {"n": 2})} + ) + assert answer.status == 400, answer.text + assert "does not support n=2" in answer.text and "drop_params" in answer.text, answer.text + assert _attempts(wire) == () + _assert_logged(answer, pinned, "failure") + + +@pytest.mark.parametrize("placement", ("top-level", "extra-body")) +async def test_a_mantle_chat_pin_drops_n_above_one_under_drop_params(gateway: Gateway, placement: str) -> None: + marker: Final = uuid.uuid4().hex + extra: Final[Mapping[str, JsonValue]] = {**_NO_RETRIES, **_placed(placement, {"n": 2}), "drop_params": True} + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + answer: Final = _assert_answered(await _http(gateway, _Call(pinned, "chat", False, marker), extra), marker) + (attempt,) = _attempts(wire) + assert attempt.api == "chat" and "n" not in attempt.body, attempt + _assert_logged(answer, pinned, "success") + + +@pytest.mark.parametrize("placement", ("top-level", "extra-body")) +async def test_a_no_provider_mantle_chat_pin_sends_an_effort_object_as_its_level( + gateway: Gateway, placement: str +) -> None: + marker: Final = uuid.uuid4().hex + effort: Final[Mapping[str, JsonValue]] = {"reasoning_effort": {"effort": "low"}} + extra: Final[Mapping[str, JsonValue]] = {**_NO_RETRIES, **_placed(placement, effort)} + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat", provider_pin=False) + answer: Final = _assert_answered(await _http(gateway, _Call(pinned, "chat", False, marker), extra), marker) + (attempt,) = _attempts(wire) + assert (attempt.api, attempt.body.get("reasoning_effort")) == ("chat", "low"), attempt + _assert_logged(answer, pinned, "success") + + +@pytest.mark.parametrize( + ("raw_model_info", "own_route"), + ( + ('{"mode": ""}', "responses"), + ('{"mode": "embedding"}', "responses"), + ('{"mode": null}', "responses"), + ('{"mode": 5}', None), + ('{"mode": ["chat"]}', None), + ('{"mode": "' + "c" * 5000 + '"}', "responses"), + ('{"mode": "responses", "mode": "chat"}', "chat"), + ), + ids=("empty", "unroutable", "null", "int", "list", "5kb", "twice"), +) +async def test_a_malformed_mode_never_moves_an_unpinned_sibling( + gateway: Gateway, raw_model_info: str, own_route: Api | None +) -> None: + odd_marker: Final = uuid.uuid4().hex + sibling_markers: Final = tuple(uuid.uuid4().hex for _ in range(4)) + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + odd: Final = _raw_model_info_deployment(scenario, wire, raw_model_info) + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + if own_route is not None: + _assert_answered(await _http(gateway, _Call(odd, "chat", False, odd_marker)), odd_marker) + for marker in sibling_markers: + _assert_answered(await _http(gateway, _Call(sibling, "chat", False, marker)), marker) + own: Final[dict[str, tuple[Api, ...]]] = {} if own_route is None else {odd_marker: (own_route,)} + assert _routes(_attempts(wire)) == own | {marker: ("responses",) for marker in sibling_markers} + + +async def test_a_bridged_sibling_reports_the_upstream_401_after_one_attempt(gateway: Gateway) -> None: + marker: Final = uuid.uuid4().hex + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + _deployment(scenario, wire, _MANTLE_GPT, "chat") + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None, api_key="wrong-bearer") + answer: Final = await _http(gateway, _Call(sibling, "chat", False, marker)) + assert answer.status == 401, answer.text + assert "Invalid bearer token." in answer.text, answer.text + assert _routes(_attempts(wire)) == {marker: ("responses",)} + _assert_logged(answer, sibling, "failure") + + +async def test_adding_and_deleting_a_chat_pin_under_traffic_never_moves_the_sibling(gateway: Gateway) -> None: + with wire_server(_upstream()) as wire, gateway.scenario() as scenario: + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + before: Final = _waves(sibling, 12) + during: Final = _waves(sibling, 12) + after: Final = _waves(sibling, 12) + first: Final = asyncio.create_task(_burst(_proxy_url(gateway), gateway.key, before)) + with gateway.scenario() as pin_scenario: + await asyncio.to_thread(_deployment, pin_scenario, wire, _MANTLE_GPT, "chat") + answered_before: Final = await first + answered_during: Final = await _burst(_proxy_url(gateway), gateway.key, during) + answered_after: Final = await _burst(_proxy_url(gateway), gateway.key, after) + calls: Final = (*before, *during, *after) + answers: Final = tuple( + _assert_answered(answer, call.marker) + for call, answer in zip(calls, (*answered_before, *answered_during, *answered_after), strict=True) + ) + assert _routes(_attempts(wire)) == {call.marker: ("responses",) for call in calls} + for answer in answers: + _assert_logged(answer, sibling, "success") + + +async def test_an_upstream_outage_mid_burst_reaches_callers_and_routing_recovers(gateway: Gateway) -> None: + down: Final = threading.Event() + with wire_server(_upstream(down)) as wire, gateway.scenario() as scenario: + pinned: Final = _deployment(scenario, wire, _MANTLE_GPT, "chat") + sibling: Final = _deployment(scenario, wire, _MANTLE_GPT, None) + outage: Final = (*_waves(pinned, 10), *_waves(sibling, 10)) + recovery: Final = (*_waves(pinned, 10), *_waves(sibling, 10)) + down.set() + failed: Final = await _burst(_proxy_url(gateway), gateway.key, outage) + down.clear() + recovered: Final = tuple( + _assert_answered(answer, call.marker) + for call, answer in zip(recovery, await _burst(_proxy_url(gateway), gateway.key, recovery), strict=True) + ) + for call, answer in zip(outage, failed, strict=True): + assert answer is not None and answer.status == 503, (call, answer) + assert "The server is overloaded." in answer.text, answer.text + _assert_logged(answer, call.model, "failure") + assert _routes(_attempts(wire)) == { + call.marker: (_expected_route(call, pinned),) for call in (*outage, *recovery) + } + for call, answer in zip(recovery, recovered, strict=True): + _assert_logged(answer, call.model, "success") + + +def _yaml_config(wire: Wire, tmp_path: Path, order: tuple[str, ...], label: str) -> Path: + base: Final = _JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + params: Final[dict[str, JsonValue]] = { + "model": _MANTLE_GPT, + "custom_llm_provider": "bedrock_mantle", + "api_base": wire.url + "/openai/v1", + "api_key": _API_KEY, + } + entries: Final[Mapping[str, dict[str, JsonValue]]] = { + _PINNED: {"model_name": _PINNED, "litellm_params": params, "model_info": {"mode": "chat"}}, + _SIBLING: {"model_name": _SIBLING, "litellm_params": params}, + } + config: Final = { + **base, + "model_list": [entries[name] for name in order], + "router_settings": {**object_value(base["router_settings"]), "num_retries": 0}, + } + path: Final = tmp_path / f"deployment-mode-routing-{label}.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _holding_upstream(held: SimpleQueue[str], release: threading.Event) -> Callable[[Request], Reply]: + respond: Final = _upstream() + + def hold(request: Request) -> Reply: + if request.method == "POST": + held.put(_marker_in(request.body.decode())) + assert release.wait(timeout=60), "The burst was never released" + return respond(request) + + return hold + + +def _held_upstream_calls(pid: int, wire: Wire) -> int: + port: Final = urlsplit(wire.url).port + return sum( + 1 + for connection in psutil.Process(pid).net_connections(kind="tcp") + if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port + ) + + +async def _follow_up(gateway: Gateway) -> tuple[_Call, ...]: + calls: Final = (*_waves(_PINNED, 6), *_waves(_SIBLING, 6)) + answers: Final = await _burst(_proxy_url(gateway), gateway.key, calls) + for call, answer in zip(calls, answers, strict=True): + _assert_logged(_assert_answered(answer, call.marker), call.model, "success") + return calls + + +@pytest.mark.timeout(5 * graceful_stop_seconds() + 120) +async def test_a_worker_kill_and_a_restart_in_either_order_keep_each_deployment_on_its_mode( + gateway: Gateway, tmp_path: Path +) -> None: + held: Final[SimpleQueue[str]] = SimpleQueue() + release: Final = threading.Event() + burst_calls: Final = (*_waves(_PINNED, 10), *_waves(_SIBLING, 10)) + with wire_server(_holding_upstream(held, release)) as wire: + sibling_first: Final = _yaml_config(wire, tmp_path, (_SIBLING, _PINNED), "sibling-first") + with owned_proxy_process(gateway, tmp_path, {}, config=sibling_first, workers=2) as owned: + workers: Final = eventually( + lambda: tuple(int(found.group(1)) for found in _STARTED_WORKER.finditer(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + burst: Final = asyncio.create_task( + _burst(_proxy_url(owned.gateway), owned.gateway.key, burst_calls, tolerate_drops=True) + ) + await asyncio.to_thread(eventually, held.qsize, lambda size: size == len(burst_calls), 60) + held_by_worker: Final = MappingProxyType({pid: _held_upstream_calls(pid, wire) for pid in workers}) + assert sum(held_by_worker.values()) == len(burst_calls), held_by_worker + victim: Final = max(workers, key=held_by_worker.__getitem__) + with owned.replacing_worker(victim) as kill: + kill.freeze() + await asyncio.to_thread(kill) + release.set() + served: Final = await burst + survivors: Final = tuple( + _assert_answered(answer, call.marker) + for call, answer in zip(burst_calls, served, strict=True) + if answer is not None + ) + assert len(survivors) == len(burst_calls) - held_by_worker[victim], (held_by_worker, served) + after_kill: Final = await _follow_up(owned.gateway) + pinned_first: Final = _yaml_config(wire, tmp_path, (_PINNED, _SIBLING), "pinned-first") + with owned_proxy_process(gateway, tmp_path, {}, config=pinned_first, workers=2) as restarted: + after_restart: Final = await _follow_up(restarted.gateway) + assert _routes(_attempts(wire)) == { + call.marker: (_expected_route(call, _PINNED),) for call in (*burst_calls, *after_kill, *after_restart) + } diff --git a/tests/integration/sdk/test_bedrock_converse_stream_sync_decoder_wire.py b/tests/integration/sdk/test_bedrock_converse_stream_sync_decoder_wire.py index 90acdd774d0..87efbc5529e 100644 --- a/tests/integration/sdk/test_bedrock_converse_stream_sync_decoder_wire.py +++ b/tests/integration/sdk/test_bedrock_converse_stream_sync_decoder_wire.py @@ -98,3 +98,17 @@ def test_k03_sync_stream_with_normal_frames_delivers_the_text_and_a_stop() -> No assert consumed.text == _ANSWER, consumed assert consumed.finish_reasons[-1] == "stop", consumed assert len(wire.drain()) == 1 + + +def test_k04_sync_stream_open_400_carries_the_bedrock_body_as_text() -> None: + body: Final = '{"message":"The request is invalid."}' + + def reject(_: Request) -> Reply: + return Reply(status=400, body=body.encode()) + + with wire_server(reject) as wire: + with pytest.raises(BadRequestError) as raised: + _consume_sync_stream(wire) + assert raised.value.status_code == 400, raised.value + assert f"BedrockException - {body}" in raised.value.message, raised.value.message + assert len(wire.drain()) == 1 diff --git a/tests/integration/sdk/test_bedrock_native_output_fallback_sdk.py b/tests/integration/sdk/test_bedrock_native_output_fallback_sdk.py new file mode 100644 index 00000000000..745c1ceb603 --- /dev/null +++ b/tests/integration/sdk/test_bedrock_native_output_fallback_sdk.py @@ -0,0 +1,336 @@ +import json +import re +import uuid +from collections.abc import Mapping +from typing import Final + +import boto3 +import pytest +from integration._support.client import list_value, object_value, string_value +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +import litellm +from litellm import CustomStreamWrapper, ModelResponse +from litellm.types.utils import Choices, StreamingChoices + +_OPUS_4_8: Final = "us.anthropic.claude-opus-4-8" +_JSON_TOOL: Final = "json_tool_call" +_MARKER: Final = re.compile(r"probe ([0-9a-f]{32})") +_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted" +_SCHEMA: Final = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, +} +_JSON: Final = TypeAdapter(dict[str, JsonValue]) +_ROUTES: Final = pytest.mark.parametrize( + ("route", "stream"), + [("converse", False), ("converse", True), ("invoke", False)], + ids=["converse", "converse_stream", "invoke"], +) + + +@pytest.fixture +def marker_hex() -> str: + return uuid.uuid4().hex + + +def _is_invoke(request: Request) -> bool: + return "/invoke" in request.target + + +def _native(body: Mapping[str, JsonValue], invoke: bool) -> bool: + output_config: Final = body.get("output_config" if invoke else "outputConfig") + return isinstance(output_config, dict) and ("format" if invoke else "textFormat") in output_config + + +def _tool_names(body: Mapping[str, JsonValue], invoke: bool) -> frozenset[str]: + if invoke: + return frozenset(string_value(object_value(tool)["name"]) for tool in list_value(body.get("tools", []))) + tools: Final = list_value(object_value(body.get("toolConfig") or {"tools": []})["tools"]) + return frozenset(string_value(object_value(object_value(tool)["toolSpec"])["name"]) for tool in tools) + + +def _tool_reply(invoke: bool, answer: Mapping[str, JsonValue]) -> Reply: + if invoke: + message: Final = { + "id": "msg_scripted", + "type": "message", + "role": "assistant", + "model": "claude-scripted", + "content": [{"type": "tool_use", "id": "toolu_scripted", "name": _JSON_TOOL, "input": answer}], + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 11, "output_tokens": 7}, + } + return Reply(body=json.dumps(message).encode()) + converse: Final = { + "output": { + "message": { + "role": "assistant", + "content": [{"toolUse": {"toolUseId": "tooluse_scripted", "name": _JSON_TOOL, "input": answer}}], + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 11, "outputTokens": 7, "totalTokens": 18}, + "metrics": {"latencyMs": 1}, + } + return Reply(body=json.dumps(converse).encode()) + + +def _bedrock_without_native_output(request: Request) -> Reply: + body: Final = _JSON.validate_json(request.body) + invoke: Final = _is_invoke(request) + if _native(body, invoke): + return Reply(status=400, body=json.dumps({"message": _REJECTION}).encode()) + found: Final = _MARKER.search(json.dumps(body)) + assert found is not None and _JSON_TOOL in _tool_names(body, invoke), body + return _tool_reply(invoke, {"city": found.group(1)}) + + +def _request(route: str, stream: bool, wire: Wire, marker_hex: str) -> Mapping[str, object]: + return { + "model": f"bedrock/{route}/{_OPUS_4_8}", + "messages": [{"role": "user", "content": f"Name a city. probe {marker_hex}"}], + "api_base": wire.url, + "api_key": "synthetic-bedrock-bearer", + "aws_region_name": "us-east-1", + "max_tokens": 2048, + "thinking": {"type": "adaptive"}, + "response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": _SCHEMA}}, + "num_retries": 0, + "stream": stream, + } + + +def _message_content(response: ModelResponse) -> str: + (choice,) = response.choices + assert isinstance(choice, Choices), response + return string_value(choice.message.content) + + +def _delta_content(chunk: ModelResponse) -> str: + (choice,) = chunk.choices + assert isinstance(choice, StreamingChoices), chunk + return choice.delta.content or "" + + +def _assert_one_retry_without_native_output(wire: Wire, route: str) -> None: + rejected, retried = (_JSON.validate_json(request.body) for request in wire.drain()) + invoke: Final = route == "invoke" + assert _native(rejected, invoke), rejected + assert not _native(retried, invoke), retried + assert _JSON_TOOL in _tool_names(retried, invoke), retried + + +@_ROUTES +def test_sync_call_retries_a_native_output_rejection_once_with_the_json_tool( + route: str, stream: bool, marker_hex: str +) -> None: + with wire_server(_bedrock_without_native_output) as wire: + response: Final = litellm.completion(**_request(route, stream, wire, marker_hex)) + content: Final = ( + "".join(_delta_content(chunk) for chunk in response) + if isinstance(response, CustomStreamWrapper) + else _message_content(response) + ) + assert json.loads(content) == {"city": marker_hex} + _assert_one_retry_without_native_output(wire, route) + + +@_ROUTES +async def test_async_call_retries_a_native_output_rejection_once_with_the_json_tool( + route: str, stream: bool, marker_hex: str +) -> None: + with wire_server(_bedrock_without_native_output) as wire: + response: Final = await litellm.acompletion(**_request(route, stream, wire, marker_hex)) + content: Final = ( + "".join([_delta_content(chunk) async for chunk in response]) + if isinstance(response, CustomStreamWrapper) + else _message_content(response) + ) + assert json.loads(content) == {"city": marker_hex} + _assert_one_retry_without_native_output(wire, route) + + +_CONSTRAINED_SCHEMA: Final = { + "type": "object", + "properties": { + "city": {"type": "string", "maxLength": 20}, + "population": {"type": "integer", "minimum": 0}, + "districts": {"type": "array", "items": {"type": "string"}, "minItems": 2, "uniqueItems": True}, + "code": {"oneOf": [{"type": "string"}, {"type": "integer"}]}, + }, + "required": ["city", "population", "districts", "code"], + "additionalProperties": False, +} + + +def _response_format(schema: Mapping[str, object]) -> Mapping[str, object]: + return {"type": "json_schema", "json_schema": {"name": "city", "schema": schema}} + + +def _consume(response: ModelResponse | CustomStreamWrapper) -> None: + if isinstance(response, CustomStreamWrapper): + tuple(response) + + +def _sent_bodies( + route: str, + stream: bool, + marker_hex: str, + request_params: Mapping[str, object], + model_info: Mapping[str, object] | None, +) -> tuple[Mapping[str, JsonValue], ...]: + with wire_server(_bedrock_without_native_output) as wire: + request: Final = { + **_request(route, stream, wire, marker_hex), + **{"thinking": None, "response_format": None}, + **request_params, + **({} if model_info is None else {"model_info": model_info}), + } + _consume(litellm.completion(**{key: value for key, value in request.items() if value is not None})) + return tuple(_JSON.validate_json(sent.body) for sent in wire.drain()) + + +@_ROUTES +@pytest.mark.parametrize( + "request_params", + [ + {"reasoning_effort": "low", "response_format": _response_format(_SCHEMA)}, + {"thinking": {"type": "adaptive"}, "response_format": _response_format(_CONSTRAINED_SCHEMA)}, + { + "thinking": {"type": "adaptive"}, + "response_format": _response_format(_SCHEMA), + "output_config": {"format": {"type": "json_schema", "schema": _SCHEMA}}, + }, + { + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "low"}, + "response_format": _response_format(_SCHEMA), + }, + ], + ids=[ + "reasoning_effort", + "constrained_schema", + "response_format_and_output_config_format", + "adaptive_thinking_with_effort", + ], +) +def test_the_retry_sends_what_a_deployment_without_native_output_sends( + route: str, stream: bool, marker_hex: str, request_params: Mapping[str, object] +) -> None: + rejected, retried = _sent_bodies(route, stream, marker_hex, request_params, None) + (opted_out,) = _sent_bodies( + route, stream, marker_hex, request_params, {"supports_bedrock_native_structured_output": False} + ) + + assert _native(rejected, route == "invoke"), rejected + assert retried == opted_out + + +@_ROUTES +def test_the_retry_keeps_the_callers_continuation_message(route: str, stream: bool, marker_hex: str) -> None: + request_params: Final = { + "thinking": {"type": "adaptive"}, + "response_format": _response_format(_SCHEMA), + "messages": [ + {"role": "user", "content": f"Name a city. probe {marker_hex}"}, + {"role": "assistant", "content": "Which country should it be in?"}, + ], + "user_continue_message": {"role": "user", "content": "Any country in South America"}, + } + + rejected, retried = _sent_bodies(route, stream, marker_hex, request_params, None) + (opted_out,) = _sent_bodies( + route, stream, marker_hex, request_params, {"supports_bedrock_native_structured_output": False} + ) + + assert _native(rejected, route == "invoke"), rejected + assert retried == opted_out + + +_MAX_LENGTH_ONE_OF_SCHEMA: Final = { + "type": "object", + "properties": { + "city": {"type": "string", "maxLength": 20}, + "code": {"oneOf": [{"type": "string"}, {"type": "integer"}]}, + }, + "required": ["city", "code"], + "additionalProperties": False, +} + + +def _messages_bedrock_without_native_output(request: Request) -> Reply: + if not _is_invoke(request) or _native(_JSON.validate_json(request.body), invoke=True): + return _bedrock_without_native_output(request) + message: Final = { + "id": "msg_scripted", + "type": "message", + "role": "assistant", + "model": "claude-scripted", + "content": [{"type": "text", "text": '{"city": "Paris"}'}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 11, "output_tokens": 7}, + } + return Reply(body=json.dumps(message).encode()) + + +async def _messages_bodies( + route: str, marker_hex: str, model_info: Mapping[str, object] | None +) -> tuple[Mapping[str, JsonValue], ...]: + with wire_server(_messages_bedrock_without_native_output) as wire: + await litellm.anthropic_messages( + model=f"bedrock/{route}/{_OPUS_4_8}", + messages=[{"role": "user", "content": f"Name a city. probe {marker_hex}"}], + api_base=wire.url, + api_key="synthetic-bedrock-bearer", + aws_region_name="us-east-1", + max_tokens=2048, + thinking={"type": "adaptive"}, + output_config={"effort": "low", "format": {"type": "json_schema", "schema": _MAX_LENGTH_ONE_OF_SCHEMA}}, + **({} if model_info is None else {"model_info": model_info}), + ) + return tuple(_JSON.validate_json(sent.body) for sent in wire.drain()) + + +@pytest.mark.parametrize("route", ["converse", "invoke"]) +async def test_a_messages_retry_sends_what_a_deployment_without_native_output_sends( + route: str, marker_hex: str +) -> None: + rejected, retried = await _messages_bodies(route, marker_hex, None) + (opted_out,) = await _messages_bodies(route, marker_hex, {"supports_bedrock_native_structured_output": False}) + + assert _native(rejected, invoke=route == "invoke"), rejected + assert retried == opted_out + + +@_ROUTES +def test_the_retry_signs_with_the_credentials_of_a_deprecated_aws_bedrock_client( + route: str, stream: bool, marker_hex: str +) -> None: + aws_bedrock_client: Final = boto3.client( + "bedrock-runtime", + region_name="us-west-2", + aws_access_key_id="AKIASYNTHETICCLIENT", + aws_secret_access_key="synthetic-client-secret", + aws_session_token="synthetic-client-session", + ) + with wire_server(_bedrock_without_native_output) as wire: + request: Final = { + key: value + for key, value in _request(route, stream, wire, marker_hex).items() + if key not in {"api_key", "aws_region_name"} + } + _consume(litellm.completion(**request, aws_bedrock_client=aws_bedrock_client)) + sent: Final = wire.drain() + + assert len(sent) == 2 + for attempt in sent: + headers = {name.lower(): value for name, value in attempt.headers.items()} + assert "Credential=AKIASYNTHETICCLIENT/" in headers["authorization"] + assert "/us-west-2/bedrock/aws4_request" in headers["authorization"] + assert headers["x-amz-security-token"] == "synthetic-client-session" diff --git a/tests/integration/sdk/test_stream_error_api_base_sdk.py b/tests/integration/sdk/test_stream_error_api_base_sdk.py new file mode 100644 index 00000000000..922e518c7bf --- /dev/null +++ b/tests/integration/sdk/test_stream_error_api_base_sdk.py @@ -0,0 +1,69 @@ +import json +from collections.abc import AsyncIterable, AsyncIterator +from typing import Final + +import openai +from integration._support.anthropic_sse import dropping_reply +from integration._support.wire import Reply, Request, wire_server + +import litellm +from litellm.exceptions import MidStreamFallbackError + +_PARTIAL_TEXT: Final = "partial answer" + + +def _chunk(content: str) -> bytes: + body: Final = { + "id": "chatcmpl-sdk-cut", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": content}, "finish_reason": None}], + } + return b"data: " + json.dumps(body).encode() + b"\n\n" + + +def _cut_after_text(request: Request) -> Reply: + assert request.method == "POST" and "/chat/completions" in request.target, request.target + return dropping_reply((_chunk(_PARTIAL_TEXT), _chunk(" never sent")), abort_after=1) + + +def _text(item: object) -> str: + if not isinstance(item, litellm.ModelResponseStream): + return "" + return "".join(choice.delta.content or "" for choice in item.choices) + + +async def _items_or_error(stream: AsyncIterable[object]) -> AsyncIterator[object]: + try: + async for item in stream: + yield item + except openai.APIError as error: + yield error + + +async def test_sdk_mid_stream_failure_debug_info_names_the_api_base_it_called() -> None: + with wire_server(_cut_after_text) as wire: + response: Final = await litellm.acompletion( + model="azure/gpt-4o-mini", + api_base=wire.url, + api_key="synthetic-azure-key", + api_version="2025-04-01-preview", + messages=[{"role": "user", "content": "stream an answer"}], + stream=True, + num_retries=0, + ) + assert isinstance(response, AsyncIterable), response + items: Final = [item async for item in _items_or_error(response)] + errors: Final = [item for item in items if isinstance(item, openai.APIError)] + assert len(errors) == 1 and items[-1] is errors[0], items + assert "".join(_text(item) for item in items) == _PARTIAL_TEXT, items + error: Final = errors[0] + mapped: Final = ( + error.original_exception + if isinstance(error, MidStreamFallbackError) and error.original_exception is not None + else error + ) + debug_info: Final = getattr(mapped, "litellm_debug_info", None) + assert isinstance(debug_info, str), mapped + assert f"\nAPI Base: `{wire.url}`" in debug_info, debug_info diff --git a/tests/integration/spend/test_background_response_billing_proxies.py b/tests/integration/spend/test_background_response_billing_proxies.py index e83f2629f33..4b4185f5da2 100644 --- a/tests/integration/spend/test_background_response_billing_proxies.py +++ b/tests/integration/spend/test_background_response_billing_proxies.py @@ -549,7 +549,7 @@ def _cache_polled_create(proxy: Gateway, key: str, model: str, create: Mapping[s @pytest.mark.timeout(OWNED_PROXY_SECONDS + 120) @pytest.mark.parametrize("create", CACHE_POLLED_CREATES.keys()) -def test_a_background_response_the_proxy_polls_through_its_cache_bills_its_creator_once( +def test_a_background_response_polled_through_the_cache_reports_its_alias_and_bills_its_creator_once( gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, create: str ) -> None: body, provider_sees_background = CACHE_POLLED_CREATES[create] @@ -564,6 +564,8 @@ def test_a_background_response_the_proxy_polls_through_its_cache_bills_its_creat seconds=SPEND_RETRY_SECONDS, ) assert finished.text == "pong" + polled: Final = object_value(proxy.request("GET", f"/v1/responses/{polling_id}", key=key).json()) + assert polled["model"] == handle.scenario_id assert_charged_exactly(lambda: key_spend(key), PRICE) assert [read_response(proxy, key, polling_id).text for _ in range(READS)] == ["pong"] * READS assert_charged_exactly(lambda: key_spend(key), PRICE) diff --git a/tests/integration/spend/test_cache_and_quota.py b/tests/integration/spend/test_cache_and_quota.py index 801a14c87d3..511a812e895 100644 --- a/tests/integration/spend/test_cache_and_quota.py +++ b/tests/integration/spend/test_cache_and_quota.py @@ -453,9 +453,11 @@ def test_in_flight_count_tokens_does_not_reserve_key_budget_away_from_a_completi [{"role": "user", "content": prompt}] ] assert count.status_code == 200, count.text - assert count.json() == {"totalTokens": 12, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12}]}, ( - count.text - ) + assert count.json() == { + "totalTokens": 12, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 12}], + "litellm_count_source": "provider", + }, count.text provider_calls: Final = wire.drain() assert [(call.method, call.target) for call in provider_calls] == [ ("POST", "/v1beta/models/gemini-3.8-flash:countTokens") diff --git a/tests/integration/streaming/test_interception_converted_stream_options.py b/tests/integration/streaming/test_interception_converted_stream_options.py index b9d39b02afd..83a3af06355 100644 --- a/tests/integration/streaming/test_interception_converted_stream_options.py +++ b/tests/integration/streaming/test_interception_converted_stream_options.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path from queue import SimpleQueue from types import MappingProxyType -from typing import Final +from typing import Final, Literal, TypeAlias from urllib.parse import urlsplit import anthropic @@ -549,6 +549,75 @@ def test_converted_chat_stream_with_usage_replays_the_single_call_usage( _assert_landed_once(deployment.model, frozenset({identity}), rows=1) +_SLOW_REPLY_SECONDS: Final = 3.0 +_SHORT_TIMEOUT_SECONDS: Final = 1 +_LONG_STREAM_TIMEOUT_SECONDS: Final = 10 +_MESSAGES_SEARCH_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"name": iv.SEARCH_TOOL, "description": "Search the web", "input_schema": dict(_PARAMETERS)} +) +_MESSAGES_LOOKUP_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"name": "lookup", "description": "Look up an order", "input_schema": dict(_PARAMETERS)} +) + +_ConvertedEndpoint: TypeAlias = Literal["chat", "messages"] + + +def _slowly(request: Request) -> Reply: + threading.Event().wait(_SLOW_REPLY_SECONDS) + return iv.respond(request) + + +def _messages_text(stream: _Stream) -> str: + if stream.text.startswith("{"): + blocks: Final = rv.ITEMS.validate_python(iv.JSON_OBJECT.validate_json(stream.text)["content"]) + return "".join(string_value(block["text"]) for block in blocks if block.get("type") == "text") + deltas: Final = tuple( + object_value(frame["delta"]) for frame in stream.frames if frame.get("type") == "content_block_delta" + ) + return "".join(string_value(delta["text"]) for delta in deltas if delta.get("type") == "text_delta") + + +def _converted_reply_text(rig: _Rig, endpoint: _ConvertedEndpoint, deployment: _Deployment) -> str: + if endpoint == "chat": + stream: Final = _chat(rig, _chat_body(deployment)) + _assert_restreamed(stream, iv.plain(deployment.name), usage=None) + return stream.content + messages: Final = _post( + rig, + "/v1/messages", + { + "model": deployment.model, + "max_tokens": 64, + "messages": [{"role": "user", "content": f"Search for {deployment.name}"}], + "tools": [dict(_MESSAGES_SEARCH_TOOL), dict(_MESSAGES_LOOKUP_TOOL)], + "stream": True, + **_NO_CACHE, + }, + ) + assert messages.status == 200, messages.text + return _messages_text(messages) + + +@pytest.mark.parametrize("endpoint", ("chat", "messages")) +def test_a_converted_stream_slower_than_the_deployment_timeout_is_answered_within_its_stream_timeout( + rig: _Rig, scenario: Scenario, endpoint: _ConvertedEndpoint +) -> None: + with wire_server(_slowly) as slow: + name: Final = iv.deployment("plain") + model: Final = scenario.model( + model=f"azure/{name}", + api_base=slow.url, + api_key=iv.AZURE_KEY, + api_version=iv.API_VERSION, + timeout=_SHORT_TIMEOUT_SECONDS, + stream_timeout=_LONG_STREAM_TIMEOUT_SECONDS, + ) + assert _converted_reply_text(rig, endpoint, _Deployment(model, name)) == iv.plain(name) + received: Final = iv.received(slow.drain(), name) + _assert_upstream(received, model_calls=1, searches=0) + assert not any(call.get("stream") for call in received.model_calls), received.model_calls + + def test_anthropic_sdk_messages_stream_with_only_web_search_on_azure_answers_from_the_search( rig: _Rig, scenario: Scenario ) -> None: diff --git a/tests/integration/translation/chat_completions/bases/anthropic.py b/tests/integration/translation/chat_completions/bases/anthropic.py index 16e1de53975..3a148c64859 100644 --- a/tests/integration/translation/chat_completions/bases/anthropic.py +++ b/tests/integration/translation/chat_completions/bases/anthropic.py @@ -42,7 +42,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011CfgDzBnuPYc7TdysZFRpo", + "id": "msg_011Cg7v1TRmiNAjM2RRxcxF8", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -58,6 +58,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -93,6 +94,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "not_available", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -123,7 +125,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CfgDzH3AcaSNXtvurLgAG", + "id": "msg_011Cg7v28wK6VaszM4tL4D6n", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -139,6 +141,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -174,6 +177,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -223,6 +227,7 @@ CLAUDE_FABLE_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -272,6 +277,7 @@ CLAUDE_FABLE_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -321,6 +327,7 @@ CLAUDE_OPUS_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -370,6 +377,7 @@ CLAUDE_OPUS_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -400,7 +408,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-5", - "id": "msg_011CfgDzPkj2HxBq8qGJsGSi", + "id": "msg_011Cg7v2SdRUNPAvGScTF7au", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -417,6 +425,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -452,6 +461,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -482,7 +492,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfgDzV9vZCkuQJt15n3Z1", + "id": "msg_011Cg7v1hPQMJ1QhaC8HaSfQ", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -499,6 +509,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -534,6 +545,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -549,8 +561,16 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( "content-type": "application/json", }, expected_provider_request={**ANTHROPIC_MESSAGES_REQUEST, "model": "claude-sonnet-5-5"}, - mock_provider_response={**ANTHROPIC_MESSAGES_RESPONSE, "model": "claude-sonnet-5-5"}, - expected_litellm_response={**CHAT_COMPLETIONS_RESPONSE, "model": "anthropic/claude-sonnet-5-5"}, + mock_provider_response={ + **ANTHROPIC_MESSAGES_RESPONSE, + "model": "claude-sonnet-5-5", + "usage": {**object_value(ANTHROPIC_MESSAGES_RESPONSE["usage"]), "speed": "standard"}, + }, + expected_litellm_response={ + **CHAT_COMPLETIONS_RESPONSE, + "model": "anthropic/claude-sonnet-5-5", + "usage": {**object_value(CHAT_COMPLETIONS_RESPONSE["usage"]), "speed": "standard"}, + }, ) @@ -618,6 +638,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 149}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -641,7 +662,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( }, "id": "toolu_015ndrvU4VqqoMBzK35jt4TX", "type": "function", - }, + } ], "reasoning_content": "There's a mention of a chat_completions client I don't actually have access " "to, so I'll ignore that and stick to my available tools. I'll proceed by " @@ -658,7 +679,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "examine next.\n" "\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], "provider_specific_fields": { "citations": None, @@ -672,11 +693,11 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "deciding what to examine next.\n" "\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], }, }, - }, + } ], "usage": { "completion_tokens": 288, @@ -694,6 +715,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -757,6 +779,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 66}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -780,7 +803,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( }, "id": "toolu_01Ew2u9E9j2mkT6RbwSw13uR", "type": "function", - }, + } ], "reasoning_content": "I'll focus on reading the validation file first, around line 18, since that's " "what was specifically requested before looking at the tests.\n" @@ -793,7 +816,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "tests.\n" "\n", "signature": CLAUDE_OPUS_5_5_THINKING_SIGNATURE_0, - }, + } ], "provider_specific_fields": { "citations": None, @@ -805,11 +828,11 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "the tests.\n" "\n", "signature": CLAUDE_OPUS_5_5_THINKING_SIGNATURE_0, - }, + } ], }, }, - }, + } ], "usage": { "completion_tokens": 215, @@ -827,6 +850,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -869,6 +893,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -892,11 +917,11 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( }, "id": "toolu_01XMSjzRvHCE753Wx4dU2Vxo", "type": "function", - }, + } ], "provider_specific_fields": {"citations": None, "thinking_blocks": None}, }, - }, + } ], "usage": { "completion_tokens": 143, @@ -914,6 +939,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) diff --git a/tests/integration/translation/chat_completions/bases/azure_ai.py b/tests/integration/translation/chat_completions/bases/azure_ai.py index e3e8a84340b..89c75f3c597 100644 --- a/tests/integration/translation/chat_completions/bases/azure_ai.py +++ b/tests/integration/translation/chat_completions/bases/azure_ai.py @@ -43,7 +43,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011Cfjax6mBrNVhFGQopmVCp", + "id": "msg_011Cg7uyAiPY51HN4rSYzQW5", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -59,6 +59,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -94,6 +95,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "not_available", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -144,6 +146,7 @@ CLAUDE_FABLE_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -193,6 +196,7 @@ CLAUDE_FABLE_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "not_available", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -224,10 +228,10 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CfjaxGKedCbjtb7HuAUBC", + "id": "msg_011Cg7uzQ1rtYRwCd9aeTyoF", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello!"}], + "content": [{"type": "text", "text": "Hello! How can I help you?"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -237,9 +241,10 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, - "output_tokens": 5, + "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -253,17 +258,17 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "finish_reason": "stop", "index": 0, "message": { - "content": "Hello!", + "content": "Hello! How can I help you?", "role": "assistant", "provider_specific_fields": {"citations": None, "thinking_blocks": None}, }, } ], "usage": { - "completion_tokens": 5, + "completion_tokens": 11, "prompt_tokens": 18, - "total_tokens": 23, - "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 5}, + "total_tokens": 29, + "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 11}, "prompt_tokens_details": { "cached_tokens": 0, "text_tokens": 18, @@ -275,6 +280,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "not_available", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -389,7 +395,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfjaxUiYCUsqMC4iqfc2U", + "id": "msg_011Cg7uyf9RNvQWkpJobt6JU", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -406,6 +412,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -441,6 +448,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -674,6 +682,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 167}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -697,7 +706,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( }, "id": "toolu_01M68cqREYA4WqXBUK8rqcEq", "type": "function", - }, + } ], "reasoning_content": "I need to read shop/validation.py next, keeping in mind edge cases like zero, " "negative, or non-integer quantities since the validation logic likely handles those. " @@ -709,7 +718,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "negative, or non-integer quantities since the validation logic likely handles those. " "I'll look at the function starting around line 18 to understand its scope.\n\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], "provider_specific_fields": { "citations": None, @@ -720,11 +729,11 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "negative, or non-integer quantities since the validation logic likely handles those. " "I'll look at the function starting around line 18 to understand its scope.\n\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], }, }, - }, + } ], "usage": { "completion_tokens": 324, @@ -742,6 +751,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "global", "service_tier": "standard", + "speed": "standard", }, }, ) @@ -763,10 +773,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "model": "claude-fable-5-1", "id": "msg_011Cg37NcfzdXibtC3dWhVWi", "content": [ - { - "type": "text", - "text": "I'll read the validation code and the test together since they are independent.", - }, + {"type": "text", "text": "I'll read the validation code and the test together since they are independent."}, { "type": "tool_use", "id": "toolu_01LxkeKf9WX9BVpiWrvfKUyc", @@ -791,6 +798,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, }, expected_litellm_response={ @@ -827,7 +835,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( ], "provider_specific_fields": {"citations": None, "thinking_blocks": None}, }, - }, + } ], "usage": { "completion_tokens": 216, @@ -845,6 +853,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "cache_read_input_tokens": 0, "inference_geo": "not_available", "service_tier": "standard", + "speed": "standard", }, }, ) diff --git a/tests/integration/translation/chat_completions/bases/vertex_ai.py b/tests/integration/translation/chat_completions/bases/vertex_ai.py index d1022da78c7..447566b0ee3 100644 --- a/tests/integration/translation/chat_completions/bases/vertex_ai.py +++ b/tests/integration/translation/chat_completions/bases/vertex_ai.py @@ -577,6 +577,154 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( ) +CLAUDE_HAIKU_5_5_THINKING_SIGNATURE: Final = ( + "CAQS8gcKEggSEAIYAjgBQgh0aGlua2luZxIMKxM+8GdyGfxkJrU6Ggw3AMSc5G/SaulUVJkiMJgPNRytmwmfF4bj7WugkKlRxBr+SRwcL9iQ" + "+S5uvmIi/D8thAXX+oVg67diUo3rxiqNB0KrfSB/DXD50OomDcy90ai3a+RMcwIy9wEMzOdOIFqQ24tUMm1YorcQLQpresEnXA9ItiMAzP+P" + "8NMJ1br7O3JHYLIi/12UzVr36AKPrMJQk86VBn5fVxo0xcphyl071Y30m3wO0INGtU+d37IXSrYCJzQdnUSKxekBK28fxsA4tmxx1BE/1Hfl" + "UTsq36+CE2MatE0ymL2pQA+povoJ1iVlB/PBZvkOvpRcQubSwm8RqJrBfmse8baU2UJot0VNNC7OprQ3wYx1j3BGmXOQCGkvCn6jd2lPLjLS" + "F1CjbWQs7X18gB69y2sUosm3aI+MnQNHkUqe8JtIaii42uu3Xe/JheeRTRrcsuAdMZJ76ID9wRZnf3/QCHaMyrYwHXf5SsoUjX3Ofs5MuQT0" + "ISf4T7q+QGf51Bxb4jdfF879h061ZSejzknCHI3fV3SwuldjIIV+FD50d63iLqM/D8GOjaGfDrYugkkdBjCNEQkTpn9NQmqalsrJWOdQQrhc" + "L8TDZG4HBasd/kGr7EnlEbLKrGaWbAWYo4vssoUE7Q57wPumuMrYh4OKInksQTnB0OV9OIZEiTgb9fyaxJBCUiypuQDGMlAP8nb0TQF1vxjR" + "vsXV/qnN0ovR6/7raePN4NM4nMEXESRQyC22Ml88z+WZhe2VG5CLH8tFNapisH+msyqtGyDa+SEie/e6xcDfI1fLAZwEHM34/YjW+C1E1PPd" + "RsGBE9Ms6JUaPrcITwImVKMLGH1pj+TsS+qK7JTGjH8FKwR1Eds/dJTFDEQaAYcmjXWxDR5HBknx7xv90K2uoKt4KmAgulWCQv/LaEAt27lm" + "aGP+4PQ9tpb/Otsub8spmcXKfNqdjWTDH3aEF5MsJ5eSddn92pFV9JAo4oFcj1fUTujfwumGCYDADjLBmyvQao9S42zpO5idmYrHWHt7h7m6" + "BoU5b4uKVS4w4ELEl8y+Oq1uiQLSB8e0cxLarWfCiiTL6v6aJUDEvUTQU+O3XiOxNMCkc00J7e1TnNnnoFqs0sqhV0mMT346vLHocBMVn2iE" + "ZSwFb9OWlHO1e9Y7ccd8XL7KjXtoZeuBTrdYlxrf3g6QHVZg3OOM3OVSOWawFU0tBZUUgH6rYH4oHh8JqwGmbTI1mtAtUX89KrHzGWgUs619" + "SPbFptVh8Vfb3zr0D0OzDqjzv1KCNW/tZcJnCaL9Zl6tAt2v5FiBTOjIbBgB" +) + +CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( + scenario="basic", + litellm_endpoint="/v1/chat/completions", + litellm_request={**CHAT_COMPLETIONS_REQUEST, "model": "vertex_ai/claude-haiku-5-5@default"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-haiku-5-5@default:rawPredict", + expected_provider_headers={ + "authorization": "Bearer scripted-token", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-api-key": "scripted-token", + }, + expected_provider_request=VERTEX_AI_ANTHROPIC_MESSAGES_REQUEST, + mock_provider_response={ + **VERTEX_AI_ANTHROPIC_MESSAGES_RESPONSE, + "model": "claude-haiku-5-5", + "id": "msg_vrtx_011Cg8AfAmbYAQCTSseeQSZJ", + "content": [ + { + "type": "thinking", + "thinking": ( + "The system prompt mentions a chat_completions client for this review, but that's not actually in my " + "available toolset — I only have search_files and read_file. I'll note that discrepancy and proceed by " + "explaining which file I need, then calling read_file on shop/validation.py, probably starting around the " + "first 60 lines to get context near line 18.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE, + }, + { + "type": "text", + "text": ( + "I'll read `shop/validation.py` next, starting at the `validate_quantity` definition on line 18 and " + "including the surrounding context. The project instructions mention a chat_completions client, but none " + "is available in my tools, so I'm using the read-only file tools." + ), + }, + { + "type": "tool_use", + "id": "toolu_vrtx_017jjdVAoN8bus5ng5NkzafQ", + "name": "read_file", + "input": {"path": "shop/validation.py", "start_line": 1, "end_line": 60}, + }, + ], + "usage": { + "input_tokens": 4, + "cache_creation_input_tokens": 876, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 876, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 348, + "output_tokens_details": {"thinking_tokens": 168}, + }, + }, + expected_litellm_response={ + **CHAT_COMPLETIONS_RESPONSE, + "model": "vertex_ai/claude-haiku-5-5@default", + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": ( + "I'll read `shop/validation.py` next, starting at the `validate_quantity` definition on line 18 and " + "including the surrounding context. The project instructions mention a chat_completions client, but " + "none is available in my tools, so I'm using the read-only file tools." + ), + "role": "assistant", + "tool_calls": [ + { + "index": 2, + "function": { + "arguments": '{"path": "shop/validation.py", "start_line": 1, "end_line": 60}', + "name": "read_file", + }, + "id": "toolu_vrtx_017jjdVAoN8bus5ng5NkzafQ", + "type": "function", + } + ], + "reasoning_content": ( + "The system prompt mentions a chat_completions client for this review, but that's not actually in my " + "available toolset — I only have search_files and read_file. I'll note that discrepancy and proceed " + "by explaining which file I need, then calling read_file on shop/validation.py, probably starting " + "around the first 60 lines to get context near line 18.\n\n" + ), + "thinking_blocks": [ + { + "type": "thinking", + "thinking": ( + "The system prompt mentions a chat_completions client for this review, but that's not actually " + "in my available toolset — I only have search_files and read_file. I'll note that discrepancy " + "and proceed by explaining which file I need, then calling read_file on shop/validation.py, " + "probably starting around the first 60 lines to get context near line 18.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE, + } + ], + "provider_specific_fields": { + "citations": None, + "thinking_blocks": [ + { + "type": "thinking", + "thinking": ( + "The system prompt mentions a chat_completions client for this review, but that's not " + "actually in my available toolset — I only have search_files and read_file. I'll note " + "that discrepancy and proceed by explaining which file I need, then calling read_file on " + "shop/validation.py, probably starting around the first 60 lines to get context near " + "line 18.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE, + } + ], + }, + }, + } + ], + "usage": { + "completion_tokens": 348, + "prompt_tokens": 880, + "total_tokens": 1228, + "completion_tokens_details": {"reasoning_tokens": 168, "text_tokens": 180}, + "prompt_tokens_details": { + "cached_tokens": 0, + "text_tokens": 4, + "cache_write_tokens": 876, + "cache_creation_tokens": 876, + "cache_creation_token_details": {"ephemeral_5m_input_tokens": 876, "ephemeral_1h_input_tokens": 0}, + }, + "cache_creation_input_tokens": 876, + "cache_read_input_tokens": 0, + }, + }, +) + + CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( scenario="basic", litellm_endpoint="/v1/chat/completions", diff --git a/tests/integration/translation/chat_completions/basic/test_chat_completions_basic_vertex_ai.py b/tests/integration/translation/chat_completions/basic/test_chat_completions_basic_vertex_ai.py index 71cbd84543e..7b7f5a7876c 100644 --- a/tests/integration/translation/chat_completions/basic/test_chat_completions_basic_vertex_ai.py +++ b/tests/integration/translation/chat_completions/basic/test_chat_completions_basic_vertex_ai.py @@ -5,6 +5,7 @@ from integration.translation.case import TranslationTestCase from integration.translation.chat_completions.bases.vertex_ai import ( CLAUDE_FABLE_5_1_TEST_CASE, CLAUDE_HAIKU_4_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_SONNET_4_6_TEST_CASE, @@ -26,6 +27,7 @@ from integration.translation.runner import assert_translation CLAUDE_SONNET_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_SONNET_5_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_FABLE_5_1_TEST_CASE, GEMINI_3_5_FLASH_TEST_CASE, diff --git a/tests/integration/translation/chat_completions/cross_model/README.md b/tests/integration/translation/chat_completions/cross_model/README.md index 0adce683f38..ee7c178da81 100644 --- a/tests/integration/translation/chat_completions/cross_model/README.md +++ b/tests/integration/translation/chat_completions/cross_model/README.md @@ -5,10 +5,10 @@ Every row sends `CODING_AGENT_REQUEST` to `/v1/chat/completions`, changing only | Tier | Model versions | | --- | --- | | Small | Haiku 4.5 and 5.5, GPT 5 Mini and 5.4 Mini, GPT 5.6 Luna and 6 Luna | -| Mid | Opus 5 and 5.5, GPT 5.6 Sol, 6 Sol and 6.1 Sol, GPT 5.6 Terra | +| Mid | Opus 5 and 5.5, Sonnet 5.5, Gemini 3.8 Flash and 3.1 Pro Preview, GPT 5.6 Sol, 6 Sol and 6.1 Sol, GPT 5.6 Terra | | High | Fable 5 and 5.1, Gemini 4 Argon, GPT 6 Astra | -The 45 rows cover Anthropic direct, OpenAI direct, Vertex, Azure OpenAI, Azure AI and Bedrock Converse where accessible. Vertex Haiku 5.5 was unavailable during capture and is not represented +The 48 rows cover Anthropic direct, OpenAI direct, Vertex, Azure OpenAI, Azure AI and Bedrock Converse where accessible. Vertex Haiku 5.5 was unavailable during capture and is not represented The request contains system instructions, conversation history, previous assistant tool calls and results, four function definitions, automatic parallel tools, low reasoning effort and an output limit. Each row exercises a tool-call response, raw streaming, SDK stream assembly and a follow-up that replays the returned assistant message and tool-call IDs @@ -33,7 +33,7 @@ Streaming cases store `expected_litellm_message` and `expected_litellm_usage`. M Usage requires nonnegative integer counts, positive prompt and completion counts, and `total_tokens == prompt_tokens + completion_tokens`. The complete normalized usage must equal the independently expected counters, including cached and reasoning tokens and derived text/cache details. Missing, changed or unexpected counters fail. Only null optional fields, the explicitly listed provider metadata/duplicate counters and computed `cost` are excluded. Thus a wrong prompt count with an adjusted total still fails -The raw stream check assembles tool names and argument fragments by index. The SDK check separately uses `OpenAI.chat.completions.stream()` without removing reasoning fields. All 45 SDK checks run, including the seven Haiku rows. [LIT-9653](https://linear.app/litellm-ai/issue/LIT-9653) was fixed in [#46611](https://github.com/BerriAI/litellm/pull/46611), so there are no Haiku skips +The raw stream check assembles tool names and argument fragments by index. The SDK check separately uses `OpenAI.chat.completions.stream()` without removing reasoning fields. All 48 SDK checks run, including the seven Haiku rows. [LIT-9653](https://linear.app/litellm-ai/issue/LIT-9653) was fixed in [#46611](https://github.com/BerriAI/litellm/pull/46611), so there are no Haiku skips ## Recorded upstreams diff --git a/tests/integration/translation/chat_completions/cross_model/anthropic.py b/tests/integration/translation/chat_completions/cross_model/anthropic.py index ccb543ccf51..3df7da17ef6 100644 --- a/tests/integration/translation/chat_completions/cross_model/anthropic.py +++ b/tests/integration/translation/chat_completions/cross_model/anthropic.py @@ -2,10 +2,7 @@ from dataclasses import replace from typing import Final from integration._support.client import list_value, object_value -from integration.translation.case import ( - SSEFrame, - StreamingTranslationTestCase, -) +from integration.translation.case import SSEFrame, StreamingTranslationTestCase from integration.translation.chat_completions.cross_model.contracts import followup_request from integration.translation.chat_completions.bases.anthropic import ( CLAUDE_FABLE_5_1_TEST_CASE, @@ -20,10 +17,7 @@ from integration.translation.chat_completions.templates import ( coding_agent_stream_message, CODING_AGENT_RESPONSE, ) -from integration.translation.templates.anthropic import ( - ANTHROPIC_AGENT_REQUEST, - ANTHROPIC_AGENT_RESPONSE, -) +from integration.translation.templates.anthropic import ANTHROPIC_AGENT_REQUEST, ANTHROPIC_AGENT_RESPONSE CLAUDE_FABLE_5_1_AGENT_TEST_CASE: Final = replace( CLAUDE_FABLE_5_1_TEST_CASE, @@ -350,7 +344,7 @@ CLAUDE_FABLE_5_1_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_01324ezmdSmGSpmxGX55GvYF", "toolu_01MxHYehH7UYVY2A8WkRJZPu"), + ("toolu_01324ezmdSmGSpmxGX55GvYF", "toolu_01MxHYehH7UYVY2A8WkRJZPu") ), expected_litellm_usage={ "prompt_tokens": 1184, @@ -596,7 +590,7 @@ CLAUDE_FABLE_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_01UXBJZGdCaY4vxon1j4uapy", "toolu_0124RYbAPnwVoGtdzfPCNPc6"), + ("toolu_01UXBJZGdCaY4vxon1j4uapy", "toolu_0124RYbAPnwVoGtdzfPCNPc6") ), expected_litellm_usage={ "prompt_tokens": 1184, @@ -1494,7 +1488,7 @@ CLAUDE_HAIKU_5_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_0177HUb25ArysRAu8eF3u4DJ", "toolu_011gegGWvYoxPwxWY7LSK4Uk"), + ("toolu_0177HUb25ArysRAu8eF3u4DJ", "toolu_011gegGWvYoxPwxWY7LSK4Uk") ), expected_litellm_usage={ "prompt_tokens": 1184, @@ -1837,7 +1831,7 @@ CLAUDE_OPUS_5_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_013mPRCxrKKH6KzYGHKPukwv", "toolu_01FkXiaw4cTiXua3PoMd4wCu"), + ("toolu_013mPRCxrKKH6KzYGHKPukwv", "toolu_01FkXiaw4cTiXua3PoMd4wCu") ), expected_litellm_usage={ "prompt_tokens": 1184, @@ -2088,7 +2082,7 @@ CLAUDE_OPUS_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_017r3PoQsgodP2mgcgDnDLxU", "toolu_01T8ECEb4XwFyozSnCKUiuB2"), + ("toolu_017r3PoQsgodP2mgcgDnDLxU", "toolu_01T8ECEb4XwFyozSnCKUiuB2") ), expected_litellm_usage={ "prompt_tokens": 1180, diff --git a/tests/integration/translation/chat_completions/cross_model/azure_ai.py b/tests/integration/translation/chat_completions/cross_model/azure_ai.py index 72c14ef103c..73d0ffbb8df 100644 --- a/tests/integration/translation/chat_completions/cross_model/azure_ai.py +++ b/tests/integration/translation/chat_completions/cross_model/azure_ai.py @@ -2,10 +2,7 @@ from dataclasses import replace from typing import Final from integration._support.client import list_value, object_value -from integration.translation.case import ( - SSEFrame, - StreamingTranslationTestCase, -) +from integration.translation.case import SSEFrame, StreamingTranslationTestCase from integration.translation.chat_completions.cross_model.contracts import followup_request from integration.translation.chat_completions.bases.azure_ai import ( CLAUDE_FABLE_5_1_TEST_CASE, @@ -18,10 +15,7 @@ from integration.translation.chat_completions.templates import ( coding_agent_stream_message, CODING_AGENT_RESPONSE, ) -from integration.translation.templates.anthropic import ( - ANTHROPIC_AGENT_REQUEST, - ANTHROPIC_AGENT_RESPONSE, -) +from integration.translation.templates.anthropic import ANTHROPIC_AGENT_REQUEST, ANTHROPIC_AGENT_RESPONSE CLAUDE_FABLE_5_1_AGENT_TEST_CASE: Final = replace( CLAUDE_FABLE_5_1_TEST_CASE, @@ -349,7 +343,7 @@ CLAUDE_FABLE_5_1_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_01PfMSQLj6cQzebc2vGbdsV8", "toolu_01SVc4RwmEaMiDK33hUEbTZW"), + ("toolu_01PfMSQLj6cQzebc2vGbdsV8", "toolu_01SVc4RwmEaMiDK33hUEbTZW") ), expected_litellm_usage={ "prompt_tokens": 1184, @@ -593,7 +587,7 @@ CLAUDE_FABLE_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( SSEFrame(data={"type": "message_stop"}, event="message_stop"), ), expected_litellm_message=coding_agent_stream_message( - ("toolu_01DprHn2GeJDAWFEyTqBzy2Z", "toolu_016kava9hqtKw71W4vBEedfW"), + ("toolu_01DprHn2GeJDAWFEyTqBzy2Z", "toolu_016kava9hqtKw71W4vBEedfW") ), expected_litellm_usage={ "prompt_tokens": 1184, diff --git a/tests/integration/translation/chat_completions/cross_model/case.py b/tests/integration/translation/chat_completions/cross_model/case.py index d0e842cfa67..d81e6a269f6 100644 --- a/tests/integration/translation/chat_completions/cross_model/case.py +++ b/tests/integration/translation/chat_completions/cross_model/case.py @@ -1,9 +1,6 @@ from typing import Final -from integration.translation.case import ( - StreamingTranslationTestCase, - TranslationTestCase, -) +from integration.translation.case import StreamingTranslationTestCase, TranslationTestCase from integration.translation.chat_completions.cross_model import ( anthropic, azure, @@ -180,6 +177,21 @@ MID: Final[tuple[AgentCases, ...]] = ( openai.GPT_6_1_SOL_AGENT_FOLLOWUP_TEST_CASE, openai.GPT_6_1_SOL_AGENT_STREAMING_TEST_CASE, ), + ( + vertex_ai.CLAUDE_SONNET_5_5_AGENT_TEST_CASE, + vertex_ai.CLAUDE_SONNET_5_5_AGENT_FOLLOWUP_TEST_CASE, + vertex_ai.CLAUDE_SONNET_5_5_AGENT_STREAMING_TEST_CASE, + ), + ( + vertex_ai.GEMINI_3_8_FLASH_AGENT_TEST_CASE, + vertex_ai.GEMINI_3_8_FLASH_AGENT_FOLLOWUP_TEST_CASE, + vertex_ai.GEMINI_3_8_FLASH_AGENT_STREAMING_TEST_CASE, + ), + ( + vertex_ai.GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE, + vertex_ai.GEMINI_3_1_PRO_PREVIEW_AGENT_FOLLOWUP_TEST_CASE, + vertex_ai.GEMINI_3_1_PRO_PREVIEW_AGENT_STREAMING_TEST_CASE, + ), ) HIGH: Final[tuple[AgentCases, ...]] = ( diff --git a/tests/integration/translation/chat_completions/cross_model/contracts.py b/tests/integration/translation/chat_completions/cross_model/contracts.py index 4fbf6a880b5..a15a43985f1 100644 --- a/tests/integration/translation/chat_completions/cross_model/contracts.py +++ b/tests/integration/translation/chat_completions/cross_model/contracts.py @@ -121,7 +121,9 @@ PROVIDER_EXTENSIONS: Final = MappingProxyType( ("provider_specific_fields", "reasoning_content", "thinking_blocks", "reasoning_items", "images") ), "choices[].message.tool_calls[]": frozenset(("index", "caller", "provider_specific_fields")), - "usage": frozenset(("cache_creation_input_tokens", "cache_read_input_tokens", "inference_geo", "service_tier")), + "usage": frozenset( + ("cache_creation_input_tokens", "cache_read_input_tokens", "inference_geo", "service_tier", "speed") + ), } ) diff --git a/tests/integration/translation/chat_completions/cross_model/vertex_ai.py b/tests/integration/translation/chat_completions/cross_model/vertex_ai.py index 42f363b1920..0dbb4b277c5 100644 --- a/tests/integration/translation/chat_completions/cross_model/vertex_ai.py +++ b/tests/integration/translation/chat_completions/cross_model/vertex_ai.py @@ -6,19 +6,22 @@ from integration.translation.case import ( SSEFrame, StreamingTranslationTestCase, ) -from integration.translation.chat_completions.cross_model.contracts import followup_request from integration.translation.chat_completions.bases.vertex_ai import ( CLAUDE_FABLE_5_1_TEST_CASE, CLAUDE_FABLE_5_TEST_CASE, CLAUDE_HAIKU_4_5_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_OPUS_5_TEST_CASE, + CLAUDE_SONNET_5_5_TEST_CASE, + GEMINI_3_1_PRO_PREVIEW_TEST_CASE, + GEMINI_3_8_FLASH_TEST_CASE, GEMINI_4_ARGON_TEST_CASE, ) +from integration.translation.chat_completions.cross_model.contracts import followup_request from integration.translation.chat_completions.templates import ( CODING_AGENT_REQUEST, - coding_agent_stream_message, CODING_AGENT_RESPONSE, + coding_agent_stream_message, ) from integration.translation.templates.anthropic import ( ANTHROPIC_AGENT_REQUEST, @@ -1721,6 +1724,1214 @@ CLAUDE_OPUS_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( ) +CLAUDE_SONNET_5_5_AGENT_TEST_CASE: Final = replace( + CLAUDE_SONNET_5_5_TEST_CASE, + scenario="agent_tools", + litellm_request={**CODING_AGENT_REQUEST, "model": "vertex_ai/claude-sonnet-5-5@default"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-5-5@default:rawPredict", + expected_provider_headers={ + "authorization": "Bearer scripted-token", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-api-key": "scripted-token", + }, + expected_provider_request={ + **ANTHROPIC_AGENT_REQUEST, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "low"}, + "anthropic_version": "vertex-2023-10-16", + }, + mock_provider_response={ + **ANTHROPIC_AGENT_RESPONSE, + "model": "claude-sonnet-5-5", + "id": "msg_vrtx_011Cg63qBoZy88hTvxUdtSEr", + "content": [ + { + "type": "tool_use", + "id": "toolu_vrtx_01AtmBHyfEtHxHSJs8imPFWJ", + "name": "search_code", + "input": {"query": "retries_remaining"}, + }, + { + "type": "tool_use", + "id": "toolu_vrtx_019seNAPMm8gmCrNPf8Fwzvf", + "name": "run_tests", + "input": {"path": "tests/test_retry.py"}, + }, + ], + "stop_reason": "tool_use", + "usage": { + "input_tokens": 1184, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 105, + "output_tokens_details": {"thinking_tokens": 0}, + }, + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "model": "vertex_ai/claude-sonnet-5-5@default", + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "function": {"arguments": '{"query": "retries_remaining"}', "name": "search_code"}, + "id": "toolu_vrtx_01AtmBHyfEtHxHSJs8imPFWJ", + "type": "function", + }, + { + "index": 1, + "function": {"arguments": '{"path": "tests/test_retry.py"}', "name": "run_tests"}, + "id": "toolu_vrtx_019seNAPMm8gmCrNPf8Fwzvf", + "type": "function", + }, + ], + "provider_specific_fields": {"citations": None, "thinking_blocks": None}, + "content": None, + }, + } + ], + "usage": { + "completion_tokens": 105, + "prompt_tokens": 1184, + "total_tokens": 1289, + "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 105}, + "prompt_tokens_details": { + "cached_tokens": 0, + "text_tokens": 1184, + "cache_write_tokens": 0, + "cache_creation_tokens": 0, + "cache_creation_token_details": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + }, +) + + +CLAUDE_SONNET_5_5_AGENT_FOLLOWUP_TEST_CASE: Final = replace( + CLAUDE_SONNET_5_5_AGENT_TEST_CASE, + scenario="agent_followup", + litellm_request=followup_request( + CLAUDE_SONNET_5_5_AGENT_TEST_CASE.litellm_request, CLAUDE_SONNET_5_5_AGENT_TEST_CASE.expected_litellm_response + ), + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-5-5@default:rawPredict", + expected_provider_request={ + **ANTHROPIC_AGENT_REQUEST, + "messages": [ + *list_value(ANTHROPIC_AGENT_REQUEST["messages"]), + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_vrtx_01AtmBHyfEtHxHSJs8imPFWJ", + "name": "search_code", + "input": {"query": "retries_remaining"}, + }, + { + "type": "tool_use", + "id": "toolu_vrtx_019seNAPMm8gmCrNPf8Fwzvf", + "name": "run_tests", + "input": {"path": "tests/test_retry.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_vrtx_01AtmBHyfEtHxHSJs8imPFWJ", + "content": '{"matches": [{"path": "src/worker.py", "line": 12, "text": "remaining = ' + 'retries_remaining(3, attempts)"}]}', + }, + { + "type": "tool_result", + "tool_use_id": "toolu_vrtx_019seNAPMm8gmCrNPf8Fwzvf", + "content": '{"exit_code": 1, "output": "FAILED tests/test_retry.py::test_retries_remaining - assert ' + '4 == 2"}', + }, + { + "type": "text", + "text": "Stop after these diagnostics. Do not call more tools or change files. Summarize the " + "bug, the caller found and the failed test in two short sentences.", + }, + ], + }, + ], + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "low"}, + "anthropic_version": "vertex-2023-10-16", + }, + mock_provider_response={ + **ANTHROPIC_AGENT_RESPONSE, + "model": "claude-sonnet-5-5", + "id": "msg_vrtx_011Cg63wSU3MbRaa6kwyvRLk", + "content": [ + { + "type": "text", + "text": "The bug is that `retries_remaining` in `src/retry.py` returns `limit + attempts` " + "instead of `max(0, limit - attempts)`, so it overcounts and can't be clamped at zero. " + "The one other caller is `src/worker.py` line 12 (`retries_remaining(3, attempts)`), and " + "`tests/test_retry.py::test_retries_remaining` fails with `assert 4 == 2`.", + } + ], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 1492, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 138, + "output_tokens_details": {"thinking_tokens": 0}, + }, + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "model": "vertex_ai/claude-sonnet-5-5@default", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "The bug is that `retries_remaining` in `src/retry.py` returns `limit + attempts` " + "instead of `max(0, limit - attempts)`, so it overcounts and can't be clamped at zero. " + "The one other caller is `src/worker.py` line 12 (`retries_remaining(3, attempts)`), and " + "`tests/test_retry.py::test_retries_remaining` fails with `assert 4 == 2`.", + "role": "assistant", + "provider_specific_fields": {"citations": None, "thinking_blocks": None}, + }, + } + ], + "usage": { + "completion_tokens": 138, + "prompt_tokens": 1492, + "total_tokens": 1630, + "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 138}, + "prompt_tokens_details": { + "cached_tokens": 0, + "text_tokens": 1492, + "cache_write_tokens": 0, + "cache_creation_tokens": 0, + "cache_creation_token_details": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + }, +) + +CLAUDE_SONNET_5_5_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( + scenario="agent_streaming", + litellm_endpoint=CLAUDE_SONNET_5_5_AGENT_TEST_CASE.litellm_endpoint, + litellm_request={ + **CLAUDE_SONNET_5_5_AGENT_TEST_CASE.litellm_request, + "stream": True, + "stream_options": {"include_usage": True}, + }, + expected_provider_headers=CLAUDE_SONNET_5_5_AGENT_TEST_CASE.expected_provider_headers, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-5-5@default:streamRawPredict?alt=sse", + expected_provider_request={ + **ANTHROPIC_AGENT_REQUEST, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": "low"}, + "anthropic_version": "vertex-2023-10-16", + "stream": True, + }, + mock_provider_frames=( + SSEFrame( + data={ + "type": "message_start", + "message": { + "model": "claude-sonnet-5-5", + "id": "msg_vrtx_011Cg63qMXiZfvJr7NuvT9B1", + "type": "message", + "role": "assistant", + "content": [], + "container": None, + "stop_reason": None, + "stop_sequence": None, + "stop_details": None, + "usage": { + "input_tokens": 1184, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 16, + }, + }, + }, + event="message_start", + ), + SSEFrame(data={"type": "ping"}, event="ping"), + SSEFrame( + data={ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_vrtx_01EJMuteinVLQX3UQ8bxJEps", + "name": "search_code", + "input": {}, + }, + }, + event="content_block_start", + ), + SSEFrame( + data={"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"query'}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '": "ret'}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "ries_re"}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "maining"}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '"}'}, + }, + event="content_block_delta", + ), + SSEFrame(data={"type": "content_block_stop", "index": 0}, event="content_block_stop"), + SSEFrame( + data={ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_vrtx_015Mb3cdfYTHCA9QowGSX2RP", + "name": "run_tests", + "input": {}, + }, + }, + event="content_block_start", + ), + SSEFrame( + data={"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": ""}}, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": '{"path"'}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": ': "test'}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": "s/test_"}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": "retry.p"}, + }, + event="content_block_delta", + ), + SSEFrame( + data={ + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": 'y"}'}, + }, + event="content_block_delta", + ), + SSEFrame(data={"type": "content_block_stop", "index": 1}, event="content_block_stop"), + SSEFrame( + data={ + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None, "stop_details": None, "container": None}, + "usage": { + "input_tokens": 1184, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 105, + "output_tokens_details": {"thinking_tokens": 0}, + }, + }, + event="message_delta", + ), + SSEFrame(data={"type": "message_stop"}, event="message_stop"), + ), + expected_litellm_message=coding_agent_stream_message( + ("toolu_vrtx_01EJMuteinVLQX3UQ8bxJEps", "toolu_vrtx_015Mb3cdfYTHCA9QowGSX2RP") + ), + expected_litellm_usage={ + "prompt_tokens": 1184, + "completion_tokens": 105, + "total_tokens": 1289, + "prompt_tokens_details": { + "cached_tokens": 0, + "text_tokens": 1184, + "cache_write_tokens": 0, + "cache_creation_tokens": 0, + "cache_creation_token_details": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + }, + "completion_tokens_details": {"reasoning_tokens": 0, "text_tokens": 105}, + }, +) + + +GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE: Final = replace( + GEMINI_3_1_PRO_PREVIEW_TEST_CASE, + scenario="agent_tools", + litellm_request={**CODING_AGENT_REQUEST, "model": "vertex_ai/gemini-3.1-pro-preview"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:generateContent", + expected_provider_headers={"content-type": "application/json", "authorization": "Bearer scripted-token"}, + expected_provider_request={**VERTEX_AI_AGENT_REQUEST}, + mock_provider_response={ + **VERTEX_AI_AGENT_RESPONSE, + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "**My Thought Process on Addressing the `retries_remaining` Issue**\n" + "\n" + "Alright, so my immediate priority is to tackle the `retries_remaining` function. The " + "core of the problem, as I see it, lies in its current implementation where it seems to " + "be adding `limit` and `attempts` rather than performing the correct subtraction to " + "determine remaining retries. This, of course, is a fundamental logic error. " + "Furthermore, I've identified a critical oversight: the function doesn't account for " + "scenarios where the number of `attempts` might actually exceed the `limit`. This is a " + "potential edge case that could lead to unexpected behavior and incorrect results, so it " + "absolutely needs to be addressed.\n" + "\n" + "My proposed solution is a straightforward fix: `return max(0, limit - attempts)`. This " + "ensures that we're correctly calculating the difference between the allowed `limit` and " + "the current `attempts`. Crucially, by wrapping this subtraction within `max(0, ...)`, " + "I'm enforcing a floor of zero. This elegantly handles the situation where `attempts` " + "might be greater than or equal to `limit`, ensuring that the function will never return " + "a negative number of remaining retries, which would be nonsensical in this context.\n" + "\n" + "With that fix in mind, my plan is to proceed with the original request: I will first " + "execute the tests, specifically `tests/test_retry.py`, and run them concurrently for " + "efficiency. Immediately after, I'll perform a thorough search for all call sites of the " + "`retries_remaining` function. This two-pronged approach will allow me to verify the " + "correctness of my proposed fix and understand the broader impact of this change across " + "the codebase.\n" + "\n", + "thought": True, + }, + { + "functionCall": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_2861166", + }, + "thoughtSignature": "synthetic-signature", + }, + { + "functionCall": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_2861167", + } + }, + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 404, + "candidatesTokenCount": 41, + "totalTokenCount": 551, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 404}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 41}], + "thoughtsTokenCount": 106, + }, + "modelVersion": "gemini-3.1-pro-preview", + "createTime": "2026-10-16T14:10:08.649083Z", + "responseId": "QDDSavvOJ6-q_9MP5byA4Qo", + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "id": "QDDSavvOJ6-q_9MP5byA4Qo", + "model": "vertex_ai/gemini-3.1-pro-preview", + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "provider_specific_fields": {"thought_signature": "synthetic-signature"}, + "function": {"arguments": '{"path": "tests/test_retry.py"}', "name": "run_tests"}, + "id": "call_2861166__thought__synthetic-signature", + "type": "function", + }, + { + "index": 1, + "function": {"arguments": '{"query": "retries_remaining"}', "name": "search_code"}, + "id": "call_2861167", + "type": "function", + }, + ], + "images": [], + "reasoning_content": "**My Thought Process on Addressing the `retries_remaining` Issue**\n" + "\n" + "Alright, so my immediate priority is to tackle the `retries_remaining` function. The " + "core of the problem, as I see it, lies in its current implementation where it seems to " + "be adding `limit` and `attempts` rather than performing the correct subtraction to " + "determine remaining retries. This, of course, is a fundamental logic error. " + "Furthermore, I've identified a critical oversight: the function doesn't account for " + "scenarios where the number of `attempts` might actually exceed the `limit`. This is a " + "potential edge case that could lead to unexpected behavior and incorrect results, so it " + "absolutely needs to be addressed.\n" + "\n" + "My proposed solution is a straightforward fix: `return max(0, limit - attempts)`. This " + "ensures that we're correctly calculating the difference between the allowed `limit` and " + "the current `attempts`. Crucially, by wrapping this subtraction within `max(0, ...)`, " + "I'm enforcing a floor of zero. This elegantly handles the situation where `attempts` " + "might be greater than or equal to `limit`, ensuring that the function will never return " + "a negative number of remaining retries, which would be nonsensical in this context.\n" + "\n" + "With that fix in mind, my plan is to proceed with the original request: I will first " + "execute the tests, specifically `tests/test_retry.py`, and run them concurrently for " + "efficiency. Immediately after, I'll perform a thorough search for all call sites of the " + "`retries_remaining` function. This two-pronged approach will allow me to verify the " + "correctness of my proposed fix and understand the broader impact of this change across " + "the codebase.\n" + "\n", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "**My Thought Process on Addressing the `retries_remaining` Issue**\n" + "\n" + "Alright, so my immediate priority is to tackle the `retries_remaining` function. The " + "core of the problem, as I see it, lies in its current implementation where it seems to " + "be adding `limit` and `attempts` rather than performing the correct subtraction to " + "determine remaining retries. This, of course, is a fundamental logic error. " + "Furthermore, I've identified a critical oversight: the function doesn't account for " + "scenarios where the number of `attempts` might actually exceed the `limit`. This is a " + "potential edge case that could lead to unexpected behavior and incorrect results, so it " + "absolutely needs to be addressed.\n" + "\n" + "My proposed solution is a straightforward fix: `return max(0, limit - attempts)`. This " + "ensures that we're correctly calculating the difference between the allowed `limit` and " + "the current `attempts`. Crucially, by wrapping this subtraction within `max(0, ...)`, " + "I'm enforcing a floor of zero. This elegantly handles the situation where `attempts` " + "might be greater than or equal to `limit`, ensuring that the function will never return " + "a negative number of remaining retries, which would be nonsensical in this context.\n" + "\n" + "With that fix in mind, my plan is to proceed with the original request: I will first " + "execute the tests, specifically `tests/test_retry.py`, and run them concurrently for " + "efficiency. Immediately after, I'll perform a thorough search for all call sites of the " + "`retries_remaining` function. This two-pronged approach will allow me to verify the " + "correctness of my proposed fix and understand the broader impact of this change across " + "the codebase.\n" + "\n", + } + ], + "provider_specific_fields": {"thought_signatures": ["synthetic-signature"]}, + "content": None, + }, + "provider_specific_fields": {"native_finish_reason": "STOP"}, + } + ], + "usage": { + "completion_tokens": 147, + "prompt_tokens": 404, + "total_tokens": 551, + "completion_tokens_details": {"reasoning_tokens": 106, "text_tokens": 41}, + "prompt_tokens_details": {"text_tokens": 404}, + }, + "vertex_ai_grounding_metadata": [], + "vertex_ai_url_context_metadata": [], + "vertex_ai_safety_results": [], + "vertex_ai_citation_metadata": [], + }, +) + + +GEMINI_3_1_PRO_PREVIEW_AGENT_FOLLOWUP_TEST_CASE: Final = replace( + GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE, + scenario="agent_followup", + litellm_request=followup_request( + GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE.litellm_request, + GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE.expected_litellm_response, + ), + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:generateContent", + expected_provider_request={ + **VERTEX_AI_AGENT_REQUEST, + "contents": [ + *list_value(VERTEX_AI_AGENT_REQUEST["contents"]), + { + "role": "model", + "parts": [ + { + "thought": True, + "text": "**My Thought Process on Addressing the `retries_remaining` Issue**\n" + "\n" + "Alright, so my immediate priority is to tackle the `retries_remaining` function. The " + "core of the problem, as I see it, lies in its current implementation where it seems to " + "be adding `limit` and `attempts` rather than performing the correct subtraction to " + "determine remaining retries. This, of course, is a fundamental logic error. " + "Furthermore, I've identified a critical oversight: the function doesn't account for " + "scenarios where the number of `attempts` might actually exceed the `limit`. This is a " + "potential edge case that could lead to unexpected behavior and incorrect results, so it " + "absolutely needs to be addressed.\n" + "\n" + "My proposed solution is a straightforward fix: `return max(0, limit - attempts)`. This " + "ensures that we're correctly calculating the difference between the allowed `limit` and " + "the current `attempts`. Crucially, by wrapping this subtraction within `max(0, ...)`, " + "I'm enforcing a floor of zero. This elegantly handles the situation where `attempts` " + "might be greater than or equal to `limit`, ensuring that the function will never return " + "a negative number of remaining retries, which would be nonsensical in this context.\n" + "\n" + "With that fix in mind, my plan is to proceed with the original request: I will first " + "execute the tests, specifically `tests/test_retry.py`, and run them concurrently for " + "efficiency. Immediately after, I'll perform a thorough search for all call sites of the " + "`retries_remaining` function. This two-pronged approach will allow me to verify the " + "correctness of my proposed fix and understand the broader impact of this change across " + "the codebase.\n" + "\n", + }, + { + "function_call": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_2861166", + }, + "thoughtSignature": "synthetic-signature", + }, + { + "function_call": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_2861167", + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "function_response": { + "name": "run_tests", + "response": { + "exit_code": 1, + "output": "FAILED tests/test_retry.py::test_retries_remaining - assert 4 == 2", + }, + "id": "call_2861166", + } + }, + { + "function_response": { + "name": "search_code", + "response": { + "matches": [ + { + "path": "src/worker.py", + "line": 12, + "text": "remaining = retries_remaining(3, attempts)", + } + ] + }, + "id": "call_2861167", + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "text": "Stop after these diagnostics. Do not call more tools or change files. Summarize the " + "bug, the caller found and the failed test in two short sentences." + } + ], + }, + ], + }, + mock_provider_response={ + **VERTEX_AI_AGENT_RESPONSE, + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "**My Debugging Session**\n" + "\n" + "It appears I've stumbled upon a rather straightforward arithmetic error within the " + "`retries_remaining` function. My current understanding is that it's incorrectly adding " + "the `limit` and `attempts` parameters instead of performing a subtraction, and " + "consequently, it's not ensuring the result is capped at zero. I've identified that " + "`src/worker.py` is a caller of this function, and specifically, the " + "`tests/test_retry.py::test_retries_remaining` test is failing because, for instance, " + "`retries_remaining(3, 1)` is returning 4 when it should be returning 2.\n" + "\n", + "thought": True, + }, + { + "text": "The bug in `retries_remaining` is that it adds `limit` and `attempts` instead of " + "calculating `max(0, limit - attempts)`. This causes `tests/test_retry.py` to fail, " + "which was verified, and the function is called by `src/worker.py`.", + "thoughtSignature": "synthetic-signature", + }, + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 525, + "candidatesTokenCount": 64, + "totalTokenCount": 706, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 525}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 64}], + "thoughtsTokenCount": 117, + }, + "modelVersion": "gemini-3.1-pro-preview", + "createTime": "2026-10-16T14:11:28.565345Z", + "responseId": "kDDSauHAIq6d4_UPv9qnMQ", + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "id": "kDDSauHAIq6d4_UPv9qnMQ", + "model": "vertex_ai/gemini-3.1-pro-preview", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "The bug in `retries_remaining` is that it adds `limit` and `attempts` instead of " + "calculating `max(0, limit - attempts)`. This causes `tests/test_retry.py` to fail, " + "which was verified, and the function is called by `src/worker.py`.", + "role": "assistant", + "images": [], + "reasoning_content": "**My Debugging Session**\n" + "\n" + "It appears I've stumbled upon a rather straightforward arithmetic error within the " + "`retries_remaining` function. My current understanding is that it's incorrectly adding " + "the `limit` and `attempts` parameters instead of performing a subtraction, and " + "consequently, it's not ensuring the result is capped at zero. I've identified that " + "`src/worker.py` is a caller of this function, and specifically, the " + "`tests/test_retry.py::test_retries_remaining` test is failing because, for instance, " + "`retries_remaining(3, 1)` is returning 4 when it should be returning 2.\n" + "\n", + "thinking_blocks": [ + { + "type": "thinking", + "thinking": "**My Debugging Session**\n" + "\n" + "It appears I've stumbled upon a rather straightforward arithmetic error within the " + "`retries_remaining` function. My current understanding is that it's incorrectly adding " + "the `limit` and `attempts` parameters instead of performing a subtraction, and " + "consequently, it's not ensuring the result is capped at zero. I've identified that " + "`src/worker.py` is a caller of this function, and specifically, the " + "`tests/test_retry.py::test_retries_remaining` test is failing because, for instance, " + "`retries_remaining(3, 1)` is returning 4 when it should be returning 2.\n" + "\n", + } + ], + "provider_specific_fields": {"thought_signatures": ["synthetic-signature"]}, + }, + "provider_specific_fields": {"native_finish_reason": "STOP"}, + } + ], + "usage": { + "completion_tokens": 181, + "prompt_tokens": 525, + "total_tokens": 706, + "completion_tokens_details": {"reasoning_tokens": 117, "text_tokens": 64}, + "prompt_tokens_details": {"text_tokens": 525}, + }, + "vertex_ai_grounding_metadata": [], + "vertex_ai_url_context_metadata": [], + "vertex_ai_safety_results": [], + "vertex_ai_citation_metadata": [], + }, +) + +GEMINI_3_1_PRO_PREVIEW_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( + scenario="agent_streaming", + litellm_endpoint=GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE.litellm_endpoint, + litellm_request={ + **GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE.litellm_request, + "stream": True, + "stream_options": {"include_usage": True}, + }, + expected_provider_headers=GEMINI_3_1_PRO_PREVIEW_AGENT_TEST_CASE.expected_provider_headers, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse", + expected_provider_request={**VERTEX_AI_AGENT_REQUEST}, + mock_provider_frames=( + SSEFrame( + data={ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_1016270", + }, + "thoughtSignature": "synthetic-signature", + } + ], + } + } + ], + "usageMetadata": {"trafficType": "ON_DEMAND"}, + "modelVersion": "gemini-3.1-pro-preview", + "createTime": "2026-10-16T14:10:13.305463Z", + "responseId": "RTDSarfSEuXG4_UP_a-tuQo", + } + ), + SSEFrame( + data={ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_1016276", + } + } + ], + } + } + ], + "usageMetadata": {"trafficType": "ON_DEMAND"}, + "modelVersion": "gemini-3.1-pro-preview", + "createTime": "2026-10-16T14:10:13.305463Z", + "responseId": "RTDSarfSEuXG4_UP_a-tuQo", + } + ), + SSEFrame( + data={ + "candidates": [{"content": {"role": "model", "parts": [{"text": ""}]}, "finishReason": "STOP"}], + "usageMetadata": { + "promptTokenCount": 404, + "candidatesTokenCount": 41, + "totalTokenCount": 473, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 404}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 41}], + "thoughtsTokenCount": 28, + }, + "modelVersion": "gemini-3.1-pro-preview", + "createTime": "2026-10-16T14:10:13.305463Z", + "responseId": "RTDSarfSEuXG4_UP_a-tuQo", + } + ), + ), + expected_litellm_message={ + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1016270__thought__synthetic-signature", + "type": "function", + "function": {"name": "run_tests", "arguments": '{"path": "tests/test_retry.py"}'}, + }, + { + "id": "call_1016276", + "type": "function", + "function": {"name": "search_code", "arguments": '{"query": "retries_remaining"}'}, + }, + ], + "reasoning_content": None, + "thinking_blocks": [], + "reasoning_items": [], + }, + expected_litellm_usage={ + "prompt_tokens": 404, + "completion_tokens": 69, + "total_tokens": 473, + "prompt_tokens_details": {"text_tokens": 404}, + "completion_tokens_details": {"reasoning_tokens": 28, "text_tokens": 41}, + }, +) + + +GEMINI_3_8_FLASH_AGENT_TEST_CASE: Final = replace( + GEMINI_3_8_FLASH_TEST_CASE, + scenario="agent_tools", + litellm_request={**CODING_AGENT_REQUEST, "model": "vertex_ai/gemini-3.8-flash"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.8-flash:generateContent", + expected_provider_headers={"content-type": "application/json", "authorization": "Bearer scripted-token"}, + expected_provider_request={**VERTEX_AI_AGENT_REQUEST}, + mock_provider_response={ + **VERTEX_AI_AGENT_RESPONSE, + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_1969624", + }, + "thoughtSignature": "synthetic-signature", + }, + { + "functionCall": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_1969625", + } + }, + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 404, + "candidatesTokenCount": 41, + "totalTokenCount": 445, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 404}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 41}], + }, + "modelVersion": "gemini-3.8-flash", + "createTime": "2026-10-16T14:10:02.505616Z", + "responseId": "OjDSapDuHpCG9LsPreTxQA", + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "id": "OjDSapDuHpCG9LsPreTxQA", + "model": "vertex_ai/gemini-3.8-flash", + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "provider_specific_fields": {"thought_signature": "synthetic-signature"}, + "function": {"arguments": '{"query": "retries_remaining"}', "name": "search_code"}, + "id": "call_1969624__thought__synthetic-signature", + "type": "function", + }, + { + "index": 1, + "function": {"arguments": '{"path": "tests/test_retry.py"}', "name": "run_tests"}, + "id": "call_1969625", + "type": "function", + }, + ], + "images": [], + "thinking_blocks": [], + "provider_specific_fields": {"thought_signatures": ["synthetic-signature"]}, + "content": None, + }, + "provider_specific_fields": {"native_finish_reason": "STOP"}, + } + ], + "usage": { + "completion_tokens": 41, + "prompt_tokens": 404, + "total_tokens": 445, + "completion_tokens_details": {"text_tokens": 41}, + "prompt_tokens_details": {"text_tokens": 404}, + }, + "vertex_ai_grounding_metadata": [], + "vertex_ai_url_context_metadata": [], + "vertex_ai_safety_results": [], + "vertex_ai_citation_metadata": [], + }, +) + + +GEMINI_3_8_FLASH_AGENT_FOLLOWUP_TEST_CASE: Final = replace( + GEMINI_3_8_FLASH_AGENT_TEST_CASE, + scenario="agent_followup", + litellm_request=followup_request( + GEMINI_3_8_FLASH_AGENT_TEST_CASE.litellm_request, GEMINI_3_8_FLASH_AGENT_TEST_CASE.expected_litellm_response + ), + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.8-flash:generateContent", + expected_provider_request={ + **VERTEX_AI_AGENT_REQUEST, + "contents": [ + *list_value(VERTEX_AI_AGENT_REQUEST["contents"]), + { + "role": "model", + "parts": [ + { + "function_call": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_1969624", + }, + "thoughtSignature": "synthetic-signature", + }, + { + "function_call": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_1969625", + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "function_response": { + "name": "search_code", + "response": { + "matches": [ + { + "path": "src/worker.py", + "line": 12, + "text": "remaining = retries_remaining(3, attempts)", + } + ] + }, + "id": "call_1969624", + } + }, + { + "function_response": { + "name": "run_tests", + "response": { + "exit_code": 1, + "output": "FAILED tests/test_retry.py::test_retries_remaining - assert 4 == 2", + }, + "id": "call_1969625", + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "text": "Stop after these diagnostics. Do not call more tools or change files. Summarize the " + "bug, the caller found and the failed test in two short sentences." + } + ], + }, + ], + }, + mock_provider_response={ + **VERTEX_AI_AGENT_RESPONSE, + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "`retries_remaining` erroneously adds `limit` and `attempts` instead of calculating " + "`max(0, limit - attempts)`, causing `assert retries_remaining(3, 1) == 2` to fail with " + "`assert 4 == 2`. Aside from its unit test, `src/worker.py` (line 12) was found as the " + "only other caller in the repository.", + "thoughtSignature": "synthetic-signature", + } + ], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 525, + "candidatesTokenCount": 85, + "totalTokenCount": 610, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 525}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 85}], + }, + "modelVersion": "gemini-3.8-flash", + "createTime": "2026-10-16T14:11:25.313560Z", + "responseId": "jTDSatiRE9ap4_UPuPC7wQo", + }, + expected_litellm_response={ + **CODING_AGENT_RESPONSE, + "id": "jTDSatiRE9ap4_UPuPC7wQo", + "model": "vertex_ai/gemini-3.8-flash", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "`retries_remaining` erroneously adds `limit` and `attempts` instead of calculating " + "`max(0, limit - attempts)`, causing `assert retries_remaining(3, 1) == 2` to fail with " + "`assert 4 == 2`. Aside from its unit test, `src/worker.py` (line 12) was found as the " + "only other caller in the repository.", + "role": "assistant", + "images": [], + "thinking_blocks": [], + "provider_specific_fields": {"thought_signatures": ["synthetic-signature"]}, + }, + "provider_specific_fields": {"native_finish_reason": "STOP"}, + } + ], + "usage": { + "completion_tokens": 85, + "prompt_tokens": 525, + "total_tokens": 610, + "completion_tokens_details": {"text_tokens": 85}, + "prompt_tokens_details": {"text_tokens": 525}, + }, + "vertex_ai_grounding_metadata": [], + "vertex_ai_url_context_metadata": [], + "vertex_ai_safety_results": [], + "vertex_ai_citation_metadata": [], + }, +) + +GEMINI_3_8_FLASH_AGENT_STREAMING_TEST_CASE: Final = StreamingTranslationTestCase( + scenario="agent_streaming", + litellm_endpoint=GEMINI_3_8_FLASH_AGENT_TEST_CASE.litellm_endpoint, + litellm_request={ + **GEMINI_3_8_FLASH_AGENT_TEST_CASE.litellm_request, + "stream": True, + "stream_options": {"include_usage": True}, + }, + expected_provider_headers=GEMINI_3_8_FLASH_AGENT_TEST_CASE.expected_provider_headers, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/google/models/gemini-3.8-flash:streamGenerateContent?alt=sse", + expected_provider_request={**VERTEX_AI_AGENT_REQUEST}, + mock_provider_frames=( + SSEFrame( + data={ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "search_code", + "args": {"query": "retries_remaining"}, + "id": "call_302633", + }, + "thoughtSignature": "synthetic-signature", + } + ], + } + } + ], + "usageMetadata": {"trafficType": "ON_DEMAND"}, + "modelVersion": "gemini-3.8-flash", + "createTime": "2026-10-16T14:10:05.862754Z", + "responseId": "PTDSaqLUNOXG4_UP_a-tuQo", + } + ), + SSEFrame( + data={ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "run_tests", + "args": {"path": "tests/test_retry.py"}, + "id": "call_302642", + } + } + ], + } + } + ], + "usageMetadata": {"trafficType": "ON_DEMAND"}, + "modelVersion": "gemini-3.8-flash", + "createTime": "2026-10-16T14:10:05.862754Z", + "responseId": "PTDSaqLUNOXG4_UP_a-tuQo", + } + ), + SSEFrame( + data={ + "candidates": [{"content": {"role": "model", "parts": [{"text": ""}]}, "finishReason": "STOP"}], + "usageMetadata": { + "promptTokenCount": 404, + "candidatesTokenCount": 41, + "totalTokenCount": 445, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 404}], + "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 41}], + }, + "modelVersion": "gemini-3.8-flash", + "createTime": "2026-10-16T14:10:05.862754Z", + "responseId": "PTDSaqLUNOXG4_UP_a-tuQo", + } + ), + ), + expected_litellm_message=coding_agent_stream_message(("call_302633__thought__synthetic-signature", "call_302642")), + expected_litellm_usage={ + "prompt_tokens": 404, + "completion_tokens": 41, + "total_tokens": 445, + "prompt_tokens_details": {"text_tokens": 404}, + "completion_tokens_details": {"text_tokens": 41}, + }, +) + + GEMINI_4_ARGON_AGENT_TEST_CASE: Final = replace( GEMINI_4_ARGON_TEST_CASE, scenario="agent_tools", diff --git a/tests/integration/translation/messages/bases/anthropic.py b/tests/integration/translation/messages/bases/anthropic.py index cc542499a07..be7f487f1b0 100644 --- a/tests/integration/translation/messages/bases/anthropic.py +++ b/tests/integration/translation/messages/bases/anthropic.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Final +from integration._support.client import object_value from integration.translation.case import TranslationTestCase from integration.translation.messages.templates import MESSAGES_REQUEST, MESSAGES_RESPONSE from integration.translation.templates.anthropic import ANTHROPIC_MESSAGES_REQUEST, ANTHROPIC_MESSAGES_RESPONSE @@ -31,7 +32,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CffzUNHaEfzVxCh5hskBG", + "id": "msg_011Cg7v1w1xZexMrWpf5GU75", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -47,12 +48,13 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "anthropic/claude-sonnet-4-6", - "id": "msg_011CffzUNHaEfzVxCh5hskBG", + "id": "msg_011Cg7v1w1xZexMrWpf5GU75", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -68,6 +70,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -98,10 +101,10 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011CfgDz9Vj8wXvKzhoM4MQA", + "id": "msg_011Cg7v1Mt9Eid844XTV8AMc", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello."}], + "content": [{"type": "text", "text": "Hello!"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -114,15 +117,16 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "anthropic/claude-haiku-4-5", - "id": "msg_011CfgDz9Vj8wXvKzhoM4MQA", + "id": "msg_011Cg7v1Mt9Eid844XTV8AMc", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello."}], + "content": [{"type": "text", "text": "Hello!"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -135,6 +139,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -165,7 +170,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-5", - "id": "msg_011CfgDzLXmJmnHRTpeBoDmZ", + "id": "msg_011Cg7v2KRMCpM2FsiVygFAS", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -182,12 +187,13 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "anthropic/claude-sonnet-5", - "id": "msg_011CfgDzLXmJmnHRTpeBoDmZ", + "id": "msg_011Cg7v2KRMCpM2FsiVygFAS", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -204,6 +210,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -234,7 +241,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfgDzSFYYPC3aYE9rH98J", + "id": "msg_011Cg7v1ZPwvewtTtXqjdwzZ", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -251,12 +258,13 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "anthropic/claude-opus-4-8", - "id": "msg_011CfgDzSFYYPC3aYE9rH98J", + "id": "msg_011Cg7v1ZPwvewtTtXqjdwzZ", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -273,6 +281,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -377,11 +386,13 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_SONNET_5_5_RESPONSE_FIELDS, "model": "claude-sonnet-5-5", + "usage": {**object_value(CLAUDE_SONNET_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_SONNET_5_5_RESPONSE_FIELDS, "model": "anthropic/claude-sonnet-5-5", + "usage": {**object_value(CLAUDE_SONNET_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) @@ -460,11 +471,13 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_HAIKU_5_5_RESPONSE_FIELDS, "model": "claude-haiku-5-5", + "usage": {**object_value(CLAUDE_HAIKU_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_HAIKU_5_5_RESPONSE_FIELDS, "model": "anthropic/claude-haiku-5-5", + "usage": {**object_value(CLAUDE_HAIKU_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) @@ -555,11 +568,13 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_OPUS_5_5_RESPONSE_FIELDS, "model": "claude-opus-5-5", + "usage": {**object_value(CLAUDE_OPUS_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_OPUS_5_5_RESPONSE_FIELDS, "model": "anthropic/claude-opus-5-5", + "usage": {**object_value(CLAUDE_OPUS_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) @@ -651,10 +666,12 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_FABLE_5_1_RESPONSE_FIELDS, "model": "claude-fable-5-1", + "usage": {**object_value(CLAUDE_FABLE_5_1_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_FABLE_5_1_RESPONSE_FIELDS, "model": "anthropic/claude-fable-5-1", + "usage": {**object_value(CLAUDE_FABLE_5_1_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) diff --git a/tests/integration/translation/messages/bases/azure_ai.py b/tests/integration/translation/messages/bases/azure_ai.py index 454a76400db..6de15226c48 100644 --- a/tests/integration/translation/messages/bases/azure_ai.py +++ b/tests/integration/translation/messages/bases/azure_ai.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Final +from integration._support.client import object_value from integration.translation.case import TranslationTestCase from integration.translation.messages.templates import MESSAGES_REQUEST, MESSAGES_RESPONSE from integration.translation.templates.anthropic import ANTHROPIC_MESSAGES_REQUEST, ANTHROPIC_MESSAGES_RESPONSE @@ -31,10 +32,10 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011Cfjax2zxeHkddgerkEGRY", + "id": "msg_011Cg7vB62SdiZVj3M7xJXpr", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello."}], + "content": [{"type": "text", "text": "Hello!"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -47,15 +48,16 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "azure_ai/claude-haiku-4-5", - "id": "msg_011Cfjax2zxeHkddgerkEGRY", + "id": "msg_011Cg7vB62SdiZVj3M7xJXpr", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello."}], + "content": [{"type": "text", "text": "Hello!"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -68,6 +70,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -98,7 +101,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CfjaxAknNm3ZLkVxQRj8M", + "id": "msg_011Cg7vCCFGaPEhwGwoS6VPy", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -114,12 +117,13 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "azure_ai/claude-sonnet-4-6", - "id": "msg_011CfjaxAknNm3ZLkVxQRj8M", + "id": "msg_011Cg7vCCFGaPEhwGwoS6VPy", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -135,6 +139,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -234,7 +239,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfjaxPE8FBKSJ9KbtQzDE", + "id": "msg_011Cg7vBVpRK6kd1kkYXJXyb", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -251,12 +256,13 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, expected_litellm_response={ "model": "azure_ai/claude-opus-4-8", - "id": "msg_011CfjaxPE8FBKSJ9KbtQzDE", + "id": "msg_011Cg7vBVpRK6kd1kkYXJXyb", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -273,6 +279,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -483,11 +490,13 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_HAIKU_5_5_RESPONSE_FIELDS, "model": "claude-haiku-5-5", + "usage": {**object_value(CLAUDE_HAIKU_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_HAIKU_5_5_RESPONSE_FIELDS, "model": "azure_ai/claude-haiku-5-5", + "usage": {**object_value(CLAUDE_HAIKU_5_5_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) @@ -541,10 +550,12 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( **ANTHROPIC_MESSAGES_RESPONSE, **CLAUDE_FABLE_5_1_RESPONSE_FIELDS, "model": "claude-fable-5-1", + "usage": {**object_value(CLAUDE_FABLE_5_1_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, expected_litellm_response={ **MESSAGES_RESPONSE, **CLAUDE_FABLE_5_1_RESPONSE_FIELDS, "model": "azure_ai/claude-fable-5-1", + "usage": {**object_value(CLAUDE_FABLE_5_1_RESPONSE_FIELDS["usage"]), "speed": "standard"}, }, ) diff --git a/tests/integration/translation/messages/bases/vertex_ai.py b/tests/integration/translation/messages/bases/vertex_ai.py index 6f506b39d93..120b764a63c 100644 --- a/tests/integration/translation/messages/bases/vertex_ai.py +++ b/tests/integration/translation/messages/bases/vertex_ai.py @@ -10,6 +10,7 @@ from integration.translation.templates.vertex_ai import ( ) GEMINI_3_5_FLASH_THOUGHT_SIGNATURE: Final = "AY89a1+kriNs9YsPegJQxgjTWsMlxsqPU/UkHDyz4EK+3FRrE52ygqPIHTvkblgKKiO5Fn8R9D3iu//i+YnuuZ+e7W0yxUoFScas89b5rn3vZHsihnUA7VTe3b7beth1IftteoTGZ3F8OSLrSIP8HKn4xTIx35eeNEt5oiD3K5rTQrdklqO277HvTDhu/LuZhZ0pzaWciGQEc61epIBBpC2ygu8V4L8TekUVZnz6qzSzxipdiQStQ8dZou43334hMnxOR6JjJCGxqt/1K+C0vXX6YP6f+Bph5VhPSnvy2Z1uApi3TUxAcsDDyVewIr+ha2G6DPhN1Oi5uZZNhmbvH01Uq5OOquF1uEICq2m/35LhJIbDNM9N7oI1Z6L94P9xdugeFXM2S7wdJfc8sdg09vEzNeA++GK25zRuB4hU7XyaG4XnWjmu2kswi4sf+EyrO/K0P2tb/ZCJu7kJTtWTiD1wgSIl1eqS8ORKKsXDN2hIbFrtsNR50WIvVzd6" +CLAUDE_HAIKU_5_5_MESSAGES_THINKING_SIGNATURE: Final = "CAQS4wYKEggSEAIYAjgBQgh0aGlua2luZxIMgzBnOw4aYVVqPKRPGgyphQhlJ0pt7tgasWIiMOWM4ATud/TWad7qo/Eh9ZSXhK3IcgnwdgqVeXog9D1q/y69qnyrHdN3a8RYUrAgtyr+BYkQvRW/cF0vH0l86vIM95N77usMXdZKRX3I8XW68KpQedYwFoR12sS9q7A24CcN5UMyQljcF76GhmAFnvpk4yEs7pk8b7ZRSXzBsU6wJfMYCoGXC9r3hZN8m4sBHLJGJRv5ekzEVGVvGCYooE1fBZ8ik/j2DCNOyVdDs4/7xzkqRo7dgFsNJI9Nm7+HgwEdvb1rORObJWooeYb8tfofTFVO43qvNdvM2p2d1Dcf6lsQmvE5fgDFc0t75AuvSZUrgDNe2bMXd2USxxL9YtJ1I9ae96hZAnyOqYZlyiuZf5lZtQQSRouVIdZvFUNonOHLy/Uyw/BGF2bO1/5rIFJBDJC8C6BpbyfDQPpal4YkALulDdgeJWI6dmJYUiK+A+3zcVgQh7at5JPbG5n0tQgO2IFkayqLjCf67AaGS6Xq8J97F+FSwppKvS0TvRbTEvIK1iO3HZZRwcRIcFBn5Llvv6R9JFknXEtohUqvfTnZfuPQ104vxqm91G2rFK1R6C1md6Ze3pJWD0wgkgZyQ7PIKsAmBF1qNoniFi5GVRJx6XHSzr4ZAPDDO1aYABIRl5OWilrkQP3xedj5pcAjJqFuto3cXlhQSNUrocOpEV5IIXxSJc5g5VwYAybpMU4FVImzkCJGJaHLWQKRo0k4IcvXLxBPNXZhop2CkEeazZskF4de0jn1dJa3sQ9rT+7fNVEWPvP+lkBqu/lfObBPVh5jGeouDrRcJvyeYKNZ0NQBdznM5rv4rN8HczeYg1WaYuNOMBvkqT5Zg/UhLHhHV7NWBSa0zt9DPevU/S3OBR1E0YIzCtPFTa0Ia4gGTX40hEc+supCA3h4HpehqMqETGe++Ah+U10HRhlb6lVfRhkU0ypdPqtpodgw/OdUOy7f3/sc31xknudQMoEQqv48F4gR34ee18Op+w1MGn0FAzAFE95D2BuJObRqUhZ6N6nWtlWOqJjCPR+0OrwR3JlxWh19Vxps/ASL6HaJKjClVG2ffojvmVnY8aHjEfI8ifbHE6EYAQ==" CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( scenario="basic", @@ -486,6 +487,106 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( ) +CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( + scenario="basic", + litellm_endpoint="/v1/messages", + litellm_request={**MESSAGES_REQUEST, "model": "vertex_ai/claude-haiku-5-5@default"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-haiku-5-5@default:rawPredict", + expected_provider_headers={"authorization": "Bearer scripted-token", "content-type": "application/json"}, + expected_provider_request={ + **VERTEX_AI_ANTHROPIC_MESSAGES_REQUEST, + "messages": MESSAGES_REQUEST["messages"], + "stream": False, + "system": [ + { + "type": "text", + "text": ( + "You help maintain a synthetic Python project. Read the relevant files before suggesting a fix. " + "Explain which file you need next, then call a read-only tool. Never change files. Use the messages " + "client for this project review. Consider zero, negative and non-integer quantities before choosing " + "the next file to inspect." + ), + }, + ], + "tools": MESSAGES_REQUEST["tools"], + }, + mock_provider_response={ + **VERTEX_AI_ANTHROPIC_MESSAGES_RESPONSE, + "model": "claude-haiku-5-5", + "id": "msg_vrtx_011Cg8BfjWDUi4ZYQxpy6aVM", + "content": [ + { + "type": "thinking", + "thinking": ( + "I found validate_quantity defined around line 18, so I'll read the validation.py file from the start " + "through roughly line 60 to capture the full context, keeping in mind I'll need to check how it handles " + "zero, negative, and non-integer cases.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_MESSAGES_THINKING_SIGNATURE, + }, + { + "type": "text", + "text": ( + "I'll read `shop/validation.py` first. The search put `validate_quantity` at line 18, so I'll start " + "there and include the lines around it to see how zero, negative, and non-integer values are handled." + ), + }, + { + "type": "tool_use", + "id": "toolu_vrtx_01YSY1kAV4ayCLVdBJkSv4dt", + "name": "read_file", + "input": {"path": "shop/validation.py", "start_line": 1, "end_line": 60}, + }, + ], + "usage": { + "input_tokens": 4, + "cache_creation_input_tokens": 873, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 873, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 292, + "output_tokens_details": {"thinking_tokens": 129}, + }, + }, + expected_litellm_response={ + **VERTEX_AI_ANTHROPIC_MESSAGES_RESPONSE, + "model": "vertex_ai/claude-haiku-5-5@default", + "id": "msg_vrtx_011Cg8BfjWDUi4ZYQxpy6aVM", + "content": [ + { + "type": "thinking", + "thinking": ( + "I found validate_quantity defined around line 18, so I'll read the validation.py file from the start " + "through roughly line 60 to capture the full context, keeping in mind I'll need to check how it handles " + "zero, negative, and non-integer cases.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_MESSAGES_THINKING_SIGNATURE, + }, + { + "type": "text", + "text": ( + "I'll read `shop/validation.py` first. The search put `validate_quantity` at line 18, so I'll start " + "there and include the lines around it to see how zero, negative, and non-integer values are handled." + ), + }, + { + "type": "tool_use", + "id": "toolu_vrtx_01YSY1kAV4ayCLVdBJkSv4dt", + "name": "read_file", + "input": {"path": "shop/validation.py", "start_line": 1, "end_line": 60}, + }, + ], + "usage": { + "input_tokens": 4, + "cache_creation_input_tokens": 873, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 873, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 292, + "output_tokens_details": {"thinking_tokens": 129}, + }, + }, +) + + CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( scenario="basic", litellm_endpoint="/v1/messages", diff --git a/tests/integration/translation/messages/basic/test_messages_basic_vertex_ai.py b/tests/integration/translation/messages/basic/test_messages_basic_vertex_ai.py index a5b01dfd2e4..4f74a648d8e 100644 --- a/tests/integration/translation/messages/basic/test_messages_basic_vertex_ai.py +++ b/tests/integration/translation/messages/basic/test_messages_basic_vertex_ai.py @@ -5,6 +5,7 @@ from integration.translation.case import TranslationTestCase from integration.translation.messages.bases.vertex_ai import ( CLAUDE_FABLE_5_1_TEST_CASE, CLAUDE_HAIKU_4_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_SONNET_4_6_TEST_CASE, @@ -26,6 +27,7 @@ from integration.translation.runner import assert_translation CLAUDE_SONNET_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_SONNET_5_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_FABLE_5_1_TEST_CASE, GEMINI_3_5_FLASH_TEST_CASE, diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py index 5a3a760ffdc..6d7959b5de5 100644 --- a/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py @@ -8,6 +8,19 @@ from integration.translation.case import TranslationTestCase from integration.translation.messages.bases.anthropic import CLAUDE_SONNET_4_6_TEST_CASE, CLAUDE_SONNET_5_5_TEST_CASE from integration.translation.runner import assert_translation +CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS: Final = { + "content": [{"type": "text", "text": "Hello!"}], + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 5, + "service_tier": "standard", + "inference_geo": "global", + }, +} + CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( CLAUDE_SONNET_4_6_TEST_CASE, scenario="service_tier", @@ -16,9 +29,14 @@ CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( **CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request, "service_tier": "standard_only", }, - mock_provider_response={**CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, "id": "msg_011Cg1RG36pCcTLE26htFFqC"}, + mock_provider_response={ + **CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, + **CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS, + "id": "msg_011Cg1RG36pCcTLE26htFFqC", + }, expected_litellm_response={ **CLAUDE_SONNET_4_6_TEST_CASE.expected_litellm_response, + **CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS, "id": "msg_011Cg1RG36pCcTLE26htFFqC", }, ) diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py index 13bf9dd888f..f987f9d1f2a 100644 --- a/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py @@ -8,6 +8,19 @@ from integration.translation.case import TranslationTestCase from integration.translation.messages.bases.azure_ai import CLAUDE_SONNET_4_6_TEST_CASE from integration.translation.runner import assert_translation +CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS: Final = { + "content": [{"type": "text", "text": "Hello!"}], + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 5, + "service_tier": "standard", + "inference_geo": "not_available", + }, +} + CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( CLAUDE_SONNET_4_6_TEST_CASE, scenario="service_tier", @@ -16,9 +29,14 @@ CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( **CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request, "service_tier": "standard_only", }, - mock_provider_response={**CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, "id": "msg_011Cg1SnrstCdzZPwmTk2qQa"}, + mock_provider_response={ + **CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, + **CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS, + "id": "msg_011Cg1SnrstCdzZPwmTk2qQa", + }, expected_litellm_response={ **CLAUDE_SONNET_4_6_TEST_CASE.expected_litellm_response, + **CLAUDE_SONNET_4_6_SERVICE_TIER_RESPONSE_FIELDS, "id": "msg_011Cg1SnrstCdzZPwmTk2qQa", }, ) diff --git a/tests/integration/translation/response_contracts.py b/tests/integration/translation/response_contracts.py index c3182d269ff..6af3f9d681d 100644 --- a/tests/integration/translation/response_contracts.py +++ b/tests/integration/translation/response_contracts.py @@ -197,6 +197,7 @@ MESSAGES_OPTIONAL: Final = MappingProxyType( "server_tool_use", "service_tier", "inference_geo", + "speed", ) ), } diff --git a/tests/integration/translation/responses/bases/anthropic.py b/tests/integration/translation/responses/bases/anthropic.py index e30c1f15699..8d5051acb92 100644 --- a/tests/integration/translation/responses/bases/anthropic.py +++ b/tests/integration/translation/responses/bases/anthropic.py @@ -35,7 +35,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011CfgDzBnuPYc7TdysZFRpo", + "id": "msg_011Cg7v1CTrRytLeZfaZojBR", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -51,6 +51,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -130,10 +131,10 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CfgDzH3AcaSNXtvurLgAG", + "id": "msg_011Cg7v1kr1sYXyQA143nsda", "type": "message", "role": "assistant", - "content": [{"type": "text", "text": "Hello!"}], + "content": [{"type": "text", "text": "Hello! How can I help you?"}], "container": None, "stop_reason": "end_turn", "stop_sequence": None, @@ -143,9 +144,10 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, - "output_tokens": 5, + "output_tokens": 11, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -164,7 +166,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "id": ANY, "status": "completed", "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!", "annotations": []}], + "content": [{"type": "output_text", "text": "Hello! How can I help you?", "annotations": []}], "phase": None, } ], @@ -190,9 +192,9 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "video_tokens": None, "cache_write_tokens": 0, }, - "output_tokens": 5, - "output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 0, "text_tokens": 5}, - "total_tokens": 23, + "output_tokens": 11, + "output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 0, "text_tokens": 11}, + "total_tokens": 29, "cost": None, }, "user": None, @@ -225,7 +227,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-5", - "id": "msg_011CfgDzPkj2HxBq8qGJsGSi", + "id": "msg_011Cg7v215mkRTDBRoPcerLf", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -242,6 +244,7 @@ CLAUDE_SONNET_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -321,7 +324,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfgDzV9vZCkuQJt15n3Z1", + "id": "msg_011Cg7v1PgnYbGrw8eGpka6f", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -338,6 +341,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -462,6 +466,7 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 313}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={**RESPONSES_RESPONSE, "model": "anthropic/claude-sonnet-5-5"}, @@ -534,6 +539,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 67}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -553,7 +559,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "requested.\n" "\n", "annotations": [], - }, + } ], "phase": None, "encrypted_content": json.dumps( @@ -565,7 +571,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "requested.\n" "\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], separators=(",", ":"), ), @@ -605,7 +611,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "range near the top of the file since no specific lines were requested.\n" "\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], }, }, @@ -698,6 +704,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 130}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -719,7 +726,7 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( "zero, negative, and non-integer inputs before moving to the test file.\n" "\n", "annotations": [], - }, + } ], "phase": None, "encrypted_content": json.dumps( @@ -842,6 +849,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -860,7 +868,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "rejection, then I'll read the test file in a separate step since there's " "nothing to derive from it for the read.", "annotations": [], - }, + } ], "phase": None, }, diff --git a/tests/integration/translation/responses/bases/azure_ai.py b/tests/integration/translation/responses/bases/azure_ai.py index 921fed14b45..dfcb454c482 100644 --- a/tests/integration/translation/responses/bases/azure_ai.py +++ b/tests/integration/translation/responses/bases/azure_ai.py @@ -31,7 +31,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-haiku-4-5-20251001", - "id": "msg_011Cfjax6mBrNVhFGQopmVCp", + "id": "msg_011Cg7vB393hLWW1Xxq6d6vm", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -47,6 +47,7 @@ CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -127,7 +128,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-sonnet-4-6", - "id": "msg_011CfjaxGKedCbjtb7HuAUBC", + "id": "msg_011Cg7vCAx8gxwjwBr2R1kxz", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], @@ -143,6 +144,7 @@ CLAUDE_SONNET_4_6_TEST_CASE: Final = TranslationTestCase( "output_tokens": 5, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, "diagnostics": None, }, @@ -320,7 +322,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( }, mock_provider_response={ "model": "claude-opus-4-8", - "id": "msg_011CfjaxUiYCUsqMC4iqfc2U", + "id": "msg_011Cg7vBUGeH2HFZFTDxVdBh", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello."}], @@ -337,6 +339,7 @@ CLAUDE_OPUS_4_8_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, "diagnostics": None, }, @@ -620,7 +623,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "suggesting a fix. Explain which file you need next, then call a read-only tool. " "Never change files. Use the responses client for this project review. Consider zero, " "negative and non-integer quantities before choosing the next file to inspect.", - }, + } ], }, mock_provider_response={ @@ -656,6 +659,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 73}, "service_tier": "standard", "inference_geo": "global", + "speed": "standard", }, }, expected_litellm_response={ @@ -673,7 +677,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "text": "I need to look at shop/validation.py to understand its contents, so I'll read " "through the first chunk of the file.\n\n", "annotations": [], - }, + } ], "phase": None, "encrypted_content": json.dumps( @@ -683,7 +687,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "thinking": "I need to look at shop/validation.py to understand its contents, so I'll read " "through the first chunk of the file.\n\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], separators=(",", ":"), ), @@ -699,7 +703,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "text": "I'll read `shop/validation.py`, starting with the `validate_quantity` function found " "in the search.", "annotations": [], - }, + } ], "phase": None, }, @@ -737,7 +741,7 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( "thinking": "I need to look at shop/validation.py to understand its contents, so I'll read " "through the first chunk of the file.\n\n", "signature": CLAUDE_HAIKU_5_5_THINKING_SIGNATURE_0, - }, + } ], }, }, @@ -764,7 +768,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "suggesting a fix. Explain which file you need next, then call a read-only tool. " "Never change files. Use the responses client for this project review. Consider zero, " "negative and non-integer quantities before choosing the next file to inspect.", - }, + } ], }, mock_provider_response={ @@ -801,6 +805,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "output_tokens_details": {"thinking_tokens": 0}, "service_tier": "standard", "inference_geo": "not_available", + "speed": "standard", }, }, expected_litellm_response={ @@ -818,7 +823,7 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase( "text": "I'll read the validation function; reading the test file at the same time is " "independent of it, so I'll fetch both now.", "annotations": [], - }, + } ], "phase": None, }, diff --git a/tests/integration/translation/responses/bases/vertex_ai.py b/tests/integration/translation/responses/bases/vertex_ai.py index 734ebf13613..59637e380ce 100644 --- a/tests/integration/translation/responses/bases/vertex_ai.py +++ b/tests/integration/translation/responses/bases/vertex_ai.py @@ -12,6 +12,7 @@ from integration.translation.templates.vertex_ai import ( ) GEMINI_3_5_FLASH_THOUGHT_SIGNATURE: Final = "AY89a1+kriNs9YsPegJQxgjTWsMlxsqPU/UkHDyz4EK+3FRrE52ygqPIHTvkblgKKiO5Fn8R9D3iu//i+YnuuZ+e7W0yxUoFScas89b5rn3vZHsihnUA7VTe3b7beth1IftteoTGZ3F8OSLrSIP8HKn4xTIx35eeNEt5oiD3K5rTQrdklqO277HvTDhu/LuZhZ0pzaWciGQEc61epIBBpC2ygu8V4L8TekUVZnz6qzSzxipdiQStQ8dZou43334hMnxOR6JjJCGxqt/1K+C0vXX6YP6f+Bph5VhPSnvy2Z1uApi3TUxAcsDDyVewIr+ha2G6DPhN1Oi5uZZNhmbvH01Uq5OOquF1uEICq2m/35LhJIbDNM9N7oI1Z6L94P9xdugeFXM2S7wdJfc8sdg09vEzNeA++GK25zRuB4hU7XyaG4XnWjmu2kswi4sf+EyrO/K0P2tb/ZCJu7kJTtWTiD1wgSIl1eqS8ORKKsXDN2hIbFrtsNR50WIvVzd6" +CLAUDE_HAIKU_5_5_RESPONSES_THINKING_SIGNATURE: Final = "CAQS0gYKEggSEAIYAjgBQgh0aGlua2luZxIMcbnLkNvj2D6NoBl7Ggw+Qgax2/68TAR1maEiMH6XEeKEqR1ewAiuj01fMNuD1KI7pCTXUsWWr6H7nSzP5BiRGXRrDCIxPYu2Xbvx7yrtBbbyId4ReZzx21wZ2+d0vkkpZM82PXY72wQRJOHLR4JNLRjiR2Jn+FrZ8mBo2mofFrlTSuioOVHc1hheiH4InRK57w2X9xaTXa+mwoWW5epPieHD26+NNJirF9vB832fvqDVOd+f8N0ZDUXUut8zl/Jxx+0BMOktMqyfehIOGumQmyv49E3JHVQGrx+Dd3KZjaQXrsRFf1K3AJ7p5XElR+cYis40W7EL3DE9EAtriZuDEt0fTct79fzZIZgHd6Dnua/rGRnjNjaaZKxGjnhfkVHwsZW4HqTNgwWQiD3lt2ncoLfsfd2IWwMEI5uWpN3GtvRkQvfzidmgGmx3dI8g0nBvLPuq0jzVIuuoKIKqzT7A5ae3Gs9eTlSDr2wOGfdyNf6Q2TLw0w7EegI4wYSExKhFkx0EytCGHJcbN0WClXXmHsRtrXnP7sxCMMY7B8TIpAoN7SXyNjJizOGgzoYL7PNUgqp5nVz/C9vTvudhSWbjLITdxFJ5hearL2rMhryfHBKvRGYC9RMy/EjjtINNwYpxdVX29mUE+8KT+41HymtJ1Ewjtoo8V/7XQnQk3cB7X2RFHcR89hAM/pSsB14ofMFoDk+jJitS+sCDZI0Xh7/ySU0o/OI4l+oD/yctZTHx/mIOiWRtnAOrveNT1ms0/2+4tcO0wi3pRrOB2s5ugYc1I3+GcRwILEIM3a4ABd5Dw2nW2fgUhfSTTGURm4Z91ciih8pKUS1ZPatFTOJ1ehKeUCXflkqY2PHnftiNB/8GzlomsQ2NoRw8UXD00oMmBvSvZpPN7khFLxWHtxLjleKkeKCzN7QZkOqvjAnUwy9TacT2ZvSXIxrvaep7dOyrlMEta/1KsYEcWuBxLr87Va4txXnmoV4RqGgaauNcR2wOGyRFJppxKAgYbmMnxams7cv/7IY2RVSgSQf2k4hFQYzRwrHngjapuZzgNS7UCMjvJJs4VdIypVOATsZdPzJToMgWaiEoRZ3zApQDgYhhGAE=" CLAUDE_HAIKU_4_5_TEST_CASE: Final = TranslationTestCase( scenario="basic", @@ -790,6 +791,160 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase( ) +CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase( + scenario="basic", + litellm_endpoint="/v1/responses", + litellm_request={**RESPONSES_REQUEST, "model": "vertex_ai/claude-haiku-5-5@default"}, + expected_provider_endpoint="/v1/projects/synthetic-vertex-project/locations/global/publishers/anthropic/models/claude-haiku-5-5@default:rawPredict", + expected_provider_headers={ + "authorization": "Bearer scripted-token", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-api-key": "scripted-token", + }, + expected_provider_request={ + **VERTEX_AI_ANTHROPIC_MESSAGES_REQUEST, + "system": [ + { + "type": "text", + "text": ( + "You help maintain a synthetic Python project. Read the relevant files before suggesting a fix. " + "Explain which file you need next, then call a read-only tool. Never change files. Use the responses " + "client for this project review. Consider zero, negative and non-integer quantities before choosing " + "the next file to inspect." + ), + }, + ], + }, + mock_provider_response={ + **VERTEX_AI_ANTHROPIC_MESSAGES_RESPONSE, + "model": "claude-haiku-5-5", + "id": "msg_vrtx_011Cg8BhBrWmiX5ZtMxpM9zA", + "content": [ + { + "type": "thinking", + "thinking": ( + "I should read shop/validation.py to check how it handles zero, negative, and non-integer values " + "before moving to the next file—I'll grab the first 60 lines since I don't know the full length yet.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_RESPONSES_THINKING_SIGNATURE, + }, + { + "type": "text", + "text": ( + "I'll read the validation function in `shop/validation.py`, starting around the match on line 18." + ), + }, + { + "type": "tool_use", + "id": "toolu_vrtx_01Jxxsojf34b9skuGH47XkJd", + "name": "read_file", + "input": {"path": "shop/validation.py", "start_line": 1, "end_line": 60}, + }, + ], + "usage": { + "input_tokens": 4, + "cache_creation_input_tokens": 873, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 873, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 255, + "output_tokens_details": {"thinking_tokens": 126}, + }, + }, + expected_litellm_response={ + **RESPONSES_RESPONSE, + "model": "vertex_ai/claude-haiku-5-5@default", + "output": [ + { + "type": "reasoning", + "id": ANY, + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": ( + "I should read shop/validation.py to check how it handles zero, negative, and non-integer " + "values before moving to the next file—I'll grab the first 60 lines since I don't know the " + "full length yet.\n\n" + ), + "annotations": [], + }, + ], + "phase": None, + "encrypted_content": json.dumps( + [ + { + "type": "thinking", + "thinking": ( + "I should read shop/validation.py to check how it handles zero, negative, and non-integer " + "values before moving to the next file—I'll grab the first 60 lines since I don't know the " + "full length yet.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_RESPONSES_THINKING_SIGNATURE, + }, + ], + separators=(",", ":"), + ), + }, + { + "type": "message", + "id": ANY, + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "I'll read the validation function in `shop/validation.py`, starting around the match on line 18.", + "annotations": [], + }, + ], + "phase": None, + }, + { + "arguments": '{"path": "shop/validation.py", "start_line": 1, "end_line": 60}', + "call_id": "toolu_vrtx_01Jxxsojf34b9skuGH47XkJd", + "name": "read_file", + "type": "function_call", + "id": "fc_toolu_vrtx_01Jxxsojf34b9skuGH47XkJd", + "namespace": None, + "status": "completed", + }, + ], + "usage": { + "input_tokens": 877, + "input_tokens_details": { + "audio_tokens": None, + "cached_tokens": 0, + "cached_tokens_details": None, + "image_tokens": None, + "text_tokens": 4, + "video_tokens": None, + "cache_write_tokens": 873, + }, + "output_tokens": 255, + "output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 126, "text_tokens": 129}, + "total_tokens": 1132, + "cost": None, + }, + "provider_specific_fields": { + "citations": None, + "thinking_blocks": [ + { + "type": "thinking", + "thinking": ( + "I should read shop/validation.py to check how it handles zero, negative, and non-integer " + "values before moving to the next file—I'll grab the first 60 lines since I don't know the " + "full length yet.\n\n" + ), + "signature": CLAUDE_HAIKU_5_5_RESPONSES_THINKING_SIGNATURE, + }, + ], + }, + }, +) + + CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase( scenario="basic", litellm_endpoint="/v1/responses", diff --git a/tests/integration/translation/responses/basic/test_responses_basic_vertex_ai.py b/tests/integration/translation/responses/basic/test_responses_basic_vertex_ai.py index 67c3cf4f7a9..bd9118482d0 100644 --- a/tests/integration/translation/responses/basic/test_responses_basic_vertex_ai.py +++ b/tests/integration/translation/responses/basic/test_responses_basic_vertex_ai.py @@ -5,6 +5,7 @@ from integration.translation.case import TranslationTestCase from integration.translation.responses.bases.vertex_ai import ( CLAUDE_FABLE_5_1_TEST_CASE, CLAUDE_HAIKU_4_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_SONNET_4_6_TEST_CASE, @@ -26,6 +27,7 @@ from integration.translation.runner import assert_translation CLAUDE_SONNET_5_TEST_CASE, CLAUDE_OPUS_4_8_TEST_CASE, CLAUDE_SONNET_5_5_TEST_CASE, + CLAUDE_HAIKU_5_5_TEST_CASE, CLAUDE_OPUS_5_5_TEST_CASE, CLAUDE_FABLE_5_1_TEST_CASE, GEMINI_3_5_FLASH_TEST_CASE, diff --git a/tests/test_litellm_rust/messages/test_dispatch.py b/tests/test_litellm_rust/messages/test_dispatch.py index c38104495a2..2a32e31c250 100644 --- a/tests/test_litellm_rust/messages/test_dispatch.py +++ b/tests/test_litellm_rust/messages/test_dispatch.py @@ -172,6 +172,28 @@ def test_native_receives_normalized_request_and_original_call_shape() -> None: assert call_kwargs["litellm_metadata"] is metadata +@pytest.mark.parametrize("stream, expected_timeout", ((True, 0.5), (False, 30.0))) +def test_native_streams_are_bounded_by_stream_timeout(stream: bool, expected_timeout: float) -> None: + captured: Final[list[NativeCall]] = [] + + def python(*call_args: object, **call_kwargs: object) -> AnthropicMessagesResponse: # kwargs-ok: rejected fallback + pytest.fail("Required Rust dispatch must not call Python") + + def native(request: NativeCall) -> AnthropicMessagesResponse: + captured.append(request) + return response() + + _ = _DISPATCH.run( + (16, MESSAGES, "anthropic/claude-sonnet-4-5"), + {"stream": stream, "timeout": 30.0, "stream_timeout": 0.5}, + python=python, + binding=messages_binding(native), + native=lambda hook, request, call_args, call_kwargs: hook(request), + rules=RUST_RULES, + ) + assert captured[0].resolved["timeout"] == expected_timeout + + def test_internal_async_marker_bypasses_native() -> None: args: Final[tuple[object, ...]] = (16, MESSAGES, "claude-sonnet-4-5") kwargs: Final[Mapping[str, object]] = {"is_async": True} diff --git a/tests/unit/batches/test_batch_utils.py b/tests/unit/batches/test_batch_utils.py index 912d4cfe84c..630b00e172b 100644 --- a/tests/unit/batches/test_batch_utils.py +++ b/tests/unit/batches/test_batch_utils.py @@ -17,6 +17,7 @@ deterministic stand-ins so the arithmetic under test is the only variable. import asyncio, json, time import logging from types import MappingProxyType +from typing import Final, Literal import httpx import pytest @@ -3347,3 +3348,30 @@ async def test_batch_retrieve_cost_tracking_with_partial_explicit_data(): assert mock_batch._hidden_params["batch_successful_requests"] == 8 assert mock_batch._hidden_params["batch_failed_requests"] == 0 assert mock_batch.usage == expected_usage + + +@pytest.mark.parametrize("provider", ("openai", "anthropic")) +@pytest.mark.parametrize("envelope", (1, "invalid", [1], None, {})) +@pytest.mark.parametrize("include_valid", (True, False)) +def test_invalid_batch_envelopes_preserve_recoverable_cost( + provider: Literal["openai", "anthropic"], envelope: object, include_valid: bool +) -> None: + invalid: Final[dict[str, object]] = {"response" if provider == "openai" else "result": envelope} + valid: Final[dict[str, object]] = ( + _success_row(model="batch-accounting-control", usage=_usage(100, 20)) + if provider == "openai" + else { + "result": { + "type": "succeeded", + "message": {"model": "batch-accounting-control", "usage": {"input_tokens": 100, "output_tokens": 20}}, + } + } + ) + entries: Final = (valid, invalid, valid) if include_valid else (invalid,) + result: Final = bu._aggregate_batch_cost_usage_models( + entries, provider, model_info={"input_cost_per_token_batches": 0.001, "output_cost_per_token_batches": 0.002} + ) + assert result.cost == pytest.approx(0.28 if include_valid else 0) + assert result.usage.prompt_tokens == (200 if include_valid else 0) + assert result.usage.completion_tokens == (40 if include_valid else 0) + assert (result.successful_requests, result.failed_requests) == (2 if include_valid else 0, 1) diff --git a/tests/unit/caching/test_caching_handler.py b/tests/unit/caching/test_caching_handler.py index 7d6b1e15fc6..51754c80dd6 100644 --- a/tests/unit/caching/test_caching_handler.py +++ b/tests/unit/caching/test_caching_handler.py @@ -15,6 +15,7 @@ from pydantic import TypeAdapter import litellm from litellm import aembedding, completion, embedding, aresponses, responses +from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.caching.caching import Cache from litellm.responses.streaming_iterator import CachedResponsesAPIStreamingIterator @@ -41,6 +42,7 @@ from litellm.types.utils import ( Embedding, ) from litellm.types.llms.openai import ResponsesAPIResponse +from collections import Counter from collections.abc import Awaitable, Callable, Mapping from datetime import timedelta, datetime from typing import Final, cast @@ -103,7 +105,7 @@ def test_sync_cache_hits_reach_sync_custom_logger( call(**request_kwargs) assert all_logged.wait(timeout=10) - assert logged_cache_hits == [None, True, True] + assert Counter(logged_cache_hits) == Counter({None: 1, True: 2}), logged_cache_hits @pytest.fixture @@ -303,6 +305,93 @@ async def test_cache_hit_reports_zero_response_cost(copilot_response_cache: Cach assert second._hidden_params["response_cost"] == 0 +@pytest.mark.asyncio +async def test_async_cache_hits_never_use_up_a_deployment_tpm_limit(copilot_response_cache: Cache) -> None: + router: Final = litellm.Router( + model_list=[ + { + "model_name": "tpm-limited", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + "mock_response": "cached answer", + "tpm": 100, + }, + } + ], + routing_strategy="usage-based-routing-v2", + num_retries=0, + ) + messages: Final = [{"role": "user", "content": "tpm limit and cache hits"}] + + async def call_and_settle() -> litellm.ModelResponse: + response: Final = await router.acompletion(model="tpm-limited", messages=messages, caching=True) + await asyncio.gather(*_PENDING_CACHE_WRITES) + await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10) + return response + + first: Final = await call_and_settle() + hits: Final = [await call_and_settle() for _ in range(8)] + + assert first.usage.total_tokens * len(hits) > 100 + assert all(hit._hidden_params["cache_hit"] is True for hit in hits) + + +def _logging_obj_left_with_a_cache_hit(messages: list[dict[str, str]]) -> LiteLLMLogging: + logging_obj: Final = LiteLLMLogging( + litellm_call_id=str(uuid.uuid4()), + call_type=CallTypes.acompletion.value, + model="gpt-4o-mini", + messages=messages, + function_id=str(uuid.uuid4()), + stream=False, + start_time=datetime.now(), + ) + logging_obj.caching_details = {"cache_hit": True, "cache_duration_ms": 1.0} + return logging_obj + + +@pytest.mark.asyncio +async def test_async_cache_miss_clears_the_hit_an_earlier_lookup_left_on_the_call( + copilot_response_cache: Cache, +) -> None: + messages: Final = [{"role": "user", "content": f"never cached {uuid.uuid4()}"}] + logging_obj: Final = _logging_obj_left_with_a_cache_hit(messages) + handler: Final = LLMCachingHandler( + original_function=litellm.acompletion, request_kwargs={}, start_time=datetime.now() + ) + + response: Final = await handler.async_get_cache( + model="gpt-4o-mini", + original_function=litellm.acompletion, + logging_obj=logging_obj, + start_time=datetime.now(), + call_type=CallTypes.acompletion.value, + kwargs={"model": "gpt-4o-mini", "messages": messages, "caching": True}, + ) + + assert response is not None and response.cached_result is None + assert logging_obj.caching_details is None + + +def test_sync_cache_miss_clears_the_hit_an_earlier_lookup_left_on_the_call(copilot_response_cache: Cache) -> None: + messages: Final = [{"role": "user", "content": f"never cached {uuid.uuid4()}"}] + logging_obj: Final = _logging_obj_left_with_a_cache_hit(messages) + handler: Final = LLMCachingHandler(original_function=completion, request_kwargs={}, start_time=datetime.now()) + + response: Final = handler.sync_get_cache( + model="gpt-4o-mini", + original_function=completion, + logging_obj=logging_obj, + start_time=datetime.now(), + call_type=CallTypes.completion.value, + kwargs={"model": "gpt-4o-mini", "messages": messages, "caching": True}, + ) + + assert response.cached_result is None + assert logging_obj.caching_details is None + + chat_completion_response = litellm.ModelResponse( id=str(uuid.uuid4()), choices=[ diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 83c59480d37..9aa494c1aab 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -82,6 +82,115 @@ def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_sea assert request_body["tools"][0] == {"type": "tool_search", "execution": "server"} +_MANTLE_RESPONSES_URL: Final = "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses" + + +@pytest.mark.parametrize( + "extra_body", + [ + {"reasoning_effort": "low", "reasoningSummary": "auto"}, + {"reasoning_effort": {"effort": "low"}, "reasoning_summary": "auto"}, + ], + ids=["string-effort", "dict-effort"], +) +@pytest.mark.parametrize( + ("model", "url", "model_info"), + [ + ("openai/gpt-5.4", "https://api.openai.com/v1/responses", None), + ("bedrock_mantle/openai.gpt-5.6-terra", _MANTLE_RESPONSES_URL, {"mode": "chat"}), + ], + ids=["openai", "mantle-chat-pin"], +) +def test_a_summary_alias_beside_an_extra_body_effort_reaches_the_responses_api( + monkeypatch: pytest.MonkeyPatch, + extra_body: Mapping[str, object], + model: str, + url: str, + model_info: Mapping[str, object] | None, +) -> None: + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + response_body: Final = { + "id": "resp_nested_effort_summary", + "object": "response", + "created_at": 1789788253, + "status": "completed", + "model": model.rsplit("/", 1)[-1], + "output": [ + { + "type": "message", + "id": "msg_nested_effort_summary", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + + with respx.mock(assert_all_called=True) as router: + upstream: Final = router.post(url).mock(return_value=httpx.Response(200, json=response_body)) + litellm.completion( + model=model, + messages=[{"role": "user", "content": "ping"}], + extra_body=dict(extra_body), + model_info=model_info, + api_key="test-key", + aws_region_name="us-east-1", + ) + + assert json.loads(upstream.calls.last.request.content)["reasoning"] == {"effort": "low", "summary": "auto"} + + +@pytest.mark.parametrize( + ("model", "url", "expected_summary"), + [ + ("bedrock_mantle/openai.gpt-5.6-terra", _MANTLE_RESPONSES_URL, "auto"), + ("openai/responses/gpt-5.5", "https://api.openai.com/v1/responses", "detailed"), + ], +) +@pytest.mark.parametrize("endpoint", ["chat_completions", "responses"]) +def test_auto_summary_sends_the_level_the_target_accepts( + monkeypatch: pytest.MonkeyPatch, model: str, url: str, expected_summary: str, endpoint: str +) -> None: + monkeypatch.setattr(litellm, "reasoning_auto_summary", True) + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + response_body: Final = { + "id": "resp_auto_summary", + "object": "response", + "created_at": 1789788253, + "status": "completed", + "model": model.rsplit("/", 1)[-1], + "output": [ + { + "type": "message", + "id": "msg_auto_summary", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + + with respx.mock(assert_all_called=True) as router: + upstream: Final = router.post(url).mock(return_value=httpx.Response(200, json=response_body)) + if endpoint == "chat_completions": + litellm.completion( + model=model, + messages=[{"role": "user", "content": "ping"}], + reasoning_effort="low", + api_key="test-key", + aws_region_name="us-east-1", + ) + else: + litellm.responses( + model=model, input="ping", reasoning_effort="low", api_key="test-key", aws_region_name="us-east-1" + ) + + request_body: Final = json.loads(upstream.calls[0].request.content) + assert request_body["reasoning"] == {"effort": "low", "summary": expected_summary} + + def test_convert_chat_completion_messages_to_responses_api_image_input(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, diff --git a/tests/unit/db_scripts/__init__.py b/tests/unit/db_scripts/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/db_scripts/test_strip_vector_store_internal_metadata.py b/tests/unit/db_scripts/test_strip_vector_store_internal_metadata.py new file mode 100644 index 00000000000..05b055f75a1 --- /dev/null +++ b/tests/unit/db_scripts/test_strip_vector_store_internal_metadata.py @@ -0,0 +1,198 @@ +import importlib.util +import json +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +import httpx +import pytest + +_SCRIPT: Final = Path(__file__).resolve().parents[3] / "db_scripts" / "strip_vector_store_internal_metadata.py" +_spec: Final = importlib.util.spec_from_file_location("strip_vector_store_internal_metadata", _SCRIPT) +strip_script: Final = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = strip_script +_spec.loader.exec_module(strip_script) + +POLLUTED: Final = { + "id": "vs_1", + "metadata": { + "user_api_key": "hash-value-1", + "user_api_key_hash": "hash-value-1", + "user_api_key_user_email": "member@example.com", + "endpoint": "http://proxy/v1/vector_stores", + "deployment": "openai/gpt-5", + "team": "llmproxy", + "trace_id": "trace-123", + }, +} +CLEAN: Final = {"id": "vs_2", "metadata": {"team": "llmproxy"}} +NO_METADATA: Final = {"id": "vs_3", "metadata": None} + + +def _fake_openai( + stores: tuple[Mapping[str, object], ...], page_size: int = 100, post_status: int = 200 +) -> tuple[httpx.MockTransport, list[httpx.Request]]: + seen: Final[list[httpx.Request]] = [] + by_id: Final = {store["id"]: store for store in stores} + + def handle(request: httpx.Request) -> httpx.Response: + seen.append(request) + path: Final = request.url.path.removeprefix("/v1") + if request.method == "POST": + if post_status != 200: + return httpx.Response(post_status, json={"error": {"message": "upstream rejected the update"}}) + return httpx.Response(200, json=by_id[path.removeprefix("/vector_stores/")]) + if path == "/vector_stores": + after: Final = request.url.params.get("after") + start: Final = 0 if after is None else [store["id"] for store in stores].index(after) + 1 + page: Final = stores[start : start + page_size] + return httpx.Response(200, json={"data": list(page), "has_more": start + page_size < len(stores)}) + return httpx.Response(200, json=by_id[path.removeprefix("/vector_stores/")]) + + return httpx.MockTransport(handle), seen + + +def _run(args: list[str], transport: httpx.MockTransport) -> int: + return strip_script.main(["--api-key", "sk-test", *args], transport=transport) + + +def _posts(seen: list[httpx.Request]) -> list[tuple[str, dict[str, object]]]: + return [(request.url.path, json.loads(request.content)) for request in seen if request.method == "POST"] + + +def test_dry_run_lists_matching_key_names_without_values_and_sends_no_update(capsys): + transport, seen = _fake_openai((POLLUTED, CLEAN)) + + exit_code: Final = _run([], transport) + + out: Final = capsys.readouterr().out + assert exit_code == 0 + assert ( + "vs_1: would delete 5 keys: deployment, endpoint, user_api_key, user_api_key_hash, user_api_key_user_email" + in out + ) + assert "scanned=2 matched=1 updated=0 failed=0" in out + assert "hash-value-1" not in out + assert "member@example.com" not in out + assert _posts(seen) == [] + assert {request.headers["authorization"] for request in seen} == {"Bearer sk-test"} + + +def test_apply_nulls_exactly_the_proxy_written_keys_and_leaves_caller_keys_alone(capsys): + transport, seen = _fake_openai((POLLUTED, CLEAN, NO_METADATA)) + + exit_code: Final = _run(["--apply"], transport) + + assert exit_code == 0 + assert _posts(seen) == [ + ( + "/v1/vector_stores/vs_1", + { + "metadata": { + "deployment": None, + "endpoint": None, + "user_api_key": None, + "user_api_key_hash": None, + "user_api_key_user_email": None, + } + }, + ) + ] + assert "scanned=3 matched=1 updated=1 failed=0" in capsys.readouterr().out + + +def test_extra_key_is_deleted_alongside_the_built_in_keys(): + transport, seen = _fake_openai((POLLUTED,)) + + _run(["--apply", "--extra-key", "trace_id"], transport) + + assert _posts(seen)[0][1]["metadata"]["trace_id"] is None + assert "team" not in _posts(seen)[0][1]["metadata"] + + +def test_listing_follows_has_more_with_the_last_store_id(capsys): + transport, seen = _fake_openai((CLEAN, POLLUTED, NO_METADATA), page_size=2) + + _run([], transport) + + assert [request.url.params.get("after") for request in seen] == [None, "vs_1"] + assert "scanned=3 matched=1" in capsys.readouterr().out + + +def test_listing_walks_more_pages_than_the_interpreter_recursion_limit(capsys): + stores: Final = tuple({"id": f"vs_{index}", "metadata": None} for index in range(sys.getrecursionlimit() + 50)) + transport, _ = _fake_openai(stores, page_size=1) + + assert _run([], transport) == 0 + assert f"scanned={len(stores)} matched=0" in capsys.readouterr().out + + +def test_non_string_metadata_values_are_scanned_without_printing_them(capsys): + transport, _ = _fake_openai(({"id": "vs_9", "metadata": {"user_api_key": "secret-value", "count": 7}},)) + + exit_code: Final = _run([], transport) + + out: Final = capsys.readouterr().out + assert exit_code == 0 + assert "vs_9: would delete 1 keys: user_api_key" in out + assert "secret-value" not in out + + +def test_an_unexpected_response_shape_exits_non_zero_without_echoing_metadata(capsys): + def malformed(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": [{"metadata": {"user_api_key": "secret-value"}}], "has_more": False}) + + exit_code: Final = _run([], httpx.MockTransport(malformed)) + + captured: Final = capsys.readouterr() + assert exit_code == 2 + assert "unexpected vector store response shape" in captured.err + assert "secret-value" not in captured.out + captured.err + + +def test_vector_store_id_retrieves_only_the_named_stores_without_listing(capsys): + transport, seen = _fake_openai((POLLUTED, CLEAN, NO_METADATA)) + + _run(["--vector-store-id", "vs_2", "--vector-store-id", "vs_1"], transport) + + assert [request.url.path for request in seen] == ["/v1/vector_stores/vs_2", "/v1/vector_stores/vs_1"] + assert "scanned=2 matched=1" in capsys.readouterr().out + + +def test_a_failed_update_is_reported_and_exits_non_zero(capsys): + transport, _ = _fake_openai((POLLUTED,), post_status=400) + + exit_code: Final = _run(["--apply"], transport) + + out: Final = capsys.readouterr().out + assert exit_code == 1 + assert "vs_1: failed to delete 5 keys (HTTP 400: upstream rejected the update)" in out + assert "updated=0 failed=1" in out + + +def test_a_listing_error_exits_non_zero_without_updating_anything(): + seen: Final[list[httpx.Request]] = [] + + def reject(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(401, json={"error": {"message": "bad key"}}) + + assert _run(["--apply"], httpx.MockTransport(reject)) == 2 + assert [request.method for request in seen] == ["GET"] + + +def test_an_unreachable_api_exits_non_zero(): + def unreachable(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + assert _run([], httpx.MockTransport(unreachable)) == 2 + + +def test_an_api_key_is_required(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + with pytest.raises(SystemExit) as exit_info: + strip_script.main([]) + + assert exit_info.value.code == 2 diff --git a/tests/unit/enterprise/proxy/test_managed_files_access_check.py b/tests/unit/enterprise/proxy/test_managed_files_access_check.py index 62302446a29..0cdc212502c 100644 --- a/tests/unit/enterprise/proxy/test_managed_files_access_check.py +++ b/tests/unit/enterprise/proxy/test_managed_files_access_check.py @@ -9,6 +9,8 @@ with deployment credentials, bypassing the managed files access-control hooks. """ import base64 +import json +from typing import Final import pytest from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -367,13 +369,14 @@ async def test_check_batch_cost_should_call_afile_content_directly_with_credenti # managed_files_obj.afile_content should NOT have been called mock_managed_files_hook.afile_content.assert_not_called() - # Verify the DB update writes batch_processed, status, and file_object - mock_prisma.db.litellm_managedobjecttable.update.assert_called_once() - update_call_kwargs = ( - mock_prisma.db.litellm_managedobjecttable.update.call_args.kwargs + claim_data: Final = next( + call.kwargs["data"] + for call in mock_prisma.db.litellm_managedobjecttable.update_many.call_args_list + if call.kwargs["where"].get("id") == mock_job.id ) - assert update_call_kwargs["data"]["batch_processed"] is True - assert update_call_kwargs["data"]["status"] == "complete" - assert ( - "file_object" in update_call_kwargs["data"] - ), "file_object must be written to DB so list_batches reads updated status" + assert claim_data["batch_processed"] is True + assert claim_data["status"] == "complete" + persisted_batch: Final = json.loads(claim_data["file_object"]) + assert persisted_batch["status"] == "completed" + assert persisted_batch["usage"]["prompt_tokens"] == 10 + assert persisted_batch["usage"]["completion_tokens"] == 5 diff --git a/tests/unit/integration_support/test_process.py b/tests/unit/integration_support/test_process.py index 4b053fb5a97..7741de93a80 100644 --- a/tests/unit/integration_support/test_process.py +++ b/tests/unit/integration_support/test_process.py @@ -2,6 +2,8 @@ from __future__ import annotations import importlib import os +import select +import socket import subprocess import sys import threading @@ -26,6 +28,7 @@ from tests.integration._support.process import ( OwnedProxy, WorkerHealthcheck, WorkerKill, + _is_gone, healthcheck_seconds, kill_stray_process, owned_proxy_process, @@ -469,6 +472,46 @@ def test_a_frozen_victim_stays_stopped_until_the_kill() -> None: assert victim.status() == psutil.STATUS_ZOMBIE +_LEADER_EXITS_FIRST: Final = """import ctypes +import socket +import threading + +listener = socket.create_server(("127.0.0.1", 0)) + + +def hold() -> None: + connection, _ = listener.accept() + connection.recv(1) + + +threading.Thread(target=hold).start() +print(listener.getsockname()[1], flush=True) +ctypes.CDLL(None).pthread_exit(None) +""" + + +def _closed_by_peer(connection: socket.socket) -> bool: + return bool(select.select((connection,), (), (), 0)[0]) + + +@pytest.mark.skipif(sys.platform != "linux", reason="only Linux keeps a zombie thread-group leader's other threads") +def test_a_process_is_not_gone_while_a_thread_outliving_its_leader_still_holds_its_sockets() -> None: + with subprocess.Popen((sys.executable, "-I", "-c", _LEADER_EXITS_FIRST), stdout=subprocess.PIPE) as child: + try: + assert child.stdout is not None + port: Final = int(child.stdout.readline()) + with socket.create_connection(("127.0.0.1", port), timeout=5) as connection: + leader: Final = psutil.Process(child.pid) + eventually(leader.status, lambda status: status == psutil.STATUS_ZOMBIE, seconds=10) + assert not _closed_by_peer(connection) + assert not _is_gone(leader) + connection.sendall(b"x") + eventually(lambda: _is_gone(leader), bool, seconds=10) + assert _closed_by_peer(connection) + finally: + child.kill() + + def _kill_after_freezing_the_supervisor(owned: OwnedProxy, worker: int, supervisor: psutil.Process) -> None: with owned.replacing_worker(worker) as kill: supervisor.suspend() diff --git a/tests/unit/integration_support/test_sharding.py b/tests/unit/integration_support/test_sharding.py index 2317279f2b2..0ffd7f3572a 100644 --- a/tests/unit/integration_support/test_sharding.py +++ b/tests/unit/integration_support/test_sharding.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +import subprocess +import sys from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -13,6 +16,21 @@ from tests.integration.conftest import COLLECTED, IntegrationReportPlugin, _shar from tests.integration.conftest import INVENTORY as WORKER_INVENTORY INVENTORY: Final = ("test_owned.py::test_first", "test_owned.py::test_skipped", "test_owned.py::test_last") +INTEGRATION_PACKAGE: Final = Path(__file__).resolve().parents[2] / "integration" +HEALTHY: Final = "tests/integration/providers/test_healthy.py" +BROKEN: Final = "tests/integration/providers/test_broken.py" +RENAMED: Final = "tests/integration/providers/test_renamed.py" +CLASS_ERROR: Final = "tests/integration/providers/test_class_error.py" +MODULES: Final = MappingProxyType( + { + HEALTHY: "def test_one():\n pass\n\n\ndef test_two():\n pass\n", + BROKEN: "from tests.integration._support.process import _removed_helper\n\n\ndef test_never():\n pass\n", + CLASS_ERROR: ( + "import pytest\n\n\nclass TestBroken:\n" + " @pytest.mark.parametrize('unused', [1])\n def test_case(self):\n pass\n" + ), + } +) def _execution( @@ -111,3 +129,88 @@ def test_worker_results_retain_the_full_inventory_and_reject_disagreement() -> N incomplete: Final = WorkerResult(MappingProxyType({"integration_inventory": INVENTORY[:1]})) with pytest.raises(pytest.UsageError, match="inventories disagree"): plugin.pytest_testnodedown(incomplete, None) + + +XDIST: Final = ("-n", "2", "--dist=loadgroup") +SERIAL: Final = ("-p", "no:xdist") + + +def _run_shard( + root: Path, selected: tuple[str, ...], distribution: tuple[str, ...] = XDIST +) -> subprocess.CompletedProcess[str]: + package: Final = root / "tests" / "integration" + (package / "providers").mkdir(parents=True) + (root / "pytest.ini").write_text("[pytest]\n") + (root / "tests" / "__init__.py").write_text("") + for name in ("__init__.py", "conftest.py", "run.py", "_support"): + (package / name).symlink_to(INTEGRATION_PACKAGE / name) + for file in selected: + if file in MODULES: + (root / file).write_text(MODULES[file]) + (root / "shard.txt").write_text("".join(f"{file}\n" for file in selected)) + return subprocess.run( + ( + sys.executable, + "-P", + "-m", + "pytest", + "tests/integration/providers", + "-p", + "no:cacheprovider", + *distribution, + "--integration-shard-count=2", + "--integration-shard-index=0", + "--integration-shard-files", + "shard.txt", + ), + cwd=root, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(root), + "PYTHONPATH": os.pathsep.join((str(root), str(root / "tests"))), + }, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + + +@pytest.mark.parametrize( + ("distribution", "exit_code", "outcome"), + ( + (XDIST, pytest.ExitCode.TESTS_FAILED, "2 passed, 1 error"), + (SERIAL, pytest.ExitCode.INTERRUPTED, "Interrupted: 1 error during collection"), + ), + ids=("xdist", "serial"), +) +def test_a_broken_import_in_a_shard_is_a_collection_error_not_a_worker_crash( + tmp_path: Path, distribution: tuple[str, ...], exit_code: pytest.ExitCode, outcome: str +) -> None: + result: Final = _run_shard(tmp_path, (HEALTHY, BROKEN), distribution) + assert result.returncode == exit_code, result.stdout + result.stderr + assert outcome in result.stdout + assert result.stdout.count(f"ERROR collecting {BROKEN}") == 1 + assert "cannot import name '_removed_helper'" in result.stdout + assert "INTERNALERROR" not in result.stdout + result.stderr + + +def test_a_shard_file_that_collects_no_tests_still_fails_as_a_collection_error(tmp_path: Path) -> None: + result: Final = _run_shard(tmp_path, (HEALTHY, RENAMED)) + assert result.returncode == pytest.ExitCode.TESTS_FAILED, result.stdout + result.stderr + assert result.stdout.count(f"ERROR collecting {RENAMED}") == 1 + assert "2 passed" in result.stdout + assert "INTERNALERROR" not in result.stdout + result.stderr + + +def test_a_class_level_collection_error_is_not_also_reported_as_a_file_without_tests(tmp_path: Path) -> None: + result: Final = _run_shard(tmp_path, (HEALTHY, CLASS_ERROR), SERIAL) + assert result.returncode == pytest.ExitCode.INTERRUPTED, result.stdout + result.stderr + assert f"ERROR {CLASS_ERROR}::TestBroken" in result.stdout + assert "which has no tests" not in result.stdout + + +def test_a_healthy_shard_runs_every_selected_test(tmp_path: Path) -> None: + result: Final = _run_shard(tmp_path, (HEALTHY,)) + assert result.returncode == pytest.ExitCode.OK, result.stdout + result.stderr + assert "2 passed" in result.stdout diff --git a/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json b/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json index f7ebf357d83..c6cf8ee7ee4 100644 --- a/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json +++ b/tests/unit/integrations/gcs_pubsub/spend_logs_payload.json @@ -11,7 +11,7 @@ "user": "", "team_id": "", "organization_id": "", - "metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}", + "metadata": "{\"actor_agent_id\": null, \"target_agent_id\": null, \"billing_agent_id\": null, \"agent_execution_mode\": null, \"verified_human_user_id\": null, \"used_client_oauth_token\": null, \"applied_guardrails\": [], \"attempted_fallbacks\": null, \"original_model_group\": null, \"batch_models\": null, \"batch_successful_requests\": null, \"batch_failed_requests\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"routing_decision\": null, \"internal_call_origin\": null, \"litellm_roi_estimator\": false, \"router_metadata\": null, \"autorouter_savings_estimate\": null, \"autorouter_baseline_observation\": null, \"azure_spillover\": null, \"guardrail_information\": null, \"compression_savings\": null, \"litellm_gateway_injected_cache\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_user_email\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"user_agent\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}", "cache_key": "Cache OFF", "spend": 0.00022500000000000002, "total_tokens": 30, diff --git a/tests/unit/integrations/otel/test_otel_v2_components.py b/tests/unit/integrations/otel/test_otel_v2_components.py index bc230a8bcb2..3f4389765d3 100644 --- a/tests/unit/integrations/otel/test_otel_v2_components.py +++ b/tests/unit/integrations/otel/test_otel_v2_components.py @@ -2,6 +2,7 @@ baggage helpers, metrics, the typed coercion helpers, mapper branches, span-name builders, and the registry validator's failure paths. Needs the OTel SDK.""" +from typing import Final import contextlib import json import threading @@ -358,6 +359,18 @@ def test_genai_mapper_cost_breakdown_absent(): assert not any(k.startswith(LiteLLM.COST_PREFIX) and k != f"{LiteLLM.COST_PREFIX}total" for k in attrs) +def test_genai_mapper_cache_hit_attrs(): + from litellm.integrations.otel.model.semconv import LiteLLM + + attrs: Final = GenAIMapper().map(replace(_full_llm_call(), cache_hit=True, saved_cache_cost=0.002)) + assert attrs[LiteLLM.CACHE_HIT] is True + assert attrs[LiteLLM.SAVED_CACHE_COST] == 0.002 + + uncached: Final = GenAIMapper().map(_full_llm_call()) + assert LiteLLM.CACHE_HIT not in uncached + assert LiteLLM.SAVED_CACHE_COST not in uncached + + def test_llm_cost_from_breakdown_maps_costbreakdown_keys(): cost = LLMCost.from_breakdown( { diff --git a/tests/unit/integrations/otel/test_otel_v2_emitter.py b/tests/unit/integrations/otel/test_otel_v2_emitter.py index 949eb97bccd..0905e9e7c30 100644 --- a/tests/unit/integrations/otel/test_otel_v2_emitter.py +++ b/tests/unit/integrations/otel/test_otel_v2_emitter.py @@ -2,6 +2,7 @@ legacy dual-emit, hierarchy, error status, and idempotency. Needs the OTel SDK.""" import json +from typing import Final import pytest @@ -642,25 +643,71 @@ def test_preset_message_key_the_fit_sheds_still_never_overflows_the_span(): assert len(finished.attributes) <= SpanLimits().max_span_attributes -def test_attribute_budget_counts_only_keys_the_fit_does_not_overwrite(): +def test_preset_keys_the_mappers_overwrite_shed_no_message_attribute(monkeypatch): """Pre-set attributes the mapped set overwrites consume no slot against the span limit. A boundary-opened LLM-call span already carries a few stamped attributes, - most of which the mappers re-emit under the same keys. Charging those - against the budget reserves slots the fit can never spend and sheds - indexed message attributes for nothing. + most of which the mappers re-emit under the same keys, so a span sized to + exactly its mapped attributes keeps every indexed message. """ + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1000") + full: Final = _conversation_span(["genai", "openinference"], _conversation_payload(6)).attributes + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", str(len(full))) + cfg: Final = OpenTelemetryV2Config( + exporter="in_memory", + legacy_compat=False, + mapper_names=["genai", "openinference"], + capture_message_content="span_only", + ) + provider, exporter = providers.in_memory_provider(cfg) + engine: Final = SpanEmitter(providers.get_tracer(provider, "litellm-test"), cfg) + span: Final = engine.start_span(SpanRole.LLM_CALL, "chat gpt-4o") + span.set_attribute("gen_ai.request.model", "gpt-4o") + span.set_attribute("llm.input_messages.1.message.role", "stale-role") + engine.finish_span( + SpanRole.LLM_CALL, + span, + LLMCallSpanData.from_standard_logging_payload(_conversation_payload(6), capture_content=True), + ) + (finished,) = exporter.get_finished_spans() + assert finished.dropped_attributes == 0 + assert dict(finished.attributes) == dict(full) + + +def test_a_preset_message_key_the_fit_sheds_never_overflows_an_exact_limit(monkeypatch): + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1000") + full: Final = _conversation_span(["genai", "openinference"], _conversation_payload(6)).attributes + limit: Final = len(full) - 2 + monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", str(limit)) + cfg: Final = OpenTelemetryV2Config( + exporter="in_memory", + legacy_compat=False, + mapper_names=["genai", "openinference"], + capture_message_content="span_only", + ) + provider, exporter = providers.in_memory_provider(cfg) + engine: Final = SpanEmitter(providers.get_tracer(provider, "litellm-test"), cfg) + span: Final = engine.start_span(SpanRole.LLM_CALL, "chat gpt-4o") + span.set_attribute("llm.input_messages.1.message.role", "stale-role") + engine.finish_span( + SpanRole.LLM_CALL, + span, + LLMCallSpanData.from_standard_logging_payload(_conversation_payload(6), capture_content=True), + ) + (finished,) = exporter.get_finished_spans() + assert finished.dropped_attributes == 0 + assert len(finished.attributes) <= limit + assert finished.attributes.get("llm.input_messages.1.message.role") != "stale-role" + + +def test_attribute_budget_charges_every_key_the_span_already_carries(): cfg = OpenTelemetryV2Config(exporter="in_memory") provider, _exporter = providers.in_memory_provider(cfg) span = providers.get_tracer(provider, "litellm-test").start_span("s") span.set_attribute("gen_ai.request.model", "gpt-4o") span.set_attribute("litellm.metadata.user_api_key_alias", "edge-key") try: - assert attribute_budget(span, 0) == SpanLimits().max_span_attributes - 2 - assert ( - attribute_budget(span, 0, frozenset({"gen_ai.request.model"})) - == SpanLimits().max_span_attributes - 1 - ) + assert attribute_budget(span, 1) == SpanLimits().max_span_attributes - 3 finally: span.end() diff --git a/tests/unit/integrations/otel/test_otel_v2_logger.py b/tests/unit/integrations/otel/test_otel_v2_logger.py index 6a9a963948e..10e12981ec1 100644 --- a/tests/unit/integrations/otel/test_otel_v2_logger.py +++ b/tests/unit/integrations/otel/test_otel_v2_logger.py @@ -494,6 +494,49 @@ def test_no_span_when_pre_call_never_ran(): assert exporter.get_finished_spans() == () # no phantom LLM span +def test_cache_hit_without_pre_call_emits_zero_cost_span(): + """A response served from the litellm cache never hands off to a provider, + so ``pre_call`` never runs and ``api_call_start_time`` is never stamped, + but the completed call's payload still warrants a span, carrying the cache + attributes rather than being dropped like a gate rejection.""" + logger, exporter = _logger() + payload: Final = _payload( + cache_hit=True, + response_cost=0.0, + saved_cache_cost=0.002, + cost_breakdown={"input_cost": 0.0, "output_cost": 0.0, "total_cost": 0.0, "tool_usage_cost": 0.0}, + ) + asyncio.run( + logger.async_log_success_event(_kwargs(payload=payload), None, None, None) + ) + (span,) = exporter.get_finished_spans() + assert span.attributes[LiteLLM.CACHE_HIT] is True + assert span.attributes[LiteLLM.SAVED_CACHE_COST] == 0.002 + assert { + name: span.attributes[f"{LiteLLM.COST_PREFIX}{name}"] for name in ("total", "input", "output", "tool_usage") + } == {"total": 0.0, "input": 0.0, "output": 0.0, "tool_usage": 0.0} + assert "server.address" not in span.attributes + + +def test_cache_miss_span_reports_cache_hit_false(): + logger, exporter = _logger() + _emit_llm(logger, _kwargs(payload=_payload(cache_hit=False, saved_cache_cost=0.0))) + (span,) = exporter.get_finished_spans() + assert span.attributes[LiteLLM.CACHE_HIT] is False + assert LiteLLM.SAVED_CACHE_COST not in span.attributes + + +def test_cache_miss_without_pre_call_still_emits_no_span(): + """The cache-hit relaxation is not a general loosening: a success callback + with no ``pre_call`` carrier and no cache hit still emits nothing.""" + logger, exporter = _logger() + payload: Final = _payload(cache_hit=False, response_cost=0.002) + asyncio.run( + logger.async_log_success_event(_kwargs(payload=payload), None, None, None) + ) + assert exporter.get_finished_spans() == () + + def test_real_llm_failure_still_emitted(): """A genuine LLM failure: ``pre_call`` ran (the call was attempted), so the CLIENT span is opened at the boundary and closed ERROR.""" diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 9ba09085ce8..b40c1a96500 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -478,6 +478,75 @@ class TestOpenTelemetryCostBreakdown(unittest.TestCase): assert ("gen_ai.cost.tool_usage_cost", 0.0001) not in call_args_list assert ("gen_ai.cost.original_cost", 0.004) not in call_args_list + def test_cache_hit_span_carries_cache_indicator_and_saved_cost(self): + """ + A cache-hit span reports litellm.cache_hit and litellm.saved_cache_cost so cost + attribution can tell a cache-served response apart from a billed call. + """ + otel: Final = OpenTelemetry() + mock_span: Final = MagicMock() + + kwargs: Final = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + "cache_hit": True, + "saved_cache_cost": 0.003, + "cost_breakdown": {"input_cost": 0.0, "output_cost": 0.0, "total_cost": 0.0, "tool_usage_cost": 0.0}, + }, + } + + response_obj: Final = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + mock_span.set_attribute.assert_any_call("litellm.cache_hit", True) + mock_span.set_attribute.assert_any_call("litellm.saved_cache_cost", 0.003) + mock_span.set_attribute.assert_any_call("gen_ai.cost.total_cost", 0.0) + mock_span.set_attribute.assert_any_call("gen_ai.cost.input_cost", 0.0) + mock_span.set_attribute.assert_any_call("gen_ai.cost.output_cost", 0.0) + + def test_non_cache_hit_span_reports_cache_hit_false(self): + otel: Final = OpenTelemetry() + mock_span: Final = MagicMock() + + kwargs: Final = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + "cache_hit": False, + }, + } + + response_obj: Final = { + "id": "test-response-id", + "model": "gpt-4", + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + call_args_list: Final = [call[0] for call in mock_span.set_attribute.call_args_list] + assert ("litellm.cache_hit", False) in call_args_list + assert all(key != "litellm.saved_cache_cost" for key, _value in call_args_list) + assert all(not key.startswith("gen_ai.cost.") for key, _value in call_args_list) + class TestOpenTelemetryProviderInitialization(unittest.TestCase): """Test suite for verifying provider initialization respects existing providers""" diff --git a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py index b24348cfaa8..731e842090a 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -20,6 +20,7 @@ from litellm.litellm_core_utils.exception_mapping_utils import ( exception_type, extract_and_raise_litellm_exception, ) +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.openai.common_utils import OpenAIError @@ -57,6 +58,104 @@ def test_openai_cyber_policy_uses_the_structured_error_code(shape: str, code: st assert caught.value.response.headers["x-request-id"] == "cyber-policy-request" + +def _debug_info_for_api_base(configured_api_base: str) -> str: + request: Final = httpx.Request("POST", "https://gateway.example/v1/chat/completions") + original: Final = OpenAIError( + status_code=500, message="upstream failed", request=request, response=httpx.Response(500, request=request) + ) + with pytest.raises(litellm.InternalServerError) as caught: + exception_type( + model="gpt-5.4-mini", + original_exception=original, + custom_llm_provider="openai", + completion_kwargs={}, + extra_kwargs={"api_base": configured_api_base}, + ) + debug_info: Final = caught.value.litellm_debug_info + assert isinstance(debug_info, str) + return debug_info + + +@pytest.mark.parametrize( + ("configured_api_base", "shown_api_base"), + [ + ("https://qa-user:function-secret@gateway.example/v1?code=function-secret", "https://gateway.example/v1"), + ("https://gateway.example/v1#function-secret", "https://gateway.example/v1"), + ("qa-user:function-secret@gateway.example:8080/v1", "gateway.example:8080/v1"), + ("https://gateway.example?code=function/secret", "https://gateway.example"), + ("https://gateway.example?user=alice@example.org&code=function-secret", "https://gateway.example"), + ("https://gateway.example?email=ops@other-host.example", "https://gateway.example"), + ("qa-user:function-secret@gateway.example/v1?next=https://other.example/y", "gateway.example/v1"), + ( + "https://qa-user:function-secret@gateway.example/models/claude-opus-5-5@default", + "https://gateway.example/models/claude-opus-5-5@default", + ), + ], + ids=[ + "userinfo_and_query", + "fragment", + "no_scheme", + "slash_in_query_without_path", + "at_sign_in_query_without_path", + "at_sign_names_other_host", + "no_scheme_with_url_in_query", + "userinfo_and_at_sign_in_path", + ], +) +def test_api_base_line_keeps_credentials_out(configured_api_base: str, shown_api_base: str) -> None: + debug_info: Final = _debug_info_for_api_base(configured_api_base) + assert f"API Base: `{shown_api_base}`" in debug_info + assert "function-secret" not in debug_info + assert "secret" not in debug_info.split("API Base:", 1)[1].split("\n", 1)[0] + + +@pytest.mark.parametrize( + "configured_api_base", + [ + "https://qa-user:qa?function-secret@gateway.example/v1", + "https://qa-user:qa#function-secret@gateway.example/v1", + "https://qa-user:qa/function-secret@gateway.example/v1", + "https://qa-user:qa/function?secret@gateway.example/v1", + "https://qa.user:qa/function-secret@gateway.example/v1", + "https://qa-user:1234/function-secret@gateway.example/v1", + "https://function-secret/more@gateway.example/v1", + "qa-user:qa://function-secret@gateway.example", + ], + ids=[ + "question_mark_in_password", + "hash_in_password", + "slash_in_password", + "slash_and_question_mark_in_password", + "dotted_username_and_slash_in_password", + "numeric_password_with_slash", + "slash_in_username", + "no_scheme_with_scheme_separator_in_password", + ], +) +def test_api_base_line_is_left_out_when_a_password_breaks_the_url(configured_api_base: str) -> None: + debug_info: Final = _debug_info_for_api_base(configured_api_base) + assert "API Base:" not in debug_info + assert not any(part in debug_info for part in ("qa-user", "qa.user", "function", "secret")) + + +@pytest.mark.parametrize( + "api_base", + [ + "https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5" + "/publishers/anthropic/models/claude-opus-5-5@default:rawPredict", + "us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1" + "/publishers/mistralai/models/mistral-large@2407:rawPredict", + "https://gateway.ai.cloudflare.com/v1/account/gateway/workers-ai/@cf/meta/llama-3.1-8b-instruct", + "http://localhost:4000/v1/models/claude@default", + "https://[::1]:8080/v1/models/claude@default", + ], + ids=["vertex_partner_model", "vertex_without_scheme", "workers_ai_path", "localhost", "ipv6"], +) +def test_api_base_line_keeps_an_at_sign_in_the_path(api_base: str) -> None: + assert f"API Base: `{api_base}`" in _debug_info_for_api_base(api_base) + + # Test cases for is_error_str_context_window_exceeded # Tuple format: (error_message, expected_result) context_window_test_cases = [ @@ -480,6 +579,131 @@ def test_vertex_ai_rate_limit_error_mapping(error_message, should_raise_rate_lim ) +def test_vertex_status_code_429_ignores_403_token_in_message() -> None: + original_exception: Final = BaseLLMException(status_code=429, message="scripted vertex rejection f4039") + + with pytest.raises(litellm.RateLimitError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 429 + + +def test_vertex_status_code_403_maps_to_403() -> None: + original_exception: Final = BaseLLMException(status_code=403, message="permission denied") + + with pytest.raises(litellm.PermissionDeniedError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 403 + + +def test_vertex_status_code_403_with_text_token_maps_to_bad_request() -> None: + original_exception: Final = BaseLLMException( + status_code=403, + message='{"error":{"code":403,"message":"Permission denied"}}', + ) + + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 403 + + +def test_vertex_text_403_without_status_code_still_maps_to_bad_request() -> None: + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=Exception("403 Forbidden: permission denied"), + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 403 + + +def test_vertex_status_substring_without_status_code_does_not_map_to_403() -> None: + with pytest.raises(litellm.APIConnectionError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=Exception("scripted vertex rejection f4039a"), + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code != 403 + + +def test_vertex_wrapped_500_with_403_body_maps_to_bad_request() -> None: + original_exception: Final = BaseLLMException( + status_code=500, + message='Error code: 403 - {"error": {"code": 403, "message": "Permission denied"}}', + ) + + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 403 + + +def test_vertex_wrapped_500_with_embedded_403_text_does_not_map_to_403() -> None: + original_exception: Final = BaseLLMException(status_code=500, message="scripted vertex rejection f4039") + + with pytest.raises(litellm.InternalServerError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 500 + + +def test_vertex_wrapped_500_with_body_code_429_and_403_token_maps_to_rate_limit() -> None: + original_exception: Final = BaseLLMException( + status_code=500, + message='{"error":{"code":429,"message":"upstream unavailable (request id: 403-xyz)"}}', + ) + + with pytest.raises(litellm.RateLimitError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 429 + + +def test_vertex_wrapped_500_with_body_code_403_maps_to_bad_request() -> None: + original_exception: Final = BaseLLMException( + status_code=500, + message='{"error":{"code":403,"message":"Permission denied"}}', + ) + + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model="gemini/gemini-2.5-flash", + original_exception=original_exception, + custom_llm_provider="vertex_ai", + ) + + assert excinfo.value.status_code == 403 + + class TestGetBodyErrorCode: """Unit tests for _get_body_error_code helper.""" diff --git a/tests/unit/litellm_core_utils/test_get_model_cost_map.py b/tests/unit/litellm_core_utils/test_get_model_cost_map.py index 0256ecf24ed..eca9bbdc170 100644 --- a/tests/unit/litellm_core_utils/test_get_model_cost_map.py +++ b/tests/unit/litellm_core_utils/test_get_model_cost_map.py @@ -686,6 +686,25 @@ def test_boot_load_does_not_retry_non_retryable_failure(): assert source["fallback_reason"] is not None +def test_boot_load_falls_back_when_urlsplit_rejects_the_url(): + client, calls = _mock_client([httpx.Response(200, content=_real_map_bytes())], client_cls=httpx.Client) + sleeper = _SyncSleepRecorder() + + cost_map = get_model_cost_map( + url="http://[bad/model_prices.json", + sleep=sleeper, + rng=random.Random(0), + client=client, + ) + assert calls["count"] == 0 + assert sleeper.waits == [] + assert _retry_threads() == [] + assert cost_map.keys() >= _load_root_cost_map().keys() - {"sample_spec", FALLBACK_GENERALIZATIONS_KEY} + source = get_model_cost_map_source_info() + assert source["source"] == "local" + assert "invalid URL" in (source["fallback_reason"] or "") + + def test_boot_load_respects_local_env_override(monkeypatch): """LITELLM_LOCAL_MODEL_COST_MAP=True still short-circuits to the backup with zero HTTP.""" monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index f314c45dae2..c7450c852c0 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -40,6 +40,7 @@ from litellm.litellm_core_utils.litellm_logging import ( StandardLoggingPayloadSetup, _extract_response_obj_and_hidden_params, _get_status_fields, + get_standard_logging_object_payload, set_callbacks, ) from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER @@ -52,6 +53,7 @@ from litellm.proxy.utils import InternalUsageCache from litellm.types.llms.openai import ResponseAPIUsage, ResponseCompletedEvent, ResponsesAPIResponse from litellm.types.utils import ( CallTypes, + CostBreakdown, ImageResponse, LiteLLMRealtimeStreamLoggingObject, ModelResponse, @@ -7400,6 +7402,75 @@ def test_payload_guardrail_cost_merges_into_existing_cost_breakdown(logging_obj) assert logging_obj.cost_breakdown["total_cost"] == pytest.approx(0.0000429) +def test_cache_hit_payload_keeps_zero_components_beside_guardrail_cost(logging_obj): + logging_obj.set_cost_breakdown( + input_cost=0.00003, + output_cost=0.0000129, + total_cost=0.0000429, + cost_for_built_in_tools_cost_usd_dollar=0.0, + ) + payload: Final = _build_success_payload(logging_obj, {**_guardrail_kwargs(response_cost=0.0), "cache_hit": True}) + + assert payload is not None + assert payload["response_cost"] == pytest.approx(0.0003) + assert payload["cost_breakdown"] == { + "input_cost": 0.0, + "output_cost": 0.0, + "tool_usage_cost": 0.0, + "service_tier": None, + "data_residency": None, + "vertex_location": None, + "guardrail_cost": pytest.approx(0.0003), + "total_cost": pytest.approx(0.0003), + } + + +def test_cache_hit_payload_keeps_the_served_pricing_basis_with_every_amount_at_zero(logging_obj): + logging_obj.set_cost_breakdown( + input_cost=0.00003, + output_cost=0.0000129, + total_cost=0.0000474, + cost_for_built_in_tools_cost_usd_dollar=0.0000011, + additional_costs={"azure_model_router_flat_cost": 0.00014}, + original_cost=0.00005, + discount_percent=0.1, + discount_amount=0.000005, + margin_percent=0.05, + margin_fixed_amount=0.000001, + margin_total_amount=0.0000035, + cache_read_cost=0.000002, + cache_creation_cost=0.000003, + reasoning_cost=0.000004, + service_tier="priority", + data_residency="eu", + vertex_location="europe-west4", + ) + kwargs: Final = {"litellm_call_id": "hit-basis-call", "model": "gpt-4o", "messages": [], "cache_hit": True} + + payload: Final = _build_success_payload(logging_obj, kwargs) + + assert payload is not None + assert payload["cost_breakdown"] == { + "input_cost": 0.0, + "output_cost": 0.0, + "total_cost": 0.0, + "tool_usage_cost": 0.0, + "additional_costs": {"azure_model_router_flat_cost": 0.0}, + "original_cost": 0.0, + "discount_percent": 0.1, + "discount_amount": 0.0, + "margin_percent": 0.05, + "margin_fixed_amount": 0.0, + "margin_total_amount": 0.0, + "cache_read_cost": 0.0, + "cache_creation_cost": 0.0, + "reasoning_cost": 0.0, + "service_tier": "priority", + "data_residency": "eu", + "vertex_location": "europe-west4", + } + + def test_payload_without_guardrail_cost_is_unchanged(logging_obj): kwargs = { "litellm_call_id": "no-guardrail-call", @@ -10187,6 +10258,245 @@ def test_signoz_dispatch_requires_an_endpoint(monkeypatch): is_otel_v2_enabled.cache_clear() +def _lit8679_priced_logging_obj(deployment_id: str, litellm_call_id: str) -> LitellmLogging: + logging_obj: Final = LitellmLogging( + model="gpt-5.4-nano", + messages=[{"role": "user", "content": "Hi"}], + stream=False, + call_type="completion", + start_time=time.time(), + litellm_call_id=litellm_call_id, + function_id="fn", + ) + logging_obj.update_environment_variables( + model="gpt-5.4-nano", + user="", + optional_params={}, + litellm_params={"metadata": {"model_group": "lit8679-group", "model_info": {"id": deployment_id}}}, + custom_llm_provider="openai", + ) + return logging_obj + + +def test_uncached_price_recomputes_price_past_a_zero_stamp(): + """A probe for the price a cache-served response would have had ignores the 0.0 stamped on it.""" + deployment_id: Final = "lit8679-uncached-cost-deployment" + litellm.register_model( + model_cost={ + deployment_id: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + }, + persist_across_reloads=False, + ) + try: + logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-uncached") + oracle_logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-oracle") + cached_response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + cached_response._hidden_params = {"cache_hit": True, "cache_key": "lit8679-key", "response_cost": 0.0} + fresh_response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + + cost: Final = logging_obj.uncached_price(result=cached_response).cost + uncached_price: Final = oracle_logging_obj._response_cost_calculator(result=fresh_response) + + assert cost == uncached_price + assert cost is not None and cost > 0.0 + assert logging_obj.cost_breakdown is None + finally: + litellm.model_cost.pop(deployment_id, None) + + +def test_uncached_price_leaves_no_pricing_state_on_the_call(): + """The uncached price probe leaves no cost breakdown or billed rates behind on the cache-hit call.""" + deployment_id: Final = "lit8679-probe-state-deployment" + litellm.register_model( + model_cost={ + deployment_id: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + }, + persist_across_reloads=False, + ) + try: + logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-probe-state") + cached_response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + cached_response._hidden_params = {"cache_hit": True, "cache_key": "lit8679-key", "response_cost": 0.0} + assert (logging_obj.cost_breakdown, logging_obj.billed_token_rates) == (None, None) + + cost: Final = logging_obj.uncached_price(result=cached_response).cost + + assert cost is not None and cost > 0.0 + assert (logging_obj.cost_breakdown, logging_obj.billed_token_rates) == (None, None) + finally: + litellm.model_cost.pop(deployment_id, None) + + +def _assert_zeroed_copy(hit_breakdown: CostBreakdown | None, fresh_breakdown: CostBreakdown) -> None: + basis_and_rates: Final = ("service_tier", "data_residency", "vertex_location", "discount_percent", "margin_percent") + assert hit_breakdown is not None and hit_breakdown.keys() == fresh_breakdown.keys() + assert all(hit_breakdown[key] == fresh_breakdown[key] for key in basis_and_rates if key in hit_breakdown) + assert all(value == 0.0 for key, value in hit_breakdown.items() if key not in basis_and_rates) + + +def test_payload_saved_cache_cost_keeps_uncached_price_when_result_stamped_zero(): + """A cache-hit payload still reports what the call would have cost, not the 0.0 stamped on the served response.""" + deployment_id: Final = "lit8679-saved-cost-deployment" + litellm.register_model( + model_cost={ + deployment_id: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + }, + persist_across_reloads=False, + ) + try: + logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-payload") + oracle_logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-payload-oracle") + response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + response._hidden_params = {"cache_hit": True, "cache_key": "lit8679-key", "response_cost": 0.0} + fresh_response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + logging_obj.model_call_details["cache_hit"] = True + now: Final = datetime.datetime.now() + payload: Final = get_standard_logging_object_payload( + kwargs={ + "model": "gpt-5.4-nano", + "messages": [], + "cache_hit": True, + "litellm_params": logging_obj.litellm_params, + }, + init_response_obj=response, + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + uncached_price: Final = oracle_logging_obj._response_cost_calculator(result=fresh_response) + + assert payload is not None + assert payload["saved_cache_cost"] == uncached_price + assert payload["saved_cache_cost"] > 0.0 + assert payload["response_cost"] == 0.0 + assert logging_obj.cost_breakdown is None + assert oracle_logging_obj.cost_breakdown is not None + _assert_zeroed_copy(payload["cost_breakdown"], oracle_logging_obj.cost_breakdown) + finally: + litellm.model_cost.pop(deployment_id, None) + + +def test_cache_hit_payload_reports_zero_cost_though_the_call_priced_the_response_as_fresh(): + """The proxy prices a /v1/messages body for its cost header before the success callback learns it was a cache + hit, and a replayed stream's assembled response carries its original price, so the call holds full-price numbers + that the hit's payload must not export.""" + deployment_id: Final = "lit8679-stale-breakdown-deployment" + litellm.register_model( + model_cost={ + deployment_id: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + }, + persist_across_reloads=False, + ) + try: + logging_obj: Final = _lit8679_priced_logging_obj(deployment_id, "lit8679-stale-breakdown") + response: Final = ModelResponse( + model="gpt-5.4-nano", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + header_price: Final = logging_obj.response_cost_calculator(result=response) + response._hidden_params["response_cost"] = header_price + logging_obj.model_call_details["cache_hit"] = True + now: Final = datetime.datetime.now() + payload: Final = get_standard_logging_object_payload( + kwargs={ + "model": "gpt-5.4-nano", + "messages": [], + "cache_hit": True, + "response_cost": 0.0, + "litellm_params": logging_obj.litellm_params, + }, + init_response_obj=response, + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + + assert header_price is not None and header_price > 0.0 + assert logging_obj.cost_breakdown is not None + assert payload is not None + assert payload["response_cost"] == 0.0 + assert payload["hidden_params"]["response_cost"] == 0.0 + _assert_zeroed_copy(payload["cost_breakdown"], logging_obj.cost_breakdown) + assert payload["saved_cache_cost"] == header_price + finally: + litellm.model_cost.pop(deployment_id, None) + + +def test_uncached_price_leaves_no_failure_debug_info_for_unpriced_model(): + """A probe that cannot price the model returns None without stamping the pricing failure on the cache-hit call.""" + logging_obj: Final = LitellmLogging( + model="lit8679-model-with-no-price", + messages=[{"role": "user", "content": "Hi"}], + stream=False, + call_type="completion", + start_time=time.time(), + litellm_call_id="lit8679-unpriced-probe", + function_id="fn", + ) + logging_obj.update_environment_variables( + model="lit8679-model-with-no-price", + user="", + optional_params={}, + litellm_params={"metadata": {}}, + custom_llm_provider="openai", + ) + cached_response: Final = ModelResponse( + model="lit8679-model-with-no-price", + choices=[litellm.Choices(message=litellm.Message(role="assistant", content="cached"))], + usage=litellm.Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30), + ) + cached_response._hidden_params = {"cache_hit": True, "cache_key": "lit8679-key", "response_cost": 0.0} + assert "response_cost_failure_debug_information" not in logging_obj.model_call_details + + cost: Final = logging_obj.uncached_price(result=cached_response).cost + + assert cost is None + assert "response_cost_failure_debug_information" not in logging_obj.model_call_details + + def test_enterprise_alerting_loggers_resolves_real_classes(): from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import PagerDutyAlerting from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ResendEmailLogger @@ -12286,3 +12596,40 @@ def test_a_websocket_turn_row_is_keyed_by_the_response_id_or_else_its_call_id( ) assert payload is not None assert payload["id"] == expected_row_id + + +@pytest.mark.parametrize("for_async_request, expected_s3_writes", [(True, 1), (False, 0)]) +def test_legacy_s3_logs_a_streamed_anthropic_messages_call_handed_off_from_the_async_side( + logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, for_async_request: bool, expected_s3_writes: int +) -> None: + """LIT-9834: the /v1/messages stream handler stores ``complete_streaming_response`` before any logger runs. + + The one hand-off from the async side (``for_async_request=True``) must still reach the legacy ``s3`` writer, + while the chunk-by-chunk sync path (``for_async_request=False``) keeps treating a stored response as logged.""" + complete_response: Final = ModelResponse( + id="msg_fake_stream_001", + model="claude-sonnet-4-5", + choices=[ + { + "message": {"role": "assistant", "content": "Hello from the fake upstream"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 8, "completion_tokens": 6, "total_tokens": 14}, + ) + logging_obj.stream = True + logging_obj.call_type = "anthropic_messages" + logging_obj.model_call_details["litellm_params"] = {"aanthropic_messages": True} + logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"] + logging_obj.model_call_details["complete_streaming_response"] = complete_response + s3_writer: Final = MagicMock() + monkeypatch.setattr(litellm_logging, "s3Logger", s3_writer) + monkeypatch.setattr(litellm, "success_callback", ["s3"]) + now: Final = datetime_unit_test(2026, 1, 1, 12, 0, 0) + + logging_obj.success_handler(complete_response, start_time=now, end_time=now, for_async_request=for_async_request) + + assert s3_writer.log_event.call_count == expected_s3_writes + if expected_s3_writes: + assert s3_writer.log_event.call_args.kwargs["response_obj"] is complete_response diff --git a/tests/unit/litellm_core_utils/test_served_output_texts.py b/tests/unit/litellm_core_utils/test_served_output_texts.py index ade39b8fca9..637a20571c8 100644 --- a/tests/unit/litellm_core_utils/test_served_output_texts.py +++ b/tests/unit/litellm_core_utils/test_served_output_texts.py @@ -1,3 +1,7 @@ +from typing import Final + +import pytest + from litellm.litellm_core_utils.served_output_texts import ( SERVED_OUTPUT_TEXTS_KEY, overlay_served_output_texts, @@ -79,6 +83,29 @@ def test_served_stream_output_texts_keeps_every_choice_index_when_chunks_carry_o assert served_stream_output_texts(chunks) == ("first choice", MASKED) +@pytest.mark.parametrize("content", (None, "", "first")) +def test_null_choice_index_joins_choice_zero_without_changing_other_choices(content: str | None) -> None: + chunks: Final = ( + _stream_chunk("other", 1), + ModelResponseStream( + choices=[StreamingChoices(index=None, delta=Delta(content=content, reasoning_content="loading"))] + ), + _stream_chunk(" answer"), + ) + assert served_stream_output_texts(chunks) == ((content or "") + " answer", "other") + assert chunks[1].choices[0].index is None + + +@pytest.mark.parametrize("content, expected", ((None, None), ("", ("",)), ("answer", ("answer",)))) +def test_null_choice_index_preserves_empty_text_and_ignores_reasoning( + content: str | None, expected: tuple[str, ...] | None +) -> None: + chunk: Final = ModelResponseStream( + choices=[StreamingChoices(index=None, delta=Delta(content=content, reasoning_content="loading"))] + ) + assert served_stream_output_texts((chunk,)) == expected + + def test_blanked_output_is_served_as_empty_text_and_overlaid(): assert served_output_texts(_chat_response("")) == ("",) assert served_output_texts({"type": "message", "role": "assistant", "content": [{"type": "text", "text": ""}]}) == ( diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index 93f700b1c2a..c07fe2d1c31 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -2,14 +2,17 @@ import asyncio import json import threading import time -from typing import Final, NoReturn, Optional +from typing import AsyncIterator, Final, Iterator, Literal, NoReturn, Optional from unittest.mock import AsyncMock, MagicMock, Mock, patch +import httpx import pytest import litellm from litellm import verbose_logger from litellm._logging import session_id_var, trace_id_var +from litellm.exceptions import MidStreamFallbackError +from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.streaming_handler import ( CustomStreamWrapper, @@ -1382,6 +1385,140 @@ async def test_async_streaming_read_timeout_triggers_midstream_fallback( assert isinstance(excinfo.value.original_exception, Exception) +_AZURE_DEPLOYMENT_API_BASE: Final = "https://mid-stream-debug.openai.azure.com" +_AZURE_DEPLOYMENT_REQUEST_URL: Final = ( + f"{_AZURE_DEPLOYMENT_API_BASE}/openai/deployments/gpt-5.4-mini/chat/completions?api-version=2025-04-01-preview" +) +_MID_STREAM_START_TIME: Final = 1_790_000_000.0 + + +def _azure_deployment_logging_obj(logged_api_base: str | None, routed_api_base: str | None) -> Logging: + logging_obj: Final = Logging( + model="gpt-5.4-mini", + messages=[{"role": "user", "content": "Count from 1 to 40"}], + stream=True, + call_type="acompletion", + start_time=_MID_STREAM_START_TIME, + litellm_call_id="mid-stream-debug-info", + function_id="mid-stream-debug-info", + ) + logging_obj.update_environment_variables( + model="gpt-5.4-mini", + optional_params={}, + litellm_params=get_litellm_params( + api_base=logged_api_base, + custom_llm_provider="azure", + metadata={ + "model_group": "gpt-5.4-mini-azure", + "deployment": "azure/gpt-5.4-mini", + "api_base": routed_api_base, + }, + ), + ) + return logging_obj + + +def _content_then_failure(failure: Literal["transport_error", "unparseable_chunk"]) -> Iterator[object]: + yield ModelResponseStream( + model="gpt-5.4-mini-2026-03-17", + choices=[StreamingChoices(index=0, delta=Delta(content="1", role="assistant"))], + ) + if failure == "transport_error": + raise httpx.ReadError("Response payload is not completed") + yield object() + + +async def _as_async(chunks: Iterator[object]) -> AsyncIterator[object]: + for chunk in chunks: + yield chunk + + +async def _drain(wrapper: CustomStreamWrapper, sync_mode: bool) -> None: + if sync_mode: + for _ in wrapper: + pass + return + async for _ in wrapper: + pass + + +async def _mid_stream_debug_info( + logged_api_base: str | None, + routed_api_base: str | None, + failure: Literal["transport_error", "unparseable_chunk"], + sync_mode: bool, +) -> str: + chunks: Final = _content_then_failure(failure) + wrapper: Final = CustomStreamWrapper( + completion_stream=chunks if sync_mode else _as_async(chunks), + model="gpt-5.4-mini-2026-03-17", + logging_obj=_azure_deployment_logging_obj(logged_api_base, routed_api_base), + custom_llm_provider="azure", + ) + with pytest.raises(MidStreamFallbackError) as excinfo: + await _drain(wrapper, sync_mode) + assert excinfo.value.is_pre_first_chunk is False + debug_info: Final = getattr(excinfo.value.original_exception, "litellm_debug_info", None) + assert isinstance(debug_info, str) + return debug_info + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize("failure", ["transport_error", "unparseable_chunk"]) +@pytest.mark.parametrize( + ("logged_api_base", "routed_api_base"), + [ + (_AZURE_DEPLOYMENT_REQUEST_URL, _AZURE_DEPLOYMENT_API_BASE), + (_AZURE_DEPLOYMENT_API_BASE, None), + (_AZURE_DEPLOYMENT_API_BASE, ""), + ], + ids=["routed", "sdk", "empty_routed"], +) +async def test_mid_stream_error_reports_the_deployment_it_was_sent_to( + logged_api_base: str, + routed_api_base: str | None, + failure: Literal["transport_error", "unparseable_chunk"], + sync_mode: bool, +): + debug_info: Final = await _mid_stream_debug_info(logged_api_base, routed_api_base, failure, sync_mode) + + assert f"API Base: `{_AZURE_DEPLOYMENT_API_BASE}`" in debug_info + assert "model_group: `gpt-5.4-mini-azure`" in debug_info + assert "deployment: `azure/gpt-5.4-mini`" in debug_info + assert "Count from 1 to 40" not in debug_info + assert "api.openai.com" not in debug_info + + +@pytest.mark.asyncio +async def test_mid_stream_error_leaves_out_an_api_base_it_cannot_resolve(): + debug_info: Final = await _mid_stream_debug_info(None, None, "transport_error", sync_mode=False) + + assert "model_group: `gpt-5.4-mini-azure`" in debug_info + assert "API Base:" not in debug_info + + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "routed_api_base", + [ + f"{_AZURE_DEPLOYMENT_API_BASE}?subscription-key=deployment-secret-value", + f"{_AZURE_DEPLOYMENT_API_BASE}?code=deployment-secret-value", + _AZURE_DEPLOYMENT_API_BASE.replace("https://", "https://qa-user:deployment-secret-value@"), + ], + ids=["subscription_key", "function_code", "userinfo"], +) +async def test_mid_stream_error_keeps_credentials_in_the_api_base_out(routed_api_base: str): + debug_info: Final = await _mid_stream_debug_info( + _AZURE_DEPLOYMENT_REQUEST_URL, routed_api_base, "transport_error", sync_mode=False + ) + + assert f"API Base: `{_AZURE_DEPLOYMENT_API_BASE}`" in debug_info + assert "deployment-secret" not in debug_info + + + def test_streaming_handler_with_created_time_propagation( initialized_custom_stream_wrapper: CustomStreamWrapper, logging_obj: Logging ): diff --git a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 892736b5043..0aca60351bf 100644 --- a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -6,7 +6,7 @@ with guardrail transformations, specifically testing edge cases with empty choic """ import json -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, Final from unittest.mock import MagicMock, patch import pytest @@ -18,7 +18,7 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import Streami from litellm.llms.anthropic.chat.guardrail_translation.handler import ( AnthropicMessagesHandler, ) -from litellm.types.utils import GenericGuardrailAPIInputs +from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIInputs class MockPassThroughGuardrail(CustomGuardrail): @@ -383,6 +383,97 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing: assert '"name": "lookup_fruit"' not in raw assert json.loads("".join(self._partial_jsons(chunks))) == {"fruit": "persimmon"} + @staticmethod + def _message_delta(chunks: list[bytes]) -> dict[str, object]: + lines: Final = b"".join(chunks).decode().split("\n") + events: Final = (json.loads(line[len("data:") :]) for line in lines if line.startswith("data:")) + return next(event["delta"] for event in events if event.get("type") == "message_delta") + + @pytest.mark.asyncio + async def test_deliver_ended_stream_text_rewrite_drops_the_upstream_explanations(self): + delta_fields: Final = { + "stop_details": {"type": "refusal", "category": "cyber", "explanation": "the reply says world"}, + "safeguard_results": [ + {"type": "prompt_injection", "status": {"type": "available", "explanation": "quotes world"}} + ], + } + source: Final[list[bytes]] = self._ended_sse_chunks() + chunks: Final = [ + chunk.replace( + b'"stop_sequence": null}', b'"stop_sequence": null, ' + json.dumps(delta_fields)[1:-1].encode() + b"}" + ) + for chunk in source + ] + + await AnthropicMessagesHandler().process_output_streaming_response( + responses_so_far=chunks, + guardrail_to_apply=self._masking_guardrail(), + litellm_logging_obj=MagicMock(), + deliver_ended_stream_rewrites=True, + ) + + assert self._delta_texts(chunks) == ["hello [MASKED]", ""] + assert self._message_delta(chunks) == { + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": {"type": "refusal", "category": "cyber", "explanation": None}, + "safeguard_results": [{"type": "prompt_injection", "status": {"type": "available"}}], + } + + @pytest.mark.asyncio + async def test_deliver_ended_stream_tool_use_rewrite_drops_the_verdict_of_the_rewritten_call_only(self): + class MaskPersimmon(CustomGuardrail): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], + input_type: Literal["request", "response"], + logging_obj: Optional[LiteLLMLoggingObj] = None, + ) -> GenericGuardrailAPIInputs: + for tool_call in inputs.get("tool_calls", []): + if isinstance(tool_call, ChatCompletionMessageToolCall) and "persimmon" in tool_call.function.arguments: + tool_call.function.arguments = '{"fruit": "[MASKED]"}' + return inputs + + first_call: Final[list[bytes]] = self._ended_tool_use_sse_chunks() + second_call: Final = [ + chunk.replace(b'"index": 0', b'"index": 1') + .replace(b"toolu_1", b"toolu_2") + .replace(b"persim", b"pear") + .replace(b'mon\\"}', b'\\"}') + for chunk in first_call[1:6] + ] + verdicts: Final = { + "toolu_1": {"type": "evaluated", "outcome": "flagged", "explanation": "looks up persimmon"}, + "toolu_2": {"type": "evaluated", "outcome": "not_flagged", "explanation": "looks up pear"}, + } + safeguard_results: Final = [ + {"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": verdicts}} + ] + message_delta: Final = first_call[6].replace( + b'"stop_sequence": null}', + b'"stop_sequence": null, "safeguard_results": ' + json.dumps(safeguard_results).encode() + b"}", + ) + chunks: Final = [*first_call[:6], *second_call, message_delta, first_call[7]] + + await AnthropicMessagesHandler().process_output_streaming_response( + responses_so_far=chunks, + guardrail_to_apply=MaskPersimmon(guardrail_name="test"), + litellm_logging_obj=MagicMock(), + deliver_ended_stream_rewrites=True, + ) + + assert self._partial_jsons(chunks)[:3] == ['{"fruit": "[MASKED]"}', "", ""] + assert self._message_delta(chunks)["safeguard_results"] == [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": {"toolu_2": {"type": "evaluated", "outcome": "not_flagged"}}, + }, + } + ] + @pytest.mark.asyncio async def test_ended_stream_tool_use_rewrite_leaves_chunks_untouched_by_default(self): handler = AnthropicMessagesHandler() diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py b/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py index 4b96d2e7991..389398dcbf4 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_handler_reasoning_effort_normalization.py @@ -49,9 +49,12 @@ def _completion_kwargs_for_thinking( *, tools: list[dict[str, object]] | None = None, api_base: str | None = None, + model_info: dict[str, str] | None = None, ) -> Mapping[str, object]: extra_kwargs: Final = { - key: value for key, value in (("custom_llm_provider", provider), ("api_base", api_base)) if value is not None + key: value + for key, value in (("custom_llm_provider", provider), ("api_base", api_base), ("model_info", model_info)) + if value is not None } completion_kwargs, _ = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs( max_tokens=1024, @@ -80,10 +83,11 @@ def _reasoning_effort_sent_for_thinking( *, tools: list[dict[str, object]] | None = None, api_base: str | None = None, + model_info: dict[str, str] | None = None, ) -> object: - return _completion_kwargs_for_thinking(model, provider, thinking, tools=tools, api_base=api_base).get( - "reasoning_effort" - ) + return _completion_kwargs_for_thinking( + model, provider, thinking, tools=tools, api_base=api_base, model_info=model_info + ).get("reasoning_effort") SUMMARIZED_THINKING = {"type": "enabled", "budget_tokens": 4096, "summary": "auto"} @@ -183,6 +187,22 @@ class TestTheSummaryWrappingOnlyRidesTheResponsesBridge: assert sent == "high" + def test_a_chat_pinned_deployment_gets_the_plain_tier_on_a_responses_row( + self, local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A ``mode: chat`` pin sends that deployment to chat completions even though its shared row + says ``responses``, so the adapter has to read the pin too or chat gets the Responses-only dict.""" + monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true") + model: Final = "bedrock_mantle/openai.gpt-5.4" + + unpinned = _reasoning_effort_sent_for_thinking(model, "bedrock_mantle", PLAIN_THINKING) + pinned = _reasoning_effort_sent_for_thinking( + model, "bedrock_mantle", PLAIN_THINKING, model_info={"id": "pinned-id", "mode": "chat"} + ) + + assert isinstance(unpinned, dict) + assert pinned == "high" + @pytest.mark.parametrize( "model, provider", [ @@ -205,6 +225,23 @@ class TestTheSummaryWrappingOnlyRidesTheResponsesBridge: assert sent == {"effort": "high", "summary": "detailed"} + @pytest.mark.parametrize( + "model, provider, summary", + [ + ("bedrock_mantle/openai.gpt-5.4", "bedrock_mantle", "auto"), + ("azure/responses/gpt-5-mini", "azure", "detailed"), + ], + ) + def test_auto_summary_asks_each_bridged_target_for_a_level_it_accepts( + self, local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch, model: str, provider: str, summary: str + ) -> None: + """Mantle's OpenAI path accepts only ``auto``, so the opt-in summary must not send it ``detailed``.""" + monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true") + + sent = _reasoning_effort_sent_for_thinking(model, provider, PLAIN_THINKING) + + assert sent == {"effort": "high", "summary": summary} + @pytest.mark.parametrize( "api_base, expected", [ diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 0b68f54c504..e44cdf2080b 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -1,10 +1,12 @@ import asyncio +import copy import json import os import uuid from typing import Any, Dict, Final, List import httpx +import respx import pytest from fastapi.testclient import TestClient from pydantic import ValidationError @@ -25,6 +27,7 @@ from litellm.types.utils import ( StandardLoggingPayloadErrorInformation, StreamingChoices, ) +from litellm.utils import _invalidate_model_cost_lowercase_map import unittest.mock @@ -1075,6 +1078,44 @@ async def test_messages_strips_provider_prefix_exactly_once(requested_model, exp assert captured["url"] == expected_url +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_info, expected_url", + [ + (None, "https://api.perplexity.ai/v1/responses"), + ({"id": "pinned-id", "mode": "chat"}, "https://api.perplexity.ai/chat/completions"), + ], +) +async def test_messages_honors_a_chat_pin_on_a_prefixed_responses_deployment( + monkeypatch: pytest.MonkeyPatch, model_info: dict[str, str] | None, expected_url: str +) -> None: + """A deployment id whose stripped form is a chat row is kept on the Responses API, unless the + deployment pins ``mode: chat``, which has to decide the route before the prefix is stripped.""" + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + litellm.model_cost["perplexity/perplexity/pin-test-model"] = { + "litellm_provider": "perplexity", + "mode": "responses", + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + } + litellm.model_cost["perplexity/pin-test-model"] = {"litellm_provider": "perplexity", "mode": "chat"} + _invalidate_model_cost_lowercase_map() + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock(assert_all_called=True) as respx_mock: + upstream: Final = respx_mock.route(host="api.perplexity.ai").mock(side_effect=httpx.ConnectError("cut at the wire")) + with pytest.raises(litellm.exceptions.InternalServerError): + await litellm.anthropic.messages.acreate( + max_tokens=100, + messages=[{"role": "user", "content": "ping"}], + model="perplexity/perplexity/pin-test-model", + api_key="test-api-key", + model_info=model_info, + ) + + assert {str(call.request.url) for call in upstream.calls} == {expected_url} + + @pytest.mark.asyncio async def test_native_messages_strips_replayed_provider_specific_fields_from_wire(): captured = {} diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_fake_stream_iterator.py b/tests/unit/llms/anthropic/pass_through/messages/test_fake_stream_iterator.py new file mode 100644 index 00000000000..476cea2833c --- /dev/null +++ b/tests/unit/llms/anthropic/pass_through/messages/test_fake_stream_iterator.py @@ -0,0 +1,93 @@ +import json +from collections.abc import Iterable, Mapping +from typing import Final + +from litellm.llms.anthropic.pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, +) + +_CLAUDE_CODE_SAFEGUARD_RESULTS: Final = [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": { + "toolu_01": {"type": "evaluated", "outcome": "flagged", "explanation": "deletes the filesystem root"} + }, + }, + } +] + + +def _decoded_event(chunk: object) -> tuple[str, dict[str, object]]: + assert isinstance(chunk, bytes) + event_line, data_line = chunk.decode().split("\n")[:2] + assert event_line.startswith("event: ") + return event_line.removeprefix("event: "), json.loads(data_line.removeprefix("data: ")) + + +def _decoded_events(chunks: Iterable[object]) -> tuple[tuple[str, dict[str, object]], ...]: + return tuple(_decoded_event(chunk) for chunk in chunks) + + +def _message_delta(response: Mapping[str, object]) -> object: + decoded: Final = _decoded_events(FakeAnthropicMessagesStreamIterator(response=response)) + assert tuple(event_type for event_type, _ in decoded)[-2:] == ("message_delta", "message_stop") + deltas: Final = tuple(payload["delta"] for event_type, payload in decoded if event_type == "message_delta") + assert len(deltas) == 1 + return deltas[0] + + +def test_message_delta_carries_safeguard_results(): + """Websearch interception and the MCP loop render a complete response as + this stream for a client that asked to stream (the guardrail path does too, + after merging the upstream fields back in, which + tests/unit/proxy/guardrails/test_anthropic_sse.py covers); a Claude Code + auto-mode client reads the classifier verdict from message_delta.delta, + where a real Anthropic stream puts it.""" + response: Final = { + "id": "msg_1", + "model": "claude-sonnet-4-6", + "role": "assistant", + "type": "message", + "content": [{"type": "text", "text": "I won't run that."}], + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "safeguard_results": _CLAUDE_CODE_SAFEGUARD_RESULTS, + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + assert _message_delta(response) == { + "stop_reason": "end_turn", + "stop_sequence": None, + "safeguard_results": _CLAUDE_CODE_SAFEGUARD_RESULTS, + } + + +def test_message_delta_carries_refusal_stop_details(): + stop_details: Final = {"type": "refusal", "category": "cyber", "explanation": "declined"} + response: Final = { + "id": "msg_2", + "content": [], + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + "usage": {"input_tokens": 10, "output_tokens": 0}, + } + assert _message_delta(response) == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + } + + +def test_message_delta_omits_absent_optional_fields(): + response: Final = { + "id": "msg_3", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "usage": {"input_tokens": 10, "output_tokens": 2}, + } + assert _message_delta(response) == {"stop_reason": "end_turn", "stop_sequence": None} diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_streaming_iterator.py b/tests/unit/llms/anthropic/pass_through/messages/test_streaming_iterator.py index 7e983be835b..b995c6dba9b 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_streaming_iterator.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_streaming_iterator.py @@ -1306,6 +1306,33 @@ def test_anthropic_messages_streaming_response_reports_withheld_output_of_its_st ) +def test_anthropic_messages_response_as_sse_events_keeps_message_delta_fields_out_of_message_start(): + safeguard_results = [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {}}}] + stop_details = {"type": "refusal", "category": "cyber", "explanation": "declined"} + response = { + "id": "msg_1", + "model": "claude-haiku", + "role": "assistant", + "type": "message", + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + "safeguard_results": safeguard_results, + "content": [{"type": "text", "text": "no"}], + "usage": {"input_tokens": 3, "output_tokens": 1}, + } + decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response)) + message_start = decoded[0][1]["message"] + assert message_start["stop_details"] is None + assert "safeguard_results" not in message_start + assert decoded[-2][1]["delta"] == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + "safeguard_results": safeguard_results, + } + + def test_anthropic_messages_response_as_sse_events_no_content_blocks(): response = {"id": "msg_4", "content": [], "stop_reason": "end_turn"} decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response)) @@ -1530,6 +1557,72 @@ async def test_relay_teardown_dispatches_deferred_billing_when_sentinel_never_co assert deferred_fired.is_set() +_CLAUDE_CODE_SAFEGUARD_RESULTS: Final = [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": { + "toolu_01": {"type": "evaluated", "outcome": "flagged", "explanation": "deletes the filesystem root"} + }, + }, + } +] + + +def _sse_events_message_delta(response: Mapping[str, object]) -> object: + decoded: Final = _decode_sse_events(anthropic_messages_response_as_sse_events(response)) + deltas: Final = tuple(payload["delta"] for event_type, payload in decoded if event_type == "message_delta") + assert len(deltas) == 1 + return deltas[0] + + +def test_anthropic_messages_response_as_sse_events_message_delta_carries_safeguard_results(): + """A Claude Code auto-mode request's classifier verdict rides in + message_delta.delta on a real Anthropic stream; a synthesized stream that + drops it leaves the client without the verdict.""" + response = { + "id": "msg_7", + "content": [{"type": "text", "text": "I won't run that."}], + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "safeguard_results": _CLAUDE_CODE_SAFEGUARD_RESULTS, + } + assert _sse_events_message_delta(response) == { + "stop_reason": "end_turn", + "stop_sequence": None, + "safeguard_results": _CLAUDE_CODE_SAFEGUARD_RESULTS, + } + + +def test_anthropic_messages_response_as_sse_events_message_delta_carries_refusal_stop_details(): + stop_details = {"type": "refusal", "category": "cyber", "explanation": "declined"} + response = { + "id": "msg_8", + "content": [], + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + } + assert _sse_events_message_delta(response) == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": stop_details, + } + + +def test_anthropic_messages_response_as_sse_events_message_delta_omits_absent_optional_fields(): + response = { + "id": "msg_9", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + } + assert _sse_events_message_delta(response) == {"stop_reason": "end_turn", "stop_sequence": None} + + class _OneVerdictClassifier: def __init__(self, reply: str) -> None: self._reply = reply diff --git a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_handler.py b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_handler.py index 453429df084..b6f831d4bef 100644 --- a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_handler.py +++ b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_handler.py @@ -3,7 +3,7 @@ import json import os import sys from collections.abc import Mapping -from typing import Final +from typing import Final, cast from unittest.mock import AsyncMock, patch import pytest @@ -110,6 +110,27 @@ def test_build_responses_kwargs_skips_include_for_a_responses_provider_that_reje assert "reasoning" in responses_kwargs +@pytest.mark.parametrize( + "model, provider, summary", + [ + ("bedrock_mantle/openai.gpt-5.6-terra", "bedrock_mantle", "auto"), + ("openai/gpt-5.6-luna", "openai", "detailed"), + ], +) +def test_build_responses_kwargs_auto_summary_asks_each_target_for_a_level_it_accepts( + monkeypatch: pytest.MonkeyPatch, model: str, provider: str, summary: str +) -> None: + monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true") + responses_kwargs: Final = _build_responses_kwargs( + max_tokens=1024, + messages=MESSAGES, + model=model, + thinking={"type": "enabled", "budget_tokens": 4096}, + extra_kwargs={"custom_llm_provider": provider}, + ) + assert cast(Mapping[str, object], responses_kwargs["reasoning"])["summary"] == summary + + def test_build_responses_kwargs_keeps_the_deployment_include_next_to_encrypted_reasoning(): responses_kwargs = _build_responses_kwargs( max_tokens=1024, diff --git a/tests/unit/llms/anthropic/pass_through/test_stream_assembly.py b/tests/unit/llms/anthropic/pass_through/test_stream_assembly.py index 33f95ccc9ff..dbd49064e4b 100644 --- a/tests/unit/llms/anthropic/pass_through/test_stream_assembly.py +++ b/tests/unit/llms/anthropic/pass_through/test_stream_assembly.py @@ -4,6 +4,7 @@ from typing import Final import pytest from litellm.llms.anthropic.pass_through.stream_assembly import ( + anthropic_message_delta_fields, assemble_anthropic_sse_stream, is_anthropic_sse_stream, is_raw_sse_stream, @@ -55,6 +56,35 @@ ANTHROPIC_STREAM: Final = ( _frame("message_stop", {"type": "message_stop"}), ) +SAFEGUARD_RESULTS: Final = [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": { + "toolu_01": {"type": "evaluated", "outcome": "flagged", "explanation": "deletes the filesystem root"} + }, + }, + } +] +SAFEGUARDED_STREAM: Final = ( + *ANTHROPIC_STREAM[:5], + _frame( + "message_delta", + { + "type": "message_delta", + "delta": { + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "safeguard_results": SAFEGUARD_RESULTS, + }, + "usage": {"output_tokens": 2}, + }, + ), + _frame("message_stop", {"type": "message_stop"}), +) + GOOGLE_STREAM: Final = (b'data: {"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]}\n\n',) ANTHROPIC_ERROR: Final = _frame("error", {"type": "error", "error": {"type": "overloaded_error", "message": "busy"}}) CHAT_ERROR: Final = b'data: {"error": {"message": "blocked by guardrail"}}\n\n' @@ -108,6 +138,12 @@ def test_assemble_anthropic_sse_stream_restores_upstream_identity_only_when_aske assert (restored.id, restored.model) == (MESSAGE_ID, MODEL) +def test_anthropic_message_delta_fields_reads_the_fields_the_openai_shape_drops(): + assert dict(anthropic_message_delta_fields(ANTHROPIC_STREAM)) == {} + assert dict(anthropic_message_delta_fields(SAFEGUARDED_STREAM)) == {"safeguard_results": SAFEGUARD_RESULTS} + assert dict(anthropic_message_delta_fields(GOOGLE_STREAM)) == {} + + @pytest.mark.parametrize( "chunks", [GOOGLE_STREAM, INVALID_UTF8, (ANTHROPIC_ERROR,), ()], diff --git a/tests/unit/llms/azure/chat/test_azure_base_model_routing.py b/tests/unit/llms/azure/chat/test_azure_base_model_routing.py index 1e8e23c38ca..aae2587085f 100644 --- a/tests/unit/llms/azure/chat/test_azure_base_model_routing.py +++ b/tests/unit/llms/azure/chat/test_azure_base_model_routing.py @@ -5,12 +5,51 @@ setting ``base_model`` should drive model-type detection (o-series, gpt-5, etc.) so the correct config, supported params, and param mapping are used. """ +from typing import Final + +import httpx +import openai import pytest import litellm from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config from litellm.llms.azure.chat.o_series_transformation import AzureOpenAIO1Config from litellm.utils import ProviderConfigManager, get_optional_params +from tests.unit.llms.bedrock.slow_upstream import STREAM_TIMEOUT_SECONDS, slow_upstream_transport + +_O3_STREAM_ANSWER: Final = ( + b'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":0,"model":"o3",' + b'"choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}\n\n' + b"data: [DONE]\n\n" +) + + +@pytest.mark.parametrize( + ("model", "base_model"), + [("azure/chat", "azure/o3"), ("azure/gpt4o-pro1", "azure/gpt-4o")], + ids=["o3_base_model_streams_natively", "o_series_looking_deployment_of_a_non_o_series_base_model"], +) +def test_live_stream_on_a_deployment_named_apart_from_its_base_model_keeps_its_stream_timeout( + model: str, base_model: str +) -> None: + client: Final = openai.AzureOpenAI( + api_key="test-key", + api_version="2024-12-01-preview", + azure_endpoint="https://example.openai.azure.com", + max_retries=0, + http_client=httpx.Client(transport=slow_upstream_transport(_O3_STREAM_ANSWER)), + ) + + with pytest.raises(litellm.Timeout): + litellm.completion( + model=model, + base_model=base_model, + messages=[{"role": "user", "content": "hi"}], + stream=True, + stream_timeout=STREAM_TIMEOUT_SECONDS, + timeout=30.0, + client=client, + ) # --------------------------------------------------------------------------- diff --git a/tests/unit/llms/azure_ai/anthropic/count_tokens/test_token_counter.py b/tests/unit/llms/azure_ai/anthropic/count_tokens/test_token_counter.py index 86ad6c8d9d9..4913d2b0d1d 100644 --- a/tests/unit/llms/azure_ai/anthropic/count_tokens/test_token_counter.py +++ b/tests/unit/llms/azure_ai/anthropic/count_tokens/test_token_counter.py @@ -54,6 +54,7 @@ async def test_count_reports_upstream_input_tokens_and_keeps_the_raw_payload( "request_model": "azure_ai/claude-sonnet-4-5", "model_used": "claude-sonnet-4-5", "tokenizer_type": "azure_ai_anthropic_api", + "count_source": None, "original_response": payload, "error": False, "error_message": None, @@ -98,6 +99,7 @@ async def test_non_integer_input_tokens_becomes_an_error_response_carrying_the_v "request_model": "azure_ai/claude-sonnet-4-5", "model_used": "claude-sonnet-4-5", "tokenizer_type": "azure_ai_anthropic_api", + "count_source": None, "original_response": None, "error": True, "error_message": str(rejection.value), @@ -126,6 +128,7 @@ async def test_count_payload_that_is_not_an_object_becomes_an_error_response_nam "request_model": "azure_ai/claude-sonnet-4-5", "model_used": "claude-sonnet-4-5", "tokenizer_type": "azure_ai_anthropic_api", + "count_source": None, "original_response": None, "error": True, "error_message": str(failed_lookup.value), @@ -156,6 +159,7 @@ async def test_upstream_error_status_becomes_an_error_response_with_that_status_ "request_model": "azure_ai/claude-sonnet-4-5", "model_used": "claude-sonnet-4-5", "tokenizer_type": "azure_ai_anthropic_api", + "count_source": None, "original_response": None, "error": True, "error_message": upstream_text, @@ -180,6 +184,7 @@ async def test_undecodable_count_body_becomes_a_500_error_response_carrying_the_ "request_model": "azure_ai/claude-sonnet-4-5", "model_used": "claude-sonnet-4-5", "tokenizer_type": "azure_ai_anthropic_api", + "count_source": None, "original_response": None, "error": True, "error_message": f"CountTokens processing error: {undecodable.value}", diff --git a/tests/unit/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/unit/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index 866bdf173a9..176dcfc86c9 100644 --- a/tests/unit/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/unit/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -7,13 +7,17 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) ) +from typing import Final from unittest.mock import patch +import httpx import pytest +import litellm from litellm.llms.azure_ai.anthropic.messages_transformation import ( AzureAnthropicMessagesConfig, ) +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.types.router import GenericLiteLLMParams @@ -470,3 +474,80 @@ def test_azure_claude_4_8_plus_cost_map_entries_carry_mid_conversation_system_fl and info.get("supports_mid_conversation_system") is not True ] assert missing == [] + + +AUTO_MODE_SAFEGUARDS: Final = ({"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}},) + + +@pytest.mark.usefixtures("local_beta_headers_config") +@pytest.mark.parametrize( + ("client_headers", "optional_params", "expected_beta"), + [ + ({}, {"safeguards": AUTO_MODE_SAFEGUARDS}, "dangerous-tool-use-2026-09-03"), + ( + {"anthropic-beta": "context-1m-2025-08-07"}, + {"safeguards": AUTO_MODE_SAFEGUARDS}, + "context-1m-2025-08-07,dangerous-tool-use-2026-09-03", + ), + ({"anthropic-beta": "context-1m-2025-08-07"}, {}, "context-1m-2025-08-07"), + ({}, {}, None), + ], +) +def test_safeguards_request_carries_the_dangerous_tool_use_beta_without_the_client_header( + client_headers: dict[str, str], optional_params: dict[str, object], expected_beta: str | None +) -> None: + headers, _ = AzureAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers=client_headers, + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Run ls -la with the Bash tool"}], + optional_params=optional_params, + litellm_params={"api_key": "test-api-key"}, + ) + assert headers.get("anthropic-beta") == expected_beta, headers + + +def _foundry_answer(request: httpx.Request) -> httpx.Response: + return httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json={ + "id": "msg_safeguards", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hello from the gateway"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 6}, + }, + request=request, + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("local_beta_headers_config") +async def test_outgoing_azure_request_sends_safeguards_with_the_dangerous_tool_use_beta() -> None: + received: Final[list[httpx.Request]] = [] + + def foundry_answers(request: httpx.Request) -> httpx.Response: + received.append(request) + return _foundry_answer(request) + + upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(foundry_answers)) + try: + await litellm.anthropic.messages.acreate( + max_tokens=256, + messages=[{"role": "user", "content": "Use the Bash tool to run: echo hello from the gateway"}], + model="azure_ai/claude-sonnet-4-6", + api_key="test-api-key", + api_base="https://test-resource.services.ai.azure.com", + safeguards=AUTO_MODE_SAFEGUARDS, + client=upstream, + ) + finally: + await upstream.close() + + assert len(received) == 1, received + sent: Final = received[0] + assert "dangerous-tool-use-2026-09-03" in sent.headers["anthropic-beta"].split(","), dict(sent.headers) + assert json.loads(sent.content)["safeguards"] == list(AUTO_MODE_SAFEGUARDS) diff --git a/tests/unit/llms/baseten/chat/test_baseten_completions.py b/tests/unit/llms/baseten/chat/test_baseten_completions.py index caf96987933..7cac16cca1c 100644 --- a/tests/unit/llms/baseten/chat/test_baseten_completions.py +++ b/tests/unit/llms/baseten/chat/test_baseten_completions.py @@ -1,4 +1,5 @@ import os +from typing import Final import pytest from unittest.mock import patch from litellm.llms.baseten.chat import BasetenConfig @@ -9,7 +10,7 @@ class TestBasetenRouting: def test_routing_logic(self): """Test routing between Model API and dedicated deployments""" - config = BasetenConfig() + config: Final = BasetenConfig() # Dedicated deployment (8-character alphanumeric) assert ( @@ -18,10 +19,7 @@ class TestBasetenRouting: ) # Model API (non-8-character) - assert ( - config.get_api_base_for_model("openai/gpt-oss-120b") - == "https://inference.baseten.co/v1" - ) + assert config.get_api_base_for_model("openai/gpt-oss-120b") == "https://inference.baseten.co/v1" class TestBasetenModelAPI: @@ -30,12 +28,12 @@ class TestBasetenModelAPI: @patch.dict(os.environ, {"BASETEN_API_KEY": "test-key"}) def test_model_api_inference(self): """Test Model API inference with basic parameters""" - config = BasetenConfig() + config: Final = BasetenConfig() # Test parameter mapping - non_default_params = {"max_tokens": 100, "temperature": 0.7, "top_p": 0.9} + non_default_params: Final = {"max_tokens": 100, "temperature": 0.7, "top_p": 0.9} - result = config.map_openai_params( + result: Final = config.map_openai_params( non_default_params=non_default_params, optional_params={}, model="openai/gpt-oss-120b", @@ -47,12 +45,37 @@ class TestBasetenModelAPI: assert result["top_p"] == 0.9 # Test provider info - api_base, api_key = config._get_openai_compatible_provider_info( - None, "test-key" - ) + api_base, api_key = config._get_openai_compatible_provider_info(None, "test-key") assert api_base == "https://inference.baseten.co/v1" assert api_key == "test-key" +class TestBasetenReasoningEffort: + def test_reasoning_effort_passthrough_with_drop_params(self): + config: Final = BasetenConfig() + + result: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "high", "max_tokens": 100}, + optional_params={}, + model="deepseek-ai/DeepSeek-V4-Flash-0731", + drop_params=True, + ) + + assert result["reasoning_effort"] == "high" + assert result["max_tokens"] == 100 + + def test_reasoning_effort_none_passthrough(self): + config: Final = BasetenConfig() + + result: Final = config.map_openai_params( + non_default_params={"reasoning_effort": "none"}, + optional_params={}, + model="moonshotai/Kimi-K3", + drop_params=True, + ) + + assert result["reasoning_effort"] == "none" + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py index f0f0f9160fb..de4b135e6d0 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py @@ -115,3 +115,22 @@ def test_tool_config_injection_point_is_neither_placed_nor_credited(local_model_ assert [tool["toolSpec"]["name"] for tool in request["toolConfig"]["tools"]] == ["f"] assert "cachePoint" not in json.dumps(request) assert GATEWAY_INJECTED_CACHE_METADATA_KEY not in metadata + + +@pytest.mark.parametrize("value", ["b c", "bad!"], ids=["nbsp", "outside-bedrock-charset"]) +def test_caller_request_metadata_is_ignored_because_invoke_model_has_no_field_for_it(local_model_cost_map, value): + caller_metadata = {"ticket": value} + optional_params = AmazonInvokeNovaConfig().map_openai_params( + non_default_params={"max_tokens": 8, "requestMetadata": caller_metadata}, + optional_params={}, + model=MODEL, + drop_params=False, + ) + + request = _transform_request( + messages=[{"role": "user", "content": "hi"}], + optional_params={**optional_params, "requestMetadata": caller_metadata}, + ) + + assert "requestMetadata" not in request + assert request["inferenceConfig"] == {"maxTokens": 8} diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index ca34a4eee06..bd98e4eb4de 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -1948,3 +1948,63 @@ def test_refusal_stop_details_kept_through_bedrock_invoke_transform(): provider_fields: Final = result.choices[0].message.provider_specific_fields assert provider_fields["stop_reason"] == "refusal" assert provider_fields["stop_details"] == ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] + + +_CITY_SCHEMA: Final = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, +} +_CITY_RESPONSE_FORMAT: Final = {"type": "json_schema", "json_schema": {"name": "city", "schema": _CITY_SCHEMA}} + + +def _invoke_chat_params( + model: str, model_info: Mapping[str, object] | None, non_default_params: Mapping[str, object] +) -> Mapping[str, object]: + return AmazonAnthropicClaudeConfig(model_info=model_info).map_openai_params( + non_default_params=dict(non_default_params), optional_params={}, model=model, drop_params=False + ) + + +@pytest.mark.parametrize( + "thinking_params", + [{"thinking": {"type": "adaptive"}}, {"reasoning_effort": "low"}], + ids=["adaptive", "reasoning_effort"], +) +@pytest.mark.parametrize("model", ["us.anthropic.claude-opus-4-8", "global.anthropic.claude-sonnet-5"]) +def test_invoke_uses_opt_in_native_output_when_the_opt_in_drove_native_output( + local_model_cost_map, model, thinking_params +): + optional_params: Final = _invoke_chat_params( + model, None, {"response_format": _CITY_RESPONSE_FORMAT, **thinking_params} + ) + + assert AmazonAnthropicClaudeConfig().uses_opt_in_native_output(model, optional_params, {}) + + +@pytest.mark.parametrize( + ("model", "model_info", "request_params"), + [ + ("us.anthropic.claude-opus-4-8", None, {"response_format": _CITY_RESPONSE_FORMAT}), + ( + "us.anthropic.claude-opus-4-8", + {"supports_bedrock_native_structured_output": False}, + {"response_format": _CITY_RESPONSE_FORMAT, "thinking": {"type": "adaptive"}}, + ), + ( + "us.anthropic.claude-sonnet-4-6", + None, + {"response_format": _CITY_RESPONSE_FORMAT, "thinking": {"type": "adaptive"}}, + ), + ], + ids=["no_thinking", "deployment_opt_out", "native_model"], +) +def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_come_from_the_opt_in( + local_model_cost_map, model, model_info, request_params +): + optional_params: Final = _invoke_chat_params(model, model_info, request_params) + + assert not AmazonAnthropicClaudeConfig().uses_opt_in_native_output( + model, optional_params, {"model_info": model_info} + ) diff --git a/tests/unit/llms/bedrock/chat/test_bedrock_converse_handler.py b/tests/unit/llms/bedrock/chat/test_bedrock_converse_handler.py index dec32b3325e..1fa83254822 100644 --- a/tests/unit/llms/bedrock/chat/test_bedrock_converse_handler.py +++ b/tests/unit/llms/bedrock/chat/test_bedrock_converse_handler.py @@ -6,6 +6,8 @@ AWS credential resolution is stubbed so nothing reaches STS. from __future__ import annotations import asyncio +import json +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from datetime import datetime, timedelta, timezone from itertools import chain from typing import Final @@ -13,18 +15,24 @@ from unittest.mock import MagicMock, patch import boto3 import httpx +import openai import pytest +import respx from botocore.credentials import Credentials from botocore.exceptions import ClientError import litellm +from litellm.integrations.custom_logger import CustomLogger from litellm.llms.bedrock.chat.converse_handler import BedrockConverseLLM from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.rust_bridge import configuration -from litellm.types.utils import ModelResponse +from litellm.types.utils import AdapterCompletionStreamWrapper, ModelResponse from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe from tests.unit.llms.bedrock.slow_upstream import ( + CONVERSE_ANSWER, STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_answer, slow_upstream_async_client, slow_upstream_sync_client, ) @@ -305,6 +313,340 @@ def test_sync_converse_streaming_fails_at_the_request_timeout_not_the_upstreams_ litellm.completion(client=slow_upstream_sync_client(), **_converse_streaming_kwargs()) +@pytest.mark.asyncio +async def test_live_stream_with_a_text_stream_timeout_reports_the_stream_timeout_it_was_cut_at() -> None: + with pytest.raises(litellm.Timeout) as excinfo: + await litellm.acompletion( + client=slow_upstream_async_client(), + **{ + **_converse_streaming_kwargs(), + "timeout": UPSTREAM_FIRST_BYTE_SECONDS * 2, + "stream_timeout": str(STREAM_TIMEOUT_SECONDS), + }, + ) + assert getattr(excinfo.value, "timeout", None) == STREAM_TIMEOUT_SECONDS + + + +_FAKE_STREAMED_REQUESTS: Final = pytest.mark.parametrize( + ("model", "request_params"), + [ + ("bedrock/converse/ai21.jamba-1-5-large-v1:0", {}), + ( + "bedrock/converse/meta.llama3-3-70b-instruct-v1:0", + {"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}]}, + ), + ( + "bedrock/converse/meta.llama3-3-70b-instruct-v1:0", + {"response_format": {"type": "json_schema", "json_schema": {"name": "answer", "schema": {"type": "object"}}}}, + ), + ("bedrock/invoke/ai21.j2-ultra-v1", {}), + ], + ids=[ + "model_without_streaming", + "request_shape_without_streaming", + "json_schema_without_native_structured_output", + "invoke_route_without_streaming", + ], +) + + +def _bedrock_router(model: str, *, timeout: float, stream_timeout: float) -> litellm.Router: + return litellm.Router( + model_list=[ + { + "model_name": "bedrock", + "litellm_params": { + "model": model, + "aws_access_key_id": "fake", + "aws_secret_access_key": "fake", + "aws_region_name": "us-east-1", + }, + } + ], + timeout=timeout, + stream_timeout=stream_timeout, + num_retries=0, + ) + + +def _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte(model: str) -> litellm.Router: + return _bedrock_router(model, timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, stream_timeout=STREAM_TIMEOUT_SECONDS) + + +@_FAKE_STREAMED_REQUESTS +def test_sync_fake_stream_is_bounded_by_the_request_timeout_not_the_stream_timeout( + model: str, request_params: Mapping[str, object] +) -> None: + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte(model) + response: Final = router.completion( + model="bedrock", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=slow_upstream_sync_client(CONVERSE_ANSWER), + **request_params, + ) + + assert "".join(chunk.choices[0].delta.content or "" for chunk in response) == "hi" + + +@pytest.mark.asyncio +@_FAKE_STREAMED_REQUESTS +async def test_async_fake_stream_is_bounded_by_the_request_timeout_not_the_stream_timeout( + model: str, request_params: Mapping[str, object] +) -> None: + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte(model) + response: Final = await router.acompletion( + model="bedrock", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=slow_upstream_async_client(CONVERSE_ANSWER), + **request_params, + ) + + assert "".join([chunk.choices[0].delta.content or "" async for chunk in response]) == "hi" + + +@pytest.mark.asyncio +async def test_fake_stream_keeps_a_stream_timeout_longer_than_the_request_timeout() -> None: + router: Final = _bedrock_router( + "bedrock/converse/ai21.jamba-1-5-large-v1:0", + timeout=STREAM_TIMEOUT_SECONDS, + stream_timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + ) + response: Final = await router.acompletion( + model="bedrock", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=slow_upstream_async_client(CONVERSE_ANSWER), + ) + + assert "".join([chunk.choices[0].delta.content or "" async for chunk in response]) == "hi" + + +_BEDROCK_RUNTIME_URL: Final = r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/.*" + + +async def _text_completion_text(router: litellm.Router) -> str: + response: Final = await router.atext_completion(model="bedrock", prompt="hi", stream=True) + return "".join([chunk.choices[0].text or "" async for chunk in response]) + + +async def _anthropic_messages_text(router: litellm.Router) -> str: + response: Final = await router.aanthropic_messages( + model="bedrock", messages=[{"role": "user", "content": "hi"}], max_tokens=10, stream=True + ) + sse: Final = b"".join([chunk async for chunk in response]).decode() + events: Final = [json.loads(line.removeprefix("data: ")) for line in sse.splitlines() if line.startswith("data: ")] + return "".join(event["delta"]["text"] for event in events if event["type"] == "content_block_delta") + + +async def _responses_text(router: litellm.Router) -> str: + response: Final = await router.aresponses(model="bedrock", input="hi", stream=True) + return "".join([event.delta async for event in response if event.type == "response.output_text.delta"]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "stream_text", + [_text_completion_text, _anthropic_messages_text, _responses_text], + ids=["text_completion", "anthropic_messages", "responses"], +) +async def test_fake_stream_on_each_router_surface_is_bounded_by_the_request_timeout( + stream_text: Callable[[litellm.Router], Awaitable[str]], + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=slow_upstream_answer(CONVERSE_ANSWER)) + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte( + "bedrock/converse/ai21.jamba-1-5-large-v1:0" + ) + + assert await stream_text(router) == "hi" + + +class _ChatCompletionAdapter(CustomLogger): + def translate_completion_input_params(self, kwargs: Mapping[str, object]) -> Mapping[str, object]: + return kwargs + + def translate_completion_output_params_streaming(self, completion_stream: object) -> AdapterCompletionStreamWrapper: + return AdapterCompletionStreamWrapper(completion_stream=completion_stream) + + +@pytest.mark.asyncio +async def test_fake_stream_through_a_router_adapter_is_bounded_by_the_request_timeout( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "adapters", [{"id": "chat", "adapter": _ChatCompletionAdapter()}]) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=slow_upstream_answer(CONVERSE_ANSWER)) + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte( + "bedrock/converse/ai21.jamba-1-5-large-v1:0" + ) + + response: Final = await router.aadapter_completion( + adapter_id="chat", model="bedrock", messages=[{"role": "user", "content": "hi"}], stream=True + ) + + assert isinstance(response, AsyncIterator) + contents: Final = [ + chunk.choices[0].delta.content or "" async for chunk in response if isinstance(chunk, litellm.ModelResponseStream) + ] + assert "".join(contents) == "hi" + + +@pytest.mark.asyncio +async def test_live_anthropic_messages_stream_through_the_router_fails_at_the_stream_timeout( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=slow_upstream_answer()) + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte( + "bedrock/invoke/anthropic.claude-sonnet-4-5-v1:0" + ) + + with pytest.raises(litellm.Timeout) as excinfo: + await _anthropic_messages_text(router) + assert getattr(excinfo.value, "timeout", None) == STREAM_TIMEOUT_SECONDS + + +def test_sync_live_stream_through_the_router_fails_at_the_stream_timeout() -> None: + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte( + "bedrock/anthropic.claude-sonnet-4-5-v1:0" + ) + with pytest.raises(litellm.Timeout): + router.completion( + model="bedrock", messages=[{"role": "user", "content": "hi"}], stream=True, client=slow_upstream_sync_client() + ) + + +@pytest.mark.asyncio +async def test_async_live_stream_through_the_router_fails_at_the_stream_timeout() -> None: + router: Final = _router_with_a_stream_timeout_shorter_than_the_upstreams_first_byte( + "bedrock/anthropic.claude-sonnet-4-5-v1:0" + ) + with pytest.raises(litellm.Timeout) as excinfo: + await router.acompletion( + model="bedrock", messages=[{"role": "user", "content": "hi"}], stream=True, client=slow_upstream_async_client() + ) + assert getattr(excinfo.value, "timeout", None) == STREAM_TIMEOUT_SECONDS + + +@pytest.mark.asyncio +async def test_failed_fake_stream_reports_the_request_timeout_it_was_bounded_by() -> None: + request_timeout: Final = UPSTREAM_FIRST_BYTE_SECONDS / 2 + router: Final = _bedrock_router( + "bedrock/converse/ai21.jamba-1-5-large-v1:0", timeout=request_timeout, stream_timeout=STREAM_TIMEOUT_SECONDS + ) + with pytest.raises(litellm.Timeout) as excinfo: + await router.acompletion( + model="bedrock", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=slow_upstream_async_client(CONVERSE_ANSWER), + ) + assert getattr(excinfo.value, "timeout", None) == request_timeout + + +_NATIVE_OUTPUT_REJECTION: Final = json.dumps( + {"message": "The model returned the following errors: output_config.format: Extra inputs are not permitted"} +).encode() + + +_ANSWER_SCHEMA: Final = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, +} + + +def _rejects_native_output_then_answers_slowly(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/converse-stream"): + return httpx.Response(400, content=_NATIVE_OUTPUT_REJECTION, request=request) + return slow_upstream_answer(CONVERSE_ANSWER)(request) + + +async def _stream_json_schema_with_thinking(router: litellm.Router) -> str: + response: Final = await router.acompletion( + model="bedrock", + messages=[{"role": "user", "content": "hi"}], + stream=True, + thinking={"type": "adaptive"}, + response_format={"type": "json_schema", "json_schema": {"name": "answer", "schema": _ANSWER_SCHEMA}}, + ) + return "".join([chunk.choices[0].delta.content or "" async for chunk in response]) + + +def _sent_paths(respx_mock: respx.MockRouter) -> list[str]: + return [call.request.url.path.rsplit("/", 1)[-1] for call in respx_mock.calls] + + +@pytest.mark.asyncio +async def test_native_output_retry_is_bounded_by_the_request_timeout_not_the_stream_timeout( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=_rejects_native_output_then_answers_slowly) + router: Final = _bedrock_router( + "bedrock/converse/us.anthropic.claude-opus-4-8", + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + stream_timeout=STREAM_TIMEOUT_SECONDS, + ) + + assert await _stream_json_schema_with_thinking(router) == "hi" + assert _sent_paths(respx_mock) == ["converse-stream", "converse"] + + +@pytest.mark.asyncio +async def test_failed_native_output_retry_reports_the_request_timeout_it_was_bounded_by( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=_rejects_native_output_then_answers_slowly) + request_timeout: Final = UPSTREAM_FIRST_BYTE_SECONDS / 2 + router: Final = _bedrock_router( + "bedrock/converse/us.anthropic.claude-opus-4-8", timeout=request_timeout, stream_timeout=STREAM_TIMEOUT_SECONDS + ) + + with pytest.raises(litellm.Timeout) as excinfo: + await _stream_json_schema_with_thinking(router) + assert getattr(excinfo.value, "timeout", None) == request_timeout + assert _sent_paths(respx_mock) == ["converse-stream", "converse"] + + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "stream_text", + [_text_completion_text, _anthropic_messages_text, _responses_text], + ids=["text_completion", "anthropic_messages", "responses"], +) +@pytest.mark.parametrize( + ("model", "upstream_answer", "bounding_timeout"), + [ + ("bedrock/converse/ai21.jamba-1-5-large-v1:0", CONVERSE_ANSWER, UPSTREAM_FIRST_BYTE_SECONDS / 2), + ("bedrock/anthropic.claude-sonnet-4-5-v1:0", b"", STREAM_TIMEOUT_SECONDS), + ], + ids=["fake_stream", "live_stream"], +) +async def test_failed_stream_on_each_router_surface_reports_the_timeout_it_was_bounded_by( + stream_text: Callable[[litellm.Router], Awaitable[str]], + model: str, + upstream_answer: bytes, + bounding_timeout: float, + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post(url__regex=_BEDROCK_RUNTIME_URL).mock(side_effect=slow_upstream_answer(upstream_answer)) + router: Final = _bedrock_router(model, timeout=UPSTREAM_FIRST_BYTE_SECONDS / 2, stream_timeout=STREAM_TIMEOUT_SECONDS) + + with pytest.raises(openai.APIError) as excinfo: + await stream_text(router) + assert getattr(excinfo.value, "timeout", None) == bounding_timeout + RECORDED_REASONING: Final = "Tokyo proper has about 14 million people." RECORDED_THINKING_TOKENS: Final = 309 JSON_MODE_THINKING_CONVERSE_RESPONSE: Final = { @@ -530,3 +872,49 @@ def test_fake_streamed_json_mode_answer_closes_merged_reasoning_before_the_answe assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == ( f"{answer.choices[0].message.reasoning_content}{answer.choices[0].message.content}" ) + + +_UPSTREAM_ERROR_BODY: Final = '{"message":"The request is invalid."}' + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + "model", ["us.anthropic.claude-sonnet-4-5-20250929-v1:0", "us.anthropic.claude-opus-4-8"], ids=["sonnet", "opus"] +) +@pytest.mark.parametrize( + ("upstream_status", "error_class", "status_code"), + [ + (400, litellm.BadRequestError, 400), + (429, litellm.RateLimitError, 429), + (500, litellm.ServiceUnavailableError, 503), + (503, litellm.ServiceUnavailableError, 503), + ], +) +def test_sync_converse_streaming_upstream_error_carries_the_upstream_body_as_text( + model: str, + upstream_status: int, + error_class: type[litellm.BadRequestError | litellm.RateLimitError | litellm.ServiceUnavailableError], + status_code: int, +) -> None: + client: Final = HTTPHandler( + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response(upstream_status, content=_UPSTREAM_ERROR_BODY.encode()) + ) + ) + ) + + with pytest.raises(error_class) as raised: + litellm.completion( + model=f"bedrock/converse/{model}", + messages=[{"role": "user", "content": "hi"}], + stream=True, + num_retries=0, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + client=client, + ) + + assert raised.value.status_code == status_code + assert raised.value.message == f"litellm.{error_class.__name__}: BedrockException - {_UPSTREAM_ERROR_BODY}" diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 0ed713f056e..f0dd40d4bc9 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -4199,7 +4199,7 @@ def test_request_metadata_key_constraints(): # Test empty key invalid_metadata = {"": "value"} - with pytest.raises(Exception, match=r"(?i)key length|empty"): + with pytest.raises(Exception, match=r"key '' must be 1-256 characters"): config._transform_request( model="anthropic.claude-haiku-4-5-20251001-v1:0", messages=messages, @@ -4241,6 +4241,18 @@ def test_request_metadata_value_constraints(): ) +@pytest.mark.parametrize("metadata", [{"ticket": "a\u00a0b"}, {"a\u2003b": "ticket"}]) +def test_request_metadata_rejects_whitespace_outside_ascii(metadata): + with pytest.raises(litellm.exceptions.BadRequestError, match=r"must be [01]-256 characters of"): + AmazonConverseConfig()._transform_request( + model="anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "Hello!"}], + optional_params={"requestMetadata": metadata}, + litellm_params={}, + headers={}, + ) + + def test_request_metadata_character_pattern(): """Test character pattern validation for keys and values.""" config = AmazonConverseConfig() @@ -10569,3 +10581,151 @@ def test_map_openai_params_drops_the_openai_tool_search_tool_and_every_deferral_ ) assert [tool["function"]["name"] for tool in mapped["tools"]] == ["get_weather", "get_stock_price"] assert all("defer_loading" not in tool and "defer_loading" not in tool["function"] for tool in mapped["tools"]) + + +def _converse_params( + model: str, model_info: Mapping[str, object] | None, non_default_params: Mapping[str, object] +) -> Mapping[str, object]: + return AmazonConverseConfig().map_openai_params( + non_default_params=dict(non_default_params), + optional_params={}, + model=model, + drop_params=False, + model_info=model_info, + ) + + +_OUTPUT_CONFIG_FORMAT: Final = { + "output_config": {"format": {"type": "json_schema", "schema": CAPITAL_RESPONSE_FORMAT["json_schema"]["schema"]}} +} + + +@pytest.mark.parametrize( + "request_params", + [ + {"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING}, + {"response_format": CAPITAL_RESPONSE_FORMAT, "reasoning_effort": "low"}, + {"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": {"type": "enabled", "budget_tokens": 2048}}, + _OUTPUT_CONFIG_FORMAT, + ], + ids=["adaptive", "reasoning_effort", "budget", "output_config_format"], +) +@pytest.mark.parametrize("model", ["us.anthropic.claude-opus-4-8", "global.anthropic.claude-sonnet-5"]) +def test_uses_opt_in_native_output_when_the_opt_in_drove_native_output(local_model_cost_map, model, request_params): + optional_params: Final = _converse_params(model, None, request_params) + + assert AmazonConverseConfig().uses_opt_in_native_output(model, optional_params, {"model_info": {"id": "d1"}}) + + +@pytest.mark.parametrize( + ("model", "model_info", "request_params"), + [ + ("us.anthropic.claude-opus-4-8", None, {"response_format": CAPITAL_RESPONSE_FORMAT}), + ( + "us.anthropic.claude-opus-4-8", + {"supports_bedrock_native_structured_output": False}, + {"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING}, + ), + ( + "us.anthropic.claude-sonnet-4-6", + {"supports_bedrock_native_structured_output": True}, + {"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING}, + ), + ], + ids=["no_thinking", "deployment_opt_out", "native_model"], +) +def test_uses_opt_in_native_output_is_false_when_native_output_did_not_come_from_the_opt_in( + local_model_cost_map, model, model_info, request_params +): + optional_params: Final = _converse_params(model, model_info, request_params) + + assert not AmazonConverseConfig().uses_opt_in_native_output(model, optional_params, {"model_info": model_info}) + + +def test_uses_opt_in_native_output_reads_the_base_model_of_an_application_inference_profile(local_model_cost_map): + profile: Final = "arn:aws:bedrock:us-east-1:000000000000:application-inference-profile/profile-id" + litellm_params: Final = {"base_model": "bedrock/us.anthropic.claude-opus-4-8"} + optional_params: Final = _converse_params( + "us.anthropic.claude-opus-4-8", + None, + {"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING}, + ) + + assert AmazonConverseConfig().uses_opt_in_native_output(profile, optional_params, litellm_params) + + +_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT: Final = { + "textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}} +} +_BEDROCK_SCHEMA_TYPE_ERROR: Final = "outputConfig.textFormat.structure.jsonSchema.schema must be a string" + + +@pytest.mark.parametrize("model", [_OPUS, _LLAMA], ids=["opt_in", "not_opt_in"]) +@pytest.mark.respx(assert_all_called=True) +def test_a_malformed_caller_output_config_gets_bedrocks_own_400( + model: str, respx_mock: respx.Router, local_model_cost_map: None +) -> None: + route: Final = respx_mock.post(url__regex=r"/converse$").mock( + return_value=httpx.Response(400, json={"message": _BEDROCK_SCHEMA_TYPE_ERROR}) + ) + + with pytest.raises(litellm.BadRequestError, match=re.escape(_BEDROCK_SCHEMA_TYPE_ERROR)): + completion( + model=f"bedrock/converse/{model}", + messages=[{"role": "user", "content": "What is the capital of France?"}], + response_format=CAPITAL_RESPONSE_FORMAT, + outputConfig=_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT, + num_retries=0, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + client=HTTPHandler(), + ) + + assert route.call_count == 1 + + +_CALLER_OUTPUT_CONFIG: Final = { + "textFormat": { + "type": "json_schema", + "structure": { + "jsonSchema": {"name": "capital", "schema": json.dumps(CAPITAL_RESPONSE_FORMAT["json_schema"]["schema"])} + }, + } +} +_BEDROCK_SCHEMA_REFUSAL: Final = "output_config.format.schema: pattern is too complex for structured output" + + +@pytest.mark.parametrize( + ("caller_params", "expected_calls"), + [ + ({"thinking": ADAPTIVE_THINKING}, 2), + ({"outputConfig": _CALLER_OUTPUT_CONFIG}, 1), + ({"outputConfig": _CALLER_OUTPUT_CONFIG, "thinking": ADAPTIVE_THINKING}, 1), + ], + ids=["litellm_native_output", "caller_output_config", "caller_output_config_thinking"], +) +@pytest.mark.respx(assert_all_called=True) +def test_a_refused_native_schema_is_sent_again_only_when_the_retry_drops_native_output( + caller_params: dict, expected_calls: int, respx_mock: respx.Router, local_model_cost_map: None +) -> None: + route: Final = respx_mock.post(url__regex=r"/converse$").mock( + return_value=httpx.Response(400, json={"message": _BEDROCK_SCHEMA_REFUSAL}) + ) + + with pytest.raises(litellm.BadRequestError, match=re.escape(_BEDROCK_SCHEMA_REFUSAL)): + completion( + model=f"bedrock/converse/{_OPUS}", + messages=[{"role": "user", "content": "What is the capital of France?"}], + response_format=CAPITAL_RESPONSE_FORMAT, + num_retries=0, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + client=HTTPHandler(), + **caller_params, + ) + + assert route.call_count == expected_calls + assert all("outputConfig" in json.loads(call.request.content) for call in route.calls[:1]) + assert all("outputConfig" not in json.loads(call.request.content) for call in route.calls[1:]) diff --git a/tests/unit/llms/bedrock/chat/test_native_output_fallback.py b/tests/unit/llms/bedrock/chat/test_native_output_fallback.py new file mode 100644 index 00000000000..301fdaeb245 --- /dev/null +++ b/tests/unit/llms/bedrock/chat/test_native_output_fallback.py @@ -0,0 +1,123 @@ +import asyncio +from collections.abc import Coroutine +from dataclasses import dataclass +from typing import Final + +import pytest + +from litellm.llms.bedrock.chat.native_output_fallback import NativeOutputAttemptResult, call_with_native_output_fallback +from litellm.llms.bedrock.common_utils import BedrockError, NativeOutputFallback +from litellm.types.utils import ModelResponse + +_MODEL: Final = "us.anthropic.claude-opus-4-8" +_FALLBACK: Final = NativeOutputFallback( + optional_params={"tools": "json_tool_call"}, litellm_params={"model_info": {"opted_out": True}} +) +_REJECTION: Final = BedrockError( + status_code=400, + message='{"message":"The model returned the following errors: output_config.format: Extra inputs are not permitted"}', +) +_SCHEMA_REFUSAL: Final = BedrockError( + status_code=400, message='{"message":"output_config.format.schema: pattern is too complex for structured output"}' +) +_OTHER_400: Final = BedrockError(status_code=400, message='{"message":"messages.0.content: Field required"}') + + +@dataclass(slots=True) +class ScriptedBedrock: + errors: tuple[BedrockError | None, ...] + asynchronous: bool + sent: tuple[NativeOutputFallback | None, ...] = () + + def _answer(self, error: BedrockError | None) -> ModelResponse: + if error is not None: + raise error + return ModelResponse(id=f"answer-{len(self.sent)}") + + async def _answer_later(self, error: BedrockError | None) -> ModelResponse: + await asyncio.sleep(0) + return self._answer(error) + + def __call__(self, fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult: + self.sent = (*self.sent, fallback) + error: Final = self.errors[len(self.sent) - 1] + return self._answer_later(error) if self.asynchronous else self._answer(error) + + +def _resolve(result: NativeOutputAttemptResult) -> object: + if isinstance(result, Coroutine): + return asyncio.run(result) + return result + + +def _call(bedrock: ScriptedBedrock, fallback: NativeOutputFallback | None) -> object: + return _resolve(call_with_native_output_fallback(bedrock, None if fallback is None else lambda: fallback, _MODEL)) + + +_MODES: Final = pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) + + +@_MODES +def test_a_native_output_rejection_is_sent_again_once_with_the_fallback_params(asynchronous: bool) -> None: + bedrock: Final = ScriptedBedrock(errors=(_REJECTION, None), asynchronous=asynchronous) + + response: Final = _call(bedrock, _FALLBACK) + + assert isinstance(response, ModelResponse) and response.id == "answer-2" + assert bedrock.sent == (None, _FALLBACK) + + +@_MODES +def test_a_second_native_output_rejection_reaches_the_caller(asynchronous: bool) -> None: + bedrock: Final = ScriptedBedrock(errors=(_REJECTION, _REJECTION), asynchronous=asynchronous) + + with pytest.raises(BedrockError): + _call(bedrock, _FALLBACK) + + assert len(bedrock.sent) == 2 + + +@_MODES +@pytest.mark.parametrize( + ("error", "fallback"), + [(_OTHER_400, _FALLBACK), (_REJECTION, None)], + ids=["other_400", "no_fallback"], +) +def test_no_second_request_without_both_a_rejection_and_a_fallback( + asynchronous: bool, error: BedrockError, fallback: NativeOutputFallback | None +) -> None: + bedrock: Final = ScriptedBedrock(errors=(error,), asynchronous=asynchronous) + + with pytest.raises(BedrockError) as raised: + _call(bedrock, fallback) + + assert raised.value is error + assert bedrock.sent == (None,) + + +@_MODES +def test_a_rejection_reaches_the_caller_when_the_retry_would_send_native_output_again(asynchronous: bool) -> None: + bedrock: Final = ScriptedBedrock(errors=(_REJECTION,), asynchronous=asynchronous) + + with pytest.raises(BedrockError) as raised: + _resolve(call_with_native_output_fallback(bedrock, lambda: None, _MODEL)) + + assert raised.value is _REJECTION + assert bedrock.sent == (None,) + + +@pytest.mark.parametrize( + ("error", "advises_opting_out"), + [(_REJECTION, True), (_SCHEMA_REFUSAL, False)], + ids=["native_output_unsupported", "schema_refused"], +) +def test_a_rejection_logs_the_retry_and_advises_opting_out_only_when_native_output_is_unsupported( + error: BedrockError, advises_opting_out: bool, caplog: pytest.LogCaptureFixture +) -> None: + bedrock: Final = ScriptedBedrock(errors=(error, None), asynchronous=False) + + with caplog.at_level("WARNING", logger="LiteLLM"): + _call(bedrock, _FALLBACK) + + assert _MODEL in caplog.text + assert ("supports_bedrock_native_structured_output: false" in caplog.text) is advises_opting_out diff --git a/tests/unit/llms/bedrock/count_tokens/test_bedrock_kimi_k3.py b/tests/unit/llms/bedrock/count_tokens/test_bedrock_kimi_k3.py new file mode 100644 index 00000000000..ffddb09cca7 --- /dev/null +++ b/tests/unit/llms/bedrock/count_tokens/test_bedrock_kimi_k3.py @@ -0,0 +1,524 @@ +import asyncio +import base64 +import hashlib +import json +import logging +import re +import threading +from collections.abc import Iterable +from itertools import product +from pathlib import Path +from typing import Final + +import httpx +import pytest +import respx +import tiktoken + +import litellm +from litellm._logging import verbose_logger +from litellm.llms.bedrock.count_tokens.kimi_k3 import ( + KimiK3Encodings, + KimiK3TokenizerSource, + kimi_k3_anthropic_tokens, + kimi_k3_tokens, + load_kimi_k3_encoding, +) +from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig + +MODEL: Final = "us.moonshotai.kimi-k3" +_CONTROL: Final = re.compile(r"<\|(?:open|close|sep|end_of_msg)\|>") +_WEATHER: Final = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather in °C", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}, "days": {"type": "integer"}}, + "required": ["city"], + }, + }, +} + + +def _call(call_id: str, arguments: dict[str, object]) -> dict[str, object]: + return {"id": call_id, "type": "function", "function": {"name": "get_weather", "arguments": json.dumps(arguments)}} + + +def _written_ranks(path: Path, tokens: Iterable[bytes]) -> KimiK3TokenizerSource: + path.write_bytes(b"\n".join(base64.b64encode(token) + b" %d" % rank for rank, token in enumerate(tokens))) + return KimiK3TokenizerSource(ranks_path=lambda: str(path), sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + + +def _single_bytes() -> tuple[bytes, ...]: + return tuple(bytes((value,)) for value in range(256)) + + +@pytest.fixture(scope="module") +def byte_source(tmp_path_factory: pytest.TempPathFactory) -> KimiK3TokenizerSource: + return _written_ranks(tmp_path_factory.mktemp("bytes") / "tiktoken.model", _single_bytes()) + + +@pytest.fixture(scope="module") +def byte_encoding(byte_source: KimiK3TokenizerSource) -> tiktoken.Encoding: + loaded: Final = load_kimi_k3_encoding(byte_source) + assert loaded is not None + return loaded + + +@pytest.fixture(scope="module") +def pair_encoding(tmp_path_factory: pytest.TempPathFactory) -> tiktoken.Encoding: + pairs: Final = (bytes(pair) for pair in product(range(256), repeat=2)) + source: Final = _written_ranks(tmp_path_factory.mktemp("pairs") / "tiktoken.model", (*_single_bytes(), *pairs)) + loaded: Final = load_kimi_k3_encoding(source) + assert loaded is not None + return loaded + + +def _template_count(encoding: tiktoken.Encoding, rendered: str) -> int: + texts: Final = _CONTROL.split(rendered) + return len(_CONTROL.findall(rendered)) + sum(len(encoding.encode_ordinary(text)) for text in texts) + + +_GENERATION_PROMPT: Final = '<|open|>message role="assistant"<|sep|><|open|>think<|sep|>' + + +# Expected renders follow the Kimi K3 chat template in moonshotai/Kimi-K3 at revision f831ab6, which matched +# Bedrock's prompt_tokens for us.moonshotai.kimi-k3 in 19 live calls on 2026-10-16 +@pytest.mark.parametrize( + ("messages", "tools", "rendered"), + [ + pytest.param( + [{"role": "user", "content": "Hello there"}], + None, + '<|open|>message role="user"<|sep|>Hello there<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT, + id="user", + ), + pytest.param( + [ + { + "role": "system", + "content": [{"type": "text", "text": "Be terse."}, {"type": "text", "text": "Answer in French."}], + }, + {"role": "user", "content": [{"type": "text", "text": "Hi"}, {"type": "text", "text": " there"}]}, + ], + None, + '<|open|>message role="system"<|sep|>Be terse.Answer in French.<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="user"<|sep|>Hi there<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT, + id="text_parts_join", + ), + pytest.param( + [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + {"role": "user", "content": "And 3+3?"}, + ], + None, + '<|open|>message role="user"<|sep|>What is 2+2?<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>' + "<|open|>response<|sep|>4<|close|>response<|sep|><|close|>message<|sep|><|end_of_msg|>" + '<|open|>message role="user"<|sep|>And 3+3?<|close|>message<|sep|><|end_of_msg|>' + _GENERATION_PROMPT, + id="assistant_turn", + ), + pytest.param( + [ + {"role": "user", "content": "Weather in Paris and Oslo?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [_call("c1", {"city": "Paris", "days": 3}), _call("c2", {"city": "Oslo"})], + }, + {"role": "tool", "tool_call_id": "c2", "content": "Snow"}, + {"role": "tool", "tool_call_id": "c1", "content": "Sun"}, + ], + [_WEATHER], + '<|open|>message role="system" type="tool-declare"<|sep|># Tools\n' + "Here are the available tools, described in JSONSchema.\n\n```json\n" + '[{"function":{"description":"Get the weather in °C","name":"get_weather","parameters":{"properties":' + '{"city":{"type":"string"},"days":{"type":"integer"}},"required":["city"],"type":"object"}},' + '"type":"function"}]\n```<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="user"<|sep|>Weather in Paris and Oslo?<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>' + "<|open|>response<|sep|><|close|>response<|sep|><|open|>tools<|sep|>" + '<|open|>call tool="get_weather" index="1"<|sep|>' + '<|open|>argument key="city" type="string"<|sep|>Paris<|close|>argument<|sep|>' + '<|open|>argument key="days" type="number"<|sep|>3<|close|>argument<|sep|><|close|>call<|sep|>' + '<|open|>call tool="get_weather" index="2"<|sep|>' + '<|open|>argument key="city" type="string"<|sep|>Oslo<|close|>argument<|sep|><|close|>call<|sep|>' + "<|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>" + '<|open|>message role="tool" tool="get_weather" index="1"<|sep|>Sun<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="tool" tool="get_weather" index="2"<|sep|>Snow<|close|>message<|sep|><|end_of_msg|>' + + _GENERATION_PROMPT, + id="tool_results_out_of_call_order", + ), + pytest.param( + [ + {"role": "user", "content": "Weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + _call( + "c1", {"a&b": 'x"y', "flag": True, "none": None, "list": [1, "zé"], "object": {"k": True}} + ) + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "Rain"}, + ], + [_WEATHER], + '<|open|>message role="system" type="tool-declare"<|sep|># Tools\n' + "Here are the available tools, described in JSONSchema.\n\n```json\n" + '[{"function":{"description":"Get the weather in °C","name":"get_weather","parameters":{"properties":' + '{"city":{"type":"string"},"days":{"type":"integer"}},"required":["city"],"type":"object"}},' + '"type":"function"}]\n```<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="user"<|sep|>Weather?<|close|>message<|sep|><|end_of_msg|>' + '<|open|>message role="assistant"<|sep|><|open|>think<|sep|><|close|>think<|sep|>' + "<|open|>response<|sep|><|close|>response<|sep|><|open|>tools<|sep|>" + '<|open|>call tool="get_weather" index="1"<|sep|>' + '<|open|>argument key="a&b" type="string"<|sep|>x"y<|close|>argument<|sep|>' + '<|open|>argument key="flag" type="boolean"<|sep|>true<|close|>argument<|sep|>' + '<|open|>argument key="none" type="null"<|sep|>null<|close|>argument<|sep|>' + '<|open|>argument key="list" type="array"<|sep|>[1, "zé"]<|close|>argument<|sep|>' + '<|open|>argument key="object" type="object"<|sep|>{"k": true}<|close|>argument<|sep|>' + "<|close|>call<|sep|><|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>" + '<|open|>message role="tool" tool="get_weather" index="1"<|sep|>Rain<|close|>message<|sep|><|end_of_msg|>' + + _GENERATION_PROMPT, + id="argument_types_and_escaping", + ), + ], +) +@pytest.mark.parametrize("encoding_fixture", ["byte_encoding", "pair_encoding"]) +def test_count_is_the_kimi_k3_chat_template_render_of_the_bedrock_request( + request: pytest.FixtureRequest, + encoding_fixture: str, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None, + rendered: str, +): + encoding: Final[tiktoken.Encoding] = request.getfixturevalue(encoding_fixture) + + assert kimi_k3_tokens(encoding, MODEL, messages, tools) == _template_count(encoding, rendered) + + +def _parallel_calls(count: int) -> list[dict[str, object]]: + ids: Final = tuple(f"c{number}" for number in range(1, count + 1)) + return [ + {"role": "user", "content": "Weather?"}, + {"role": "assistant", "content": None, "tool_calls": [_call(call_id, {"city": "Paris"}) for call_id in ids]}, + *({"role": "tool", "tool_call_id": call_id, "content": "Sun"} for call_id in ids), + ] + + +def test_tenth_parallel_call_and_its_result_are_numbered_ten(byte_encoding: tiktoken.Encoding): + nine: Final = kimi_k3_tokens(byte_encoding, MODEL, _parallel_calls(9), [_WEATHER]) + ten: Final = kimi_k3_tokens(byte_encoding, MODEL, _parallel_calls(10), [_WEATHER]) + assert nine is not None and ten is not None + + assert ten - nine == _template_count( + byte_encoding, + '<|open|>call tool="get_weather" index="10"<|sep|>' + '<|open|>argument key="city" type="string"<|sep|>Paris<|close|>argument<|sep|><|close|>call<|sep|>' + '<|open|>message role="tool" tool="get_weather" index="10"<|sep|>Sun<|close|>message<|sep|><|end_of_msg|>', + ) + + +@pytest.mark.parametrize( + "text", + [ + pytest.param("The quick brown fox, jumps over 12345 lazy dogs. " * 60, id="prose"), + pytest.param("a" + "1234567890" * 250 + "1", id="long_digit_run"), + pytest.param("--- " * 600, id="symbols"), + pytest.param("def f(x):\n return x # it's 42\n\n" * 80, id="code"), + pytest.param("word next\t\ttab\n \n" * 120, id="whitespace"), + pytest.param("naïve café, “quotes” & 中文汉字,日本語のテキスト 😀🎉\r\n" * 40, id="unicode"), + pytest.param("We're sure they'd say it's DON'T or I'M " * 60, id="contractions"), + pytest.param("abc123def4567ghi89 " * 120, id="letters_and_digits"), + ], +) +def test_long_text_counts_what_the_tokenizer_counts_for_the_whole_text(pair_encoding: tiktoken.Encoding, text: str): + def counted(content: str) -> int | None: + return kimi_k3_tokens(pair_encoding, MODEL, [{"role": "user", "content": content}], None) + + short: Final = counted("x") + assert short is not None + + assert counted(text) == short - len(pair_encoding.encode_ordinary("x")) + len(pair_encoding.encode_ordinary(text)) + + +@pytest.mark.parametrize( + ("messages", "tools"), + [ + pytest.param( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, + ], + } + ], + None, + id="image", + ), + pytest.param( + [{"role": "user", "content": "Weather?"}, {"role": "tool", "tool_call_id": "c1", "content": "Sun"}], + [_WEATHER], + id="tool_result_without_its_call", + ), + ], +) +def test_request_the_template_cannot_render_is_not_counted( + pair_encoding: tiktoken.Encoding, messages: list[dict[str, object]], tools: list[dict[str, object]] | None +): + assert kimi_k3_tokens(pair_encoding, MODEL, messages, tools) is None + + +@pytest.mark.parametrize( + "tool", + [ + pytest.param( + { + "type": "function", + "name": "get_weather", + "description": "Get the weather in °C", + "parameters": _WEATHER["function"]["parameters"], + }, + id="responses_api", + ), + pytest.param( + { + "name": "get_weather", + "description": "Get the weather in °C", + "input_schema": _WEATHER["function"]["parameters"], + }, + id="anthropic_messages", + ), + ], +) +def test_tool_in_another_api_shape_counts_like_the_same_chat_completions_tool( + pair_encoding: tiktoken.Encoding, tool: dict[str, object] +): + messages: Final = [{"role": "user", "content": "Weather in Paris?"}] + + counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [tool]) + + assert counted is not None + assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER]) + + +@pytest.mark.parametrize( + "tool", + [ + pytest.param({"type": "web_search_20250305", "name": "web_search"}, id="anthropic_web_search"), + pytest.param({"type": "web_search"}, id="responses_web_search"), + pytest.param( + { + "type": "mcp", + "server_label": "docs", + "server_url": "https://mcp.example/mcp", + "require_approval": "never", + }, + id="mcp", + ), + ], +) +def test_tool_bedrock_inference_drops_counts_like_the_function_tools_alone( + pair_encoding: tiktoken.Encoding, tool: dict[str, object] +): + messages: Final = [{"role": "user", "content": "Weather in Paris?"}] + + counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, tool]) + + assert counted is not None + assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER]) + + +def test_responses_custom_tool_counts_like_the_function_tool_inference_sends(pair_encoding: tiktoken.Encoding): + messages: Final = [{"role": "user", "content": "Weather in Paris?"}] + custom_tool: Final = {"type": "custom", "name": "grammar", "description": "Free-form SQL"} + sent_to_inference, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + [dict(custom_tool)] + ) + + without_it: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER]) + + counted: Final = kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, custom_tool]) + + assert counted is not None and without_it is not None + assert counted > without_it + assert counted == kimi_k3_tokens(pair_encoding, MODEL, messages, [_WEATHER, *sent_to_inference]) + + +def test_counting_a_request_with_an_image_url_never_downloads_the_image( + pair_encoding: tiktoken.Encoding, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "user_url_validation", False) + image: Final = respx_mock.get("https://images.example/cat.png").mock(return_value=httpx.Response(200)) + messages: Final = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + {"type": "image_url", "image_url": {"url": "https://images.example/cat.png"}}, + ], + } + ] + + assert kimi_k3_tokens(pair_encoding, MODEL, messages, None) is None + assert not image.called + + +def test_anthropic_request_counts_like_the_same_openai_request(pair_encoding: tiktoken.Encoding): + anthropic: Final = kimi_k3_anthropic_tokens( + pair_encoding, + MODEL, + { + "system": "Be terse.", + "messages": [ + {"role": "user", "content": "Weather in Paris?"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "c1", "name": "get_weather", "input": {"city": "Paris"}}], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "c1", "content": "Sun"}]}, + ], + "tools": [ + { + "name": "get_weather", + "description": "Get the weather in °C", + "input_schema": _WEATHER["function"]["parameters"], + } + ], + }, + ) + openai: Final = kimi_k3_tokens( + pair_encoding, + MODEL, + [ + {"role": "system", "content": "Be terse."}, + {"role": "user", "content": "Weather in Paris?"}, + {"role": "assistant", "content": None, "tool_calls": [_call("c1", {"city": "Paris"})]}, + {"role": "tool", "tool_call_id": "c1", "content": "Sun"}, + ], + [_WEATHER], + ) + + assert openai is not None + assert anthropic == openai + + +@pytest.mark.parametrize("failure", ["checksum", "missing_file"]) +def test_tokenizer_that_fails_its_checksum_or_cannot_be_read_is_not_loaded( + byte_source: KimiK3TokenizerSource, tmp_path: Path, caplog: pytest.LogCaptureFixture, failure: str +): + source: Final = ( + KimiK3TokenizerSource(ranks_path=byte_source.ranks_path, sha256=hashlib.sha256(b"other").hexdigest()) + if failure == "checksum" + else KimiK3TokenizerSource(ranks_path=lambda: str(tmp_path / "missing"), sha256=byte_source.sha256) + ) + verbose_logger.addHandler(caplog.handler) + try: + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + loaded: Final = load_kimi_k3_encoding(source) + finally: + verbose_logger.removeHandler(caplog.handler) + + assert loaded is None + assert any("Kimi K3 tokenizer unavailable" in record.getMessage() for record in caplog.records) + + +@pytest.mark.asyncio +async def test_slow_tokenizer_load_is_not_waited_on_past_the_wait_and_is_shared_once_done( + byte_source: KimiK3TokenizerSource, +): + released: Final = threading.Event() + + def slow_ranks_path() -> str: + released.wait(10) + return byte_source.ranks_path() + + encodings: Final = KimiK3Encodings( + source=KimiK3TokenizerSource(ranks_path=slow_ranks_path, sha256=byte_source.sha256), + retry_after_seconds=60, + clock=lambda: 0.0, + ) + + assert await encodings.encoding(wait_seconds=0) is None + released.set() + loaded: Final = await encodings.encoding(wait_seconds=5) + assert loaded is not None + assert await encodings.encoding(wait_seconds=0) is loaded + + +class _Clock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +async def _loaded_eventually(encodings: KimiK3Encodings) -> tiktoken.Encoding | None: + for _ in range(500): + if (loaded := await encodings.encoding(wait_seconds=0)) is not None: + return loaded + await asyncio.sleep(0.01) + return None + + +@pytest.mark.asyncio +async def test_failed_tokenizer_load_is_retried_in_the_background_only_after_the_retry_window( + byte_source: KimiK3TokenizerSource, tmp_path: Path +): + clock: Final = _Clock() + reads: Final[list[float]] = [] + released: Final = threading.Event() + + def ranks_path() -> str: + reads.append(clock.now) + if len(reads) == 1: + return str(tmp_path / "missing") + released.wait(10) + return byte_source.ranks_path() + + encodings: Final = KimiK3Encodings( + source=KimiK3TokenizerSource(ranks_path=ranks_path, sha256=byte_source.sha256), + retry_after_seconds=60, + clock=clock, + ) + + assert await encodings.encoding(wait_seconds=5) is None + clock.now = 59.0 + assert await encodings.encoding(wait_seconds=5) is None + clock.now = 60.0 + assert await asyncio.wait_for(encodings.encoding(wait_seconds=5), timeout=1) is None + released.set() + assert await _loaded_eventually(encodings) is not None + assert reads == [0.0, 60.0] + + +@pytest.mark.asyncio +async def test_tokenizer_load_still_running_after_the_wait_is_logged( + byte_source: KimiK3TokenizerSource, caplog: pytest.LogCaptureFixture +): + released: Final = threading.Event() + + def hung_ranks_path() -> str: + released.wait(10) + return byte_source.ranks_path() + + encodings: Final = KimiK3Encodings( + source=KimiK3TokenizerSource(ranks_path=hung_ranks_path, sha256=byte_source.sha256), retry_after_seconds=60 + ) + verbose_logger.addHandler(caplog.handler) + try: + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + loaded: Final = await encodings.encoding(wait_seconds=0.05) + finally: + verbose_logger.removeHandler(caplog.handler) + released.set() + + assert loaded is None + assert any("Kimi K3 tokenizer still loading" in record.getMessage() for record in caplog.records) diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index 425beefac8a..d64a9ee845a 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -1760,6 +1760,23 @@ class TestBedrockBatchNonChatEndpointRecords: assert "metadata" not in model_input assert "requestMetadata" not in model_input + def test_nova_record_request_metadata_stays_out_of_the_bedrock_body(self): + model_input = self._transform( + { + "custom_id": "4d", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": self.NOVA_MODEL, + "messages": [{"role": "user", "content": "hi"}], + "requestMetadata": {"ticket": "b\u00a0c"}, + }, + } + ) + + assert "requestMetadata" not in model_input + assert model_input["messages"] == [{"role": "user", "content": [{"text": "hi"}]}] + @pytest.mark.parametrize( "body", [ @@ -2030,6 +2047,31 @@ class TestBedrockBatchAnthropicRowParams: assert "response_format" not in model_input assert ("output_config" in model_input) == (response_format["type"] == "json_schema") + @pytest.mark.parametrize( + ("model", "native_output"), + [("bedrock/us.anthropic.claude-opus-4-8", False), (MODEL, True)], + ids=["opt_in_native_output_model", "native_output_model"], + ) + def test_thinking_json_schema_rows_use_native_output_only_on_models_that_always_take_it( + self, local_model_cost_map, model, native_output + ): + model_input: Final = self._transform( + "/v1/chat/completions", + { + "messages": [{"role": "user", "content": "Weather in Paris?"}], + "thinking": {"type": "adaptive"}, + "response_format": { + "type": "json_schema", + "json_schema": {"name": "weather", "schema": self.PARAMETERS}, + }, + }, + model=model, + ) + tool_names: Final = frozenset(tool["name"] for tool in model_input.get("tools", [])) + + assert ("format" in model_input.get("output_config", {})) is native_output + assert (tool_names == {"json_tool_call"}) is not native_output + def test_provider_native_params_still_pass_through(self): model_input = self._transform( "/v1/chat/completions", diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index eb6b4fdb063..6fe32785281 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -6,6 +6,7 @@ import os import struct import zlib from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass from datetime import datetime from types import SimpleNamespace from typing import Final @@ -4009,3 +4010,162 @@ def test_bedrock_removed_tool_change_does_not_add_beta(explicit_beta: bool) -> N assert result["messages"] == [{"role": "user", "content": "Reply with OK"}] assert result.get("anthropic_beta", []).count(beta) == int(explicit_beta) + + +_NATIVE_OUTPUT_REJECTION: Final = { + "message": "The model returned the following errors: compilation exceeded limits: Schema is too complex." +} +_STALE_SIGNATURE_ERROR: Final = {"message": "messages.1.content.0: Invalid `signature` in `thinking` block"} +_OPTED_OUT: Final = {"supports_bedrock_native_structured_output": False} +_CONSTRAINED_SCHEMA: Final = { + "type": "object", + "properties": { + "summary": {"type": "string", "maxLength": 50}, + "kind": {"oneOf": [{"type": "string"}, {"type": "integer"}]}, + }, + "required": ["summary", "kind"], + "additionalProperties": False, +} +_ANTHROPIC_MESSAGE: Final = { + "id": "msg_scripted", + "type": "message", + "role": "assistant", + "model": "claude-scripted", + "content": [{"type": "text", "text": '{"summary": "sunny", "kind": 1}'}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, +} +_THINKING_HISTORY: Final = ( + {"role": "user", "content": "Describe the weather."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "checking", "signature": "stale"}, + {"type": "text", "text": "Sunny."}, + ], + }, + {"role": "user", "content": "Summarise it as JSON."}, +) + + +@dataclass(slots=True) +class ScriptedBedrockInvoke: + replies: tuple[tuple[int, Mapping[str, object]], ...] + bodies: tuple[dict[str, object], ...] = () + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.bodies = (*self.bodies, REASONING_BODY_ADAPTER.validate_json(request.content)) + status_code, body = self.replies[len(self.bodies) - 1] + return httpx.Response(status_code, json=body) + + +async def _send_bedrock_messages( + model: str, + model_info: Mapping[str, object] | None, + replies: Sequence[tuple[int, Mapping[str, object]]], + messages: Sequence[Mapping[str, object]] = ({"role": "user", "content": "Summarise the weather as JSON."},), +) -> tuple[ScriptedBedrockInvoke, Exception | None]: + bedrock: Final = ScriptedBedrockInvoke(replies=tuple(replies)) + try: + await litellm.anthropic_messages( + model=f"bedrock/invoke/{model}", + messages=[dict(message) for message in messages], + max_tokens=4000, + system="Answer in French.", + thinking={"type": "adaptive"}, + output_config={"format": {"type": "json_schema", "schema": _CONSTRAINED_SCHEMA}}, + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + aws_region_name="us-east-1", + client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)), + **({} if model_info is None else {"model_info": model_info}), + ) + except Exception as error: + return bedrock, error + return bedrock, None + + +_OPT_IN_DEPLOYMENTS: Final = pytest.mark.parametrize( + ("model", "model_info"), + [ + ("us.anthropic.claude-opus-4-8", None), + ("us.anthropic.claude-opus-5-5", {"supports_bedrock_native_structured_output": True}), + ], + ids=["mapped_opt_in", "deployment_opt_in"], +) + + +@pytest.mark.usefixtures("local_model_cost_map") +@_OPT_IN_DEPLOYMENTS +async def test_bedrock_messages_native_output_rejection_resends_the_body_an_opted_out_deployment_sends( + model: str, model_info: Mapping[str, object] | None +) -> None: + opt_in, opt_in_error = await _send_bedrock_messages( + model, model_info, [(400, _NATIVE_OUTPUT_REJECTION), (200, _ANTHROPIC_MESSAGE)] + ) + opted_out, opted_out_error = await _send_bedrock_messages(model, _OPTED_OUT, [(200, _ANTHROPIC_MESSAGE)]) + + assert opt_in_error is None and opted_out_error is None + native, retried = opt_in.bodies + assert "format" in REASONING_BODY_ADAPTER.validate_python(native["output_config"]) + assert opted_out.bodies == (retried,) + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize("model_info", [None, _OPTED_OUT], ids=["native_output", "opted_out"]) +async def test_bedrock_messages_thinking_signature_400_that_persists_reaches_bedrock_twice_as_before( + model_info: Mapping[str, object] | None, +) -> None: + bedrock, error = await _send_bedrock_messages( + "us.anthropic.claude-opus-4-8", model_info, [(400, _STALE_SIGNATURE_ERROR)] * 3, _THINKING_HISTORY + ) + + assert isinstance(error, litellm.BadRequestError) + assert len(bedrock.bodies) == 2 + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + "first_error, second_error", + [(_NATIVE_OUTPUT_REJECTION, _STALE_SIGNATURE_ERROR), (_STALE_SIGNATURE_ERROR, _NATIVE_OUTPUT_REJECTION)], + ids=["rejection_first", "signature_first"], +) +async def test_bedrock_messages_native_output_rejection_and_signature_400_each_get_one_retry_like_an_opted_out_deployment( + first_error: Mapping[str, object], second_error: Mapping[str, object] +) -> None: + opt_in, opt_in_error = await _send_bedrock_messages( + "us.anthropic.claude-opus-4-8", + None, + [(400, first_error), (400, second_error), (200, _ANTHROPIC_MESSAGE)], + _THINKING_HISTORY, + ) + opted_out, opted_out_error = await _send_bedrock_messages( + "us.anthropic.claude-opus-4-8", + _OPTED_OUT, + [(400, _STALE_SIGNATURE_ERROR), (200, _ANTHROPIC_MESSAGE)], + _THINKING_HISTORY, + ) + + assert opt_in_error is None and opted_out_error is None + assert len(opt_in.bodies) == 3 + assert opt_in.bodies[-1] == opted_out.bodies[-1] + + +@pytest.mark.usefixtures("local_model_cost_map") +@pytest.mark.parametrize( + ("model", "model_info", "error"), + [ + ("us.anthropic.claude-opus-4-8", None, {"message": "messages.0.content: Field required"}), + ("us.anthropic.claude-sonnet-4-6", None, _NATIVE_OUTPUT_REJECTION), + ("us.anthropic.claude-opus-4-8", _OPTED_OUT, _NATIVE_OUTPUT_REJECTION), + ], + ids=["other_400", "native_model", "deployment_opt_out"], +) +async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_native_output( + model: str, model_info: Mapping[str, object] | None, error: Mapping[str, object] +) -> None: + bedrock, raised = await _send_bedrock_messages(model, model_info, [(400, error), (200, _ANTHROPIC_MESSAGE)]) + + assert isinstance(raised, litellm.BadRequestError) + assert len(bedrock.bodies) == 1 diff --git a/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py index 84d3160e609..da5c4699925 100644 --- a/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py +++ b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py @@ -17,6 +17,7 @@ import litellm from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.base_llm.passthrough.transformation import PassthroughStreamCollector +from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.passthrough.transformation import ( BedrockPassthroughConfig, is_bedrock_streaming_endpoint, @@ -898,3 +899,293 @@ def test_stream_collector_logs_nothing_for_an_unrecognized_endpoint(): collector.add(_event_frame("messageStart", {"role": "assistant"})) assert collector.build_logged_response(_converse_stream_logging_obj()) is None + + +REQUEST_METADATA_HEADER = "x-amzn-bedrock-request-metadata" +REQUEST_METADATA_FIELDS = ["user_api_key_alias", "user_api_key_team_alias", "spend_logs_metadata"] +PROXY_IDENTITY = { + "user_api_key_alias": "prod-key", + "user_api_key_team_alias": "platform", + "spend_logs_metadata": {"agent": "triage-bot"}, +} +EXPECTED_REQUEST_METADATA = { + "user_api_key_alias": "prod-key", + "user_api_key_team_alias": "platform", + "agent": "triage-bot", +} +CONVERSE_BODY = {"messages": [{"role": "user", "content": [{"text": "hi"}]}]} +INVOKE_BODY = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}], +} + + +async def _sent_passthrough_request( + action: str, body: dict[str, object], identity: dict[str, object], **kwargs: object +) -> httpx.Request: + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"ok": True}, request=request) + + router: Final = litellm.Router( + model_list=[ + { + "model_name": "bedrock-pt", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_access_key_id": "AKIAROUTERKEY", + "aws_secret_access_key": "router-secret", + "aws_region_name": "us-west-2", + }, + } + ] + ) + await router.allm_passthrough_route( + model="bedrock-pt", + method="POST", + endpoint=f"/model/bedrock-pt/{action}", + data=body, + client=AsyncHTTPHandler(transport=httpx.MockTransport(respond)), + litellm_metadata=identity, + **kwargs, + ) + return requests[0] + + +def _signed_header_names(request: httpx.Request) -> frozenset[str]: + return frozenset(request.headers["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "query_params"), + [("converse", None), ("converse-stream", None), ("converse/", None), ("converse", {"trace": "1"})], +) +async def test_converse_passthrough_signs_proxy_identity_into_request_metadata( + action: str, query_params: dict[str, str] | None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + caller_body: Final = {**CONVERSE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged", "ticket": "T-42"}} + + request: Final = await _sent_passthrough_request( + action, caller_body, PROXY_IDENTITY, request_query_params=query_params + ) + + assert json.loads(request.content) == { + **CONVERSE_BODY, + "requestMetadata": {**EXPECTED_REQUEST_METADATA, "ticket": "T-42"}, + } + assert caller_body["requestMetadata"] == {"user_api_key_team_alias": "forged", "ticket": "T-42"} + assert REQUEST_METADATA_HEADER not in request.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +async def test_invoke_passthrough_sends_proxy_identity_as_a_signed_header( + action: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + + request: Final = await _sent_passthrough_request( + action, + INVOKE_BODY, + PROXY_IDENTITY, + request_headers={f"x-pass-{REQUEST_METADATA_HEADER}": '{"user_api_key_team_alias":"forged"}'}, + ) + + assert json.loads(request.headers[REQUEST_METADATA_HEADER]) == EXPECTED_REQUEST_METADATA + assert REQUEST_METADATA_HEADER in _signed_header_names(request) + assert json.loads(request.content) == INVOKE_BODY + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +async def test_invoke_passthrough_keeps_caller_body_request_metadata_in_the_body( + action: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + caller_body: Final = {**INVOKE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged", "ticket": "T-42"}} + + request: Final = await _sent_passthrough_request( + action, + caller_body, + PROXY_IDENTITY, + request_headers={f"x-pass-{REQUEST_METADATA_HEADER.upper()}": '{"user_api_key_team_alias":"forged"}'}, + ) + + assert json.loads(request.content) == { + **INVOKE_BODY, + "requestMetadata": {**EXPECTED_REQUEST_METADATA, "ticket": "T-42"}, + } + assert REQUEST_METADATA_HEADER not in request.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("setting", [None, []]) +async def test_passthrough_requests_are_untouched_when_forwarding_is_off( + setting: list[str] | None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", setting) + caller_body: Final = {**CONVERSE_BODY, "requestMetadata": {"user_api_key_team_alias": "forged"}} + + converse: Final = await _sent_passthrough_request("converse", caller_body, PROXY_IDENTITY) + invoke: Final = await _sent_passthrough_request("invoke", INVOKE_BODY, PROXY_IDENTITY) + + assert json.loads(converse.content) == caller_body + assert REQUEST_METADATA_HEADER not in invoke.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("action", "body"), [("converse", CONVERSE_BODY), ("invoke", INVOKE_BODY)]) +async def test_passthrough_drops_reserved_caller_keys_when_nothing_resolves( + action: str, body: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + caller_body: Final = {**body, "requestMetadata": {"user_api_key_team_alias": "forged"}} + + request: Final = await _sent_passthrough_request(action, caller_body, {}) + + assert json.loads(request.content) == body + assert REQUEST_METADATA_HEADER not in request.headers + + +MALFORMED_REQUEST_METADATA: Final = ( + pytest.param( + {"user_api_key_team_alias": "forged", "bad!": "x"}, "key 'bad!' must be", "ValidationException", id="bad-key" + ), + pytest.param({"ticket": "a\u00a0b"}, "value for key 'ticket' must be", "ValidationException", id="bad-value"), + pytest.param("ticket=T-42", "must be a dictionary", "SerializationException", id="not-a-dictionary"), + pytest.param({f"key_{index}": "v" for index in range(17)}, "maximum of 16 items", "ValidationException", id="17"), + pytest.param({"ticket": 42}, "must be strings", "SerializationException", id="non-string-value"), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["converse", "converse-stream"]) +@pytest.mark.parametrize(("caller_metadata", "violation", "converse_error_type"), MALFORMED_REQUEST_METADATA) +async def test_malformed_converse_request_metadata_is_rejected_with_the_error_type_bedrock_returns( + action: str, caller_metadata: object, violation: str, converse_error_type: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + + with pytest.raises(BedrockError, match=violation) as rejected: + await _sent_passthrough_request(action, {**CONVERSE_BODY, "requestMetadata": caller_metadata}, PROXY_IDENTITY) + + assert rejected.value.status_code == 400 + assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == converse_error_type + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +@pytest.mark.parametrize(("caller_metadata", "violation", "converse_error_type"), MALFORMED_REQUEST_METADATA) +async def test_malformed_invoke_request_metadata_is_rejected_as_a_bedrock_validation_error( + action: str, caller_metadata: object, violation: str, converse_error_type: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + + with pytest.raises(BedrockError, match=violation) as rejected: + await _sent_passthrough_request(action, {**INVOKE_BODY, "requestMetadata": caller_metadata}, PROXY_IDENTITY) + + assert rejected.value.status_code == 400 + assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == "ValidationException" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +async def test_invoke_null_request_metadata_is_rejected_like_bedrock_does( + action: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + + with pytest.raises(BedrockError, match="must be a dictionary") as rejected: + await _sent_passthrough_request(action, {**INVOKE_BODY, "requestMetadata": None}, PROXY_IDENTITY) + + assert rejected.value.status_code == 400 + assert httpx.Headers(rejected.value.headers).get("x-amzn-errortype") == "ValidationException" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["converse", "converse-stream"]) +async def test_converse_null_request_metadata_is_treated_as_absent_like_bedrock_does( + action: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + + request: Final = await _sent_passthrough_request(action, {**CONVERSE_BODY, "requestMetadata": None}, PROXY_IDENTITY) + + assert json.loads(request.content) == {**CONVERSE_BODY, "requestMetadata": EXPECTED_REQUEST_METADATA} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["invoke", "invoke-with-response-stream"]) +async def test_invoke_metadata_too_long_for_the_header_sends_the_leading_pairs_that_fit( + action: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + long_pairs: Final = {f"{index:02d}".ljust(256, "k"): "\n" * 256 for index in range(12)} + resolved: Final = tuple( + {"user_api_key_alias": "prod-key", "user_api_key_team_alias": "platform", **long_pairs}.items() + ) + + request: Final = await _sent_passthrough_request( + action, + INVOKE_BODY, + {**PROXY_IDENTITY, "spend_logs_metadata": long_pairs}, + request_headers={f"x-pass-{REQUEST_METADATA_HEADER}": '{"user_api_key_team_alias":"forged"}'}, + ) + + header: Final = request.headers[REQUEST_METADATA_HEADER] + sent: Final = json.loads(header) + one_more_pair: Final = json.dumps(dict(resolved[: len(sent) + 1]), separators=(",", ":")) + assert sent == dict(resolved[: len(sent)]) + assert len(header) <= 8500 < len(one_more_pair) + assert json.loads(request.content) == INVOKE_BODY + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("action", "body"), [("converse", CONVERSE_BODY), ("invoke", INVOKE_BODY)]) +@pytest.mark.parametrize("drop_globally", [True, False]) +async def test_malformed_caller_request_metadata_is_dropped_when_params_are_dropped( + action: str, body: dict[str, object], drop_globally: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", REQUEST_METADATA_FIELDS) + monkeypatch.setattr(litellm, "drop_params", drop_globally) + caller_body: Final = {**body, "requestMetadata": {"user_api_key_team_alias": "forged", "bad!": "x"}} + + dropped: Final = await _sent_passthrough_request( + action, caller_body, PROXY_IDENTITY, **({} if drop_globally else {"drop_params": True}) + ) + + assert json.loads(dropped.content) == {**body, "requestMetadata": EXPECTED_REQUEST_METADATA} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "body", "caller_metadata"), + [ + ("converse", CONVERSE_BODY, {"user_api_key_team_alias": "caller-team", "bad!": "x"}), + ("invoke", INVOKE_BODY, {"user_api_key_team_alias": "caller-team", "bad!": "x"}), + ("invoke", INVOKE_BODY, None), + ], +) +@pytest.mark.parametrize("drop_globally", [True, False]) +async def test_passthrough_body_is_sent_as_is_when_params_are_dropped_and_forwarding_is_off( + action: str, + body: dict[str, object], + caller_metadata: dict[str, str] | None, + drop_globally: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", None) + monkeypatch.setattr(litellm, "drop_params", drop_globally) + caller_body: Final = {**body, "requestMetadata": caller_metadata} + + sent: Final = await _sent_passthrough_request( + action, caller_body, PROXY_IDENTITY, **({} if drop_globally else {"drop_params": True}) + ) + + assert json.loads(sent.content) == caller_body + assert REQUEST_METADATA_HEADER not in sent.headers diff --git a/tests/unit/llms/bedrock/slow_upstream.py b/tests/unit/llms/bedrock/slow_upstream.py index ed262d0f3ed..f23a947105c 100644 --- a/tests/unit/llms/bedrock/slow_upstream.py +++ b/tests/unit/llms/bedrock/slow_upstream.py @@ -7,6 +7,8 @@ gets the answer. from __future__ import annotations +import json +from collections.abc import Callable from typing import Final import httpx @@ -16,20 +18,35 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler STREAM_TIMEOUT_SECONDS: Final = 0.5 UPSTREAM_FIRST_BYTE_SECONDS: Final = 2.0 +CONVERSE_ANSWER: Final = json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + "metrics": {"latencyMs": 1}, + } +).encode() _TIMEOUT_EXTENSION: Final = TypeAdapter(dict[str, float | None]) -def _answer_once_the_first_byte_is_due(request: httpx.Request) -> httpx.Response: - read_timeout: Final = _TIMEOUT_EXTENSION.validate_python(request.extensions["timeout"])["read"] - if read_timeout is not None and read_timeout < UPSTREAM_FIRST_BYTE_SECONDS: - raise httpx.ReadTimeout(f"no byte arrived within {read_timeout}s", request=request) - return httpx.Response(200, content=b"", request=request) +def slow_upstream_answer(body: bytes = b"") -> Callable[[httpx.Request], httpx.Response]: + def answer(request: httpx.Request) -> httpx.Response: + read_timeout: Final = _TIMEOUT_EXTENSION.validate_python(request.extensions["timeout"])["read"] + if read_timeout is not None and read_timeout < UPSTREAM_FIRST_BYTE_SECONDS: + raise httpx.ReadTimeout(f"no byte arrived within {read_timeout}s", request=request) + return httpx.Response(200, content=body, request=request) + + return answer -def slow_upstream_async_client() -> AsyncHTTPHandler: - return AsyncHTTPHandler(transport=httpx.MockTransport(_answer_once_the_first_byte_is_due)) +def slow_upstream_transport(body: bytes = b"") -> httpx.MockTransport: + return httpx.MockTransport(slow_upstream_answer(body)) -def slow_upstream_sync_client() -> HTTPHandler: - return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_answer_once_the_first_byte_is_due))) +def slow_upstream_async_client(body: bytes = b"") -> AsyncHTTPHandler: + return AsyncHTTPHandler(transport=slow_upstream_transport(body)) + + +def slow_upstream_sync_client(body: bytes = b"") -> HTTPHandler: + return HTTPHandler(client=httpx.Client(transport=slow_upstream_transport(body))) diff --git a/tests/unit/llms/bedrock/test_bedrock_common_utils.py b/tests/unit/llms/bedrock/test_bedrock_common_utils.py index 9aab5052cfe..d5bd7d44223 100644 --- a/tests/unit/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/unit/llms/bedrock/test_bedrock_common_utils.py @@ -1,15 +1,20 @@ import asyncio, importlib, litellm, litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map, pytest +from typing import Final from litellm.llms.bedrock.common_utils import( AmazonBedrockGlobalConfig, BedrockModelInfo, + bedrock_native_output_is_opt_in, + deployment_model_info, extract_model_name_from_bedrock_arn, get_bedrock_base_model, get_bedrock_cross_region_inference_regions, + is_bedrock_native_output_rejection, strip_bedrock_routing_prefix, strip_bedrock_throughput_suffix, + without_bedrock_native_output_opt_in, ) from collections.abc import Iterator from litellm import completion @@ -2054,3 +2059,70 @@ def test_bedrock_capability_model_reads_base_model_only_for_application_profile_ from litellm.llms.bedrock.common_utils import bedrock_capability_model assert bedrock_capability_model(model, base_model) == expected + + +_FORMAT_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted" +_COMPILE_REJECTION: Final = "The model returned the following errors: compilation exceeded limits: Schema is too complex." +_SCHEMA_PATTERN_REJECTION: Final = ( + "The model returned the following errors: output_config.format.schema: Unsupported regex feature in pattern " + "field: pattern is too complex for structured output: reduce the {n,m} upper bound, narrow the character class " + "range, or avoid nesting quantified groups" +) + + +@pytest.mark.parametrize( + ("status_code", "error_text", "expected"), + [ + (400, f'{{"message":"{_FORMAT_REJECTION}"}}', True), + (400, f'{{"message":"{_COMPILE_REJECTION}"}}', True), + (400, f'{{"message":"{_SCHEMA_PATTERN_REJECTION}"}}', True), + (400, "Malformed input request: #: extraneous key [output_config] is not permitted", False), + (400, "The model returned the following errors: messages.0.content: Field required", False), + (500, _FORMAT_REJECTION, False), + (429, _COMPILE_REJECTION, False), + ], + ids=[ + "format_not_permitted", + "schema_too_complex", + "schema_pattern_too_complex", + "other_key_not_permitted", + "other_400", + "500", + "429", + ], +) +def test_is_bedrock_native_output_rejection_matches_only_the_400s_that_reject_native_output( + status_code, error_text, expected +): + assert is_bedrock_native_output_rejection(status_code, error_text) is expected + + +@pytest.mark.parametrize( + ("model", "model_info", "expected"), + [ + ("us.anthropic.claude-opus-4-8", None, True), + ("global.anthropic.claude-sonnet-5", None, True), + ("us-gov.anthropic.claude-opus-4-8", None, False), + ("us.anthropic.claude-opus-4-8", {"supports_bedrock_native_structured_output": False}, False), + ("us.anthropic.claude-opus-5-5", {"supports_bedrock_native_structured_output": True}, True), + ("us.anthropic.claude-sonnet-4-6", {"supports_bedrock_native_structured_output": True}, False), + ], + ids=["opus_4_8", "sonnet_5", "govcloud_opus_4_8", "deployment_opt_out", "deployment_opt_in", "native_model"], +) +def test_bedrock_native_output_is_opt_in_only_where_the_opt_in_key_turns_native_output_on( + local_model_cost_map, model, model_info, expected +): + assert bedrock_native_output_is_opt_in(model, model_info=model_info) is expected + + +def test_without_bedrock_native_output_opt_in_turns_the_opt_in_off_and_keeps_the_deployment(local_model_cost_map): + litellm_params: Final = {"api_base": "https://bedrock.example", "model_info": {"id": "deployment-1"}} + + turned_off: Final = without_bedrock_native_output_opt_in(litellm_params) + + assert not bedrock_native_output_is_opt_in( + "us.anthropic.claude-opus-4-8", model_info=deployment_model_info(turned_off) + ) + assert turned_off["api_base"] == "https://bedrock.example" + assert deployment_model_info(turned_off)["id"] == "deployment-1" + assert litellm_params["model_info"] == {"id": "deployment-1"} diff --git a/tests/unit/llms/bedrock/test_request_metadata.py b/tests/unit/llms/bedrock/test_request_metadata.py index 5a14bbea9f2..6b74ad90cff 100644 --- a/tests/unit/llms/bedrock/test_request_metadata.py +++ b/tests/unit/llms/bedrock/test_request_metadata.py @@ -1,8 +1,10 @@ import asyncio import json +from collections.abc import Callable, Mapping +from typing import Final import pytest - +from pydantic import TypeAdapter import litellm from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM @@ -18,6 +20,7 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran ) from litellm.llms.bedrock.request_metadata import ( BEDROCK_REQUEST_METADATA_HEADER, + BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS, BEDROCK_REQUEST_METADATA_MAX_PAIRS, resolve_bedrock_request_metadata, ) @@ -128,6 +131,20 @@ def test_identity_survives_a_caller_filling_every_slot( ) +def test_a_key_sent_twice_keeps_the_caller_value_and_uses_one_slot(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS) + caller: Final = { + f"client_{index:02d}": "caller" for index in range(BEDROCK_REQUEST_METADATA_MAX_PAIRS - len(IDENTITY) - 1) + } + spend_logs: Final = {**{key: "spend" for key in caller}, "extra": "spend"} + + resolved: Final = resolve_bedrock_request_metadata( + litellm_params("metadata", spend_logs_metadata=spend_logs, **IDENTITY), caller_metadata=caller + ) + + assert resolved == {**IDENTITY, **caller, "extra": "spend"} + + @pytest.mark.parametrize( "field_order", [ @@ -158,7 +175,14 @@ def test_a_field_repeated_in_the_allow_list_does_not_consume_a_client_slot( @pytest.mark.parametrize("client_source", ["spend_logs_metadata", "requestMetadata"]) @pytest.mark.parametrize( "forged_key", - ["user_api_key_team_alias", "user_api_key_org_alias", "user_api_key_hash"], + [ + "user_api_key_team_alias", + "user_api_key_org_alias", + "user_api_key_hash", + "USER_API_KEY_TEAM_ALIAS", + "User_Api_Key_Hash", + " user_api_key_user_id", + ], ) def test_caller_cannot_forge_or_shadow_a_reserved_identity_key( forged_key, @@ -181,14 +205,21 @@ def test_caller_cannot_forge_or_shadow_a_reserved_identity_key( assert "attacker-controlled" not in resolved.values() -def test_identity_violating_the_character_class_is_dropped_and_the_request_succeeds(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize( + "team_alias", + ["O'Brien's team", "platform\u00a0team", "platform\u3000team", "x" * 256 + "\n"], + ids=["apostrophe", "no-break-space", "ideographic-space", "257-chars-ending-in-newline"], +) +def test_identity_violating_the_character_class_is_dropped_and_the_request_succeeds( + team_alias: str, monkeypatch: pytest.MonkeyPatch +) -> None: """A team alias with an apostrophe must not turn a working request into a 400 the moment - an operator flips the setting on.""" + an operator flips the setting on. Bedrock also rejects non-ASCII whitespace (checked live 2026-10-16).""" monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS) params = litellm_params( "metadata", user_api_key_alias="prod-key", - user_api_key_team_alias="O'Brien's team", + user_api_key_team_alias=team_alias, user_api_key_user_email="x" * 300, ) @@ -198,13 +229,54 @@ def test_identity_violating_the_character_class_is_dropped_and_the_request_succe assert body["messages"] -def test_caller_supplied_violation_still_raises_bad_request(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize( + "caller_metadata", + [{"team": "O'Brien's team"}, {"k" * 256 + "\n": "v"}, {"team": "v" * 256 + "\n"}], + ids=["bad-character", "257-char-key-ending-in-newline", "257-char-value-ending-in-newline"], +) +def test_caller_supplied_violation_still_raises_bad_request( + caller_metadata: Mapping[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS) with pytest.raises(litellm.exceptions.BadRequestError): - converse_body( - litellm_params("metadata", **IDENTITY), - {"requestMetadata": {"team": "O'Brien's team"}}, + converse_body(litellm_params("metadata", **IDENTITY), {"requestMetadata": caller_metadata}) + + +@pytest.mark.parametrize("driver", CONVERSE_DRIVERS) +@pytest.mark.parametrize("drop_globally", [True, False]) +@pytest.mark.parametrize(("fields", "forwarded_identity"), [(ALL_FIELDS, IDENTITY), (None, {})]) +def test_caller_violations_are_dropped_like_the_passthrough_routes_when_params_are_dropped( + driver: Callable[[Mapping[str, object], Mapping[str, object]], Mapping[str, object]], + drop_globally: bool, + fields: list[str] | None, + forwarded_identity: Mapping[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", fields) + monkeypatch.setattr(litellm, "drop_params", drop_globally) + params: Final = {**litellm_params("metadata", **IDENTITY), **({} if drop_globally else {"drop_params": True})} + + body: Final = driver(params, {"requestMetadata": {"team": "O'Brien's team", "ticket": "T-42"}}) + + assert body["requestMetadata"] == {**forwarded_identity, "ticket": "T-42"} + + +@pytest.mark.parametrize("fields", [ALL_FIELDS, None]) +def test_mapped_caller_violations_are_dropped_when_params_are_dropped( + fields: list[str] | None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", fields) + caller: Final = {"team": "O'Brien's team", "ticket": "T-42"} + + mapped: Final = AmazonConverseConfig().map_openai_params( + non_default_params={"requestMetadata": caller}, optional_params={}, model=MODEL, drop_params=True + ) + + assert mapped["requestMetadata"] == caller + with pytest.raises(litellm.exceptions.BadRequestError): + AmazonConverseConfig().map_openai_params( + non_default_params={"requestMetadata": caller}, optional_params={}, model=MODEL, drop_params=False ) @@ -320,6 +392,27 @@ def metadata_header_values(headers): return [value for name, value in headers.items() if name.lower() == BEDROCK_REQUEST_METADATA_HEADER.lower()] +@pytest.mark.parametrize("driver", HEADER_DRIVERS) +def test_the_metadata_header_keeps_the_leading_pairs_that_fit_bedrocks_header_limit( + driver: Callable[[Mapping[str, str], Mapping[str, object]], Mapping[str, str]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ALL_FIELDS) + long_pairs: Final = {f"{index:02d}".ljust(256, "k"): "\n" * 256 for index in range(12)} + params: Final = litellm_params("metadata", spend_logs_metadata=long_pairs, **IDENTITY) + resolved: Final = tuple((resolve_bedrock_request_metadata(params) or {}).items()) + + headers: Final = driver({}, params) + (header,) = (value for name, value in headers.items() if name.lower() == BEDROCK_REQUEST_METADATA_HEADER.lower()) + sent: Final = TypeAdapter(dict[str, str]).validate_json(header) + one_more_pair: Final = json.dumps(dict(resolved[: len(sent) + 1]), separators=(",", ":")) + + assert len(header) <= BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS + assert tuple(sent.items()) == resolved[: len(sent)] + assert IDENTITY.items() <= sent.items() + assert len(one_more_pair) > BEDROCK_REQUEST_METADATA_HEADER_MAX_CHARS + + def test_converse_still_sets_the_bearer_authorization_header(monkeypatch: pytest.MonkeyPatch): """Converse owns the metadata header now, and that must not disturb the api_key path its validate_environment existed for. Closing the forgery hole cannot break authentication.""" diff --git a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index 951911ac066..a2289167ad4 100644 --- a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -625,6 +625,38 @@ class TestBedrockMantleReasoningSummary: ) assert params["reasoning"] == {"effort": "medium", "summary": "auto"} + def test_auto_summary_on_still_drops_a_client_detailed_summary(self, monkeypatch): + monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true") + cfg = BedrockMantleResponsesAPIConfig() + params = cfg.map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium", "summary": "detailed"}}, + model="openai.gpt-5.6-sol", + drop_params=True, + ) + assert params["reasoning"] == {"effort": "medium"} + + def test_auto_summary_on_still_rejects_a_client_detailed_summary_without_drop_params(self, monkeypatch): + monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true") + cfg = BedrockMantleResponsesAPIConfig() + with pytest.raises(litellm.UnsupportedParamsError): + cfg.map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium", "summary": "detailed"}}, + model="openai.gpt-5.6-sol", + drop_params=False, + ) + + @pytest.mark.parametrize("use_openai_path, expected", [(True, "auto"), (False, "detailed")]) + def test_auto_reasoning_summary_is_a_level_the_path_accepts(self, use_openai_path, expected): + cfg = BedrockMantleResponsesAPIConfig(use_openai_path=use_openai_path) + summary = cfg.auto_reasoning_summary() + assert summary == expected + params = cfg.map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium", "summary": summary}}, + model="openai.gpt-5.6-sol" if use_openai_path else "openai.gpt-oss-120b", + drop_params=False, + ) + assert params["reasoning"] == {"effort": "medium", "summary": summary} + def test_reasoning_summary_kept_on_standard_path(self): cfg = BedrockMantleResponsesAPIConfig(use_openai_path=False) params = cfg.map_openai_params( diff --git a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_transformation.py b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_transformation.py index a148ef1f57d..ee41af2110d 100644 --- a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_transformation.py +++ b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_transformation.py @@ -21,6 +21,7 @@ import litellm from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.llms.bedrock_mantle.chat.transformation import BedrockMantleChatConfig from litellm.llms.bedrock.base_aws_llm import sign_request_off_loop_if_aws +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.types.utils import LlmProviders from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe @@ -42,6 +43,63 @@ def local_cost_map(monkeypatch): litellm.get_model_info.cache_clear() +_RESPONSES_API_REPLY: Final = { + "id": "resp_test", + "object": "response", + "created_at": 1733529600, + "status": "completed", + "model": "openai.gpt-5.6-terra", + "output": [ + { + "type": "message", + "id": "msg_test", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, +} + + +_CHAT_COMPLETIONS_REPLY: Final = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1733529600, + "model": "openai.gpt-5.6-terra", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + + +def _use_mantle_bearer(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("AWS_BEARER_TOKEN_BEDROCK", "BEDROCK_MANTLE_API_BASE", "BEDROCK_MANTLE_REGION"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("BEDROCK_MANTLE_API_KEY", "test-key") + + +def _reply_by_route(request: httpx.Request) -> httpx.Response: + reply: Final = _RESPONSES_API_REPLY if request.url.path.endswith("/responses") else _CHAT_COMPLETIONS_REPLY + return httpx.Response(status_code=200, json=reply, request=request) + + +def _recording_client(handler: Mock) -> HTTPHandler: + return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) + + +def _mantle_chat_request(drop_params: bool, **params: object) -> dict[str, object]: + optional_params: Final = litellm.get_optional_params( + model="openai.gpt-5.6-terra", custom_llm_provider="bedrock_mantle", drop_params=drop_params, **params + ) + return BedrockMantleChatConfig().transform_request( + model="openai.gpt-5.6-terra", + messages=[{"role": "user", "content": "hello"}], + optional_params=optional_params, + litellm_params={"drop_params": drop_params}, + headers={}, + ) + + class TestBedrockMantleProviderRegistration: def test_provider_enum_exists(self): assert LlmProviders.BEDROCK_MANTLE == "bedrock_mantle" @@ -273,6 +331,110 @@ class TestBedrockMantleConfig: assert optional_params["max_completion_tokens"] == 50 assert "max_tokens" not in optional_params + @pytest.mark.parametrize( + "reasoning_effort, expected", + [({"effort": "low", "summary": "auto"}, "low"), ("medium", "medium"), ({"summary": "auto"}, None)], + ) + def test_chat_completions_gets_reasoning_effort_as_a_level(self, reasoning_effort, expected) -> None: + """Mantle chat answers a Responses-shaped ``{"effort", "summary"}`` with ``invalid_type``.""" + request = _mantle_chat_request(drop_params=True, reasoning_effort=reasoning_effort) + assert request.get("reasoning_effort") == expected + + @pytest.mark.parametrize("params", [{"n": 2}, {"reasoning_effort": {"effort": "low", "summary": "auto"}}]) + def test_chat_completions_answers_400_for_what_mantle_chat_cannot_serve_without_drop_params(self, params) -> None: + with pytest.raises(litellm.UnsupportedParamsError): + _mantle_chat_request(drop_params=False, **params) + + @pytest.mark.parametrize("n, drop_params, expected", [(1, False, 1), (2, True, None)]) + def test_chat_completions_sends_n_only_where_mantle_chat_serves_it(self, n, drop_params, expected) -> None: + assert _mantle_chat_request(drop_params=drop_params, n=n).get("n") == expected + + @pytest.mark.parametrize( + "params, model_info", + [ + ({"n": 2}, None), + ({"reasoning_effort": {"effort": "low", "summary": "auto"}}, {"mode": "chat"}), + ({"web_search_options": {"search_context_size": "low"}}, {"mode": "chat"}), + ], + ) + def test_what_mantle_chat_cannot_serve_reaches_the_responses_api( + self, monkeypatch, local_cost_map, params, model_info + ) -> None: + _use_mantle_bearer(monkeypatch) + handler: Final = Mock(side_effect=_reply_by_route) + + response: Final = litellm.completion( + model="bedrock_mantle/openai.gpt-5.6-terra", + messages=[{"role": "user", "content": "hello"}], + aws_region_name="us-east-1", + model_info=model_info, + client=_recording_client(handler), + **params, + ) + + sent: Final = tuple(call.args[0] for call in handler.call_args_list) + assert response.choices[0].message.content == "ok" + assert [request.url.path.rsplit("/", 1)[-1] for request in sent] == ["responses"] + assert json.loads(sent[0].content).get("reasoning", {}).get("summary") == params.get( + "reasoning_effort", {} + ).get("summary") + + @pytest.mark.parametrize( + "extra_body, drop_params, expected", + [ + ({"reasoning_effort": {"effort": "low"}}, False, {"reasoning_effort": "low"}), + ({"n": 2}, True, {}), + ({"reasoning_effort": {}}, False, {}), + ({"reasoning_effort": {"effort": None}}, False, {}), + ({"n": 1, "reasoning_effort": "high"}, False, {"n": 1, "reasoning_effort": "high"}), + ], + ids=[ + "dict-effort", + "n-above-one-dropped", + "empty-effort-asks-for-the-default", + "null-effort-asks-for-the-default", + "servable", + ], + ) + def test_a_chat_pin_sends_extra_body_through_the_same_chat_checks( + self, monkeypatch, local_cost_map, extra_body, drop_params, expected + ) -> None: + _use_mantle_bearer(monkeypatch) + handler: Final = Mock(side_effect=_reply_by_route) + + litellm.completion( + model="bedrock_mantle/openai.gpt-5.6-terra", + messages=[{"role": "user", "content": "hello"}], + aws_region_name="us-east-1", + model_info={"mode": "chat"}, + client=_recording_client(handler), + extra_body=extra_body, + drop_params=drop_params, + ) + + (sent,) = (call.args[0] for call in handler.call_args_list) + body: Final = json.loads(sent.content) + assert sent.url.path.endswith("/chat/completions") + assert {key: body[key] for key in ("n", "reasoning_effort") if key in body} == expected + + def test_a_chat_pin_answers_400_for_n_above_one_in_extra_body_without_drop_params( + self, monkeypatch, local_cost_map + ) -> None: + _use_mantle_bearer(monkeypatch) + handler: Final = Mock(side_effect=_reply_by_route) + + with pytest.raises(litellm.BadRequestError, match="does not support n=2"): + litellm.completion( + model="bedrock_mantle/openai.gpt-5.6-terra", + messages=[{"role": "user", "content": "hello"}], + aws_region_name="us-east-1", + model_info={"mode": "chat"}, + client=_recording_client(handler), + extra_body={"n": 2}, + ) + + handler.assert_not_called() + def test_non_gpt_reasoning_models_keep_the_openai_compatible_max_tokens(self): optional_params = litellm.get_optional_params( model="openai.gpt-oss-120b", diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index 50ed70e09f5..23c142b61ab 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -5313,3 +5313,45 @@ async def test_retrieve_file_rejects_unverified_bedrock_range_error( ) finally: sync_client.close() + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("local_beta_headers_config") +async def test_anthropic_messages_dropped_safeguards_send_neither_the_field_nor_its_beta() -> None: + received: Final[list[httpx.Request]] = [] + + def anthropic_answers(request: httpx.Request) -> httpx.Response: + received.append(request) + return httpx.Response( + status_code=200, + json={ + "id": "msg_dropped_safeguards", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 1}, + }, + request=request, + ) + + upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(anthropic_answers)) + try: + await litellm.anthropic.messages.acreate( + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + model="anthropic/claude-sonnet-4-6", + api_key="sk-ant-test", + safeguards=({"type": "dangerous_tool_use"},), + additional_drop_params=["safeguards"], + client=upstream, + ) + finally: + await upstream.close() + + assert len(received) == 1, received + sent: Final = received[0] + assert "safeguards" not in json.loads(sent.content) + assert "dangerous-tool-use-2026-09-03" not in sent.headers.get("anthropic-beta", ""), dict(sent.headers) diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 5cbb9a0a7b4..46062f16c9a 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -1,3 +1,4 @@ +from typing import Final from unittest.mock import MagicMock import httpx @@ -9,6 +10,10 @@ from litellm.llms.github_copilot.common_utils import GetAPIKeyError from litellm.llms.github_copilot.messages.transformation import ( GithubCopilotAnthropicMessagesConfig, ) +from litellm.llms.github_copilot.per_user_auth import ( + GITHUB_COPILOT_USER_SESSION_KWARG_KEY, + GithubCopilotUserSession, +) def test_github_copilot_anthropic_messages_get_complete_url(): @@ -185,6 +190,26 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_ assert "context-management-2025-06-27" in validated_headers["anthropic-beta"] +@pytest.mark.usefixtures("local_beta_headers_config") +def test_github_copilot_safeguards_do_not_derive_the_dangerous_tool_use_beta() -> None: + session: Final = GithubCopilotUserSession(token="gh.test-key", api_base="https://api.githubcopilot.com") + + validated_headers, _ = GithubCopilotAnthropicMessagesConfig().validate_anthropic_messages_environment( + headers={}, + model="github_copilot/claude-haiku-4.5", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "safeguards": ({"type": "dangerous_tool_use"},), + "context_management": {"edits": ({"type": "clear_tool_uses_20250919"},)}, + }, + litellm_params={GITHUB_COPILOT_USER_SESSION_KWARG_KEY: session}, + api_key=None, + api_base=None, + ) + + assert validated_headers["anthropic-beta"] == "context-management-2025-06-27" + + def test_github_copilot_anthropic_messages_validate_environment_auth_error(): """Test error handling when authentication fails.""" config = GithubCopilotAnthropicMessagesConfig() diff --git a/tests/unit/llms/microsoft_365_copilot/chat/test_handler.py b/tests/unit/llms/microsoft_365_copilot/chat/test_handler.py index 7be5db74af5..6c41a6dcad5 100644 --- a/tests/unit/llms/microsoft_365_copilot/chat/test_handler.py +++ b/tests/unit/llms/microsoft_365_copilot/chat/test_handler.py @@ -17,6 +17,11 @@ from litellm.types.llms.openai import AllMessageValues from litellm.types.proxy.litellm_pre_call_utils import SecretFields from litellm.types.utils import ModelResponse from litellm.utils import token_counter +from tests.unit.llms.bedrock.slow_upstream import ( + STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_answer, +) _MODEL: Final = "microsoft_365_copilot/chat" _MESSAGES_ADAPTER: Final = TypeAdapter(list[AllMessageValues]) @@ -552,6 +557,28 @@ def test_streaming_wraps_full_reply_as_fake_stream() -> None: assert usage_chunk.usage.total_tokens == (usage_chunk.usage.prompt_tokens + usage_chunk.usage.completion_tokens) +def test_streaming_is_bounded_by_the_request_timeout_not_the_stream_timeout() -> None: + slow_chat: Final = slow_upstream_answer(json.dumps(_DEFAULT_CHAT_RESPONSE).encode()) + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/chat"): + return slow_chat(request) + return httpx.Response(status_code=201, json={"id": "conversation/42", "state": "active"}, request=request) + + response: Final = litellm.completion( + model=_MODEL, + messages=_messages(), + api_key="delegated-stream-token-9306", + stream=True, + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + stream_timeout=STREAM_TIMEOUT_SECONDS, + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))), + ) + + assert isinstance(response, CustomStreamWrapper) + assert "".join(chunk.choices[0].delta.content or "" for chunk in response if chunk.choices) == "Copilot response" + + @pytest.mark.asyncio async def test_async_streaming_wraps_full_reply_as_fake_stream() -> None: client, _ = _async_client() diff --git a/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py b/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py index befdc5ee9fd..be394b71ec7 100644 --- a/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py +++ b/tests/unit/llms/openai/responses/count_tokens/test_token_counter.py @@ -54,6 +54,7 @@ async def test_count_reports_upstream_input_tokens_and_keeps_the_raw_payload( "request_model": "openai/gpt-4o", "model_used": "gpt-4o", "tokenizer_type": "openai_api", + "count_source": None, "original_response": payload, "error": False, "error_message": None, @@ -98,6 +99,7 @@ async def test_non_integer_input_tokens_becomes_an_error_response_carrying_the_v "request_model": "openai/gpt-4o", "model_used": "gpt-4o", "tokenizer_type": "openai_api", + "count_source": None, "original_response": None, "error": True, "error_message": str(rejection.value), @@ -126,6 +128,7 @@ async def test_count_payload_that_is_not_an_object_becomes_an_error_response_nam "request_model": "openai/gpt-4o", "model_used": "gpt-4o", "tokenizer_type": "openai_api", + "count_source": None, "original_response": None, "error": True, "error_message": str(failed_lookup.value), @@ -156,6 +159,7 @@ async def test_upstream_error_status_becomes_an_error_response_with_that_status_ "request_model": "openai/gpt-4o", "model_used": "gpt-4o", "tokenizer_type": "openai_api", + "count_source": None, "original_response": None, "error": True, "error_message": upstream_text, @@ -382,6 +386,7 @@ async def test_undecodable_count_body_becomes_a_500_error_response_carrying_the_ "request_model": "openai/gpt-4o", "model_used": "gpt-4o", "tokenizer_type": "openai_api", + "count_source": None, "original_response": None, "error": True, "error_message": f"CountTokens processing error: {undecodable.value}", diff --git a/tests/unit/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py b/tests/unit/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py index 9167853bf64..1d5ef50ca3a 100644 --- a/tests/unit/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py +++ b/tests/unit/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py @@ -516,3 +516,26 @@ def test_request_strips_ttl_only_where_the_messages_api_defines_cache_control(co assert tool_result["content"][0]["cache_control"] == {"type": "ephemeral"} assert payload["messages"][1]["content"][1]["cache_control"] == {"type": "ephemeral"} assert payload["messages"][2] == {"role": "user", "content": "a plain string message"} + + +@pytest.mark.usefixtures("local_beta_headers_config") +@pytest.mark.parametrize( + ("client_headers", "expected_beta"), + [ + ({}, None), + ({"anthropic-beta": "dangerous-tool-use-2026-09-03"}, "dangerous-tool-use-2026-09-03"), + ], +) +def test_safeguards_reach_an_openai_like_host_with_only_the_client_betas( + config: OpenAILikeAnthropicMessagesConfig, client_headers: dict[str, str], expected_beta: str | None +) -> None: + headers, _ = config.validate_anthropic_messages_environment( + headers=client_headers, + model="some-model", + messages=[{"role": "user", "content": "hi"}], + optional_params={"safeguards": ({"type": "dangerous_tool_use"},)}, + litellm_params={}, + api_key="sk-test", + api_base="https://host/v1", + ) + assert headers.get("anthropic-beta") == expected_beta, headers diff --git a/tests/unit/llms/openai_like/test_openinfer_provider.py b/tests/unit/llms/openai_like/test_openinfer_provider.py new file mode 100644 index 00000000000..7bcc489fcf8 --- /dev/null +++ b/tests/unit/llms/openai_like/test_openinfer_provider.py @@ -0,0 +1,247 @@ +import json +from pathlib import Path +from typing import Final + +import pytest +import respx + +import litellm +from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.proxy.auth.model_checks import get_provider_models + +_REPO_ROOT: Final = Path(__file__).resolve().parents[4] +_PRIMARY_PRICES: Final = json.loads((_REPO_ROOT / "model_prices_and_context_window.json").read_text()) +_BACKUP_PRICES: Final = json.loads( + (_REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json").read_text() +) +_CATALOG_MODELS: Final = tuple( + key for key, row in _PRIMARY_PRICES.items() if isinstance(row, dict) and row.get("litellm_provider") == "openinfer" +) +_REASONING_MODELS: Final = tuple(key for key in _CATALOG_MODELS if _PRIMARY_PRICES[key].get("supports_reasoning")) +_CHAT_URL: Final = "https://api.openinfer.ai/v1/chat/completions" +_WEATHER_TOOL: Final = { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, +} +_CHAT_RESPONSE: Final = { + "id": "chatcmpl_openinfer", + "object": "chat.completion", + "created": 1_792_000_000, + "model": "@oi/Qwen3.5-27B", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello from OpenInfer"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}, +} + + +def _sent_chat_request(model: str, **params: object) -> tuple[str, str, dict[str, object]]: + with respx.mock() as upstream: + route: Final = upstream.post(_CHAT_URL).respond(200, json=_CHAT_RESPONSE) + litellm.completion( + model=f"openinfer/{model}", + messages=[{"role": "user", "content": "Say hello"}], + api_key="sk-test", + **params, + ) + request: Final = route.calls.last.request + return str(request.url), request.headers["authorization"], json.loads(request.content) + + +def test_provider_prefixed_model_routes_to_openinfer(): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="openinfer/@oi/Qwen3.5-27B", + custom_llm_provider=None, + api_base=None, + api_key="sk-test", + ) + + assert model == "@oi/Qwen3.5-27B" + assert provider == "openinfer" + assert api_key == "sk-test" + assert api_base == "https://api.openinfer.ai/v1" + + +def test_api_key_and_base_resolved_from_env(monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("OPENINFER_API_KEY", "sk-env-key") + monkeypatch.setenv("OPENINFER_API_BASE", "https://proxy.internal/v1") + + _, provider, api_key, api_base = get_llm_provider( + model="openinfer/@oi/Qwen3.5-9B", + custom_llm_provider=None, + api_base=None, + api_key=None, + ) + + assert provider == "openinfer" + assert api_key == "sk-env-key" + assert api_base == "https://proxy.internal/v1" + + +def test_url_autodetection_from_api_base(monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("OPENINFER_API_KEY", "sk-env-key") + + _, provider, api_key, _ = get_llm_provider( + model="@oi/Qwen3.5-27B", + custom_llm_provider=None, + api_base="https://api.openinfer.ai/v1", + api_key=None, + ) + + assert provider == "openinfer" + assert api_key == "sk-env-key" + + +def test_chat_request_reaches_openinfer_with_the_bare_model_id(): + url, authorization, body = _sent_chat_request("@oi/Qwen3.5-27B") + + assert url == _CHAT_URL + assert authorization == "Bearer sk-test" + assert body["model"] == "@oi/Qwen3.5-27B" + + +def test_tools_reach_openinfer_for_a_model_without_a_cost_map_row(): + _, _, body = _sent_chat_request("@oi/model-without-a-catalog-row", tools=[_WEATHER_TOOL]) + + assert body["tools"] == [_WEATHER_TOOL] + + +def _register_openinfer_row(model: str, **capabilities: bool) -> None: + litellm.register_model( + {f"openinfer/{model}": {"litellm_provider": "openinfer", "mode": "chat", **capabilities}}, + persist_across_reloads=False, + add_to_provider_model_sets=False, + ) + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_model_info_keeps_a_row_that_turns_tools_off(): + model: Final = "@oi/row-without-tools" + _register_openinfer_row(model, supports_function_calling=False) + + assert litellm.get_model_info(model, custom_llm_provider="openinfer")["supports_function_calling"] is False + assert litellm.supports_function_calling(model, custom_llm_provider="openinfer") is False + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_model_info_fills_tool_support_from_the_openinfer_default(): + model: Final = "@oi/row-silent-on-tools" + _register_openinfer_row(model) + + assert litellm.get_model_info(model, custom_llm_provider="openinfer")["supports_function_calling"] is True + assert litellm.supports_function_calling(model, custom_llm_provider="openinfer") is True + + +def test_max_tokens_and_max_completion_tokens_both_reach_openinfer(): + _, _, body = _sent_chat_request("@oi/Qwen3.5-27B", max_tokens=64, max_completion_tokens=128) + + assert body["max_tokens"] == 64 + assert body["max_completion_tokens"] == 128 + + +@pytest.mark.parametrize("model", _REASONING_MODELS) +def test_reasoning_effort_reaches_openinfer_for_reasoning_rows(model: str): + _, _, body = _sent_chat_request(model.removeprefix("openinfer/"), reasoning_effort="low") + + assert body["reasoning_effort"] == "low" + + +def test_responses_request_goes_to_openinfer_natively(): + with respx.mock() as upstream: + route: Final = upstream.post("https://api.openinfer.ai/v1/responses").respond( + 200, + json={ + "id": "resp_openinfer", + "object": "response", + "created_at": 1_792_000_000, + "model": "@oi/Qwen3.5-27B", + "status": "completed", + "output": [ + { + "id": "msg_openinfer", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "Hello from OpenInfer", "annotations": []}], + } + ], + "usage": {"input_tokens": 4, "output_tokens": 3, "total_tokens": 7}, + }, + ) + response: Final = litellm.responses(model="openinfer/@oi/Qwen3.5-27B", input="Say hello", api_key="sk-test") + + request: Final = route.calls.last.request + body: Final = json.loads(request.content) + assert request.headers["authorization"] == "Bearer sk-test" + assert body["model"] == "@oi/Qwen3.5-27B" + assert body["input"] == "Say hello" + assert response.output[0].content[0].text == "Hello from OpenInfer" + + +@pytest.mark.asyncio +async def test_anthropic_messages_request_goes_to_openinfer_natively(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) + with respx.mock() as upstream: + route: Final = upstream.post("https://api.openinfer.ai/v1/messages").respond( + 200, + json={ + "id": "msg_openinfer", + "type": "message", + "role": "assistant", + "model": "@oi/Qwen3.5-27B", + "content": [{"type": "text", "text": "Hello from OpenInfer"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 4, "output_tokens": 3}, + }, + ) + response: Final = await litellm.anthropic.messages.acreate( + model="openinfer/@oi/Qwen3.5-27B", + messages=[{"role": "user", "content": "Say hello"}], + max_tokens=32, + api_key="sk-test", + ) + + request: Final = route.calls.last.request + body: Final = json.loads(request.content) + assert request.headers["authorization"] == "Bearer sk-test" + assert body["model"] == "@oi/Qwen3.5-27B" + assert body["messages"] == [{"role": "user", "content": "Say hello"}] + assert response["content"][0]["text"] == "Hello from OpenInfer" + + +def test_wildcard_lists_every_catalog_model(): + assert sorted(get_provider_models("openinfer") or []) == sorted(_CATALOG_MODELS) + + +@pytest.mark.parametrize("model", _CATALOG_MODELS) +def test_catalog_row_matches_its_backup_and_fits_its_context(model: str): + row: Final = _PRIMARY_PRICES[model] + + assert row == _BACKUP_PRICES[model] + assert row["mode"] == "chat" + assert row["max_tokens"] == row["max_output_tokens"] + assert 0 < row["max_output_tokens"] <= row["max_input_tokens"] + assert row["input_cost_per_token"] > 0 + assert row["output_cost_per_token"] > 0 + + +@pytest.mark.parametrize("model", _CATALOG_MODELS) +def test_chat_response_is_priced_at_its_catalog_row(model: str): + row: Final = litellm.model_cost[model] + response: Final = litellm.ModelResponse( + model=model.removeprefix("openinfer/"), + usage=litellm.Usage(prompt_tokens=1000, completion_tokens=200, total_tokens=1200), + ) + + cost: Final = litellm.completion_cost(completion_response=response, model=model) + + assert cost == pytest.approx(1000 * row["input_cost_per_token"] + 200 * row["output_cost_per_token"]) diff --git a/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py index 97c0a657ca4..20dba332c08 100644 --- a/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/unit/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -51,6 +51,35 @@ async def test_vertex_gemini_rejects_non_text_system_blocks_before_request( assert "text blocks" in (result.error_message or "") +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model", + ( + "zai-org/glm-5.2-maas", + "deepseek-ai/deepseek-v3.2-maas", + "qwen/qwen3-coder-480b-a35b-instruct-maas", + "mistral-medium-3", + "codestral-2501", + "openai/gpt-oss-120b-maas", + "google/gemma-4-26b-a4b-it-maas", + ), +) +async def test_vertex_partner_model_without_an_inference_matching_count_answers_400_naming_the_model(model: str) -> None: + result: Final = await VertexAITokenCounter().count_tokens( + model_to_use=model, + messages=[{"role": "user", "content": "Count this"}], + contents=None, + deployment={"litellm_params": {"vertex_project": "test-project", "vertex_location": "global"}}, + request_model=f"vertex_ai/{model}", + messages_format="anthropic", + ) + + assert result is not None + assert result.error is True + assert result.status_code == 400 + assert result.error_message == f"LiteLLM does not support counting tokens for Vertex AI model {model}." + + class _VertexCountTokensPeer: def __init__(self, status_code: int) -> None: self.status_code: Final = status_code diff --git a/tests/unit/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py b/tests/unit/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py index 1110b53624a..fb1cf4ced2d 100644 --- a/tests/unit/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py +++ b/tests/unit/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py @@ -6,9 +6,10 @@ Maps to: litellm/llms/vertex_ai/vertex_gemma_models/transformation.py import json from collections.abc import AsyncIterator -from typing import cast +from typing import Final, cast from unittest.mock import AsyncMock, Mock, patch +import httpx import pytest import litellm @@ -17,6 +18,11 @@ from litellm.types.llms.openai import ( ResponseCompletedEvent, ResponsesAPIStreamingResponse, ) +from tests.unit.llms.bedrock.slow_upstream import ( + STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_transport, +) @pytest.fixture(autouse=True) @@ -1354,6 +1360,25 @@ def test_sync_gemma_stream(_gemma_cached_access_token): assert chunks[1].choices[0].finish_reason == "stop" +def test_gemma_stream_is_bounded_by_the_request_timeout_not_the_stream_timeout(_gemma_cached_access_token: None) -> None: + stream: Final = litellm.completion( + model="vertex_ai/gemma/test-model", + messages=[{"role": "user", "content": "Reply exactly READY"}], + stream=True, + stream_timeout=STREAM_TIMEOUT_SECONDS, + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + api_base=_GEMMA_VERTEX_URL, + vertex_project="test", + vertex_location="us-central1", + vertex_credentials=_FAKE_GEMMA_CREDENTIALS, + client=httpx.Client( + transport=slow_upstream_transport(json.dumps(_make_gemma_vertex_response(content="READY")).encode()) + ), + ) + + assert "".join(chunk.choices[0].delta.content or "" for chunk in stream) == "READY" + + @pytest.mark.asyncio async def test_async_gemma_responses_stream(_gemma_cached_access_token): import httpx diff --git a/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 5fdc7669496..a43dacaf100 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -5,11 +5,12 @@ import json import time from base64 import urlsafe_b64encode from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Literal from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, Response +from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import LiteLLM_MCPServerTable from litellm.types.mcp import MCPAuth @@ -26,6 +27,14 @@ if TYPE_CHECKING: from litellm.types.mcp_server.mcp_server_manager import MCPServer +class _BridgeRegistrationPayload(TypedDict, total=False): + client_name: ReadOnly[str] + grant_types: ReadOnly[list[str]] + response_types: ReadOnly[list[str]] + token_endpoint_auth_method: ReadOnly[str] + redirect_uris: ReadOnly[list[str]] + + def _stored_grant(access_token="access-token", refresh_token=None, expires_in_seconds=None, expires_at=None): credential = {"type": "oauth2", "access_token": access_token} if refresh_token is not None: @@ -5849,7 +5858,12 @@ def test_oauth_authorization_server_404_for_non_bridge_client_forwarded_server() assert exc.value.status_code == 404 -async def _bridge_register_response(server, request_payload, replace_stale_client=False): +async def _bridge_register_response( + server: "MCPServer", + request_payload: _BridgeRegistrationPayload, + replace_stale_client: bool = False, + database_connected: bool = True, +) -> tuple[Response, MagicMock, AsyncMock]: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( register_client_with_server, ) @@ -5879,6 +5893,10 @@ async def _bridge_register_response(server, request_payload, replace_stale_clien new_callable=AsyncMock, return_value=False, ), + patch( + "litellm.proxy.utils.get_prisma_client_or_throw", + **({"return_value": MagicMock()} if database_connected else {"side_effect": HTTPException(status_code=500)}), + ), ): response = await register_client_with_server( request=_bridge_mock_request(), @@ -6033,6 +6051,309 @@ async def test_register_bridge_relay_never_persists(): mock_persist.assert_not_called() +_LAPTOP_LOOPBACK_REDIRECT: Final = "http://127.0.0.1:33418/" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type_value", ["oauth_delegate", "true_passthrough"]) +async def test_register_bridge_with_proxy_base_url_registers_gateway_client_not_laptop_loopback( + monkeypatch: pytest.MonkeyPatch, auth_type_value: Literal["oauth_delegate", "true_passthrough"] +) -> None: + """A laptop MCP client (VS Code) registers with its own loopback redirect. With PROXY_BASE_URL set + the gateway must not relay that registration upstream: it registers its one shared client bound + to the gateway /callback, keeps it, and hands the caller a client bound to the caller's loopback. + Relaying the loopback is the customer failure on upstreams that restrict redirect domains.""" + import json + + from litellm.types.mcp import MCPAuth + + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com") + response, mock_async_client, mock_persist = await _bridge_register_response( + _bridge_server(auth_type=MCPAuth(auth_type_value)), + {"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]}, + ) + + posted: Final = mock_async_client.post.call_args.kwargs["json"] + assert posted["redirect_uris"] == ["https://litellm.example.com/callback"], posted + assert posted["client_name"] == "LiteLLM Proxy", posted + mock_persist.assert_awaited_once() + + payload: Final = json.loads(response.body.decode("utf-8")) + assert payload["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT], payload + + +@pytest.mark.asyncio +async def test_register_bridge_without_database_still_relays_client_registration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A database-free gateway cannot keep the shared client past this process, so even with + PROXY_BASE_URL set it relays the caller's own registration instead of stranding the next authorize.""" + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com") + _, mock_async_client, mock_persist = await _bridge_register_response( + _bridge_server(), + {"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]}, + database_connected=False, + ) + + assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT] + mock_persist.assert_not_called() + + +@pytest.mark.asyncio +async def test_register_bridge_without_proxy_base_url_still_relays_client_registration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without PROXY_BASE_URL the gateway cannot keep a shared client, so the relay arm remains the + fallback and the caller's own redirect goes upstream as before.""" + monkeypatch.delenv("PROXY_BASE_URL", raising=False) + _, mock_async_client, mock_persist = await _bridge_register_response( + _bridge_server(), + {"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]}, + ) + + assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT] + mock_persist.assert_not_called() + + +def _persisted_shared_gateway_client( + server: "MCPServer", client_id: str = "shared-gateway-client" +) -> LiteLLM_MCPServerTable: + request: Final = _bridge_mock_request() + credentials: Final = { + "client_id": client_id, + "client_secret": None, + "token_endpoint_auth_method": None, + "redirect_uris": [f"{request.base_url.rstrip('/')}/callback"], + "dcr_issuer": server.issuer, + "dcr_server_url": server.url, + } + return LiteLLM_MCPServerTable.model_validate({**server.model_dump(exclude_none=True), "credentials": credentials}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("shared_client_persisted", "expected_redirect_uri", "expected_client_id"), + [ + (True, "https://litellm.example.com/callback", "shared-gateway-client"), + (False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"), + ], +) +async def test_authorize_clientless_bridge_loads_shared_client_before_choosing_the_arm( + monkeypatch: pytest.MonkeyPatch, + shared_client_persisted: bool, + expected_redirect_uri: str, + expected_client_id: str, +) -> None: + """This worker's catalog snapshot has no client_id. When another worker already persisted the shared + client, authorize must load it and send it with the gateway /callback upstream, never the caller's + own client_id or loopback. When no shared client exists yet (a client registered before the gateway + kept one), the caller keeps its own upstream binding through the relay.""" + from urllib.parse import parse_qs, urlparse + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import authorize_with_server + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com") + monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding") + server: Final = _bridge_server(url="https://mcp.example.com/mcp") + persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None + prisma_client: Final = MagicMock() + mock_get_mcp_server: Final = AsyncMock(return_value=persisted) + mock_update_server: Final = AsyncMock() + with ( + patch( + "litellm.proxy.utils.get_prisma_client_or_throw", + return_value=prisma_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=mock_get_mcp_server, + ), + patch.object( + global_mcp_server_manager, + "update_server", + new=mock_update_server, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper", + return_value="mocked_encrypted_state", + ), + ): + response: Final = await authorize_with_server( + request=_bridge_mock_request(), + mcp_server=server, + client_id="dcr-client-123", + redirect_uri=_LAPTOP_LOOPBACK_REDIRECT, + state="s", + code_challenge="chal", + code_challenge_method="S256", + ) + + location: Final = response.headers["location"] + assert location.startswith("https://provider.com/oauth/authorize"), location + query: Final = parse_qs(urlparse(location).query) + assert query["redirect_uri"] == [expected_redirect_uri], query + assert query["client_id"] == [expected_client_id], query + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("shared_client_persisted", "expected_redirect_uri", "expected_client_id"), + [ + (True, "https://litellm.example.com/callback", "shared-gateway-client"), + (False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"), + ], +) +async def test_token_clientless_bridge_loads_shared_client_before_choosing_the_arm( + monkeypatch: pytest.MonkeyPatch, + shared_client_persisted: bool, + expected_redirect_uri: str, + expected_client_id: str, +) -> None: + """The token leg mirrors authorize: with the shared client persisted it redeems the code with that + client and the gateway /callback, and with none yet it relays the caller's own client and redirect.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com") + monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding") + server: Final = _bridge_server(url="https://mcp.example.com/mcp") + persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None + prisma_client: Final = MagicMock() + mock_get_mcp_server: Final = AsyncMock(return_value=persisted) + mock_update_server: Final = AsyncMock() + with ( + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma_client), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=mock_get_mcp_server, + ), + patch.object( + global_mcp_server_manager, + "update_server", + new=mock_update_server, + ), + ): + data: Final = await _bridge_token_post_data(server, redirect_uri=_LAPTOP_LOOPBACK_REDIRECT) + + assert data["redirect_uri"] == expected_redirect_uri, data + assert data["client_id"] == expected_client_id, data + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("dcr_bridge", "expected"), + [(True, "persisted"), (False, "skipped")], +) +async def test_persist_dcr_client_for_client_forwarded_server_only_when_it_is_a_bridge( + dcr_bridge: bool, + expected: Literal["persisted", "skipped"], +) -> None: + """A DCR bridge keeps the shared gateway client on the server row (without stamping oauth2_flow on + a client-forwarded row), while a client-forwarded server without the bridge still stores nothing.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _persist_dcr_client_registration, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + + server: Final = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=dcr_bridge) + prisma: Final = MagicMock() + prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None) + mock_update: Final = AsyncMock(return_value=MagicMock()) + with ( + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False), + ): + result: Final = await _persist_dcr_client_registration( + server, + {"client_id": "shared-gateway-client", "token_endpoint_auth_method": "none"}, + "https://litellm.example.com/callback", + replace_stale_client=False, + ) + + assert result == expected + if expected == "skipped": + mock_update.assert_not_awaited() + return + written: Final = mock_update.await_args.kwargs["data"] + assert written.oauth2_flow is None, written + assert written.credentials["client_id"] == "shared-gateway-client", written + assert server.client_id == "shared-gateway-client" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("server_has_shared_client", [False, True]) +async def test_token_sealed_ephemeral_client_is_not_replaced_by_persisted_gateway_client( + monkeypatch: pytest.MonkeyPatch, + server_has_shared_client: bool, +) -> None: + """The token leg must use the sealed ephemeral client even if this worker's catalog has a shared client.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com") + monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding") + server: Final = _bridge_server( + url="https://mcp.example.com/mcp", + client_id="shared-gateway-client" if server_has_shared_client else None, + client_secret="shared-secret" if server_has_shared_client else None, + token_endpoint_auth_method="client_secret_basic" if server_has_shared_client else None, + ) + fake_http_response: Final = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client: Final = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + prisma_client: Final = MagicMock() + mock_get_mcp_server: Final = AsyncMock( + return_value=_persisted_shared_gateway_client(server, client_id="persisted-gateway-client") + ) + mock_update_server: Final = AsyncMock() + with ( + patch( + "litellm.proxy.utils.get_prisma_client_or_throw", + return_value=prisma_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=mock_get_mcp_server, + ), + patch.object( + global_mcp_server_manager, + "update_server", + new=mock_update_server, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + ): + await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="authorization_code", + code="upstream-code", + redirect_uri="https://litellm.example.com/callback", + client_id="ephemeral-client", + client_secret="ephemeral-secret" if server_has_shared_client else None, + code_verifier="verifier", + client_token_endpoint_auth_method="client_secret_post" if server_has_shared_client else None, + sealed_client=True, + ) + + data: Final = fake_http_client.post.call_args.kwargs["data"] + assert data["client_id"] == "ephemeral-client", data + assert data["redirect_uri"] == "https://litellm.example.com/callback", data + if server_has_shared_client: + headers: Final = fake_http_client.post.call_args.kwargs["headers"] + assert data["client_secret"] == "ephemeral-secret", data + assert "Authorization" not in headers, headers + + _BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef" diff --git a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py index ef95df23ff8..530667fdfdd 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py @@ -3384,6 +3384,7 @@ async def test_jwt_bearer_grant_claims_the_jti_before_it_mints(): ("invalid_client", 401), ("invalid_grant", 400), ("invalid_request", 400), + ("invalid_target", 400), ("unsupported_grant_type", 400), ("temporarily_unavailable", 503), ], diff --git a/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py index fdfa0544ad1..f4fa51d57ba 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py @@ -33,6 +33,7 @@ from litellm.proxy._experimental.mcp_server.id_jag_grant import ( jwt_bearer_available, names_mcp_resource, proxy_user_lookup, + resource_names_mcp, select_team, subject_resolver, ) @@ -87,6 +88,16 @@ def _assertion(typ: str = ID_JAG_TYP, **claims: object) -> str: ) +def _unscoped_assertion(**claims: object) -> str: + unscoped_claims: Final = {key: value for key, value in _claims(**claims).items() if key != "scope"} + return jwt.encode( + unscoped_claims, + "unused-by-the-fake-verifier-but-long-enough-for-hs256", + algorithm="HS256", + headers={"typ": ID_JAG_TYP}, + ) + + class _Verifier: """Stands in for the injected ``DecodeAssertion`` port; records each call as a new tuple.""" @@ -714,6 +725,40 @@ def test_the_mcp_resource_audience_is_the_mcp_path_without_the_bare_origin(aud, assert names_mcp_resource(aud, None) is False +@pytest.mark.parametrize( + "claims, expected", + [ + ({}, False), + ({"resource": f"{GATEWAY}/mcp"}, True), + ({"resource": [f"{GATEWAY}/mcp"]}, True), + ({"resource": GATEWAY}, False), + ({"resource": [GATEWAY, f"{GATEWAY}/mcp"]}, False), + ({"resource": "https://other.example"}, None), + ({"resource": [f"{GATEWAY}/mcp", "https://other.example"]}, None), + ({"resource": f"{GATEWAY}/mcp/"}, None), + ({"resource": None}, None), + ({"resource": 7}, None), + ({"resource": []}, None), + ({"resource": [7]}, None), + ], +) +def test_the_signed_resource_claim_must_name_only_gateway_resources( + claims: Mapping[str, object], expected: bool | None +) -> None: + result: Final = resource_names_mcp(claims, GATEWAY) + if isinstance(result, bool): + assert expected is result + return + assert expected is None + assert result.error == "invalid_target" + + +def test_the_signed_resource_claim_is_refused_without_a_gateway_base_url() -> None: + result: Final = resource_names_mcp({"resource": f"{GATEWAY}/mcp"}, None) + assert isinstance(result, SubjectTokenRefusal) + assert result.error == "invalid_target" + + @pytest.mark.asyncio async def test_redeeming_for_the_mcp_resource_narrows_the_identity_and_refuses_llm_scope(): """End to end through identity_from_id_jag: the same verified claims, with ``aud`` the MCP @@ -736,3 +781,33 @@ async def test_redeeming_for_the_mcp_resource_narrows_the_identity_and_refuses_l scoped = await _identity(_redemption(_assertion(aud=AUDIENCES[1], scope="litellm:llm")), _Verifier(), _Resolver()) assert isinstance(scoped, AssertionIdentity) assert scoped.scope == "litellm:llm" + + +@pytest.mark.asyncio +async def test_redeeming_for_the_signed_resource_caps_the_identity_before_subject_lookup() -> None: + mcp_claim: Final = _unscoped_assertion(aud=GATEWAY, resource=f"{GATEWAY}/mcp") + narrowed: Final = await _identity(_redemption(mcp_claim), _Verifier(), _Resolver()) + assert isinstance(narrowed, AssertionIdentity) + assert (narrowed.scope, narrowed.allowed_routes) == ("litellm:mcp", ("mcp_inference_routes",)) + + llm_claim: Final = _assertion(aud=GATEWAY, resource=f"{GATEWAY}/mcp", scope="litellm:llm") + refused_scope: Final = await _identity(_redemption(llm_claim), _Verifier(), _Resolver()) + assert isinstance(refused_scope, SubjectTokenRefusal) + assert refused_scope.error == "invalid_scope" + + other_resource: Final = _unscoped_assertion(aud=GATEWAY, resource="https://other.example") + resolver: Final = _Resolver() + refused_resource: Final = await _identity(_redemption(other_resource), _Verifier(), resolver) + assert isinstance(refused_resource, SubjectTokenRefusal) + assert refused_resource.error == "invalid_target" + assert resolver.calls == () + + origin_resource: Final = _unscoped_assertion(aud=GATEWAY, resource=GATEWAY) + full_resource: Final = await _identity(_redemption(origin_resource), _Verifier(), _Resolver()) + assert isinstance(full_resource, AssertionIdentity) + assert (full_resource.scope, full_resource.allowed_routes) == (None, None) + + no_resource: Final = _unscoped_assertion(aud=GATEWAY) + full_control: Final = await _identity(_redemption(no_resource), _Verifier(), _Resolver()) + assert isinstance(full_control, AssertionIdentity) + assert (full_control.scope, full_control.allowed_routes) == (None, None) diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 691da35b8c4..d47b2f73a48 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -20056,6 +20056,74 @@ async def test_disabled_stdio_tools_are_empty_initially_and_reject_continuation( assert page.next_cursor is None +@pytest.mark.asyncio +async def test_aggregate_bounds_each_paginated_server_by_its_own_listing_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from mcp.types import JSONRPCRequest, PaginatedRequestParams + + from litellm.proxy import proxy_server + from litellm.proxy._experimental.mcp_server import catalog, operations + from litellm.proxy._experimental.mcp_server import mcp_server_manager as manager_module + from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR, get_server_prefix + + monkeypatch.setenv("LITELLM_SALT_KEY", "paginated-listing-deadline-test") + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(manager_module, "MCP_TOOL_LISTING_TIMEOUT", 0.0) + extended: Final = MCPServer( + server_id="extended", + name="extended", + transport=MCPTransport.http, + url="https://extended.example/mcp", + timeout=60.0, + ) + global_deadline: Final = MCPServer( + server_id="global", name="global", transport=MCPTransport.http, url="https://global.example/mcp" + ) + servers: Final = (extended, global_deadline) + manager: Final = MCPServerManager() + manager.registry = {server.server_id: server for server in servers} + monkeypatch.setattr(operations, "global_mcp_server_manager", manager) + context: Final = operations.prepare_context(UserAPIKeyAuth(api_key="owned-caller", user_id="alice")) + + async def respond(request: httpx2.Request) -> httpx2.Response: + if request.method == "DELETE": + return httpx2.Response(200) + payload: Final = _JSONRPC_ADAPTER.validate_json(request.content) + if not isinstance(payload, JSONRPCRequest): + return httpx2.Response(202) + result: Final = { + "initialize": { + "protocolVersion": "2025-03-26", + "serverInfo": {"name": "test", "version": "1"}, + "capabilities": {"tools": {}}, + }, + "server/discover": { + "supportedVersions": ["2026-07-28"], + "capabilities": {"tools": {}}, + "ttlMs": 60000, + "cacheScope": "private", + "resultType": "complete", + }, + "tools/list": { + "tools": [{"name": "echo", "inputSchema": {"type": "object"}}], + "ttlMs": 60000, + "cacheScope": "private", + "resultType": "complete", + }, + }[payload.method] + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) + + with _mcp_upstream(respond): + listing: Final = await catalog.aggregate_gateway_tools( + context, PaginatedRequestParams(), servers, {}, record_listing=True, enforce_rate_limits=False + ) + + assert [tool.name for tool in listing.tools] == [f"{get_server_prefix(extended)}{MCP_TOOL_PREFIX_SEPARATOR}echo"] + assert listing.outcomes["extended"].tag == "ok" + assert listing.outcomes["global"].tag == "timeout" + + @pytest.mark.asyncio async def test_failed_aggregate_continuation_preserves_only_delivered_tool_metadata(monkeypatch): from mcp.shared.exceptions import MCPError diff --git a/tests/unit/proxy/auth/test_handle_jwt.py b/tests/unit/proxy/auth/test_handle_jwt.py index e4a3935a58d..a0ea6ce0403 100644 --- a/tests/unit/proxy/auth/test_handle_jwt.py +++ b/tests/unit/proxy/auth/test_handle_jwt.py @@ -1,7 +1,9 @@ import asyncio import re +import sys import time from collections.abc import Mapping, Sequence +from types import ModuleType from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -296,6 +298,84 @@ async def test_find_team_with_model_access_uses_request_method_for_passthrough_a assert "allowed_passthrough_routes" in exc_info.value.detail + +class _TeamClaims(set[str]): + def __init__(self, claim_order: tuple[str, ...]): + super().__init__(claim_order) + self.claim_order = claim_order + + def __iter__(self): + return iter(self.claim_order) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("claim_order", [("team-a", "team-b"), ("team-b", "team-a")]) +async def test_a_jwt_in_two_teams_resolves_the_same_team_in_any_claim_order(claim_order: tuple[str, ...]): + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + teams = {team_id: LiteLLM_TeamTable(team_id=team_id, models=["gpt-4"], metadata={}) for team_id in claim_order} + + with ( + patch( + "litellm.proxy.auth.handle_jwt.get_team_object", + new_callable=AsyncMock, + side_effect=lambda team_id, **_: teams[team_id], + ), + patch( + "litellm.proxy.auth.handle_jwt.can_team_access_model", + new_callable=AsyncMock, + return_value=True, + ), + ): + team_id, team_obj = await JWTAuthManager.find_team_with_model_access( + team_ids=_TeamClaims(claim_order), + requested_model="gpt-4", + route="/v1/rag/query", + jwt_handler=jwt_handler, + prisma_client=None, + user_api_key_cache=MagicMock(), + parent_otel_span=None, + proxy_logging_obj=MagicMock(), + ) + + assert team_id == "team-a" + assert team_obj == teams["team-a"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stray_claim", [1, None]) +async def test_a_jwt_team_claim_with_a_non_string_entry_still_resolves_the_string_team(stray_claim: object): + jwt_handler: Final = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(team_ids_jwt_field="groups") + team: Final = LiteLLM_TeamTable(team_id="aa-team", models=["gpt-4"], metadata={}) + + with ( + patch( + "litellm.proxy.auth.handle_jwt.get_team_object", + new_callable=AsyncMock, + side_effect=lambda team_id, **_: {"aa-team": team}[team_id], + ), + patch( + "litellm.proxy.auth.handle_jwt.can_team_access_model", + new_callable=AsyncMock, + return_value=True, + ), + ): + team_id, team_obj = await JWTAuthManager.find_team_with_model_access( + team_ids=set(jwt_handler.get_team_ids_from_jwt({"groups": ["aa-team", stray_claim]})), + requested_model="gpt-4", + route="/chat/completions", + jwt_handler=jwt_handler, + prisma_client=None, + user_api_key_cache=MagicMock(), + parent_otel_span=None, + proxy_logging_obj=MagicMock(), + ) + + assert team_id == "aa-team" + assert team_obj == team + + _AUTH_ENFORCED_MODEL_HOST_ROUTES: Final = { "test-uuid-1:subpath:/model-host/v1/extractor:GET,POST": { "endpoint_id": "test-uuid-1", @@ -1483,8 +1563,13 @@ async def test_find_team_with_model_access_v1_messages_default_routes(monkeypatc [ ("/v1/messages", True), ("/v1/messages/count_tokens", True), + ("/rag/ingest", True), + ("/v1/rag/ingest", True), + ("/rag/query", True), + ("/v1/rag/query", True), ("/v1/skills", False), ("/v1/skills/skill_abc123", False), + ("/lens", False), ], ) def test_default_team_allowed_routes_cover_messages_but_not_skills(route, expected): @@ -1500,6 +1585,40 @@ def test_default_team_allowed_routes_cover_messages_but_not_skills(route, expect ) +@pytest.mark.asyncio +async def test_find_team_with_model_access_reports_team_route_allowlist_denial(): + jwt_handler: Final = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(team_allowed_routes=["/chat/completions"]) + team: Final = LiteLLM_TeamTable(team_id="team-route-denied", models=["gpt-4o-mini"]) + user_api_key_cache: Final = DualCache() + user_api_key_cache.set_cache(key="team_id:team-route-denied", value=team) + prisma_client: Final = MagicMock() + prisma_team_table: Final = prisma_client.db.litellm_teamtable + prisma_team_table.find_unique = AsyncMock() + proxy_server_module: Final = ModuleType("proxy_server") + proxy_server_module.llm_router = None + + with patch.dict(sys.modules, {"litellm.proxy.proxy_server": proxy_server_module}): + with pytest.raises(HTTPException) as exc_info: + await JWTAuthManager.find_team_with_model_access( + team_ids={"team-route-denied"}, + requested_model="gpt-4o-mini", + route="/rag/query", + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=MagicMock(), + ) + + prisma_team_table.find_unique.assert_not_awaited() + assert exc_info.value.status_code == 403 + assert ( + exc_info.value.detail == "Team team-route-denied can access model gpt-4o-mini but route /rag/query " + "is not in litellm_jwtauth.team_allowed_routes" + ) + + @pytest.mark.asyncio async def test_auth_builder_returns_team_membership_object(): """ @@ -7011,6 +7130,23 @@ async def test_resolve_team_from_header_denies_aliases_of_teams_the_jwt_does_not ) +@pytest.mark.asyncio +@pytest.mark.parametrize("stray_claim", [1, None]) +async def test_resolve_team_from_header_denies_an_unknown_team_with_403_when_the_jwt_claim_holds_a_non_string( + stray_claim: object, +): + jwt_handler: Final = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(team_ids_jwt_field="groups") + allowed_team_ids: Final = set(jwt_handler.get_team_ids_from_jwt({"groups": ["team_a", stray_claim]})) + + with pytest.raises(HTTPException) as denied: + await _resolve_header("no_such", allowed_team_ids, False, _team_lookup_404, _teams_by_alias({})) + + assert denied.value.status_code == 403 + assert "'no_such' does not resolve" in denied.value.detail + assert "'team_a'" in denied.value.detail + + @pytest.mark.asyncio async def test_resolve_team_from_header_under_db_fallback_tries_the_id_before_the_alias(): """Under fallback_to_db_teams a claimless JWT's header is provisional: a value diff --git a/tests/unit/proxy/auth/test_login_utils.py b/tests/unit/proxy/auth/test_login_utils.py index 1fb648fe5e6..ec9b9a04f72 100644 --- a/tests/unit/proxy/auth/test_login_utils.py +++ b/tests/unit/proxy/auth/test_login_utils.py @@ -2144,7 +2144,7 @@ class TestPasswordResetRequiredSessionMinting: row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True) result, key_kwargs = await self._login(_prisma_with_user(row)) - assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout"] + assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout", "/management/v1/users/me"] assert key_kwargs["metadata"] == {"login_method": "username_password", "password_reset_required": True} assert result.password_reset_required is True @@ -2205,7 +2205,7 @@ class TestPasswordResetRequiredSessionMinting: result, key_kwargs, _ = await self._login_with_screen_result(mock_prisma_client, breached=True) - assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout"] + assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout", "/management/v1/users/me"] assert key_kwargs["metadata"] == {"login_method": "username_password", "password_reset_required": True} assert result.password_reset_required is True diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index 21958e88008..a519a4e576a 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -17,6 +17,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.auth_checks import _is_api_route_allowed from litellm.proxy.auth.auth_checks_organization import user_is_org_admin +from litellm.proxy.auth.login_utils import PASSWORD_RESET_ALLOWED_ROUTES from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import router as llm_passthrough_router @@ -3992,20 +3993,38 @@ def _password_reset_session_token() -> UserAPIKeyAuth: `password_reset_required`.""" return UserAPIKeyAuth( user_id="flagged_user", - allowed_routes=["/user/password/change"], + allowed_routes=list(PASSWORD_RESET_ALLOWED_ROUTES), metadata={"password_reset_required": True}, ) -def test_password_reset_session_can_reach_change_password(): +@pytest.mark.parametrize("route", ["/user/password/change", "/management/v1/users/me"]) +def test_password_reset_session_can_reach_change_password_and_its_own_identity(route): result = RouteChecks.is_virtual_key_allowed_to_call_route( - route="/user/password/change", + route=route, valid_token=_password_reset_session_token(), ) assert result is True +@pytest.mark.parametrize( + "role", + [LitellmUserRoles.INTERNAL_USER.value, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value], +) +def test_non_admin_user_can_read_its_own_identity(role): + allowed = RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=LiteLLM_UserTable(user_id="test_user", user_role=role), + _user_role=role, + route="/management/v1/users/me", + request=MagicMock(spec=Request, query_params={}), + valid_token=UserAPIKeyAuth(user_id="test_user", user_role=role), + request_data={}, + ) + + assert allowed is None + + @pytest.mark.parametrize( "route", [ @@ -4782,6 +4801,51 @@ def test_view_only_user_denied_credential_connection_routes(method: str, route: _internal_user_check(route, method, role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY) +_SELF_SCOPED_CREDENTIAL_WRITES = ( + ("POST", "/credentials/x/user_connection/start"), + ("POST", "/credentials/x/user_connection/poll"), + ("DELETE", "/credentials/x/user_connection"), + ("POST", "/v1/mcp/server/oauth/srv-1/register"), + ("POST", "/v1/mcp/server/srv-1/oauth-user-credential"), + ("DELETE", "/v1/mcp/server/srv-1/oauth-user-credential"), + ("POST", "/v1/mcp/server/srv-1/user-credential"), + ("DELETE", "/v1/mcp/server/srv-1/user-credential"), + ("POST", "/v1/mcp/server/srv-1/user-env-vars"), + ("DELETE", "/v1/mcp/server/srv-1/user-env-vars"), +) + + +@pytest.mark.parametrize(("method", "route"), _SELF_SCOPED_CREDENTIAL_WRITES) +def test_proxy_admin_viewer_allowed_self_scoped_credential_writes(method: str, route: str) -> None: + """Regression for LIT-9320: these handlers only write the caller's own credential + row, so a read-only admin can use them like /user/password/change.""" + assert _internal_user_check(route, method, role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) is None + + +@pytest.mark.parametrize( + ("method", "route"), + [ + ("POST", "/v1/mcp/server"), + ("PUT", "/v1/mcp/server"), + ("DELETE", "/v1/mcp/server/srv-1"), + ("POST", "/v1/mcp/server/import"), + ("POST", "/v1/mcp/server/srv-1/pin"), + ("PATCH", "/v1/mcp/server/srv-1/oauth-user-credential"), + ("PUT", "/v1/mcp/server/srv-1/user-credential"), + ("PATCH", "/v1/mcp/server/srv-1/user-env-vars"), + ("POST", "/v1/mcp/server/srv-1/user-credential/rotate"), + ("POST", "/v1/mcp/server/oauth/srv-1/token"), + ("DELETE", "/v1/mcp/server/oauth/srv-1/register"), + ("PATCH", "/credentials/x/user_connection"), + ("DELETE", "/credentials/user_connections"), + ("POST", "/key/generate"), + ], +) +def test_proxy_admin_viewer_denied_mcp_admin_and_sibling_writes(method: str, route: str) -> None: + with pytest.raises(HTTPException, match="user not allowed to access this route"): + _internal_user_check(route, method, role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + + def test_user_connection_route_registration_order_wins_over_generic_credential_routes() -> None: """The :path placeholders in the generic CRUD routes could swallow the user_connection paths; the concrete routes must register (and match) first.""" diff --git a/tests/unit/proxy/batches_endpoints/test_endpoints.py b/tests/unit/proxy/batches_endpoints/test_endpoints.py index 15a254dd7a1..9ef35444419 100644 --- a/tests/unit/proxy/batches_endpoints/test_endpoints.py +++ b/tests/unit/proxy/batches_endpoints/test_endpoints.py @@ -36,7 +36,7 @@ from contextlib import ExitStack from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Final, Optional from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -59,6 +59,7 @@ from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo from litellm.types.utils import CredentialItem, LiteLLMBatch, SpecialEnums from fastapi import HTTPException, Request, Response +from starlette.datastructures import Headers, QueryParams # --------------------------------------------------------------------------- # # Fixtures: distinguishable credentials per model so a wrong/hardcoded model_id @@ -124,8 +125,8 @@ class FakeRequest: headers: Optional[Dict[str, str]] = None, query: Optional[Dict[str, str]] = None, ): - self.headers = headers or {} - self.query_params = query or {} + self.headers = Headers(headers=headers or {}) + self.query_params = QueryParams(query or {}) @pytest.fixture @@ -937,6 +938,42 @@ async def test_create__raw_file_with_executed_model_400_with_upload_guidance(har harness.router_acreate.assert_not_called() +async def _create_with_model_headers(harness: Harness, *models: str): + harness.upstream_files_route.mock(return_value=httpx.Response(200, json={"object": "list", "data": []})) + set_body(harness, {"input_file_id": "file-plain", "endpoint": "/v1/chat/completions", "completion_window": "24h"}) + request: Final = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/batches", + "query_string": b"", + "headers": [(b"x-litellm-model", model.encode()) for model in models], + } + ) + return await endpoints.create_batch( + request=request, fastapi_response=Response(), provider=None, user_api_key_dict=UserAPIKeyAuth(api_key="sk-test") + ) + + +@pytest.mark.asyncio +async def test_create__model_headers_naming_different_models_are_rejected_before_anything_runs(harness): + with pytest.raises(ProxyException) as exc: + await _create_with_model_headers(harness, "vertex-model", "my-vllm") + + assert exc.value.code == "400" + assert "x-litellm-model" in exc.value.message + harness.pre_call.assert_not_called() + harness.litellm_acreate.assert_not_called() + harness.router_acreate.assert_not_called() + + +@pytest.mark.asyncio +async def test_create__the_same_model_header_twice_still_routes_to_that_model(harness): + await _create_with_model_headers(harness, "my-vllm", "my-vllm") + + assert harness.acreate_kwargs()["api_base"] == CREDS["my-vllm"]["api_base"] + + @pytest.mark.asyncio @pytest.mark.parametrize( "upstream_answer", @@ -1059,8 +1096,8 @@ def _raw_batches_request(body: Dict[str, Any]) -> MagicMock: request.url.path = "/v1/batches" request.method = "POST" request.scope = {"type": "http", "method": "POST", "path": "/v1/batches"} - request.query_params = {} - request.headers = {"Content-Type": "application/json"} + request.query_params = QueryParams({}) + request.headers = Headers(headers={"Content-Type": "application/json"}) request.client = MagicMock() request.client.host = "127.0.0.1" request.body = AsyncMock(return_value=json.dumps(body).encode()) diff --git a/tests/unit/proxy/common_utils/test_check_batch_cost.py b/tests/unit/proxy/common_utils/test_check_batch_cost.py index a5ee7fe23bb..eb2a5b7f66c 100644 --- a/tests/unit/proxy/common_utils/test_check_batch_cost.py +++ b/tests/unit/proxy/common_utils/test_check_batch_cost.py @@ -11,12 +11,14 @@ import json import logging from contextlib import contextmanager from hashlib import sha256 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException +from litellm.types.utils import LiteLLMBatch + if TYPE_CHECKING: from litellm.batches.batch_utils import BatchCostUsageResult @@ -668,10 +670,16 @@ class TestCheckBatchCost: assert check_batch_cost_instance._has_batch_processed_column is True mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job]) - mock_response = MagicMock() - mock_response.status = "completed" - mock_response.output_file_id = "file-output-123" - mock_response.model_dump_json.return_value = '{"id":"batch-1","status":"completed"}' + mock_response: Final = LiteLLMBatch( + id="batch-1", + status="completed", + output_file_id="file-output-123", + created_at=1, + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + object="batch", + ) mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response) mock_llm_router.get_deployment_credentials_with_provider = MagicMock(return_value={"api_key": "sk-test"}) @@ -730,10 +738,10 @@ class TestCheckBatchCost: await check_batch_cost_instance.check_batch_cost() - assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1, ( + assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count >= 3, ( "Expected update() to be called exactly once for the completed job" ) - update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[1]["data"] + update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"] assert update_data["batch_processed"] is True, ( "update() must include batch_processed=True when column is present" ) @@ -863,7 +871,7 @@ class TestCheckBatchCost: mock_update_database.assert_awaited_once() assert mock_update_database.call_args.kwargs["response_cost"] == 0.01 - assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1, ( + assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count >= 3, ( "the job must still be marked processed once cost tracking succeeds" ) @@ -1285,11 +1293,16 @@ class TestCheckBatchCost: assert check_batch_cost_instance._has_batch_processed_column is True mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job]) - mock_response = MagicMock() - mock_response.status = terminal_status - mock_response.output_file_id = "file-output-123" - mock_response.error_file_id = None - mock_response.model_dump_json.return_value = f'{{"id":"batch-1","status":"{terminal_status}"}}' + mock_response: Final = LiteLLMBatch( + id="batch-1", + status=terminal_status, + output_file_id="file-output-123", + created_at=1, + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + object="batch", + ) mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response) mock_llm_router.get_deployment_credentials_with_provider = MagicMock(return_value={"api_key": "sk-test"}) @@ -1352,8 +1365,8 @@ class TestCheckBatchCost: f"{terminal_status} batch with an output file must fetch results and be billed" ) mock_logging_obj.async_success_handler.assert_awaited_once() - assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1 - update_data = mock_prisma_client.db.litellm_managedobjecttable.update.call_args[1]["data"] + assert mock_prisma_client.db.litellm_managedobjecttable.update_many.call_count >= 3 + update_data = mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args[1]["data"] assert update_data["batch_processed"] is True assert update_data["status"] == terminal_status, ( f"billed {terminal_status} batch must keep its real terminal status in the DB" @@ -1847,13 +1860,16 @@ class TestUnmanagedVertexRouting: router.resolve_model_name_from_model_id.return_value = "gemini-2.5-flash" router.get_model_ids.return_value = ["deploy-1"] - mock_response = MagicMock() - mock_response.status = "completed" - mock_response.output_file_id = "gs://bucket/out/predictions.jsonl" - mock_response.error_file_id = None - mock_response.completed_at = None - mock_response.created_at = None - mock_response.model_dump_json.return_value = '{"id":"8823717160934178816","status":"completed"}' + mock_response: Final = LiteLLMBatch( + id="8823717160934178816", + status="completed", + output_file_id="gs://bucket/out/predictions.jsonl", + created_at=1, + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + object="batch", + ) router.aretrieve_batch = AsyncMock(return_value=mock_response) router.get_deployment_credentials_with_provider = MagicMock( return_value={"vertex_project": "p", "vertex_location": "us-central1"} @@ -1921,8 +1937,8 @@ class TestUnmanagedVertexRouting: mock_logging_obj.async_success_handler.assert_awaited_once() assert mock_logging_obj.async_success_handler.call_args[1]["batch_cost"] == 0.01 - assert prisma.db.litellm_managedobjecttable.update.call_count == 1 - update_data = prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] + assert prisma.db.litellm_managedobjecttable.update_many.call_count >= 3 + update_data = prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"] assert update_data["batch_processed"] is True assert update_data["status"] == "complete" @@ -2065,13 +2081,16 @@ class TestUnmanagedBedrockRouting: router.resolve_model_name_from_model_id.return_value = "claude-sonnet-4" router.get_model_ids.return_value = ["deploy-1"] - mock_response = MagicMock() - mock_response.status = "completed" - mock_response.output_file_id = "s3://bucket/out/predictions.jsonl" - mock_response.error_file_id = None - mock_response.completed_at = None - mock_response.created_at = None - mock_response.model_dump_json.return_value = f'{{"id":"{self._ARN}","status":"completed"}}' + mock_response: Final = LiteLLMBatch( + id=self._ARN, + status="completed", + output_file_id="s3://bucket/out/predictions.jsonl", + created_at=1, + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + object="batch", + ) router.aretrieve_batch = AsyncMock(return_value=mock_response) router.get_deployment_credentials_with_provider = MagicMock(return_value={"aws_region_name": "us-east-1"}) @@ -2135,8 +2154,8 @@ class TestUnmanagedBedrockRouting: mock_logging_obj.async_success_handler.assert_awaited_once() assert mock_logging_obj.async_success_handler.call_args[1]["batch_cost"] == 0.02 - assert prisma.db.litellm_managedobjecttable.update.call_count == 1 - update_data = prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] + assert prisma.db.litellm_managedobjecttable.update_many.call_count >= 3 + update_data = prisma.db.litellm_managedobjecttable.update_many.call_args[1]["data"] assert update_data["batch_processed"] is True assert update_data["status"] == "complete" @@ -3003,13 +3022,20 @@ class TestMultiPodBatchCostClaim: @staticmethod def _router(): - response = MagicMock() - response.status = "completed" - response.output_file_id = _CLAIM_OUTPUT_FILE_ID - response.error_file_id = None - response.created_at = 1 - response.completed_at = 2 - response.model_dump_json.return_value = '{"id":"batch-456","status":"completed"}' + from litellm.types.utils import LiteLLMBatch + + response: Final = LiteLLMBatch( + id="batch-456", + status="completed", + output_file_id=_CLAIM_OUTPUT_FILE_ID, + error_file_id=None, + created_at=1, + completed_at=2, + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + object="batch", + ) deployment = MagicMock() deployment.litellm_params.custom_llm_provider = "openai" @@ -3078,6 +3104,123 @@ class TestMultiPodBatchCostClaim: ): yield logging_obj + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("deployment_model", "successes", "failures", "expected_model"), + ( + ("openai/", 0, 2, "openai/"), + ("", 0, 2, "model-123"), + ("openai/", 2, 1, "batch-test-model"), + ("openai/batch-test-model", 2, 1, "batch-test-model"), + ("openai/batch-test-model", 2, 0, "batch-test-model"), + ), + ) + @pytest.mark.parametrize("failure_envelope", (None, 1, "invalid", [1])) + async def test_batch_accounting_preserves_models_and_mixed_results( + self, deployment_model: str, successes: int, failures: int, expected_model: str, failure_envelope: object + ) -> None: + import base64 + from datetime import datetime + + import httpx + import litellm + import respx + + from litellm.integrations.custom_logger import CustomLogger + from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo + from litellm.types.utils import LiteLLMBatch, SpecialEnums + + input_rate: Final = 0.000002 + output_rate: Final = 0.000006 + litellm.register_model( + { + "batch-test-model": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token_batches": input_rate, + "output_cost_per_token_batches": output_rate, + } + } + ) + success: Final = { + "response": { + "status_code": 200, + "body": { + "model": "batch-test-model", + "usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120}, + }, + }, + "error": None, + } + failure: Final = {"response": failure_envelope, "error": {"code": "batch_expired", "message": "expired"}} + rows: Final = (success,) * successes + (failure,) * failures + content: Final = b"\n".join(json.dumps(row).encode() for row in rows) + + class BatchSpendRecorder(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.calls: tuple[dict[str, object], ...] = () + + async def async_log_success_event( + self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime + ) -> None: + if kwargs["call_type"] == "aretrieve_batch": + self.calls += (kwargs,) + + callback: Final = BatchSpendRecorder() + litellm.logging_callback_manager.add_litellm_async_success_callback(callback) + row: Final = _FakeManagedObjectRow() + row.unified_object_id = ( + base64.urlsafe_b64encode( + SpecialEnums.LITELLM_MANAGED_BATCH_COMPLETE_STR.value.format("model-123", "batch-456").encode() + ) + .decode() + .rstrip("=") + ) + prisma: Final = self._prisma(row, []) + router: Final = self._router() + router.get_deployment.return_value = Deployment( + model_name="batch-alias", + litellm_params=LiteLLM_Params(model=deployment_model, custom_llm_provider="openai"), + model_info=ModelInfo(id="model-123"), + ) + router.aretrieve_batch.return_value = LiteLLMBatch( + id="batch-456", + completion_window="24h", + created_at=1, + endpoint="/v1/chat/completions", + input_file_id="file-input-1", + output_file_id=_CLAIM_OUTPUT_FILE_ID, + object="batch", + status="completed", + ) + router.get_deployment_credentials_with_provider.return_value = { + "api_key": "synthetic-key", + "custom_llm_provider": "openai", + } + with respx.mock: + respx.get(f"https://api.openai.com/v1/files/{_CLAIM_OUTPUT_FILE_ID}/content").mock( + return_value=httpx.Response(200, content=content) + ) + instance: Final = self._instance(prisma, router) + await instance.check_batch_cost() + await instance.check_batch_cost() + + assert row.batch_processed is True + assert row.status == "complete" + router.aretrieve_batch.assert_awaited_once() + assert len(callback.calls) == 1 + payload: Final = callback.calls[0]["standard_logging_object"] + assert payload["model"] == expected_model + assert payload["response_cost"] == pytest.approx(successes * (100 * input_rate + 20 * output_rate)) + assert payload["prompt_tokens"] == successes * 100 + assert payload["completion_tokens"] == successes * 20 + assert payload["hidden_params"]["batch_successful_requests"] == successes + assert payload["hidden_params"]["batch_failed_requests"] == failures + assert payload["hidden_params"]["batch_models"] == ( + ["batch-test-model"] if deployment_model == "openai/batch-test-model" else ["batch-test-model"] * successes + ) + @staticmethod def _claim_calls(prisma) -> list: return [ @@ -3101,6 +3244,30 @@ class TestMultiPodBatchCostClaim: with patch("litellm.proxy.proxy_server.scheduler", scheduler): await guard._check_file_deletion_allowed(file_id) + @pytest.mark.asyncio + @pytest.mark.parametrize("status", ("completed", "failed", "expired", "cancelled")) + @pytest.mark.parametrize("metadata_text", ("before\x00after", r"\u0000")) + async def test_cost_claim_persists_terminal_metadata_without_a_later_write( + self, status: str, metadata_text: str + ) -> None: + row: Final = _FakeManagedObjectRow() + journal: Final = [] + prisma: Final = self._prisma(row, journal) + router: Final = self._router() + router.aretrieve_batch.return_value = router.aretrieve_batch.return_value.model_copy( + update={"status": status, "metadata": {"annotation": metadata_text}} + ) + prisma.db.litellm_managedobjecttable.update.side_effect = RuntimeError("final write unavailable") + worker: Final = self._instance(prisma, router) + with self._billing_patches(journal) as logging_obj: + await worker.check_batch_cost() + await worker.check_batch_cost() + assert row.batch_processed is True + assert row.status == ("complete" if status == "completed" else status) + assert json.loads(row.file_object)["status"] == status + assert json.loads(row.file_object)["metadata"] == {"annotation": metadata_text.replace("\x00", "")} + assert logging_obj.async_success_handler.await_count == 1 + @pytest.mark.asyncio async def test_winning_pod_claims_the_row_between_fetching_and_billing(self): """The claim flips batch_processed false -> true after the results are in hand and @@ -3112,13 +3279,9 @@ class TestMultiPodBatchCostClaim: with self._billing_patches(journal) as logging_obj: await self._instance(prisma, self._router()).check_batch_cost() - assert journal == ["fetch", "claim", "bill", "finalize"] - assert self._claim_calls(prisma) == [ - { - "where": {"id": "job-claim-1", "batch_processed": False}, - "data": {"batch_processed": True}, - } - ] + assert journal == ["fetch", "claim", "bill"] + assert row.status == "complete" + assert json.loads(row.file_object)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5} logging_obj.async_success_handler.assert_awaited_once() assert row.batch_processed is True @@ -3138,18 +3301,17 @@ class TestMultiPodBatchCostClaim: assert journal == ["fetch"] logging_obj.async_success_handler.assert_not_awaited() - assert self._claim_calls(prisma) == [ - { - "where": {"id": "job-claim-1", "batch_processed": False}, - "data": {"batch_processed": True}, - } - ] + assert row.status == "in_progress" + assert json.loads(row.file_object)["status"] == "in_progress" @pytest.mark.asyncio - async def test_a_failed_spend_log_write_releases_the_claim(self): + @pytest.mark.parametrize("stored_as_json_string", (True, False)) + async def test_a_failed_spend_log_write_releases_the_claim(self, stored_as_json_string: bool) -> None: """A transient failure while billing a claimed batch must hand the row back, or its spend is silently lost instead of being retried on the next cycle.""" row = _FakeManagedObjectRow() + original_file_object: Final = row.file_object if stored_as_json_string else json.loads(row.file_object) + row.file_object = original_file_object journal = [] prisma = self._prisma(row, journal) @@ -3158,10 +3320,16 @@ class TestMultiPodBatchCostClaim: assert journal == ["fetch", "claim", "bill", "release"] assert row.batch_processed is False - assert self._claim_calls(prisma)[-1] == { - "where": {"id": "job-claim-1", "batch_processed": True}, - "data": {"batch_processed": False}, - } + assert row.status == "in_progress" + assert json.loads(row.file_object) == original_file_object + + with self._billing_patches(journal) as logging_obj: + await self._instance(prisma, self._router()).check_batch_cost() + await self._instance(prisma, self._router()).check_batch_cost() + + assert row.batch_processed is True + assert row.status == "complete" + logging_obj.async_success_handler.assert_awaited_once() @pytest.mark.asyncio async def test_a_worker_interrupted_mid_costing_leaves_the_batch_billable(self): @@ -3193,7 +3361,7 @@ class TestMultiPodBatchCostClaim: with self._billing_patches(survivor_journal) as survivor_logging: await self._instance(survivor_prisma, self._router()).check_batch_cost() - assert survivor_journal == ["fetch", "claim", "bill", "finalize"] + assert survivor_journal == ["fetch", "claim", "bill"] survivor_logging.async_success_handler.assert_awaited_once() assert row.batch_processed is True @@ -3224,7 +3392,7 @@ class TestMultiPodBatchCostClaim: finish_fetch.set() await asyncio.wait_for(costing, timeout=5) - assert journal == ["fetch", "claim", "bill", "finalize"] + assert journal == ["fetch", "claim", "bill"] assert row.batch_processed is True await self._run_deletion_guard(prisma, _CLAIM_OUTPUT_FILE_ID) @@ -3349,7 +3517,6 @@ class TestBatchLineItemsFromThePoller: f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", - "finalize", f"read {_CLAIM_OUTPUT_FILE_ID}", "read file-input-1", "lines", @@ -3423,7 +3590,7 @@ class TestBatchLineItemsFromThePoller: await instance.check_batch_cost() await self._stored(instance) - assert journal[-4:] == ["finalize", f"read {_CLAIM_OUTPUT_FILE_ID}", "read file-input-1", "lines"] + assert journal[-4:] == ["bill", f"read {_CLAIM_OUTPUT_FILE_ID}", "read file-input-1", "lines"] ((line,),) = [payload["lines"] for payload in logger.payloads] assert line["request"] == json.loads(self._INPUT) assert line["response"] == json.loads(self._OUTPUT) @@ -3442,7 +3609,7 @@ class TestBatchLineItemsFromThePoller: await instance.check_batch_cost() await self._stored(instance) - assert [step for step in journal if not step.startswith("read")] == ["claim", "bill", "finalize", "lines"] + assert [step for step in journal if not step.startswith("read")] == ["claim", "bill", "lines"] assert row.batch_processed is True assert row.status == "complete" @@ -3458,7 +3625,7 @@ class TestBatchLineItemsFromThePoller: await instance.check_batch_cost() await self._stored(instance) - assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", "finalize"] + assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill"] @pytest.mark.asyncio async def test_a_batch_where_every_line_failed_sends_its_error_lines_after_it_is_retired(self, monkeypatch): @@ -3635,7 +3802,6 @@ class TestBatchLineItemsFromThePoller: f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", - "finalize", f"read {_CLAIM_OUTPUT_FILE_ID}", "read file-input-1", "lines started", @@ -3718,7 +3884,6 @@ class TestBatchLineItemsFromThePoller: f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", - "finalize", "store batch-earlier", "store batch-456", ] @@ -3747,7 +3912,7 @@ class TestBatchLineItemsFromThePoller: await instance.check_batch_cost() await self._stored(instance) - assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", "finalize"] + assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill"] assert logger.payloads == [] assert (row.batch_processed, row.status) == (True, "complete") assert f"the line items of batch batch-456 (job {row.id}) were not stored" in caplog.text @@ -3771,7 +3936,7 @@ class TestBatchLineItemsFromThePoller: await instance.check_batch_cost() await self._stored(instance) - assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill", "finalize"] + assert journal == [f"read {_CLAIM_OUTPUT_FILE_ID}", "claim", "bill"] assert logger.payloads == [] assert (row.batch_processed, row.status) == (True, "complete") assert f"the line items of batch batch-456 (job {row.id}) were not stored" in caplog.text diff --git a/tests/unit/proxy/common_utils/test_token_count_settings.py b/tests/unit/proxy/common_utils/test_token_count_settings.py new file mode 100644 index 00000000000..8bd5ced1b58 --- /dev/null +++ b/tests/unit/proxy/common_utils/test_token_count_settings.py @@ -0,0 +1,209 @@ +import json +from pathlib import Path +from typing import Final + +import pytest + +from litellm.proxy.common_utils.token_count_settings import ( + ProviderCountFailure, + TokenCountModel, + TokenCountSettings, + count_source_fields, + resolve_token_count_settings, +) +from litellm.types.utils import TokenCountResponse + +_OPUS_US_EAST_1: Final = TokenCountModel(model="anthropic.claude-opus-5-5", region="us-east-1") +_DEFAULT: Final = { + "token_count_framing": 97, + "token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-east-1"}, +} +_COST_MAP: Final = Path(__file__).parents[4] / "model_prices_and_context_window.json" + + +def _in_us_west_2() -> str: + return "us-west-2" + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ({}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)), + ( + {"token_count_framing": 5, "token_count_model": {"model": "custom-model", "region": "ap-southeast-4"}}, + TokenCountSettings(framing=5, count_model=TokenCountModel(model="custom-model", region="ap-southeast-4")), + ), + ( + {"token_count_model": {"model": "custom-model"}}, + TokenCountSettings(framing=97, count_model=TokenCountModel(model="custom-model", region=None)), + ), + ( + {"token_count_model": {"model": "custom-model", "region": " "}}, + TokenCountSettings(framing=97, count_model=TokenCountModel(model="custom-model", region=None)), + ), + ({"token_count_framing": 0}, TokenCountSettings(framing=0, count_model=_OPUS_US_EAST_1)), + ( + {"token_count_framing": None, "token_count_model": None}, + TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1), + ), + ( + {"token_count_framing": True, "token_count_model": "custom-model"}, + TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1), + ), + ({"token_count_framing": 78.0}, TokenCountSettings(framing=78, count_model=_OPUS_US_EAST_1)), + ({"token_count_framing": 1.5}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)), + ({"token_count_framing": -1}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)), + ({"token_count_framing": "78"}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)), + ({"token_count_model": {}}, TokenCountSettings(framing=97, count_model=None)), + ({"token_count_model": ""}, TokenCountSettings(framing=97, count_model=_OPUS_US_EAST_1)), + ( + {"token_count_model": {"model": " ", "region": "us-east-1"}}, + TokenCountSettings(framing=97, count_model=None), + ), + ], +) +def test_config_overrides_the_cost_map_default_key_by_key( + configured: dict[str, object], expected: TokenCountSettings +) -> None: + assert ( + resolve_token_count_settings(configured=configured, default=_DEFAULT, deployment_region=_in_us_west_2) + == expected + ) + + +@pytest.mark.parametrize( + ("deployment_region", "configured", "expected_region"), + [ + ("us-west-2", {}, "us-east-1"), + ("eu-central-1", {}, None), + ("ca-central-1", {}, None), + ("us-gov-west-1", {}, None), + ( + "eu-central-1", + {"token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-east-1"}}, + "us-east-1", + ), + ], +) +def test_shipped_count_region_never_takes_the_prompt_out_of_the_deployment_geography( + deployment_region: str, configured: dict[str, object], expected_region: str | None +) -> None: + settings: Final = resolve_token_count_settings( + configured=configured, default=_DEFAULT, deployment_region=lambda: deployment_region + ) + assert settings.count_model == TokenCountModel(model="anthropic.claude-opus-5-5", region=expected_region) + + +def test_shipped_global_count_model_keeps_its_region_for_a_deployment_in_any_geography() -> None: + settings: Final = resolve_token_count_settings( + configured={}, + default={"token_count_model": {"model": "global.anthropic.claude-opus-5-5", "region": "us-east-1"}}, + deployment_region=lambda: "eu-west-1", + ) + assert settings.count_model == TokenCountModel(model="global.anthropic.claude-opus-5-5", region="us-east-1") + + +def test_shipped_australian_count_region_stays_with_an_australian_deployment() -> None: + settings: Final = resolve_token_count_settings( + configured={}, + default={"token_count_model": {"model": "au.anthropic.claude-opus-5-5", "region": "ap-southeast-4"}}, + deployment_region=lambda: "ap-southeast-2", + ) + assert settings.count_model == TokenCountModel(model="au.anthropic.claude-opus-5-5", region="ap-southeast-4") + + +def test_no_setting_anywhere_means_the_model_counts_itself_without_framing() -> None: + assert resolve_token_count_settings( + configured={}, default={}, deployment_region=_in_us_west_2 + ) == TokenCountSettings(framing=None, count_model=None) + + +@pytest.mark.parametrize( + ("value", "expected"), + [("kimi_k3", "kimi_k3"), ("cl100k_base", None), (None, None), (True, None)], +) +def test_only_a_tokenizer_litellm_ships_is_taken_from_the_cost_map(value: object, expected: str | None) -> None: + settings: Final = resolve_token_count_settings( + configured={}, default={"token_count_tokenizer": value}, deployment_region=_in_us_west_2 + ) + assert settings.tokenizer == expected + + +def _counted(source: str | None, model_used: str = "us.anthropic.claude-opus-5-5") -> TokenCountResponse: + return TokenCountResponse( + total_tokens=7, + request_model="sonnet", + model_used=model_used, + tokenizer_type="bedrock_api", + count_source=source, + ) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + (None, {}), + ("provider", {"litellm_count_source": "provider"}), + ("local_estimate", {"litellm_count_source": "local_estimate"}), + ( + "token_count_model", + {"litellm_count_source": "token_count_model", "litellm_count_model": "us.anthropic.claude-opus-5-5"}, + ), + ], +) +def test_count_source_fields_name_the_counting_model_only_when_token_count_model_counted( + source: str | None, expected: dict[str, str] +) -> None: + assert count_source_fields(_counted(source)) == expected + + +def test_every_shipped_token_count_model_is_another_mapped_model_of_the_same_provider_in_a_named_region() -> None: + cost_map: Final = json.loads(_COST_MAP.read_text()) + count_models: Final = { + name: entry["token_count_model"] + for name, entry in cost_map.items() + if isinstance(entry, dict) and "token_count_model" in entry + } + assert count_models + assert { + name: count_model + for name, count_model in count_models.items() + if cost_map.get(count_model["model"], {}).get("litellm_provider") != cost_map[name]["litellm_provider"] + or count_model["model"] == name + or not count_model.get("region") + } == {} + + +_UPSTREAM_404: Final = { + "type": "error", + "request_id": "req_upstream", + "error": {"type": "not_found_error", "message": "The model 'anthropic.claude-opus-5-5' does not exist"}, +} + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + json.dumps(_UPSTREAM_404), + { + **_UPSTREAM_404, + "error": { + "type": "not_found_error", + "message": "The model 'anthropic.claude-opus-5-5' does not exist. Enable eu-west-1", + }, + }, + ), + (json.dumps({"message": "Too many tokens"}), "Too many tokens. Enable eu-west-1"), + ("scripted 404", "scripted 404. Enable eu-west-1"), + ( + "Credential should be scoped to a valid region. ", + "Credential should be scoped to a valid region. Enable eu-west-1", + ), + ], +) +def test_hint_lands_in_the_upstream_error_message_and_keeps_its_envelope(message: str, expected: object) -> None: + hinted: Final = ProviderCountFailure(status_code=404, message=message).with_hint("Enable eu-west-1") + + assert (json.loads(hinted.message) if isinstance(expected, dict) else hinted.message) == expected + assert hinted.status_code == 404 diff --git a/tests/unit/proxy/google_endpoints/test_google_api_endpoints.py b/tests/unit/proxy/google_endpoints/test_google_api_endpoints.py index 114bbb184c6..2777d18a399 100644 --- a/tests/unit/proxy/google_endpoints/test_google_api_endpoints.py +++ b/tests/unit/proxy/google_endpoints/test_google_api_endpoints.py @@ -4,10 +4,11 @@ Test to verify the Google GenAI proxy API endpoints """ from typing import Final -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest +from litellm.types.utils import TokenCountResponse def _build_test_client(): @@ -88,9 +89,7 @@ def test_google_stream_generate_content_endpoint(): assert init_kwargs["data"]["_litellm_raw_sse_stream"] is True assert init_kwargs["data"]["_litellm_skip_openai_stream_done"] is True assert init_kwargs["data"]["model"] == "test-model" - assert init_kwargs["data"]["contents"] == [ - {"role": "user", "parts": [{"text": "Hello"}]} - ] + assert init_kwargs["data"]["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] def test_google_generate_content_data_flows_through_processor(): @@ -123,9 +122,7 @@ def test_google_generate_content_data_flows_through_processor(): data = mock_init.call_args.kwargs["data"] assert data["model"] == "test-model" assert data["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] - assert data["systemInstruction"] == { - "parts": [{"text": "Your name is Doodle."}] - } + assert data["systemInstruction"] == {"parts": [{"text": "Your name is Doodle."}]} # generationConfig arrives intact here; the rename to `config` is # done downstream in route_request (see test_route_llm_request). assert data["generationConfig"]["responseModalities"] == ["TEXT", "IMAGE"] @@ -160,12 +157,17 @@ def test_google_count_tokens_unchanged(): except ImportError as e: pytest.skip(f"Skipping test due to missing dependency: {e}") - fake_response = MagicMock() - fake_response.original_response = { - "totalTokens": 7, - "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 7}], - } - fake_response.total_tokens = 7 + fake_response = TokenCountResponse( + total_tokens=7, + request_model="test-model", + model_used="test-model", + tokenizer_type="gemini_api", + original_response={ + "totalTokens": 7, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 7}], + }, + count_source="provider", + ) with patch( "litellm.proxy.proxy_server.token_counter", @@ -180,6 +182,7 @@ def test_google_count_tokens_unchanged(): assert response.status_code == 200 body = response.json() assert body["totalTokens"] == 7 + assert body["litellm_count_source"] == "provider" def test_google_count_tokens_unexpected_failure_uses_the_google_error_envelope(): @@ -212,9 +215,14 @@ def test_google_count_tokens_uses_normalized_total_when_provider_shape_differs() except ImportError as e: pytest.skip(f"Skipping test due to missing dependency: {e}") - fake_response: Final = MagicMock() - fake_response.original_response = {"input_tokens": 2167} - fake_response.total_tokens = 2167 + fake_response: Final = TokenCountResponse( + total_tokens=2167, + request_model="claude-opus-4-8", + model_used="us.anthropic.claude-opus-5-5", + tokenizer_type="bedrock_api", + original_response={"input_tokens": 2167}, + count_source="token_count_model", + ) with patch( "litellm.proxy.proxy_server.token_counter", @@ -230,3 +238,5 @@ def test_google_count_tokens_uses_normalized_total_when_provider_shape_differs() body: Final = response.json() assert body["totalTokens"] == 2167 assert body["promptTokensDetails"] == [] + assert body["litellm_count_source"] == "token_count_model" + assert body["litellm_count_model"] == "us.anthropic.claude-opus-5-5" diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index f50da1bff64..8308a7ba386 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -8,6 +8,9 @@ specifically focusing on metadata extraction and passing. import json import logging import os +from collections.abc import Callable +from functools import partial +from queue import SimpleQueue from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -26,7 +29,7 @@ from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api.generic_guardrail_api import ( _HEADER_PRESENT_PLACEHOLDER, ) -from litellm.types.utils import Choices, Message +from litellm.types.utils import Choices, GenericGuardrailAPIInputs, Message @pytest.fixture @@ -2470,3 +2473,151 @@ class TestFailOnError: request_data={}, input_type="response", ) + + +Outcome = Exception | Callable[[], httpx.Response] + +REWRITTEN: Final = partial(httpx.Response, 200, json={"action": "GUARDRAIL_INTERVENED", "texts": ["redacted"]}) + + +def _scripted(*outcomes: Outcome) -> tuple[httpx.MockTransport, SimpleQueue[bytes]]: + """Each check gets the next outcome; the last one repeats once the script runs out.""" + sent: Final[SimpleQueue[bytes]] = SimpleQueue() + script: Final = iter(outcomes) + + def answer(request: httpx.Request) -> httpx.Response: + sent.put(request.read()) + outcome: Final = next(script, outcomes[-1]) + if isinstance(outcome, Exception): + raise outcome + return outcome() + + return httpx.MockTransport(answer), sent + + +def _guardrail_on( + transport: httpx.MockTransport, num_retries: int | None, fail_on_error: bool = True +) -> GenericGuardrailAPI: + return GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + guardrail_name="retrying-guardrail", + fail_on_error=fail_on_error, + num_retries=num_retries, + timeout=5, + async_handler=AsyncHTTPHandler(transport=transport), + fresh_handler_factory=lambda: AsyncHTTPHandler(transport=transport), + ) + + +async def _check(guardrail: GenericGuardrailAPI) -> GenericGuardrailAPIInputs: + return await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data={}, input_type="request") + + +async def _attempts_until_it_raises(outcome: Outcome, num_retries: int | None) -> int: + transport, sent = _scripted(outcome) + with pytest.raises(Exception, match=r"Generic Guardrail API failed|policy violation"): + await _check(_guardrail_on(transport, num_retries)) + return sent.qsize() + + +class _OpenCloseLoggingHandler(AsyncHTTPHandler): + def __init__(self, transport: httpx.MockTransport, events: SimpleQueue[str]) -> None: + super().__init__(transport=transport) + self._events: Final = events + events.put("open") + + async def close(self) -> None: + self._events.put("close") + await super().close() + + +class TestNumRetries: + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("outcomes", "fresh_connections"), + [ + ((httpx.ReadTimeout("no answer before the read timeout"),), 3), + ((httpx.ReadTimeout("no answer"), httpx.ReadTimeout("no answer"), REWRITTEN), 2), + ], + ids=["every_retry_fails", "second_retry_succeeds"], + ) + async def test_each_retry_connection_closes_before_the_next_attempt( + self, outcomes: tuple[Outcome, ...], fresh_connections: int + ) -> None: + transport, _ = _scripted(*outcomes) + events: Final[SimpleQueue[str]] = SimpleQueue() + guardrail: Final = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + guardrail_name="retrying-guardrail", + fail_on_error=False, + num_retries=3, + timeout=5, + async_handler=AsyncHTTPHandler(transport=transport), + fresh_handler_factory=lambda: _OpenCloseLoggingHandler(transport, events), + ) + + await _check(guardrail) + + assert [events.get_nowait() for _ in range(events.qsize())] == ["open", "close"] * fresh_connections + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "failure", + [ + httpx.ReadTimeout("no answer before the read timeout"), + httpx.WriteTimeout("request body stalled"), + httpx.ReadError("connection reset by peer"), + httpx.WriteError("broken pipe"), + httpx.RemoteProtocolError("Server disconnected without sending a response."), + ], + ids=["read_timeout", "write_timeout", "read_error", "write_error", "remote_protocol_error"], + ) + async def test_check_lost_after_connect_is_resent_and_the_retry_verdict_applies(self, failure: Exception) -> None: + transport, sent = _scripted(failure, REWRITTEN) + + result: Final = await _check(_guardrail_on(transport, num_retries=1)) + + bodies: Final = [sent.get_nowait() for _ in range(sent.qsize())] + assert len(bodies) == 2 + assert bodies[0] == bodies[1] + assert result == {"texts": ["redacted"]} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "outcome", + [ + httpx.ConnectTimeout("no TCP handshake before the timeout"), + httpx.ConnectError("connection refused"), + httpx.PoolTimeout("no free pooled connection"), + partial(httpx.Response, 503, json={"error": "unavailable"}), + partial(httpx.Response, 500, json={"error": "boom"}), + partial(httpx.Response, 200, json={"action": "BLOCKED", "blocked_reason": "policy violation"}), + ], + ids=["connect_timeout", "connect_error", "pool_timeout", "http_503", "http_500", "blocked"], + ) + async def test_num_retries_does_not_resend_connect_failures_error_statuses_or_blocks( + self, outcome: Outcome + ) -> None: + assert await _attempts_until_it_raises(outcome, num_retries=3) == await _attempts_until_it_raises( + outcome, num_retries=None + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("num_retries", [None, 0]) + async def test_unset_num_retries_sends_a_timed_out_check_once(self, num_retries: int | None) -> None: + transport, sent = _scripted(httpx.ReadTimeout("no answer before the read timeout"), REWRITTEN) + + with pytest.raises(Exception, match="Generic Guardrail API failed"): + await _check(_guardrail_on(transport, num_retries)) + + assert sent.qsize() == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("num_retries", [1, 3]) + async def test_num_retries_bounds_the_attempts_before_fail_on_error_applies(self, num_retries: int) -> None: + transport, sent = _scripted(httpx.ReadTimeout("no answer before the read timeout")) + + result: Final = await _check(_guardrail_on(transport, num_retries, fail_on_error=False)) + + assert sent.qsize() == num_retries + 1 + assert result == {"texts": ["hi"]} diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py index 7d0c81af4c2..bd1e9ae4068 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -3789,6 +3789,246 @@ async def test_output_parse_pii_numbered_tokens_across_chunks(): assert pii_tokens[""] == CHUNK_MARKER_TWO +@pytest.mark.parametrize( + ("text", "analyze_results", "expected_text", "expected_tokens", "expected_counts"), + [ + pytest.param( + "Repeat exactly: my number is 4251234567", + [ + {"entity_type": "PHONE_NUMBER", "start": 29, "end": 39, "score": 0.75}, + {"entity_type": "US_BANK_NUMBER", "start": 29, "end": 39, "score": 0.05}, + {"entity_type": "US_DRIVER_LICENSE", "start": 29, "end": 39, "score": 0.01}, + ], + "Repeat exactly: my number is ", + {"": "4251234567"}, + {"PHONE_NUMBER": 1}, + id="same-span", + ), + pytest.param( + "contact john.smith@example.com today", + [ + {"entity_type": "EMAIL_ADDRESS", "start": 8, "end": 30, "score": 1.0}, + {"entity_type": "DATE_TIME", "start": 31, "end": 36, "score": 0.85}, + {"entity_type": "URL", "start": 8, "end": 15, "score": 0.5}, + {"entity_type": "URL", "start": 19, "end": 30, "score": 0.5}, + ], + "contact ", + {"": "john.smith@example.com", "": "today"}, + {"EMAIL_ADDRESS": 1, "DATE_TIME": 1}, + id="nested", + ), + pytest.param( + "her phone is +33 1 42 68 53 00.", + [ + {"entity_type": "DATE_TIME", "start": 25, "end": 30, "score": 0.85}, + {"entity_type": "PHONE_NUMBER", "start": 13, "end": 30, "score": 0.75}, + ], + "her phone is .", + {"": "+33 1 42 68 53 00"}, + {"PHONE_NUMBER": 1}, + id="container-outranks-higher-scored-fragment", + ), + pytest.param( + "ids ab0123456789 and cd0123456789", + [ + {"entity_type": "AAA", "start": 4, "end": 16, "score": 0.5}, + {"entity_type": "BBB", "start": 21, "end": 29, "score": 0.5}, + {"entity_type": "CCC", "start": 25, "end": 33, "score": 0.9}, + ], + "ids and ", + {"": "ab0123456789", "": "cd0123456789"}, + {"AAA": 1, "CCC": 1}, + id="partial-overlap-in-second-run", + ), + pytest.param( + "id ab0123456789", + [ + {"entity_type": "AAA", "start": 3, "end": 15, "score": 0.5}, + {"entity_type": "BBB", "start": 3, "end": 8, "score": 0.9}, + ], + "id ", + {"": "ab0123456789"}, + {"AAA": 1}, + id="container-sharing-start-outranks-fragment", + ), + pytest.param( + "id ab0123456789", + [ + {"entity_type": "AAA", "start": 3, "end": 10, "score": 0.9}, + {"entity_type": "BBB", "start": 7, "end": 15, "score": 0.5}, + ], + "id ", + {"": "ab0123456789"}, + {"AAA": 1}, + id="earlier-higher-scored-detection-labels-the-whole-run", + ), + pytest.param( + "id ab0123456789", + [ + {"entity_type": "AAA", "start": 6, "end": 15, "score": 0.5}, + {"entity_type": "BBB", "start": 3, "end": 8, "score": 0.5}, + ], + "id ", + {"": "ab0123456789"}, + {"AAA": 1}, + id="score-tie-goes-to-the-longer-detection", + ), + pytest.param( + "id ab0123456789", + [ + {"entity_type": "AAA", "start": 3, "end": 9, "score": 0.5}, + {"entity_type": "BBB", "start": 9, "end": 15, "score": 0.5}, + ], + "id ", + {"": "ab0123", "": "456789"}, + {"AAA": 1, "BBB": 1}, + id="touching-detections-stay-separate", + ), + pytest.param( + "id ab0123456789", + [ + {"entity_type": "AAA", "start": 3, "end": 15, "score": 0.5}, + {"entity_type": "BBB", "start": 3, "end": 15, "score": 0.5}, + ], + "id ", + {"": "ab0123456789"}, + {"BBB": 1}, + id="identical-spans-with-equal-scores-label-the-same-in-any-order", + ), + pytest.param( + "My name is John Smith, phone +1 415 555 2671, please check the order.", + [ + {"entity_type": "EMPTY", "start": 25, "end": 25, "score": 0.99}, + {"entity_type": "INVERTED", "start": 40, "end": 30, "score": 0.99}, + {"entity_type": "PERSON", "start": 11, "end": 21, "score": 0.9}, + {"entity_type": "PHONE_NUMBER", "start": 29, "end": 44, "score": 0.85}, + {"entity_type": "UK_NHS", "start": 32, "end": 44, "score": 0.6}, + ], + "My name is , phone , please check the order.", + {"": "John Smith", "": "+1 415 555 2671"}, + {"PERSON": 1, "PHONE_NUMBER": 1}, + id="empty-and-inverted-spans-are-ignored", + ), + pytest.param( + "Code ABC-12345-XYZ is valid.", + [ + {"entity_type": "GENERIC_ID", "start": 5, "end": 14, "score": 0.5}, + {"entity_type": "SECRET_KEY", "start": 9, "end": 18, "score": 0.95}, + ], + "Code is valid.", + {"": "ABC-12345-XYZ"}, + {"SECRET_KEY": 1}, + id="later-higher-scored-detection-labels-the-whole-run", + ), + ], +) +@pytest.mark.parametrize("order", ["analyzer", "reversed"]) +def test_numbered_tokens_collapse_overlapping_detections_into_one_token_per_run( + text: str, + analyze_results: list[PresidioAnalyzeResponseItem], + expected_text: str, + expected_tokens: dict[str, str], + expected_counts: dict[str, int], + order: Literal["analyzer", "reversed"], +) -> None: + guardrail: Final = OPTIONAL_PresidioPIIMasking(mock_testing=True) + request_data: Final = {"metadata": {}} + masked_entity_count: Final = {} + ordered: Final = analyze_results if order == "analyzer" else analyze_results[::-1] + + masked: Final = guardrail._finalize_presidio_anonymize_numbered_tokens( + text, ordered, request_data, masked_entity_count + ) + + assert masked == expected_text + assert request_data["metadata"]["pii_tokens"] == expected_tokens + assert masked_entity_count == expected_counts + assert OPTIONAL_PresidioPIIMasking._unmask_pii_text(masked, expected_tokens) == text + + +@pytest.mark.parametrize( + ("parts", "expected_masked", "expected_tokens"), + [ + pytest.param( + ( + ( + "My number is 4251234567.", + [ + {"entity_type": "PHONE_NUMBER", "start": 13, "end": 23, "score": 0.75}, + {"entity_type": "US_BANK_NUMBER", "start": 13, "end": 23, "score": 0.05}, + {"entity_type": "US_DRIVER_LICENSE", "start": 13, "end": 23, "score": 0.01}, + ], + ), + ( + "Also call +1 (206) 555-0199.", + [{"entity_type": "PHONE_NUMBER", "start": 10, "end": 27, "score": 0.4}], + ), + ), + ("My number is .", "Also call ."), + {"": "4251234567", "": "+1 (206) 555-0199"}, + id="overlapping-run-then-plain-detection", + ), + pytest.param( + ( + ("Call John Smith.", [{"entity_type": "PERSON", "start": 5, "end": 15, "score": 0.85}]), + ("Again John Smith.", [{"entity_type": "PERSON", "start": 6, "end": 16, "score": 0.85}]), + ), + ("Call .", "Again ."), + {"": "John Smith"}, + id="same-value-keeps-its-token", + ), + pytest.param( + ( + ("Call John Smith.", [{"entity_type": "PERSON", "start": 5, "end": 15, "score": 0.85}]), + ( + "Ann Lee and Bob Ray.", + [ + {"entity_type": "PERSON", "start": 0, "end": 7, "score": 0.85}, + {"entity_type": "PERSON", "start": 12, "end": 19, "score": 0.85}, + ], + ), + ), + ("Call .", " and ."), + {"": "John Smith", "": "Bob Ray", "": "Ann Lee"}, + id="bump-skips-every-claimed-number", + ), + pytest.param( + ( + ( + "Ann Lee and Bob Ray.", + [ + {"entity_type": "PERSON", "start": 0, "end": 7, "score": 0.85}, + {"entity_type": "PERSON", "start": 12, "end": 19, "score": 0.85}, + ], + ), + ("Call John Smith.", [{"entity_type": "PERSON", "start": 5, "end": 15, "score": 0.85}]), + ), + (" and .", "Call ."), + {"": "Ann Lee", "": "Bob Ray", "": "John Smith"}, + id="spare-number-starts-above-every-earlier-part", + ), + ], +) +def test_numbered_tokens_stay_unique_across_parts_of_one_request( + parts: tuple[tuple[str, list[PresidioAnalyzeResponseItem]], ...], + expected_masked: tuple[str, ...], + expected_tokens: dict[str, str], +) -> None: + guardrail: Final = OPTIONAL_PresidioPIIMasking(mock_testing=True) + request_data: Final = {"metadata": {}} + + masked: Final = tuple( + guardrail._finalize_presidio_anonymize_numbered_tokens(text, results, request_data, {}) + for text, results in parts + ) + + assert masked == expected_masked + assert request_data["metadata"]["pii_tokens"] == expected_tokens + assert OPTIONAL_PresidioPIIMasking._unmask_pii_text(" | ".join(masked), expected_tokens) == " | ".join( + text for text, _ in parts + ) + + @pytest.mark.asyncio async def test_analyze_text_chunked_failure_stays_fail_closed(): """If one chunk still fails, the chunked path raises exactly like a single diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_tool_permission.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_tool_permission.py index 4e19c7ed328..2ee4c8c72c0 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_tool_permission.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_tool_permission.py @@ -5,6 +5,7 @@ Unit tests for Tool Permission Guardrail (OpenAI tool_calls semantics) import json import logging import re +from typing import Final from unittest.mock import patch import pytest @@ -1379,6 +1380,54 @@ class TestToolPermissionGuardrailAnthropicMessages: assert message_start["id"].startswith("chatcmpl-"), "the rewritten stream must not adopt the upstream message id" assert message_start["model"] == "claude-sonnet-4-5", "the rewritten stream reports the model the upstream served" + @pytest.mark.asyncio + async def test_rewrite_mode_keeps_the_allowed_tool_use_verdict_with_its_explanation(self): + """Claude Code auto mode reads the classifier verdict from message_delta. The rewrite changes + no text the explanation could repeat, so the allowed call keeps its verdict whole.""" + default_on_rewriting: Final = ToolPermissionGuardrail( + guardrail_name="anthropic-rewrite-default-on", + rules=self.rules, + default_action="deny", + on_disallowed_action="rewrite", + event_hook=GuardrailEventHooks.post_call, + default_on=True, + ) + bash_verdict: Final = {"type": "evaluated", "outcome": "flagged", "explanation": "lists the home directory"} + safeguard_results: Final = [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": {"tu_ok": bash_verdict, "tu_bad": {"type": "evaluated", "outcome": "not_flagged"}}, + }, + } + ] + bash_chunks: Final = self._sse_chunks("Bash", "tu_ok") + read_chunks: Final = self._sse_chunks("Read", "tu_bad") + read_tool_use: Final = [chunk.replace(b'"index": 1', b'"index": 2') for chunk in read_chunks[4:7]] + message_delta: Final = { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "safeguard_results": safeguard_results}, + "usage": {"output_tokens": 5}, + } + chunks: Final = [ + *bash_chunks[:7], + *read_tool_use, + f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode(), + bash_chunks[-1], + ] + + body: Final = b"".join(await self._drain(default_on_rewriting, chunks)).decode() + delta: Final = next( + json.loads(line[6:])["delta"] + for line in body.splitlines() + if line.startswith("data: ") and json.loads(line[6:]).get("type") == "message_delta" + ) + assert "Permission denied" in body + assert delta["safeguard_results"] == [ + {"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": {"tu_ok": bash_verdict}}} + ] + @pytest.mark.asyncio async def test_message_start_without_a_dict_message_fails_closed(self): """Malformed SSE must not be forwarded unscanned. diff --git a/tests/unit/proxy/guardrails/test_anthropic_sse.py b/tests/unit/proxy/guardrails/test_anthropic_sse.py new file mode 100644 index 00000000000..95f596aab11 --- /dev/null +++ b/tests/unit/proxy/guardrails/test_anthropic_sse.py @@ -0,0 +1,316 @@ +import json +from collections.abc import Callable +from itertools import chain +from typing import Final + +import pytest + +from litellm.proxy.guardrails.anthropic_sse import ( + anthropic_sse_chunks_from_response, + assemble_anthropic_sse_stream, +) +from litellm.types.utils import Choices, ModelResponse + + +def _frame(event: str, data: dict[str, object]) -> bytes: + return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() + + +def _decoded_event(chunk: bytes) -> tuple[str, dict[str, object]]: + event_line, data_line = chunk.decode().split("\n")[:2] + assert event_line.startswith("event: ") + return event_line.removeprefix("event: "), json.loads(data_line.removeprefix("data: ")) + + +def _decode(chunks: tuple[bytes, ...]) -> tuple[tuple[str, dict[str, object]], ...]: + return tuple(_decoded_event(chunk) for chunk in chunks) + + +def _rewritten_delta( + upstream: tuple[bytes, ...], rewrite: Callable[[ModelResponse], None], keep_explanations: bool = False +) -> object: + assembled: Final = assemble_anthropic_sse_stream(upstream, restore_identity=True) + assert assembled is not None + rewrite(assembled) + rewritten: Final = anthropic_sse_chunks_from_response(assembled, upstream, keep_explanations=keep_explanations) + return next(payload["delta"] for event, payload in _decode(rewritten) if event == "message_delta") + + +def _set_text(text: str) -> Callable[[ModelResponse], None]: + def rewrite(assembled: ModelResponse) -> None: + choice: Final = assembled.choices[0] + assert isinstance(choice, Choices) + choice.message.content = text + + return rewrite + + +def _tool_use_frames(index: int, tool_use_id: str, name: str) -> tuple[bytes, ...]: + return ( + _frame( + "content_block_start", + { + "type": "content_block_start", + "index": index, + "content_block": {"type": "tool_use", "id": tool_use_id, "name": name, "input": {}}, + }, + ), + _frame( + "content_block_delta", + { + "type": "content_block_delta", + "index": index, + "delta": {"type": "input_json_delta", "partial_json": '{"command": "ls"}'}, + }, + ), + _frame("content_block_stop", {"type": "content_block_stop", "index": index}), + ) + + +def _upstream_stream( + delta: dict[str, object], text: str = "call 555-0100", tool_uses: tuple[tuple[str, str], ...] = () +) -> tuple[bytes, ...]: + tool_use_frames = tuple( + chain.from_iterable( + _tool_use_frames(index, tool_use_id, name) for index, (tool_use_id, name) in enumerate(tool_uses, start=1) + ) + ) + return ( + _frame( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_upstream_1", + "type": "message", + "role": "assistant", + "model": "claude-upstream", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + }, + ), + _frame( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + _frame( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}}, + ), + _frame("content_block_stop", {"type": "content_block_stop", "index": 0}), + *tool_use_frames, + _frame("message_delta", {"type": "message_delta", "delta": delta, "usage": {"output_tokens": 2}}), + _frame("message_stop", {"type": "message_stop"}), + ) + + +def _safeguard_results(tool_uses: dict[str, dict[str, str]]) -> list[dict[str, object]]: + return [{"type": "dangerous_tool_use", "status": {"type": "available", "tool_uses": tool_uses}}] + + +SAFEGUARD_RESULTS: Final = _safeguard_results({}) +REFUSAL_STOP_DETAILS: Final = {"type": "refusal", "category": "cyber", "explanation": None} + + +@pytest.mark.parametrize( + ("stop_reason", "delta_fields"), + [ + ("end_turn", {"safeguard_results": SAFEGUARD_RESULTS}), + ("refusal", {"stop_details": REFUSAL_STOP_DETAILS}), + ], + ids=["safeguard_results", "refusal_stop_details"], +) +def test_rewritten_stream_keeps_the_upstream_message_delta_fields(stop_reason: str, delta_fields: dict[str, object]): + """A guardrail that rewrites a streamed response assembles it into the OpenAI shape, which has no + slot for ``safeguard_results`` or ``stop_details``, so the re-emitted ``message_delta`` used to + drop the classifier verdict a Claude Code auto-mode session reads from it.""" + upstream_delta: dict[str, object] = {"stop_reason": stop_reason, "stop_sequence": None, **delta_fields} + upstream = _upstream_stream(upstream_delta) + assembled = assemble_anthropic_sse_stream(upstream, restore_identity=True) + assert assembled is not None + choice = assembled.choices[0] + assert isinstance(choice, Choices) + choice.message.content = "call " + + decoded = _decode(anthropic_sse_chunks_from_response(assembled, upstream)) + payloads = {event: payload for event, payload in decoded} + + assert payloads["content_block_delta"]["delta"] == {"type": "text_delta", "text": "call "} + assert payloads["message_delta"]["delta"] == upstream_delta + assert not set(delta_fields) & set(payloads["message_start"]["message"]) + + +SSN: Final = "123-45-6789" + + +@pytest.mark.parametrize( + ("upstream_delta", "tool_uses", "expected_fields"), + [ + ( + { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": {"type": "refusal", "category": "cyber", "explanation": f"Request involves SSN {SSN}"}, + }, + (), + {"stop_details": {"type": "refusal", "category": "cyber", "explanation": None}}, + ), + ( + { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": _safeguard_results( + {"toolu_bash": {"type": "evaluated", "outcome": "flagged", "explanation": f"leaks SSN {SSN}"}} + ), + }, + (("toolu_bash", "Bash"),), + {"safeguard_results": _safeguard_results({"toolu_bash": {"type": "evaluated", "outcome": "flagged"}})}, + ), + ( + { + "stop_reason": "end_turn", + "stop_sequence": None, + "safeguard_results": [ + { + "type": "prompt_injection", + "status": {"type": "available", "explanation": f"quotes SSN {SSN}"}, + "explanation": f"saw SSN {SSN}", + } + ], + }, + (), + {"safeguard_results": [{"type": "prompt_injection", "status": {"type": "available"}}]}, + ), + ], + ids=["stop_details_explanation", "verdict_explanation", "unknown_shape_explanation"], +) +def test_masked_stream_drops_the_unmasked_upstream_explanations( + upstream_delta: dict[str, object], tool_uses: tuple[tuple[str, str], ...], expected_fields: dict[str, object] +): + upstream = _upstream_stream(upstream_delta, text=f"my SSN is {SSN}", tool_uses=tool_uses) + assembled = assemble_anthropic_sse_stream(upstream, restore_identity=True) + assert assembled is not None + choice = assembled.choices[0] + assert isinstance(choice, Choices) + choice.message.content = "my SSN is " + + rewritten = anthropic_sse_chunks_from_response(assembled, upstream) + message_delta = next(payload for event, payload in _decode(rewritten) if event == "message_delta") + + assert not [chunk for chunk in rewritten if SSN.encode() in chunk] + assert {key: message_delta["delta"][key] for key in expected_fields} == expected_fields + + +def test_masked_stream_drops_safeguard_results_it_cannot_read(): + upstream_delta: Final = { + "stop_reason": "end_turn", + "stop_sequence": None, + "safeguard_results": {"type": "dangerous_tool_use", "explanation": f"saw SSN {SSN}"}, + } + + assert _rewritten_delta(_upstream_stream(upstream_delta), _set_text("my SSN is ")) == { + "stop_reason": "end_turn", + "stop_sequence": None, + } + + +def test_rewritten_stream_keeps_only_the_verdicts_of_tool_uses_it_still_delivers(): + kept_verdict = {"type": "evaluated", "outcome": "not_flagged"} + upstream = _upstream_stream( + { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": _safeguard_results( + {"toolu_glob": {"type": "evaluated", "outcome": "flagged"}, "toolu_bash": kept_verdict} + ), + }, + tool_uses=(("toolu_glob", "Glob"), ("toolu_bash", "Bash")), + ) + assembled = assemble_anthropic_sse_stream(upstream) + assert assembled is not None + choice = assembled.choices[0] + assert isinstance(choice, Choices) + choice.message.tool_calls = [call for call in choice.message.tool_calls or [] if call.id == "toolu_bash"] + choice.message.content = "Permission denied: Tool 'Glob' denied" + + decoded = _decode(anthropic_sse_chunks_from_response(assembled, upstream)) + message_delta = next(payload for event, payload in decoded if event == "message_delta") + delivered_tool_use_ids = [ + payload["content_block"]["id"] + for event, payload in decoded + if event == "content_block_start" and payload["content_block"]["type"] == "tool_use" + ] + + assert delivered_tool_use_ids == ["toolu_bash"] + assert message_delta["delta"]["safeguard_results"] == _safeguard_results({"toolu_bash": kept_verdict}) + + +def test_rewritten_refusal_gets_no_stop_details_the_upstream_did_not_send(): + """The OpenAI round trip sets ``stop_details`` on every refusal, and the repo reads a refusal + carrying one as a safeguard refusal, so a plain refusal must not come back as one.""" + upstream_delta: Final = {"stop_reason": "refusal", "stop_sequence": None} + + assert _rewritten_delta(_upstream_stream(upstream_delta), _set_text("call ")) == upstream_delta + + +def test_rewritten_stream_keeps_safeguard_shapes_it_does_not_know(): + upstream_delta: Final = { + "stop_reason": "tool_use", + "stop_sequence": None, + "stop_details": {"type": "pause_turn", "category": None, "explanation": None, "policy_url": "https://x.test"}, + "safeguard_results": [ + { + "type": "dangerous_tool_use", + "status": { + "type": "available", + "tool_uses": {"toolu_bash": {"type": "unavailable", "reason": "overloaded"}}, + }, + "classifier_version": "2026-10", + }, + {"type": "prompt_injection", "status": {"type": "skipped"}}, + ], + } + upstream: Final = _upstream_stream(upstream_delta, tool_uses=(("toolu_bash", "Bash"),)) + + assert _rewritten_delta(upstream, _set_text("call ")) == upstream_delta + + +def test_rewrite_that_changes_no_text_keeps_the_upstream_explanations(): + upstream_delta: Final = { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": _safeguard_results( + {"toolu_bash": {"type": "evaluated", "outcome": "flagged", "explanation": "lists the home directory"}} + ), + } + upstream: Final = _upstream_stream(upstream_delta, tool_uses=(("toolu_bash", "Bash"),)) + + assert _rewritten_delta(upstream, _set_text("Permission denied"), keep_explanations=True) == upstream_delta + + +def test_rewritten_stream_drops_the_verdict_of_a_tool_use_whose_input_the_guardrail_changed(): + """A verdict scored on the upstream input must not vouch for the input the client runs.""" + verdict: Final = {"type": "evaluated", "outcome": "not_flagged"} + upstream: Final = _upstream_stream( + { + "stop_reason": "tool_use", + "stop_sequence": None, + "safeguard_results": _safeguard_results({"toolu_masked": verdict, "toolu_untouched": verdict}), + }, + tool_uses=(("toolu_masked", "Bash"), ("toolu_untouched", "Bash")), + ) + + def mask_first_tool_input(assembled: ModelResponse) -> None: + choice: Final = assembled.choices[0] + assert isinstance(choice, Choices) + tool_calls: Final = choice.message.tool_calls or [] + assert [call.id for call in tool_calls] == ["toolu_masked", "toolu_untouched"] + tool_calls[0].function.arguments = '{"command": ""}' + + rewritten_delta: Final = _rewritten_delta(upstream, mask_first_tool_input) + + assert isinstance(rewritten_delta, dict) + assert rewritten_delta["safeguard_results"] == _safeguard_results({"toolu_untouched": verdict}) diff --git a/tests/unit/proxy/hooks/test_batch_rate_limiter.py b/tests/unit/proxy/hooks/test_batch_rate_limiter.py index b4e972ea34f..b10cd9e7107 100644 --- a/tests/unit/proxy/hooks/test_batch_rate_limiter.py +++ b/tests/unit/proxy/hooks/test_batch_rate_limiter.py @@ -8,7 +8,7 @@ are charged against a 24h token window instead of their minute counters. import json import time -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from datetime import datetime, timezone from typing import Final, Literal from unittest.mock import AsyncMock, MagicMock @@ -482,6 +482,155 @@ async def test_batch_rate_limiter_without_user_context(openai_files: respx.MockR assert usage_without_context.total_tokens == usage_with_context.total_tokens == _token_counter_total(rows) +@pytest.mark.asyncio +async def test_batch_input_without_a_model_is_read_through_the_team_deployment( + openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + from litellm import Router + from litellm.proxy import proxy_server + + monkeypatch.setattr( + proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "sk-team", + "api_base": "https://team.example/v1", + }, + "model_info": {"id": "team-openai", "team_id": "team-1"}, + } + ] + ), + ) + _, _, batch_limiter = _make_limiters() + rows: Final = _batch_rows(("Hello",)) + environment_route: Final = _serve_file(openai_files, "file-abc123", rows) + team_route: Final = openai_files.get("https://team.example/v1/files/file-abc123/content").mock( + return_value=httpx.Response(200, content="\n".join(json.dumps(row) for row in rows).encode()) + ) + team_key: Final = UserAPIKeyAuth(api_key="sk", team_id="team-1", team_models=["openai/*"], models=[_BATCH_MODEL]) + teamless_key: Final = UserAPIKeyAuth(api_key="sk") + + team_usage: Final = await batch_limiter.count_input_file_usage( + file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=team_key, data={} + ) + teamless_usage: Final = await batch_limiter.count_input_file_usage( + file_id="file-abc123", custom_llm_provider="openai", user_api_key_dict=teamless_key, data={} + ) + + assert team_route.call_count == 1 + assert team_route.calls.last.request.headers["authorization"] == "Bearer sk-team" + assert environment_route.call_count == 1 + assert environment_route.calls.last.request.headers["authorization"] == "Bearer sk-test" + assert team_usage.total_tokens == teamless_usage.total_tokens == _token_counter_total(rows) + + forwarded_usage: Final = await batch_limiter.count_input_file_usage( + file_id="file-abc123", + custom_llm_provider="openai", + user_api_key_dict=team_key, + data={"api_key": "sk-forwarded"}, + ) + + assert team_route.call_count == 2 + assert team_route.calls.last.request.headers["authorization"] == "Bearer sk-forwarded" + assert environment_route.call_count == 1 + assert forwarded_usage.total_tokens == _token_counter_total(rows) + + +_ROUTED_FILE_URL: Final = "https://routed.example/v1/files/file-abc123/content" + + +def _serve_file_to_the_deployment_key(rows: Sequence[_BatchLine]) -> Callable[[httpx.Request], httpx.Response]: + jsonl: Final = "\n".join(json.dumps(row) for row in rows).encode() + + def respond(request: httpx.Request) -> httpx.Response: + if request.headers.get("authorization") != "Bearer sk-deployment": + return httpx.Response(401, json={"error": {"message": "Incorrect API key provided"}}) + return httpx.Response(200, content=jsonl) + + return respond + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "query_string, routing_headers", + [(b"model=routed-batch-model", ()), (b"", ((b"x-litellm-model", b"routed-batch-model"),))], + ids=["query", "header"], +) +async def test_model_routed_batch_is_counted_with_the_deployment_key_not_the_request_key( + openai_files: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + query_string: bytes, + routing_headers: tuple[tuple[bytes, bytes], ...], +): + from starlette.requests import Request + + from litellm import Router + from litellm.proxy import proxy_server + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request + + monkeypatch.setattr( + proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "routed-batch-model", + "litellm_params": { + "model": f"openai/{_BATCH_MODEL}", + "api_key": "sk-deployment", + "api_base": "https://routed.example/v1", + }, + "model_info": {"id": "routed-batch-deployment"}, + } + ] + ), + ) + file_route: Final = openai_files.get(_ROUTED_FILE_URL).mock( + side_effect=_serve_file_to_the_deployment_key(_batch_rows(("Hello " * 50,))) + ) + user_api_key_dict: Final = UserAPIKeyAuth(api_key=hash_token("sk-batch"), tpm_limit=1) + request: Final = Request( + { + "type": "http", + "method": "POST", + "path": "/v1/batches", + "raw_path": b"/v1/batches", + "root_path": "", + "scheme": "http", + "server": ("proxy.test", 80), + "client": ("127.0.0.1", 50000), + "query_string": query_string, + "headers": [(b"content-type", b"application/json"), *routing_headers], + } + ) + data: Final = await add_litellm_data_to_request( + data={ + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "api_key": "sk-not-the-deployment-key", + }, + request=request, + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + ) + _, _, batch_limiter = _make_limiters() + + with pytest.raises(HTTPException) as exc: + await batch_limiter.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, cache=DualCache(), data=data, call_type="acreate_batch" + ) + + assert exc.value.status_code == 429 + assert file_route.call_count == 1 + + async def _install_managed_file_hook( openai_files: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, rows: str ) -> tuple[respx.Route, respx.Route]: diff --git a/tests/unit/proxy/management/users/test_endpoints.py b/tests/unit/proxy/management/users/test_endpoints.py new file mode 100644 index 00000000000..7ec0e5b7f53 --- /dev/null +++ b/tests/unit/proxy/management/users/test_endpoints.py @@ -0,0 +1,67 @@ +from collections.abc import Iterator +from typing import Final + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from litellm.proxy._types import LitellmUserRoles +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +from litellm.proxy.list_api.common import ManagementProblem, problem_response +from litellm.proxy.management_endpoints.management_v1 import router + +app: Final = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +app.include_router(router) +client: Final = TestClient(app) + +USERS_ME_PATH: Final = "/management/v1/users/me" + + +@pytest.fixture +def caller() -> Iterator[None]: + yield + app.dependency_overrides.clear() + + +def authenticate_as(resolved: UserAPIKeyAuth) -> None: + app.dependency_overrides[user_api_key_auth] = lambda: resolved + + +@pytest.mark.parametrize( + "role", + [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY], +) +def test_returns_the_identity_auth_resolved_for_the_caller(caller, role): + authenticate_as(UserAPIKeyAuth(user_id="u-1", user_role=role, user_email="u1@example.com")) + + response = client.get(USERS_ME_PATH, headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json() == {"user_id": "u-1", "user_role": role.value, "user_email": "u1@example.com"} + + +def test_key_without_a_user_gets_a_404_problem_document(caller): + authenticate_as(UserAPIKeyAuth(team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER)) + + response = client.get(USERS_ME_PATH, headers={"Authorization": "Bearer k"}) + + assert response.status_code == 404 + assert response.headers["content-type"] == "application/problem+json" + assert response.json()["type"] == "urn:litellm:error:user-not-found" + assert response.json()["status"] == 404 + + +def test_route_is_served_but_hidden_from_the_openapi_schema(caller): + authenticate_as(UserAPIKeyAuth(user_id="u-1", user_role=LitellmUserRoles.INTERNAL_USER)) + paths: Final = app.openapi()["paths"] + + assert client.get(USERS_ME_PATH).status_code == 200 + assert "/management/v1/users/bulk" in paths + assert USERS_ME_PATH not in paths diff --git a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py index 0d59c655835..65c4a8faeb7 100644 --- a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -3545,6 +3545,95 @@ class TestTemporaryMCPSessionEndpoints: assert exchange_mock.await_args.kwargs["redirect_uri"] == "https://litellm.example.com/callback" assert exchange_mock.await_args.kwargs["client_token_endpoint_auth_method"] == "client_secret_basic" + @pytest.mark.asyncio + async def test_mcp_token_prefers_sealed_client_when_server_has_shared_client( + self: "TestTemporaryMCPSessionEndpoints", + ) -> None: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + seal_passthrough_authorization_code, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + mcp_token, + ) + + request: Final = MagicMock() + request.base_url = "https://litellm.example.com/" + request.headers = {} + server: Final = generate_mock_mcp_server_config_record(server_id="server-1").model_copy( + update={ + "auth_type": MCPAuth.true_passthrough, + "client_id": "shared-gateway-client", + "client_secret": "shared-secret", + "token_endpoint_auth_method": "client_secret_basic", + "token_url": "https://provider.example/oauth/token", + } + ) + admin_auth: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN) + fake_http_response: Final = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client: Final = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + prisma_client: Final = MagicMock() + mock_get_mcp_server: Final = AsyncMock(return_value=None) + mock_update_server: Final = AsyncMock() + + with ( + patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._oauth_server_operation", + return_value=nullcontext(server), + ), + patch( + "litellm.proxy.utils.get_prisma_client_or_throw", + return_value=prisma_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=mock_get_mcp_server, + ), + patch.object( + global_mcp_server_manager, + "update_server", + new=mock_update_server, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + ): + sealed: Final = seal_passthrough_authorization_code( + upstream_code="up-code", + client_id="ephemeral-client", + client_secret="ephemeral-secret", + mcp_server_id="server-1", + token_endpoint_auth_method="client_secret_post", + ) + result: Final = await mcp_token( + request=request, + server_id="server-1", + user_api_key_dict=admin_auth, + grant_type="authorization_code", + code=sealed, + redirect_uri="https://example.com/callback", + client_id=None, + client_secret=None, + code_verifier="verifier", + refresh_token=None, + scope=None, + ) + + data: Final = fake_http_client.post.call_args.kwargs["data"] + headers: Final = fake_http_client.post.call_args.kwargs["headers"] + assert data["client_id"] == "ephemeral-client", data + assert data["client_secret"] == "ephemeral-secret", data + assert data["code"] == "up-code", data + assert data["redirect_uri"] == "https://litellm.example.com/callback", data + assert "Authorization" not in headers, headers + assert result.status_code == 200 + assert result.body == b'{"access_token":"tok","token_type":"Bearer"}' + @pytest.mark.asyncio async def test_mcp_token_refresh_grant_never_opens_sealed_code(self): """The minted client is unrecoverable outside the single authorization_code flow by @@ -3841,6 +3930,7 @@ class TestTemporaryMCPSessionEndpoints: refresh_token=None, scope=None, client_token_endpoint_auth_method=None, + sealed_client=False, ) @pytest.mark.asyncio @@ -3895,6 +3985,7 @@ class TestTemporaryMCPSessionEndpoints: refresh_token="rt-123", scope=None, client_token_endpoint_auth_method=None, + sealed_client=False, ) @pytest.mark.asyncio diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index ad75344afba..7cd1107abce 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -4184,6 +4184,39 @@ class TestModelInfoCostMapEchoFilter: assert "litellm_provider" not in info assert "supported_openai_params" not in info + @pytest.mark.parametrize( + ("model", "field", "edited"), + [ + ("bedrock/us.anthropic.claude-sonnet-5-5", "token_count_model", {}), + ("bedrock/us.moonshotai.kimi-k3", "token_count_framing", 0), + ], + ) + def test_token_count_setting_is_stored_only_when_the_operator_changed_it( + self, model: str, field: str, edited: object + ) -> None: + import litellm + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + + entry: Final = litellm.get_model_info(model) + assert entry[field] not in (None, edited), entry + db_model: Final = Deployment( + model_name="count-settings", + litellm_params=LiteLLM_Params(model=model), + model_info=ModelInfo(id="dep-echo-count"), + ) + + echoed: Final = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(model_info=ModelInfo(**{**entry, "id": "dep-echo-count"})), + ) + changed: Final = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(model_info=ModelInfo(**{**entry, "id": "dep-echo-count", field: edited})), + ) + + assert field not in json.loads(echoed["model_info"]) + assert json.loads(changed["model_info"])[field] == edited + def test_metadata_without_a_cost_map_key_is_persisted(self): import litellm diff --git a/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py b/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py index 180ece77a6e..f6c6b51fc93 100644 --- a/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py +++ b/tests/unit/proxy/openai_files_endpoint/test_files_common_utils.py @@ -1,14 +1,20 @@ +from datetime import datetime, timezone from types import MappingProxyType from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles +from litellm import Router from litellm.caching.caching import DualCache +from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.openai_files_endpoints.common_utils import ( + apply_team_provider_credentials, apply_unified_file_ids, get_credentials_for_model, + get_team_provider_credentials, is_litellm_executed_batch, map_raw_file_ids_to_unified, ) @@ -461,9 +467,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( ) -def _completed_batch_for_retire( - output_file_id: str | None, counts: BatchRequestCounts | None = None -) -> LiteLLMBatch: +def _completed_batch_for_retire(output_file_id: str | None, counts: BatchRequestCounts | None = None) -> LiteLLMBatch: kwargs = dict( id="batch-1", completion_window="24h", @@ -652,3 +656,198 @@ async def test_batch_cancel_updates_database(caplog: pytest.LogCaptureFixture): assert "batch_processed" not in written assert json.loads(written["file_object"])["cancelled_at"] == 1234567999 assert f"Updating batch {batch_id} status to cancelled after cancel" in caplog.messages + + +def _team_openai_router(team_public_model_name: str, deployment_model: str) -> Router: + return Router( + model_list=[ + { + "model_name": team_public_model_name, + "litellm_params": {"model": deployment_model, "api_key": "team-openai-key"}, + "model_info": { + "id": "team-a-openai", + "team_id": "team-a", + "team_public_model_name": team_public_model_name, + }, + }, + { + "model_name": "claude-opus-4-6", + "litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "anthropic-key"}, + }, + ] + ) + + +def _team_only_openai_credentials( + team_public_model_name: str, deployment_model: str, key_models: list[str] +) -> dict | None: + return get_team_provider_credentials( + llm_router=_team_openai_router(team_public_model_name, deployment_model), + user_api_key_dict=UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id="team-a", + team_models=[team_public_model_name, "claude-opus-4-6"], + models=key_models, + ), + custom_llm_provider="openai", + include_accessible_deployments=False, + ) + + +@pytest.mark.parametrize( + "team_public_model_name, deployment_model, key_models", + [ + ("openai/*", "openai/*", ["gpt-4o"]), + ("openai/*", "openai/*", ["openai/gpt-4o"]), + ("gpt-4o", "openai/gpt-4o", ["openai/*"]), + ], +) +def test_a_restricted_key_resolves_the_team_deployment_its_wildcard_or_model_covers( + team_public_model_name: str, deployment_model: str, key_models: list[str] +): + credentials = _team_only_openai_credentials(team_public_model_name, deployment_model, key_models) + + assert credentials is not None + assert credentials["api_key"] == "team-openai-key" + + +@pytest.mark.parametrize("key_models", [["claude-opus-4-6"], ["anthropic/*"]]) +def test_a_restricted_key_outside_the_team_wildcard_never_resolves_its_credentials(key_models: list[str]): + assert _team_only_openai_credentials("openai/*", "openai/*", key_models) is None + + +@pytest.mark.parametrize( + "request_credentials, sent_api_key, sent_api_base", + [ + ({}, "team-openai-key", "https://team.example/v1"), + ({"api_key": ""}, "team-openai-key", "https://team.example/v1"), + ({"api_key": " "}, "team-openai-key", "https://team.example/v1"), + ({"api_key": "\n"}, "team-openai-key", "https://team.example/v1"), + ({"api_key": "sk-forwarded"}, "sk-forwarded", "https://team.example/v1"), + ({"api_base": "https://caller.example/v1"}, "team-openai-key", "https://team.example/v1"), + ( + {"api_key": "sk-forwarded", "api_base": "https://caller.example/v1"}, + "sk-forwarded", + "https://caller.example/v1", + ), + ({"api_key": 123}, "team-openai-key", "https://team.example/v1"), + ( + {"api_key": "sk-forwarded", "api_base": ["https://caller.example/v1"]}, + "sk-forwarded", + "https://team.example/v1", + ), + ({"api_key": "sk-forwarded", "api_base": " "}, "sk-forwarded", "https://team.example/v1"), + ({"litellm_credential_name": "no-such-credential"}, "team-openai-key", "https://team.example/v1"), + ({"api_key": "sk-forwarded\n", "drop_params": True}, "team-openai-key", "https://team.example/v1"), + ({"api_key": "sk-for warded"}, "sk-for warded", "https://team.example/v1"), + ], +) +def test_the_team_key_only_goes_to_the_team_host_unless_the_request_brings_its_own_key( + request_credentials: dict[str, object], sent_api_key: str, sent_api_base: str +): + data: Final[dict[str, object]] = dict(request_credentials) + + _apply_team_a_openai_credentials(data) + + assert (data.get("api_key"), data.get("api_base")) == (sent_api_key, sent_api_base) + + +@pytest.mark.parametrize("api_key", ["sk-forwarded\n", " sk-forwarded", "\u200b", "sk-forwarded\u00a0"]) +def test_a_request_key_that_cannot_be_an_http_header_is_a_bad_request(api_key: str): + data: Final[dict[str, object]] = {"api_key": api_key} + + with pytest.raises(HTTPException) as refused: + _apply_team_a_openai_credentials(data) + + assert refused.value.status_code == 400 + assert api_key not in str(refused.value.detail) + + +def _apply_team_a_openai_credentials(data: dict[str, object]) -> None: + apply_team_provider_credentials( + data=data, + llm_router=Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "team-openai-key", + "api_base": "https://team.example/v1", + }, + "model_info": {"id": "team-a-openai", "team_id": "team-a"}, + } + ] + ), + user_api_key_dict=UserAPIKeyAuth( + api_key="test-key", user_id="test-user", team_id="team-a", team_models=["openai/*"], models=["gpt-4o"] + ), + custom_llm_provider="openai", + ) + + +def _team_openai_deployment( + deployment_id: str, api_key: str, created_at: datetime | None = None, db_model: bool = True +) -> dict[str, object]: + return { + "model_name": f"team-gpt-{deployment_id}", + "litellm_params": {"model": "openai/gpt-4o", "api_key": api_key}, + "model_info": {"id": deployment_id, "team_id": "team-a", "db_model": db_model} + | ({} if created_at is None else {"created_at": created_at}), + } + + +@pytest.mark.parametrize("deployment_ids", [("deployment-a", "deployment-b"), ("deployment-b", "deployment-a")]) +def test_a_team_with_two_openai_deployments_resolves_the_same_one_in_any_router_order( + deployment_ids: tuple[str, str], +): + router: Final = Router( + model_list=[_team_openai_deployment(deployment_id, f"{deployment_id}-key") for deployment_id in deployment_ids] + ) + + credentials: Final = get_team_provider_credentials( + llm_router=router, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="team-a"), + custom_llm_provider="openai", + include_accessible_deployments=False, + ) + + assert credentials is not None + assert credentials["api_key"] == "deployment-a-key" + + +@pytest.mark.parametrize("newer_first", [True, False]) +def test_a_team_keeps_its_older_openai_deployment_when_a_newer_one_has_a_lower_id(newer_first: bool): + older: Final = _team_openai_deployment("deployment-z", "older-key", datetime(2026, 1, 1, tzinfo=timezone.utc)) + newer: Final = _team_openai_deployment("deployment-a", "newer-key", datetime(2026, 6, 1, tzinfo=timezone.utc)) + router: Final = Router(model_list=[newer, older] if newer_first else [older, newer]) + + credentials: Final = get_team_provider_credentials( + llm_router=router, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="team-a"), + custom_llm_provider="openai", + include_accessible_deployments=False, + ) + + assert credentials is not None + assert credentials["api_key"] == "older-key" + + +def test_a_team_keeps_its_first_config_file_openai_deployment_over_older_db_ones(): + db_deployment: Final = _team_openai_deployment("deployment-a", "db-key", datetime(2020, 1, 1, tzinfo=timezone.utc)) + config_deployments: Final = tuple( + _team_openai_deployment(deployment_id, f"{deployment_id}-key", db_model=False) + for deployment_id in ("deployment-z", "deployment-b") + ) + router: Final = Router(model_list=[db_deployment, *config_deployments]) + + credentials: Final = get_team_provider_credentials( + llm_router=router, + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="team-a"), + custom_llm_provider="openai", + include_accessible_deployments=False, + ) + + assert credentials is not None + assert credentials["api_key"] == "deployment-z-key" diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 21170abaeb1..ed89cc37544 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -33,6 +33,7 @@ from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException, from litellm.proxy._types import AwsErrorTypeProxyException, ProxyException from litellm.proxy.utils import ProxyLogging from litellm.passthrough.main import raise_for_provider_status +from litellm.llms.bedrock.common_utils import BedrockError from tests.unit.llms.bedrock.event_loop_probe import EventLoopProbe from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( @@ -2137,6 +2138,47 @@ class TestBedrockPassthroughAwsErrors: assert exc_info.value.aws_error_type == "ValidationException" assert (exc_info.value.error.message, exc_info.value.error.code) == (message, status_code) + + @pytest.mark.asyncio + async def test_bedrock_request_check_is_raised_with_the_aws_error_type_it_carries( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(litellm, "callbacks", []) + error: Final = BedrockError( + status_code=400, + message="requestMetadata can contain a maximum of 16 items", + headers={"x-amzn-errortype": "ValidationException"}, + ) + + with pytest.raises(AwsErrorTypeProxyException) as exc_info: + await self._answer(error) + + assert exc_info.value.aws_error_type == "ValidationException" + assert "requestMetadata can contain a maximum of 16 items" in exc_info.value.error.message + assert exc_info.value.error.code == "400" + + @pytest.mark.asyncio + async def test_bedrock_error_without_an_aws_error_type_keeps_the_proxy_shape( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(litellm, "callbacks", []) + + with pytest.raises(ProxyException) as exc_info: + await self._answer(BedrockError(status_code=400, message="bad request")) + + assert exc_info.value.code == "400" + + @pytest.mark.asyncio + async def test_error_with_a_non_ascii_header_value_keeps_the_proxy_shape( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(litellm, "callbacks", []) + error: Final = HTTPException(status_code=403, detail="model not allowed", headers={"x-model-group": "modèle"}) + + with pytest.raises(ProxyException) as exc_info: + await self._answer(error) + + assert (exc_info.value.code, exc_info.value.headers.get("x-model-group")) == ("403", "modèle") @pytest.mark.asyncio @pytest.mark.parametrize( ("error", "message", "code"), diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 51c84618a74..8837fa095e8 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -2381,6 +2381,30 @@ def test_ProxyConfig__load_environment_variables_sets_env(monkeypatch): assert result == {"TEST_LOAD_ENV_X": "hello", "set": True, "len": 1} +def test_ProxyConfig__load_environment_variables_warns_when_the_config_replaces_a_process_value( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + monkeypatch.setenv("TEST_LOAD_ENV_OVERRIDDEN", "from-process") + monkeypatch.setenv("TEST_LOAD_ENV_SAME", "same-value") + monkeypatch.delenv("TEST_LOAD_ENV_FRESH", raising=False) + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + ProxyConfig()._load_environment_variables( + { + "environment_variables": { + "TEST_LOAD_ENV_OVERRIDDEN": "from-config", + "TEST_LOAD_ENV_SAME": "same-value", + "TEST_LOAD_ENV_FRESH": "fresh-value", + } + } + ) + warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING] + assert os.environ["TEST_LOAD_ENV_OVERRIDDEN"] == "from-config" + assert len(warnings) == 1 + assert "TEST_LOAD_ENV_OVERRIDDEN" in warnings[0] + assert "from-process" not in warnings[0] and "from-config" not in warnings[0] + assert "TEST_LOAD_ENV_SAME" not in warnings[0] and "TEST_LOAD_ENV_FRESH" not in warnings[0] + + def test_ProxyConfig__load_environment_variables_blocks_dangerous_keys(monkeypatch): original_path = os.environ.get("PATH", "") pc = ProxyConfig() diff --git a/tests/unit/proxy/proxy_server/test_routes_utils.py b/tests/unit/proxy/proxy_server/test_routes_utils.py index 118b80da31e..f93c23c8ce4 100644 --- a/tests/unit/proxy/proxy_server/test_routes_utils.py +++ b/tests/unit/proxy/proxy_server/test_routes_utils.py @@ -54,6 +54,7 @@ def test_token_counter_happy_path(client, auth_as, patched_token_counter): "request_model": "gpt-4", "model_used": "gpt-4", "tokenizer_type": "openai_tokenizer", + "count_source": "local_estimate", "original_response": None, "error": False, "error_message": None, diff --git a/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py b/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py index 2d654ea28ec..d9f3ceda1ed 100644 --- a/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/unit/proxy/rag_endpoints/test_rag_endpoints.py @@ -1,21 +1,37 @@ """ Tests for RAG proxy endpoints. - -Covers: -- internal_user_viewer restriction: can only ingest to existing vector stores (must provide vector_store_id) """ import io import json +from collections.abc import Iterator, Mapping, Sequence +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest +import respx from fastapi.testclient import TestClient import litellm +from litellm import Router from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import app +from litellm.types.vector_stores import LiteLLM_ManagedVectorStore +from litellm.vector_stores.vector_store_registry import VectorStoreRegistry + +_TEAM_PROVIDER_KEY: Final = "team-openai-key" +_REGISTRY_PROVIDER_KEY: Final = "registry-openai-key" +_ENVIRONMENT_OPENAI_KEY: Final = "environment-openai-key" +_TEAM_ID: Final = "team-a" +_TEAM_OPENAI_BASE: Final = "https://team-openai.example/v1" +_REGISTRY_OPENAI_BASE: Final = "https://registry-openai.example/v1" +_OPENAI_SEARCH_PAGE: Final = {"object": "vector_store.search_results.page", "search_query": "q", "data": []} +_BEDROCK_KNOWLEDGE_BASE_ID: Final = "KB12345678" +_CALLER_AWS_ACCESS_KEY: Final = "caller-access-key" +_TEAM_AWS_ACCESS_KEY: Final = "team-access-key" +_HEADER_UNSAFE_PROVIDER_KEY: Final = "sk-forwarded\u00a0".encode("latin-1") @pytest.fixture @@ -48,31 +64,32 @@ def client_internal_user(): app.dependency_overrides = original_overrides -def test_internal_user_viewer_rag_ingest_without_vector_store_id_rejected( - client_internal_user_viewer, -): - """ - internal_user_viewer cannot create new vector stores - must provide vector_store_id. - """ - # Form upload without vector_store_id (would create new store) - response = client_internal_user_viewer.post( - "/v1/rag/ingest", - files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")}, - data={ - "request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}' - }, - ) +@pytest.fixture +def client_team_a() -> Iterator[TestClient]: + mock_auth: Final = UserAPIKeyAuth(user_id="test_team_user", team_id=_TEAM_ID) + original_overrides: Final = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: mock_auth + try: + yield TestClient(app) + finally: + app.dependency_overrides = original_overrides - assert response.status_code == 403 - detail = response.json() - assert "detail" in detail - error_msg = ( - detail["detail"]["error"] - if isinstance(detail["detail"], dict) - else str(detail["detail"]) - ) - assert "internal_user_viewer" in error_msg - assert "vector_store_id" in error_msg + +def test_internal_user_viewer_rag_ingest_without_vector_store_id_allowed(client_internal_user_viewer): + with patch( + "litellm.proxy.rag_endpoints.endpoints.litellm.aingest", + new_callable=AsyncMock, + return_value={"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) as mock_aingest: + response: Final = client_internal_user_viewer.post( + "/v1/rag/ingest", + files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")}, + data={"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'}, + ) + + assert response.status_code == 200, response.json() + assert response.json()["status"] == "completed" + mock_aingest.assert_awaited_once() def test_internal_user_viewer_rag_ingest_with_vector_store_id_passes_check( @@ -97,8 +114,7 @@ def test_internal_user_viewer_rag_ingest_with_vector_store_id_passes_check( # Should not be 403 (role check passed) assert response.status_code != 403, ( - f"internal_user_viewer with vector_store_id should pass role check. " - f"Response: {response.json()}" + f"internal_user_viewer with vector_store_id should pass role check. Response: {response.json()}" ) @@ -114,15 +130,12 @@ def test_internal_user_rag_ingest_without_vector_store_id_allowed(client_interna response = client_internal_user.post( "/v1/rag/ingest", files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")}, - data={ - "request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}' - }, + data={"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'}, ) # Should not be 403 assert response.status_code != 403, ( - f"internal_user should be allowed to create new vector stores. " - f"Response: {response.json()}" + f"internal_user should be allowed to create new vector stores. Response: {response.json()}" ) @@ -170,13 +183,13 @@ def test_rag_ingest_blocks_clientside_credentials(client_internal_user, blocked_ }, }, ) - assert ( - response.status_code == 400 - ), f"Expected 400 when '{blocked_field}' is set clientside, got {response.status_code}: {response.json()}" + assert response.status_code == 400, ( + f"Expected 400 when '{blocked_field}' is set clientside, got {response.status_code}: {response.json()}" + ) body = response.json() - assert blocked_field in str( - body - ), f"Response should mention '{blocked_field}': {body}" + assert blocked_field in str(body), f"Response should mention '{blocked_field}': {body}" + + class TestRagIngestSSRFBlocked: """ aws_sts_endpoint and related credential-redirect fields must be rejected @@ -193,9 +206,7 @@ class TestRagIngestSSRFBlocked: ("aws_bedrock_runtime_endpoint", "https://attacker.example/bedrock"), ], ) - def test_ssrf_field_in_vector_store_config_rejected( - self, field, value, client_internal_user - ): + def test_ssrf_field_in_vector_store_config_rejected(self, field, value, client_internal_user): payload = { "file_url": "https://example.com/doc.pdf", "ingest_options": { @@ -215,9 +226,7 @@ class TestRagIngestSSRFBlocked: ) body = response.json() detail = body.get("detail", {}) - error_text = ( - detail.get("error", "") if isinstance(detail, dict) else str(detail) - ) + error_text = detail.get("error", "") if isinstance(detail, dict) else str(detail) assert field in error_text, f"Error should name the offending field: {error_text}" def test_clean_bedrock_ingest_options_not_rejected(self, client_internal_user): @@ -230,14 +239,10 @@ class TestRagIngestSSRFBlocked: "/v1/rag/ingest", json={ "file_url": "https://example.com/doc.pdf", - "ingest_options": { - "vector_store": {"custom_llm_provider": "bedrock"} - }, + "ingest_options": {"vector_store": {"custom_llm_provider": "bedrock"}}, }, ) - assert response.status_code != 400, ( - f"Clean Bedrock ingest_options should not be rejected: {response.json()}" - ) + assert response.status_code != 400, f"Clean Bedrock ingest_options should not be rejected: {response.json()}" S3_REGISTRY_STORE = { @@ -292,10 +297,20 @@ def _registry_with(store): return registry -def _ingest_form(vector_store): +def _ingest_form(vector_store, **request_fields): return { "files": {"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")}, - "data": {"request": json.dumps({"ingest_options": {"vector_store": vector_store}})}, + "data": {"request": json.dumps({"ingest_options": {"vector_store": vector_store}, **request_fields})}, + } + + +def _ingest_json(vector_store, **request_fields): + return { + "json": { + "file_url": "https://example.com/doc.txt", + "ingest_options": {"vector_store": vector_store}, + **request_fields, + } } @@ -320,6 +335,557 @@ def _patched_prisma_client(prisma_client): ) +def _team_provider_router() -> Router: + return Router( + model_list=[ + { + "model_name": "openai/team-provider", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": _TEAM_PROVIDER_KEY, + "api_base": _TEAM_OPENAI_BASE, + }, + "model_info": {"team_id": _TEAM_ID}, + }, + _completion_deployment(), + ] + ) + + +def _completion_deployment() -> dict[str, object]: + return { + "model_name": "gpt-4o-mini", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "completion-key", + "mock_response": "hello", + }, + } + + +def _team_bedrock_router() -> Router: + return Router( + model_list=[ + { + "model_name": "bedrock/team-provider", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_region_name": "us-west-2", + "aws_access_key_id": _TEAM_AWS_ACCESS_KEY, + "aws_secret_access_key": "team-secret-key", + }, + "model_info": {"team_id": _TEAM_ID}, + }, + _completion_deployment(), + ] + ) + + +def _managed_openai_store(vector_store_id: str, litellm_params: Mapping[str, object]) -> LiteLLM_ManagedVectorStore: + return LiteLLM_ManagedVectorStore( + vector_store_id=vector_store_id, + custom_llm_provider="openai", + litellm_credential_name=None, + litellm_params=dict(litellm_params), + ) + + +def _post_rag_query( + client: TestClient, + *, + retrieval_config: Mapping[str, object], + managed_stores: Sequence[LiteLLM_ManagedVectorStore], + router: Router, + headers: Mapping[str, str | bytes] | None = None, + body: Mapping[str, object] | None = None, +) -> httpx.Response: + with ( + patch.object( # test-quality-ok: seeds the real managed-store registry the query path reads + litellm, + "vector_store_registry", + VectorStoreRegistry(vector_stores=list(managed_stores)), + ), + patch("litellm.proxy.proxy_server.llm_router", router), + _patched_prisma_client(None), + ): + return client.post( + "/v1/rag/query", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "retrieval_config": dict(retrieval_config), + **(body or {}), + }, + headers=dict(headers or {}), + ) + + +def test_rag_ingest_forwards_team_provider_credentials_without_persisting_them(client_team_a): + router: Final = _team_provider_router() + prisma_client: Final = MagicMock() + managed_vector_store_table: Final = prisma_client.db.litellm_managedvectorstorestable + managed_vector_store_table.find_unique = AsyncMock(return_value=None) + created_row: Final = MagicMock() + created_row.model_dump.return_value = { + "vector_store_id": "vs_new", + "custom_llm_provider": "openai", + "litellm_params": "{}", + "vector_store_metadata": "{}", + } + managed_vector_store_table.create = AsyncMock(return_value=created_row) + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", router), + _patched_prisma_client(prisma_client), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + **_ingest_form({"custom_llm_provider": "openai"}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == _TEAM_PROVIDER_KEY + assert forwarded["api_base"] == _TEAM_OPENAI_BASE + managed_vector_store_table.create.assert_awaited_once() + saved_data: Final = managed_vector_store_table.create.await_args.kwargs["data"] + assert saved_data["vector_store_id"] == "vs_new" + assert _TEAM_PROVIDER_KEY not in json.dumps(saved_data, default=str) + + +def test_rag_ingest_uses_the_forwarded_provider_key_over_team_provider_credentials(client_team_a): + forwarded_provider_key: Final = "sk-forwarded-provider-key" + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": forwarded_provider_key}, + **_ingest_form({"custom_llm_provider": "openai"}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == forwarded_provider_key + assert forwarded["api_base"] == _TEAM_OPENAI_BASE + + +@pytest.mark.parametrize("ingest_request", [_ingest_form, _ingest_json]) +def test_rag_ingest_drop_params_drops_a_forwarded_key_that_cannot_be_an_http_header(client_team_a, ingest_request): + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY}, + **ingest_request({"custom_llm_provider": "openai"}, drop_params=True), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == _TEAM_PROVIDER_KEY + assert forwarded["api_base"] == _TEAM_OPENAI_BASE + + +@pytest.mark.parametrize("unread_request_field", ["not json", json.dumps({"drop_params": False})]) +def test_rag_ingest_json_body_ignores_a_request_field_it_does_not_read(client_team_a, unread_request_field): + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY}, + **_ingest_json({"custom_llm_provider": "openai"}, drop_params=True, request=unread_request_field), + ) + + assert response.status_code == 200, response.json() + assert mock_aingest.await_args.kwargs["ingest_options"]["vector_store"]["api_key"] == _TEAM_PROVIDER_KEY + + +def test_rag_ingest_caller_credential_name_suppresses_team_provider_credentials(client_team_a): + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + **_ingest_form({"custom_llm_provider": "openai", "litellm_credential_name": "caller-openai"}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["litellm_credential_name"] == "caller-openai" + assert "api_key" not in forwarded + + +def test_rag_ingest_managed_store_credentials_override_team_provider_credentials(client_team_a): + managed_store: Final = _managed_openai_store("registry-store-with-key", {"api_key": _REGISTRY_PROVIDER_KEY}) + aingest_patch, registry_patch = _patched_ingest_boundary( + managed_store, + {"status": "completed", "vector_store_id": "registry-store-with-key", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + patch( + "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", + new=AsyncMock(return_value=True), + ), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + **_ingest_form({"vector_store_id": "registry-store-with-key"}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == _REGISTRY_PROVIDER_KEY + assert forwarded["api_key"] != _TEAM_PROVIDER_KEY + + +def test_rag_ingest_managed_store_without_credentials_uses_team_provider_credentials(client_team_a): + managed_store: Final = _managed_openai_store("registry-store-without-key", {}) + aingest_patch, registry_patch = _patched_ingest_boundary( + managed_store, + {"status": "completed", "vector_store_id": "registry-store-without-key", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + patch( + "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", + new=AsyncMock(return_value=True), + ), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + **_ingest_form({"vector_store_id": "registry-store-without-key"}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == _TEAM_PROVIDER_KEY + + +@pytest.mark.parametrize("blank_credential_name", [None, "", " "]) +def test_rag_ingest_blank_caller_credential_name_uses_team_provider_credentials(client_team_a, blank_credential_name): + aingest_patch, registry_patch = _patched_ingest_boundary( + None, + {"status": "completed", "vector_store_id": "vs_new", "file_id": "file_123"}, + ) + + with ( + aingest_patch as mock_aingest, + registry_patch, + patch("litellm.proxy.proxy_server.llm_router", _team_provider_router()), + _patched_prisma_client(None), + ): + response: Final = client_team_a.post( + "/v1/rag/ingest", + **_ingest_form({"custom_llm_provider": "openai", "litellm_credential_name": blank_credential_name}), + ) + + assert response.status_code == 200, response.json() + forwarded: Final = mock_aingest.await_args.kwargs["ingest_options"]["vector_store"] + assert forwarded["api_key"] == _TEAM_PROVIDER_KEY + assert forwarded["api_base"] == _TEAM_OPENAI_BASE + + +def test_rag_ingest_failed_upstream_status_is_returned_without_persisting_store(client_internal_user): + prisma_client: Final = MagicMock() + managed_vector_store_table: Final = prisma_client.db.litellm_managedvectorstorestable + managed_vector_store_table.find_unique = AsyncMock(return_value=None) + managed_vector_store_table.create = AsyncMock() + with ( + patch( + "litellm.proxy.rag_endpoints.endpoints.litellm.aingest", + new=AsyncMock( + return_value={ + "id": "ingest_abc", + "status": "failed", + "error": "unauthorized", + "error_status_code": 401, + } + ), + ), + patch("litellm.vector_store_registry", None), + _patched_prisma_client(prisma_client), + ): + response: Final = client_internal_user.post( + "/v1/rag/ingest", + **_ingest_form({"custom_llm_provider": "openai"}), + ) + + assert response.status_code == 401, response.json() + assert response.json() == {"detail": {"id": "ingest_abc", "status": "failed", "error": "unauthorized"}} + managed_vector_store_table.create.assert_not_called() + + +@pytest.mark.usefixtures("httpx_transport") +@pytest.mark.parametrize( + "caller_fields", + [ + {}, + {"api_key": None}, + {"api_base": None}, + {"api_key": "sk-caller-key-the-search-never-uses"}, + {"custom_llm_provider": None}, + ], +) +def test_rag_query_forwards_team_provider_credentials_to_unmanaged_search( + client_team_a: TestClient, respx_mock: respx.MockRouter, caller_fields: Mapping[str, object] +) -> None: + search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai", **caller_fields}, + managed_stores=(), + router=_team_provider_router(), + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}" + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_managed_store_credentials_override_team_provider_credentials( + client_team_a: TestClient, respx_mock: respx.MockRouter +) -> None: + search: Final = respx_mock.post(f"{_REGISTRY_OPENAI_BASE}/vector_stores/registry-store-with-key/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "registry-store-with-key"}, + managed_stores=( + _managed_openai_store( + "registry-store-with-key", + {"api_key": _REGISTRY_PROVIDER_KEY, "api_base": _REGISTRY_OPENAI_BASE}, + ), + ), + router=_team_provider_router(), + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_REGISTRY_PROVIDER_KEY}" + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_managed_store_without_credentials_uses_team_provider_credentials( + client_team_a: TestClient, respx_mock: respx.MockRouter +) -> None: + search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/registry-store-without-key/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "registry-store-without-key"}, + managed_stores=(_managed_openai_store("registry-store-without-key", {}),), + router=_team_provider_router(), + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}" + + +@pytest.mark.usefixtures("httpx_transport") +@pytest.mark.parametrize("blank_key", ["", " "]) +def test_rag_query_managed_store_with_an_empty_key_uses_team_provider_credentials( + client_team_a: TestClient, respx_mock: respx.MockRouter, blank_key: str +) -> None: + search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/registry-store-blank-key/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "registry-store-blank-key"}, + managed_stores=(_managed_openai_store("registry-store-blank-key", {"api_key": blank_key}),), + router=_team_provider_router(), + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}" + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_without_a_team_deployment_keeps_the_environment_openai_credentials( + client_internal_user: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY) + for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"): + monkeypatch.delenv(unset, raising=False) + search: Final = respx_mock.post("https://api.openai.com/v1/vector_stores/provider-native-store/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + response: Final = _post_rag_query( + client_internal_user, + retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"}, + managed_stores=(), + router=_team_provider_router(), + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_ENVIRONMENT_OPENAI_KEY}" + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_search_uses_the_forwarded_provider_key_over_team_provider_credentials( + client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + forwarded_provider_key: Final = "sk-forwarded-provider-key" + monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY) + for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"): + monkeypatch.delenv(unset, raising=False) + search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + with patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}): + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"}, + managed_stores=(), + router=_team_provider_router(), + headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": forwarded_provider_key}, + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {forwarded_provider_key}" + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_drop_params_drops_a_forwarded_key_that_cannot_be_an_http_header( + client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", _ENVIRONMENT_OPENAI_KEY) + for unset in ("OPENAI_BASE_URL", "OPENAI_API_BASE"): + monkeypatch.delenv(unset, raising=False) + search: Final = respx_mock.post(f"{_TEAM_OPENAI_BASE}/vector_stores/provider-native-store/search").respond( + json=_OPENAI_SEARCH_PAGE + ) + + with patch.dict("litellm.proxy.proxy_server.general_settings", {"forward_llm_provider_auth_headers": True}): + response: Final = _post_rag_query( + client_team_a, + retrieval_config={"vector_store_id": "provider-native-store", "custom_llm_provider": "openai"}, + managed_stores=(), + router=_team_provider_router(), + headers={"Authorization": "Bearer sk-litellm-virtual-key", "x-api-key": _HEADER_UNSAFE_PROVIDER_KEY}, + body={"drop_params": True}, + ) + + assert response.status_code == 200, response.json() + assert search.call_count == 1 + assert search.calls.last.request.headers["authorization"] == f"Bearer {_TEAM_PROVIDER_KEY}" + + +@pytest.mark.parametrize( + "retrieval_config, error", + [ + ({"custom_llm_provider": "openai"}, "retrieval_config must contain 'vector_store_id'"), + ({"vector_store_id": 7, "custom_llm_provider": "openai"}, "vector_store_id must be a non-empty string"), + ], +) +def test_rag_query_names_what_is_wrong_with_the_vector_store_id( + client_team_a: TestClient, retrieval_config: Mapping[str, object], error: str +) -> None: + response: Final = _post_rag_query( + client_team_a, retrieval_config=retrieval_config, managed_stores=(), router=_team_provider_router() + ) + + assert response.status_code == 400, response.json() + assert error in response.text + + +@pytest.mark.usefixtures("httpx_transport") +def test_rag_query_bedrock_search_keeps_the_callers_region_and_credentials_over_team_deployment( + client_team_a: TestClient, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AWS_ACCESS_KEY_ID", _CALLER_AWS_ACCESS_KEY) + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "caller-secret-key") + for unset in ("AWS_BEARER_TOKEN_BEDROCK", "AWS_SESSION_TOKEN", "AWS_PROFILE", "AWS_REGION_NAME", "AWS_REGION"): + monkeypatch.delenv(unset, raising=False) + retrieve: Final = respx_mock.post( + url__regex=rf"^https://bedrock-agent-runtime\.[a-z0-9-]+\.amazonaws\.com/knowledgebases/{_BEDROCK_KNOWLEDGE_BASE_ID}/retrieve$" + ).respond(json={"retrievalResults": []}) + + response: Final = _post_rag_query( + client_team_a, + retrieval_config={ + "vector_store_id": _BEDROCK_KNOWLEDGE_BASE_ID, + "custom_llm_provider": "bedrock", + "aws_region_name": "us-east-1", + }, + managed_stores=(), + router=_team_bedrock_router(), + ) + + assert response.status_code == 200, response.json() + assert retrieve.call_count == 1 + sent: Final = retrieve.calls.last.request + assert sent.url.host == "bedrock-agent-runtime.us-east-1.amazonaws.com" + assert f"Credential={_CALLER_AWS_ACCESS_KEY}/" in sent.headers["authorization"] + + def test_rag_ingest_resolves_registry_store_provider_and_params(client_internal_user): aingest_patch, registry_patch = _patched_ingest_boundary( S3_REGISTRY_STORE, {"vector_store_id": "s3-store", "file_id": "file_123"} @@ -703,12 +1269,14 @@ def test_rag_query_returns_response_cost_header(client_internal_user): ) mock_response._hidden_params["response_cost"] = 3.45e-06 - with patch( - "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", - new_callable=AsyncMock, - return_value=mock_response, - ), patch("litellm.vector_store_registry", None), patch( - "litellm.proxy.proxy_server.prisma_client", None + with ( + patch( + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new_callable=AsyncMock, + return_value=mock_response, + ), + patch("litellm.vector_store_registry", None), + patch("litellm.proxy.proxy_server.prisma_client", None), ): response = client_internal_user.post( "/v1/rag/query", @@ -742,7 +1310,9 @@ def test_rag_query_surfaces_upstream_status_code(client_internal_user, upstream_ new=AsyncMock(side_effect=upstream_error), ), patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam - patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + patch( + "litellm.proxy.proxy_server.prisma_client", None + ), # test-quality-ok: proxy module global, no injection seam ): response = client_internal_user.post( "/v1/rag/query", @@ -779,10 +1349,14 @@ def test_rag_query_stream_returns_event_stream(client_internal_user): api_key="test-key", ) - with patch( - "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", - new=AsyncMock(side_effect=fake_aquery), - ), patch("litellm.vector_store_registry", None), patch("litellm.proxy.proxy_server.prisma_client", None): + with ( + patch( + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new=AsyncMock(side_effect=fake_aquery), + ), + patch("litellm.vector_store_registry", None), + patch("litellm.proxy.proxy_server.prisma_client", None), + ): response = client_internal_user.post( "/v1/rag/query", json={ @@ -825,7 +1399,9 @@ def test_rag_query_stream_pings_while_retrieval_is_still_running(client_internal new=AsyncMock(side_effect=slow_aquery), ), patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam - patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + patch( + "litellm.proxy.proxy_server.prisma_client", None + ), # test-quality-ok: proxy module global, no injection seam ): response = client_internal_user.post( "/v1/rag/query", @@ -849,9 +1425,7 @@ def test_rag_query_stream_pings_while_retrieval_is_still_running(client_internal assert response.text.endswith("data: [DONE]\n\n") -def test_rag_query_stream_keeps_response_headers_when_retrieval_beats_the_keepalive( - client_internal_user, monkeypatch -): +def test_rag_query_stream_keeps_response_headers_when_retrieval_beats_the_keepalive(client_internal_user, monkeypatch): import litellm as litellm_module monkeypatch.setattr(litellm_module, "sse_keepalive_ping_interval_seconds", 5) @@ -873,7 +1447,9 @@ def test_rag_query_stream_keeps_response_headers_when_retrieval_beats_the_keepal new=AsyncMock(side_effect=fast_aquery), ), patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam - patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + patch( + "litellm.proxy.proxy_server.prisma_client", None + ), # test-quality-ok: proxy module global, no injection seam ): response = client_internal_user.post( "/v1/rag/query", @@ -925,13 +1501,17 @@ def test_rag_query_merges_managed_store_params(client_internal_user): model="gpt-4o-mini", ) - with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts - "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", - new_callable=AsyncMock, - return_value=mock_response, - ) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs - "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", - new=AsyncMock(return_value=True), + with ( + patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_aquery, + patch.object(litellm, "vector_store_registry", mock_registry), + patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs + "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", + new=AsyncMock(return_value=True), + ), ): response = client_internal_user.post( "/v1/rag/query", @@ -971,13 +1551,17 @@ def test_rag_query_store_params_win_over_user_retrieval_config(client_internal_u model="gpt-4o-mini", ) - with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts - "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", - new_callable=AsyncMock, - return_value=mock_response, - ) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs - "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", - new=AsyncMock(return_value=True), + with ( + patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_aquery, + patch.object(litellm, "vector_store_registry", mock_registry), + patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs + "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", + new=AsyncMock(return_value=True), + ), ): response = client_internal_user.post( "/v1/rag/query", @@ -1164,9 +1748,7 @@ class TestVectorStoreUploadControls: content=b"benign document text\n", content_type="text/plain", ) - _options, file_data, _url, _file_id = await parse_rag_ingest_request( - request, scanner=EicarTestMalwareScanner() - ) + _options, file_data, _url, _file_id = await parse_rag_ingest_request(request, scanner=EicarTestMalwareScanner()) assert file_data is not None server_filename, content_bytes, secured_content_type = file_data assert server_filename != "../../etc/passwd" diff --git a/tests/unit/proxy/response_api_endpoints/test_endpoints.py b/tests/unit/proxy/response_api_endpoints/test_endpoints.py index a7b955f5b84..fc329eaec5f 100644 --- a/tests/unit/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/unit/proxy/response_api_endpoints/test_endpoints.py @@ -480,7 +480,12 @@ def _advertised_cost_split(headers: Mapping[str, str]) -> list[tuple[float, floa @pytest.mark.asyncio @pytest.mark.parametrize( "background, stored, status, expected_billed_events", - [(True, True, "completed", 1), (True, True, "in_progress", 0), (True, False, "completed", 0), (False, True, "completed", 0)], + [ + (True, True, "completed", 1), + (True, True, "in_progress", 0), + (True, False, "completed", 0), + (False, True, "completed", 0), + ], ) async def test_reading_a_finished_background_response_bills_it( monkeypatch: pytest.MonkeyPatch, background: bool, stored: bool, status: str, expected_billed_events: int @@ -531,8 +536,12 @@ async def test_reading_a_finished_background_response_bills_it( prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=1) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", MagicMock()), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", MagicMock() + ), # test-quality-ok: the endpoint reads the proxy singletons at call time patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")), ): await _bill_finished_background_response( @@ -662,9 +671,15 @@ async def _read_twice_on_two_pods( router.aget_responses = AsyncMock(side_effect=AssertionError("billing must not read the provider again")) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", router + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging + ), # test-quality-ok: the endpoint reads the proxy singletons at call time patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")), ): await asyncio.gather( @@ -702,7 +717,9 @@ async def test_a_finished_response_the_create_could_not_store_is_billed_once_und monkeypatch, table, response_id, - UserAPIKeyAuth(api_key="sk-reader", user_id="bob", team_id="team-beta", org_id="org-gamma", end_user_id="customer-42"), + UserAPIKeyAuth( + api_key="sk-reader", user_id="bob", team_id="team-beta", org_id="org-gamma", end_user_id="customer-42" + ), ) (billed,) = payloads @@ -746,7 +763,9 @@ async def test_a_finished_read_never_bills_a_response_already_billed_or_billed_b ) table: Final = _UniqueManagedObjectTable(stored) - payloads: Final = await _read_twice_on_two_pods(monkeypatch, table, response_id, UserAPIKeyAuth(api_key="sk-reader")) + payloads: Final = await _read_twice_on_two_pods( + monkeypatch, table, response_id, UserAPIKeyAuth(api_key="sk-reader") + ) assert payloads == () assert table.rows == stored @@ -878,9 +897,15 @@ async def test_a_finished_read_or_cancel_bills_usage_only_the_configured_deploym "litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing", return_value=processor, ), - patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", stored.prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", router + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging + ), # test-quality-ok: the endpoint reads the proxy singletons at call time patch("litellm.aget_responses", side_effect=AssertionError("the configured deployment answers billing reads")), ): await getattr(endpoints, endpoint_name)( @@ -941,9 +966,15 @@ async def test_deleting_a_finished_unbilled_background_response_bills_it_before_ "litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing", return_value=processor, ), - patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", stored.prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", router + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging + ), # test-quality-ok: the endpoint reads the proxy singletons at call time ): result: Final = await delete_response( response_id=stored.response_id, @@ -989,7 +1020,9 @@ async def test_a_delete_the_proxy_refuses_never_fetches_or_bills_the_response(ca caller: Final = UserAPIKeyAuth(api_key="sk-caller", user_role=caller_role) async def pipeline(**_: object) -> DeleteResponseResult: - await security.async_pre_call_hook(caller, MagicMock(), {"response_id": stored.response_id}, "adelete_responses") + await security.async_pre_call_hook( + caller, MagicMock(), {"response_id": stored.response_id}, "adelete_responses" + ) return DeleteResponseResult(id=stored.response_id, object="response", deleted=True) processor: Final = MagicMock() @@ -1002,9 +1035,15 @@ async def test_a_delete_the_proxy_refuses_never_fetches_or_bills_the_response(ca "litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing", return_value=processor, ), - patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", stored.prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", router + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging + ), # test-quality-ok: the endpoint reads the proxy singletons at call time ): with contextlib.suppress(HTTPException): await delete_response( @@ -1058,9 +1097,15 @@ def _capture_spend_events(monkeypatch: pytest.MonkeyPatch) -> _SpendEvents: @contextlib.contextmanager def _proxy_over(prisma_client: MagicMock, proxy_logging: MagicMock, router: MagicMock): with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time - patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.llm_router", router + ), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging + ), # test-quality-ok: the endpoint reads the proxy singletons at call time ): yield @@ -1211,7 +1256,9 @@ async def test_a_streamed_background_create_is_billed_wherever_it_sets_backgroun "litellm.proxy.response_polling.polling_handler.should_use_polling_for_request", return_value=False, ), - patch("litellm.proxy.proxy_server.select_data_generator", provider_events), # test-quality-ok: the endpoint reads the proxy singletons at call time + patch( + "litellm.proxy.proxy_server.select_data_generator", provider_events + ), # test-quality-ok: the endpoint reads the proxy singletons at call time ): await responses_api( request=_request_for("POST", "/v1/responses", body), @@ -1329,7 +1376,12 @@ async def test_background_create_the_provider_confirms_is_stored_under_its_provi } request = Request( - {"type": "http", "method": "POST", "path": "/v1/responses", "headers": [(b"content-type", b"application/json")]}, + { + "type": "http", + "method": "POST", + "path": "/v1/responses", + "headers": [(b"content-type", b"application/json")], + }, receive, ) @@ -1918,9 +1970,327 @@ class TestResponsesWSFirstFrameModelAuth: assert routed["input"] == replayed_input assert routed["previous_response_id"] == "resp_prev" assert processor.common_processing_pre_call_logic.await_args.kwargs["model"] == "gpt-4o-mini" + assert processor.common_processing_pre_call_logic.await_args.kwargs["skip_guardrails"] is True + assert callable(routed["responses_ws_frame_guard"]) assert mock_route_request.await_args.kwargs["route_type"] == "_aresponses_websocket" ws.close.assert_not_awaited() + @pytest.mark.asyncio + async def test_frame_guard_runs_the_guardrail_walk_on_the_frame_body_and_returns_its_records(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + assert call_type == "aresponses" and guardrails_only is True + assert "type" not in data and data["model"] == "gpt-4o-mini" + assert data["litellm_metadata"]["user_api_key_hash"] == "hash" + assert "standard_logging_guardrail_information" not in data["litellm_metadata"] + data["litellm_metadata"]["standard_logging_guardrail_information"] = [{"guardrail_name": "turn"}] + return {**data, "input": data["input"] + " [guarded]"} + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + admitted = { + "model": "gpt-4o-mini", + "litellm_metadata": { + "user_api_key_hash": "hash", + "standard_logging_guardrail_information": [{"guardrail_name": "admission"}], + }, + } + guard = _responses_ws_frame_guard(admitted, MagicMock(), proxy_logging_obj) + + first = await guard(json.dumps({"type": "response.create", "model": "alias", "input": "hi"})) + second = await guard(json.dumps({"type": "response.create", "input": "again"})) + + assert json.loads(first.frame) == {"type": "response.create", "model": "alias", "input": "hi [guarded]"} + assert first.guardrail_information == ({"guardrail_name": "turn"},) + assert json.loads(second.frame)["input"] == "again [guarded]" + assert admitted["litellm_metadata"]["standard_logging_guardrail_information"] == [ + {"guardrail_name": "admission"} + ] + + @pytest.mark.asyncio + async def test_frame_guard_bills_a_rejected_frame_as_a_failed_request(self): + import litellm + from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + data["litellm_metadata"]["standard_logging_guardrail_information"] = [{"guardrail_cost": 0.5}] + raise ValueError("blocked") + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + proxy_logging_obj.post_call_failure_hook = AsyncMock() + spend_logger = MagicMock(spec=ProxyDBLogger) + spend_logger.async_post_call_failure_hook = AsyncMock() + rate_limiter = MagicMock() + rate_limiter.async_post_call_failure_hook = AsyncMock() + user_api_key_dict = MagicMock() + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, user_api_key_dict, proxy_logging_obj + ) + + with ( + patch.object(litellm, "callbacks", [rate_limiter, spend_logger]), + pytest.raises(ValueError, match="blocked"), + ): + await guard(json.dumps({"type": "response.create", "input": "hi"})) + + proxy_logging_obj.post_call_failure_hook.assert_not_awaited() + rate_limiter.async_post_call_failure_hook.assert_not_awaited() + spend_logger.async_post_call_failure_hook.assert_awaited_once() + failure = spend_logger.async_post_call_failure_hook.await_args.kwargs + user_api_key_dict.model_copy.assert_called_once_with(update={"budget_reservation": None}) + assert failure["user_api_key_dict"] is user_api_key_dict.model_copy.return_value + assert str(failure["original_exception"]) == "blocked" + assert failure["request_data"]["litellm_metadata"]["standard_logging_guardrail_information"] == [ + {"guardrail_cost": 0.5} + ] + assert failure["request_data"]["input"] == "hi" + assert len(failure["request_data"]["litellm_call_id"]) == 36 + + @pytest.mark.asyncio + async def test_frame_guard_guards_the_nested_response_body_and_copies_back_tools(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + return {**data, "input": data["input"] + " [guarded]", "tools": data["tools"][:1]} + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + tools = [{"type": "function", "name": "a"}, {"type": "function", "name": "b"}] + + guarded = await guard(json.dumps({"type": "response.create", "response": {"input": "hi", "tools": tools}})) + + assert proxy_logging_obj.pre_call_hook.await_args.kwargs["data"]["input"] == "hi" + assert json.loads(guarded.frame) == { + "type": "response.create", + "response": {"input": "hi [guarded]", "tools": tools[:1]}, + } + + @pytest.mark.asyncio + async def test_frame_guard_strips_the_proxy_owned_metadata_keys_a_client_sends(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + metadata = { + "user_api_key_metadata": {"disable_global_guardrails": True}, + "disable_global_guardrails": True, + "headers": {"x-litellm-tags": "forged"}, + "tag": "kept", + } + + await guard(json.dumps({"type": "response.create", "input": "hi", "metadata": metadata})) + + assert proxy_logging_obj.pre_call_hook.await_args.kwargs["data"]["metadata"] == {"tag": "kept"} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "frame", + [ + {"type": "response.create", "input": "forbidden", "guardrails": []}, + {"type": "response.create", "input": "forbidden", "metadata": {"guardrails": []}}, + {"type": "response.create", "response": {"input": "forbidden", "guardrails": [], "policies": []}}, + ], + ) + async def test_frame_guard_keeps_the_admitted_guardrail_list_when_a_frame_tries_to_replace_it(self, frame): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {"guardrails": ["key-required"]}}, + MagicMock(), + proxy_logging_obj, + ) + + await guard(json.dumps(frame)) + + scanned = proxy_logging_obj.pre_call_hook.await_args.kwargs["data"] + assert "guardrails" not in scanned and "policies" not in scanned + assert "guardrails" not in (scanned.get("metadata") or {}) + assert scanned["litellm_metadata"]["guardrails"] == ["key-required"] + + @pytest.mark.asyncio + async def test_frame_guard_copies_an_admitted_bucket_that_holds_the_otel_parent_span(self): + import threading + + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + span = threading.RLock() + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {"litellm_parent_otel_span": span}}, + MagicMock(), + proxy_logging_obj, + ) + + guarded = await guard(json.dumps({"type": "response.create", "input": "hi"})) + + assert json.loads(guarded.frame) == {"type": "response.create", "input": "hi"} + assert ( + proxy_logging_obj.pre_call_hook.await_args.kwargs["data"]["litellm_metadata"]["litellm_parent_otel_span"] + is span + ) + + @pytest.mark.asyncio + async def test_frame_guard_rejects_a_turn_a_guardrail_rerouted_to_another_model(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + data["litellm_metadata"]["standard_logging_guardrail_information"] = [{"guardrail_cost": 0.5}] + return {**data, "model": "onprem-llama"} + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + spend_logger = MagicMock(spec=ProxyDBLogger) + spend_logger.async_post_call_failure_hook = AsyncMock() + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + + with ( + patch.object(litellm, "callbacks", [spend_logger]), + pytest.raises(ValueError, match="rerouted this turn to 'onprem-llama'"), + ): + await guard(json.dumps({"type": "response.create", "input": "my SSN is 123-45-6789"})) + + failure = spend_logger.async_post_call_failure_hook.await_args.kwargs + assert "rerouted this turn to 'onprem-llama'" in str(failure["original_exception"]) + assert failure["request_data"]["litellm_metadata"]["standard_logging_guardrail_information"] == [ + {"guardrail_cost": 0.5} + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("nested", [False, True]) + async def test_frame_guard_drops_a_client_s_guardrail_cost_records_so_a_blocked_frame_bills_the_real_scan( + self, nested: bool + ): + import litellm + from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + data["litellm_metadata"]["standard_logging_guardrail_information"] = [{"guardrail_cost": 0.5}] + raise ValueError("blocked") + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + spend_logger = MagicMock(spec=ProxyDBLogger) + spend_logger.async_post_call_failure_hook = AsyncMock() + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + body = { + "input": "forbidden", + "metadata": {"trace": "t1", "standard_logging_guardrail_information": [], "model_info": {"x": 1}}, + } + frame = {"type": "response.create", "response": body} if nested else {"type": "response.create", **body} + + with patch.object(litellm, "callbacks", [spend_logger]), pytest.raises(ValueError, match="blocked"): + await guard(json.dumps(frame)) + + billed = spend_logger.async_post_call_failure_hook.await_args.kwargs["request_data"] + assert billed["metadata"] == {"trace": "t1"} + assert billed["litellm_metadata"]["standard_logging_guardrail_information"] == [{"guardrail_cost": 0.5}] + + @pytest.mark.asyncio + @pytest.mark.parametrize("nested", [False, True]) + async def test_frame_guard_forwards_a_frame_without_the_instructions_a_guardrail_deleted(self, nested: bool): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + return {key: value for key, value in data.items() if key != "instructions"} + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + body = {"instructions": "SECRET system prompt", "input": "hi", "store": False} + frame = {"type": "response.create", "response": body} if nested else {"type": "response.create", **body} + + with patch.object(litellm, "callbacks", []): + guarded = await guard(json.dumps(frame)) + + forwarded = json.loads(guarded.frame) + assert forwarded["type"] == "response.create" + sent_body = forwarded["response"] if nested else {k: v for k, v in forwarded.items() if k != "type"} + assert sent_body == {"input": "hi", "store": False} + + @pytest.mark.asyncio + async def test_frame_guard_hands_back_the_pii_tokens_presidio_masked_with_and_drops_forged_ones(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + async def pre_call_hook(user_api_key_dict, data, call_type, guardrails_only): + assert data["metadata"] == {"trace": "t1"}, "a client cannot seed pii_tokens" + data["metadata"]["pii_tokens"] = {"": "alice@example.com"} + return {**data, "input": "email please"} + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=pre_call_hook) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + + with patch.object(litellm, "callbacks", []): + guarded = await guard( + json.dumps( + { + "type": "response.create", + "input": "email alice@example.com please", + "metadata": {"trace": "t1", "pii_tokens": {"": "mallory@example.com"}}, + } + ) + ) + + assert json.loads(guarded.frame)["input"] == "email please" + assert dict(guarded.pii_tokens) == {"": "alice@example.com"} + + @pytest.mark.asyncio + async def test_frame_guard_rejects_a_frame_that_carries_input_at_both_levels(self): + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["data"]) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + + with patch.object(litellm, "callbacks", []), pytest.raises(ValueError, match="both at the top level"): + await guard(json.dumps({"type": "response.create", "input": "forbidden", "response": {}})) + + proxy_logging_obj.pre_call_hook.assert_not_awaited() + + @pytest.mark.asyncio + async def test_frame_guard_keeps_the_guardrail_error_when_billing_the_rejected_frame_fails(self): + import litellm + from litellm.proxy.hooks.proxy_track_cost_callback import ProxyDBLogger + from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard + + proxy_logging_obj = MagicMock() + proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=ValueError("blocked")) + spend_logger = MagicMock(spec=ProxyDBLogger) + spend_logger.async_post_call_failure_hook = AsyncMock(side_effect=RuntimeError("db down")) + guard = _responses_ws_frame_guard( + {"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock(), proxy_logging_obj + ) + + with patch.object(litellm, "callbacks", [spend_logger]), pytest.raises(ValueError, match="blocked"): + await guard(json.dumps({"type": "response.create", "input": "hi"})) + + spend_logger.async_post_call_failure_hook.assert_awaited_once() + @pytest.mark.asyncio @pytest.mark.parametrize("provider_rejected", [True, False]) async def test_endpoint_books_a_provider_rejected_connection_as_a_failed_request(self, provider_rejected: bool): @@ -3614,16 +3984,18 @@ class TestResponsesInputTokens: model_list=[ { "model_name": "gpt-5.2", - "litellm_params": {"model": "openai/gpt-5.2", "api_key": "sk-test", "additional_drop_params": ["tools"]}, + "litellm_params": { + "model": "openai/gpt-5.2", + "api_key": "sk-test", + "additional_drop_params": ["tools"], + }, }, {"model_name": "backup", "litellm_params": {"model": "openai/gpt-5.4-mini", "api_key": "sk-test"}}, ], fallbacks=[{"gpt-5.2": ["backup"]}], ) - response, counter = self._post_input_tokens( - {"model": "gpt-5.2", "input": "hi", "tools": 5}, llm_router=router - ) + response, counter = self._post_input_tokens({"model": "gpt-5.2", "input": "hi", "tools": 5}, llm_router=router) assert response.status_code == 200, response.text assert counter.await_args.kwargs["request"].tools is None @@ -3657,6 +4029,219 @@ class TestResponsesInputTokens: assert response.status_code == 429, response.text assert response.json()["error"]["message"] == "rate limited" + @pytest.mark.parametrize( + "provider_body, status_code, expected_error", + [ + pytest.param( + { + "error": { + "message": "Missing required parameter: 'tools[0].name'.", + "type": "invalid_request_error", + "param": "tools[0].name", + "code": "missing_required_parameter", + } + }, + 400, + { + "message": "Missing required parameter: 'tools[0].name'.", + "type": "invalid_request_error", + "param": "tools[0].name", + "code": "missing_required_parameter", + }, + id="openai_error_passes_through", + ), + pytest.param( + { + "error": { + "message": "Missing required parameter: 'tools[0].name'.", + "type": "invalid_request_error", + "param": "tools[0].name", + } + }, + 400, + { + "message": "Missing required parameter: 'tools[0].name'.", + "type": "invalid_request_error", + "param": "tools[0].name", + "code": None, + }, + id="openai_error_without_code_keeps_type_and_param", + ), + pytest.param( + { + "type": "error", + "error": {"type": "invalid_request_error", "message": "tools.0: Input tag 'function' is invalid"}, + "request_id": "req_vrtx_1", + }, + 400, + { + "message": "tools.0: Input tag 'function' is invalid", + "type": "invalid_request_error", + "param": None, + "code": None, + }, + id="anthropic_error_message_unwrapped", + ), + pytest.param( + { + "type": "error", + "error": {"type": "not_found_error", "message": "model: claude-typo"}, + "request_id": "req_vrtx_2", + }, + 404, + {"message": "model: claude-typo", "type": "invalid_request_error", "param": None, "code": None}, + id="anthropic_error_type_follows_the_status", + ), + pytest.param( + {"error": {"code": 404, "message": "Publisher model not found.", "status": "NOT_FOUND"}}, + 404, + {"message": "Publisher model not found.", "type": "invalid_request_error", "param": None, "code": None}, + id="google_error_message_unwrapped", + ), + pytest.param( + "litellm.APIError: Google Gen AI Studio API error: 404 - " + + json.dumps( + {"error": {"code": 404, "message": "models/gemini-typo is not found.", "status": "NOT_FOUND"}} + ), + 404, + { + "message": "models/gemini-typo is not found.", + "type": "invalid_request_error", + "param": None, + "code": None, + }, + id="prefixed_gemini_error_message_unwrapped", + ), + pytest.param( + "litellm.BadRequestError: AzureException - " + + json.dumps( + { + "error": { + "message": "Unrecognized request argument supplied: foo", + "type": "invalid_request_error", + "param": "foo", + "code": "unknown_parameter", + } + } + ), + 400, + { + "message": "Unrecognized request argument supplied: foo", + "type": "invalid_request_error", + "param": "foo", + "code": "unknown_parameter", + }, + id="prefixed_openai_error_passes_through", + ), + pytest.param( + {"message": "The provided model doesn't support counting tokens."}, + 400, + { + "message": "The provided model doesn't support counting tokens.", + "type": "invalid_request_error", + "param": None, + "code": None, + }, + id="bedrock_error_message_unwrapped", + ), + pytest.param( + "BedrockException - " + json.dumps({"message": "The provided model doesn't support counting tokens."}), + 400, + { + "message": "The provided model doesn't support counting tokens.", + "type": "invalid_request_error", + "param": None, + "code": None, + }, + id="prefixed_bedrock_error_message_unwrapped", + ), + pytest.param( + "tool schema {unclosed is invalid", + 400, + { + "message": "tool schema {unclosed is invalid", + "type": "invalid_request_error", + "param": None, + "code": None, + }, + id="plain_text_with_a_brace_kept_whole", + ), + pytest.param( + "upstream count service unavailable", + 503, + { + "message": "upstream count service unavailable", + "type": "internal_server_error", + "param": None, + "code": None, + }, + id="plain_text_error", + ), + ], + ) + def test_provider_count_error_answers_in_openai_error_shape( + self, + provider_body: str | Mapping[str, object], + status_code: int, + expected_error: Mapping[str, str | None], + ) -> None: + from litellm.proxy._types import ProxyException + + failing_counter: Final = AsyncMock( + side_effect=ProxyException( + message=provider_body if isinstance(provider_body, str) else json.dumps(provider_body), + type="token_counting_error", + param="model", + code=str(status_code), + ) + ) + response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter) + + assert response.status_code == status_code, response.text + assert response.json() == {"error": expected_error} + + def test_non_provider_count_error_keeps_its_own_type(self) -> None: + from litellm.proxy._types import ProxyException + + failing_counter: Final = AsyncMock( + side_effect=ProxyException( + message="Token counting is disabled and no provider API result available", + type="token_counting_disabled", + param="model", + code=503, + ) + ) + response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter) + + assert response.status_code == 503, response.text + assert response.json()["error"]["type"] == "token_counting_disabled" + + def test_provider_count_error_names_the_call_id_when_opted_in(self) -> None: + from litellm.proxy._types import ProxyException + + failing_counter: Final = AsyncMock( + side_effect=ProxyException( + message="LiteLLM does not support counting tokens for Vertex AI model zai-org/glm-5.2-maas.", + type="token_counting_error", + param="model", + code="400", + headers={"x-litellm-call-id": "call-9564"}, + ) + ) + with patch("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True}): + response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter) + + assert response.status_code == 400, response.text + assert response.json() == { + "error": { + "message": "LiteLLM does not support counting tokens for Vertex AI model zai-org/glm-5.2-maas.", + "type": "invalid_request_error", + "param": None, + "code": None, + "litellm_call_id": "call-9564", + } + } + class TestGuardrailBlockedResponsesShape: """A pre_call block raises ModifyResponseException before any provider call. diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index f6b36c184ec..47cfa2df117 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -3725,7 +3725,7 @@ class TestSpendLogsPayload: "model": "gpt-4o", "user": "", "team_id": "", - "metadata": '{"actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "litellm_roi_estimator": false, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', + "metadata": '{"user_api_key_user_email": null, "actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "litellm_roi_estimator": false, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', "cache_key": "Cache OFF", "spend": 0.00022500000000000002, "total_tokens": 30, diff --git a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py index 65dcce97b90..262357fe1bd 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/unit/proxy/spend_tracking/test_spend_tracking_utils.py @@ -526,6 +526,7 @@ def _make_standard_logging_payload_with_usage_object(usage_object: dict) -> Stan user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -1957,6 +1958,7 @@ def test_get_logging_payload_includes_overhead_in_spend_logs_metadata(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -2090,6 +2092,7 @@ def test_get_logging_payload_handles_missing_overhead_gracefully(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -2677,6 +2680,7 @@ def test_get_logging_payload_includes_retry_info_in_spend_logs_metadata(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -2772,6 +2776,7 @@ def test_get_logging_payload_handles_missing_retry_info_gracefully(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -5243,6 +5248,37 @@ def test_get_spend_logs_metadata_sibling_fields_preserved(): assert meta["user_api_key_team_id"] == "team-123" +@pytest.mark.parametrize( + ("user_email", "stored_email"), + [ + ("sso-user@example.com", "sso-user@example.com"), + ("", None), + (" ", None), + (None, None), + ], +) +def test_get_logging_payload_stores_user_email_in_spend_log_metadata(user_email, stored_email): + payload = get_logging_payload( + kwargs={ + "model": "gpt-4o-mini", + "litellm_params": { + "metadata": { + "user_api_key_user_email": user_email, + "user_api_key_user_id": "okta|abc123", + } + }, + }, + response_obj=litellm.ModelResponse(id="chatcmpl-user-email", choices=[], usage=litellm.Usage()), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + metadata = json.loads(payload["metadata"]) + + assert metadata["user_api_key_user_email"] == stored_email + assert metadata["user_api_key_user_id"] == "okta|abc123" + + def test_redact_logged_api_key_partial_sha256_is_hashed(): partial_hex = "a" * 63 result = _redact_logged_api_key(partial_hex) @@ -5342,6 +5378,7 @@ def test_get_logging_payload_includes_fallback_info_in_spend_logs_metadata(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, @@ -5437,6 +5474,7 @@ def test_get_logging_payload_handles_missing_fallback_info_gracefully(): user_api_key_team_id=None, user_api_key_org_id=None, user_api_key_user_id=None, + user_api_key_user_email=None, user_api_key_team_alias=None, spend_logs_metadata=None, requester_ip_address=None, diff --git a/tests/unit/proxy/test_batch_x_litellm_model_encoding.py b/tests/unit/proxy/test_batch_x_litellm_model_encoding.py index f8c734c4efd..22fada05aa7 100644 --- a/tests/unit/proxy/test_batch_x_litellm_model_encoding.py +++ b/tests/unit/proxy/test_batch_x_litellm_model_encoding.py @@ -10,6 +10,7 @@ from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest +from starlette.datastructures import Headers, QueryParams from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.openai_files_endpoints.common_utils import ( @@ -23,8 +24,8 @@ from litellm.types.utils import LiteLLMBatch def _make_mock_request(headers: dict) -> MagicMock: """Create a mock FastAPI Request with the given headers.""" mock_request = MagicMock() - mock_request.headers = headers - mock_request.query_params = {} + mock_request.headers = Headers(headers=headers) + mock_request.query_params = QueryParams({}) mock_request.url = MagicMock() mock_request.url.port = 4000 mock_request.method = "POST" diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index 6cc4e22a26a..679e1bfd85a 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -25,6 +25,7 @@ from litellm.litellm_core_utils.bug_report import ( build_bug_report, ) from litellm.types.router import RouterRateLimitError, RouterRateLimitErrorBasic +from litellm.types.utils import CachingDetails from litellm.constants import ( CLIENT_REQUESTED_MODEL_SCOPE_KEY, MAX_LITELLM_CALL_ID_LENGTH, @@ -11637,3 +11638,98 @@ class TestResponseCostFromLoggingObjForBridgedMessages: assert logging_obj.cost_breakdown is not None assert logging_obj.cost_breakdown["output_cost"] == pytest.approx(expected_output) assert logging_obj.cost_breakdown["reasoning_cost"] == pytest.approx(expected_reasoning) + + +class TestResponseCostFromLoggingObjForCacheHits: + BODY: Final = MappingProxyType( + { + "id": "msg_cached", + "type": "message", + "role": "assistant", + "model": "claude-haiku-5-5", + "content": [{"type": "text", "text": "cached answer"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1200, "output_tokens": 400}, + } + ) + + @staticmethod + def _logging_obj(): + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj: Final = Logging( + model="claude-haiku-5-5", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="anthropic_messages", + start_time=datetime.datetime.now(), + litellm_call_id=str(uuid.uuid4()), + function_id="cache-hit-header", + ) + logging_obj.optional_params = {} + return logging_obj + + def test_a_v1_messages_cache_hit_costs_nothing_though_its_usage_prices_as_a_fresh_call(self): + fresh_cost: Final = ProxyBaseLLMRequestProcessing._response_cost_from_logging_obj( + response=dict(self.BODY), logging_obj=self._logging_obj() + ) + hit: Final = self._logging_obj() + hit.caching_details = {"cache_hit": True, "cache_duration_ms": 2.0} + + hit_cost: Final = ProxyBaseLLMRequestProcessing._response_cost_from_logging_obj( + response=dict(self.BODY), logging_obj=hit + ) + + assert fresh_cost is not None and fresh_cost > 0 + assert hit_cost == 0.0 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("hidden_params", "caching_details", "expected_header"), + ( + ( + {"cache_hit": True, "response_cost": 0.0}, + CachingDetails(cache_hit=True, cache_duration_ms=2.0), + "0.0", + ), + ({"response_cost": 0.0}, None, None), + ), + ) + async def test_only_a_cache_hit_sends_zero_cost_headers( + self, + hidden_params: dict[str, object], + caching_details: CachingDetails | None, + expected_header: str | None, + ): + response: Final = litellm.ModelResponse() + response._hidden_params = hidden_params + logging_obj: Final = self._logging_obj() + logging_obj.caching_details = caching_details + + headers: Final = await ProxyBaseLLMRequestProcessing.build_litellm_proxy_success_headers_from_llm_response( + response=response, + request_data={"model": "gpt-5.4-mini"}, + request=Request({"type": "http", "method": "POST", "headers": []}), + user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"), + logging_obj=logging_obj, + version=None, + proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()), + ) + + cost_headers: Final = ( + "x-litellm-response-cost", + "x-litellm-response-cost-original", + "x-litellm-response-cost-input", + "x-litellm-response-cost-output", + ) + assert {name: headers.get(name) for name in cost_headers} == dict.fromkeys(cost_headers, expected_header) + + def test_a_fresh_zero_cost_v1_messages_call_still_sends_no_cost_header(self): + logging_obj: Final = self._logging_obj() + logging_obj.model_call_details["response_cost"] = 0.0 + + cost: Final = ProxyBaseLLMRequestProcessing._response_cost_from_logging_obj( + response=dict(self.BODY), logging_obj=logging_obj + ) + + assert cost == "" diff --git a/tests/unit/proxy/test_component_allowlists.py b/tests/unit/proxy/test_component_allowlists.py index 42da00fcb6a..eb058d01770 100644 --- a/tests/unit/proxy/test_component_allowlists.py +++ b/tests/unit/proxy/test_component_allowlists.py @@ -57,7 +57,7 @@ _PRE_EXISTING_ENV = {key: os.environ.get(key) for key in _THROWAWAY_ENV} for _key, _value in _THROWAWAY_ENV.items(): os.environ.setdefault(_key, _value) -from prometheus_client import make_asgi_app +from prometheus_client import CollectorRegistry, Counter, make_asgi_app # gateway/ and backend/ live at the repo root, not inside litellm/. _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) @@ -71,6 +71,7 @@ from litellm.proxy._lazy_features import LazyFeature, attach_lazy_features from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import app +from litellm.integrations.prometheus_metrics_endpoint import make_metrics_asgi_app from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter for _key, _previous in _PRE_EXISTING_ENV.items(): @@ -355,6 +356,26 @@ def test_gateway_trim_keeps_metrics_mount(): "/metrics Mount must survive the gateway route trim" +@pytest.mark.parametrize("enabled", (False, True)) +def test_backend_serves_metrics_after_startup_when_enabled(enabled: bool) -> None: + registry: Final = CollectorRegistry() + failures: Final = Counter("litellm_postgres_failed_requests", "Failed database requests", registry=registry) + failures.inc(2) + + @asynccontextmanager + async def lifespan(application: Starlette) -> AsyncGenerator[None, None]: + if enabled: + application.mount("/metrics", make_metrics_asgi_app(registry)) + yield + + application: Final = FastAPI(lifespan=partial(_backend_lifespan, lifespan=lifespan)) + with TestClient(application) as client: + response: Final = client.get("/metrics") + assert response.status_code == (200 if enabled else 404), response.text + if enabled: + assert "litellm_postgres_failed_requests_total 2.0\n" in response.text + + def test_gateway_drops_ui_and_swagger_mounts(): """UI static and swagger Mounts must still be trimmed from the gateway.""" for path in ("/ui", "/_next", "/litellm-asset-prefix/_next", "/swagger"): diff --git a/tests/unit/proxy/test_custom_tokenizer_bug.py b/tests/unit/proxy/test_custom_tokenizer_bug.py index 23b6b302202..29625ea1a32 100644 --- a/tests/unit/proxy/test_custom_tokenizer_bug.py +++ b/tests/unit/proxy/test_custom_tokenizer_bug.py @@ -9,6 +9,7 @@ The HuggingFace download boundary (Tokenizer.from_pretrained) is mocked so these stay hermetic unit tests; the proxy's extraction-and-selection path runs for real. """ +from typing import Final from unittest.mock import MagicMock, patch import pytest @@ -17,7 +18,7 @@ import litellm import litellm.proxy.proxy_server import litellm.utils from litellm import Router -from litellm.proxy._types import TokenCountRequest +from litellm.proxy._types import ProxyException, TokenCountRequest from litellm.proxy.proxy_server import token_counter @@ -102,3 +103,60 @@ async def test_model_without_custom_tokenizer_uses_default(monkeypatch): assert response.tokenizer_type == "openai_tokenizer" assert response.model_used == "gpt-4" assert response.total_tokens > 0 + + + +def _vertex_glm_router_with_its_own_tokenizer() -> Router: + return Router( + model_list=[ + { + "model_name": "vertex-glm", + "litellm_params": { + "model": "vertex_ai/zai-org/glm-5.2-maas", + "vertex_project": "test-project", + "vertex_location": "global", + }, + "model_info": { + "custom_tokenizer": {"identifier": "zai-org/GLM-5.2", "revision": "main", "auth_token": None}, + }, + } + ] + ) + + +@pytest.mark.asyncio +async def test_custom_tokenizer_counts_a_vertex_partner_model_without_a_count_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", _vertex_glm_router_with_its_own_tokenizer()) + + with patch.object(litellm.utils, "tokenizer_dispatch") as mock_tokenizer_cls: + mock_tokenizer_cls.from_pretrained.return_value = _fake_hf_tokenizer(9) + + response: Final = await token_counter( + request=TokenCountRequest(model="vertex-glm", messages=[{"role": "user", "content": "Return exactly OK."}]), + call_endpoint=True, + ) + + mock_tokenizer_cls.from_pretrained.assert_called_once_with("zai-org/GLM-5.2", revision="main", token=None) + assert response.error is not True + assert response.tokenizer_type == "huggingface_tokenizer" + assert response.total_tokens >= 9 + + +@pytest.mark.asyncio +async def test_contents_only_count_of_a_vertex_partner_model_without_a_count_route_answers_400( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", _vertex_glm_router_with_its_own_tokenizer()) + + with pytest.raises(ProxyException) as raised: + await token_counter( + request=TokenCountRequest( + model="vertex-glm", contents=[{"role": "user", "parts": [{"text": "Return exactly OK."}]}] + ), + call_endpoint=True, + ) + + assert raised.value.code == "400" + assert raised.value.message == "LiteLLM does not support counting tokens for Vertex AI model zai-org/glm-5.2-maas." diff --git a/tests/unit/proxy/test_health_check_max_tokens.py b/tests/unit/proxy/test_health_check_max_tokens.py index cad919b09e3..24117f45106 100644 --- a/tests/unit/proxy/test_health_check_max_tokens.py +++ b/tests/unit/proxy/test_health_check_max_tokens.py @@ -1,4 +1,5 @@ import asyncio +import copy import json import logging from typing import Final @@ -607,6 +608,101 @@ async def test_run_model_health_check_honors_an_explicit_chat_mode_on_mantle_cla assert fake_ahealth_check.call_args.kwargs["mode"] == "chat" +_MANTLE_GPT_CHAT_REPLY: Final = { + "id": "chatcmpl-health", + "object": "chat.completion", + "created": 1, + "model": "openai.gpt-5.6-terra", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + +_MANTLE_GPT_RESPONSES_REPLY: Final = { + "id": "resp_health", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "openai.gpt-5.6-terra", + "output": [ + { + "type": "message", + "id": "msg_health", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, +} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model_info", "probed_api"), + (({"mode": "chat"}, "chat"), ({}, "responses")), + ids=("chat-pin", "unpinned"), +) +async def test_run_model_health_check_probes_the_api_a_mantle_gpt_deployment_sends_chat_to( + monkeypatch, model_info, probed_api +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + + with respx.mock(assert_all_called=False) as respx_mock: + chat_route: Final = respx_mock.post( + url__regex=r"^https://bedrock-mantle\.us-east-2\.api\.aws/.*/chat/completions$" + ).respond(json=_MANTLE_GPT_CHAT_REPLY) + responses_route: Final = respx_mock.post( + url__regex=r"^https://bedrock-mantle\.us-east-2\.api\.aws/.*/responses$" + ).respond(json=_MANTLE_GPT_RESPONSES_REPLY) + result: Final = await hc_module._run_model_health_check( + { + "litellm_params": { + "model": "bedrock_mantle/openai.gpt-5.6-terra", + "api_key": "test-bearer", + "aws_region_name": "us-east-2", + }, + "model_info": model_info, + } + ) + + assert "error" not in result, result + assert {"chat": chat_route.call_count, "responses": responses_route.call_count} == { + "chat": int(probed_api == "chat"), + "responses": int(probed_api == "responses"), + } + + +@pytest.mark.asyncio +async def test_a_health_probe_of_a_custom_priced_chat_pin_leaves_plain_calls_on_responses(monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + litellm.in_memory_llm_clients_cache.flush_cache() + params: Final = { + "model": "bedrock_mantle/openai.gpt-5.6-terra", + "api_key": "test-bearer", + "aws_region_name": "us-east-2", + } + + with respx.mock(assert_all_called=False) as respx_mock: + chat_route: Final = respx_mock.post( + url__regex=r"^https://bedrock-mantle\.us-east-2\.api\.aws/.*/chat/completions$" + ).respond(json=_MANTLE_GPT_CHAT_REPLY) + responses_route: Final = respx_mock.post( + url__regex=r"^https://bedrock-mantle\.us-east-2\.api\.aws/.*/responses$" + ).respond(json=_MANTLE_GPT_RESPONSES_REPLY) + result: Final = await hc_module._run_model_health_check( + { + "litellm_params": {**params, "input_cost_per_token": 2e-05, "output_cost_per_token": 8e-05}, + "model_info": {"mode": "chat"}, + } + ) + await litellm.acompletion(messages=[{"role": "user", "content": "Say ok"}], **params) + + assert "error" not in result, result + assert (chat_route.call_count, responses_route.call_count) == (1, 1) + + def test_autodetected_embedding_skips_reasoning_effort(): """reasoning_effort must not leak into an embedding probe whose mode is auto-detected. @@ -1313,3 +1409,36 @@ async def test_background_health_check_timeout_does_not_cancel_a_sibling( assert fast_upstream.called assert healthy_model_ids == {"fast"} assert unhealthy_model_ids == {"slow"} + + +@pytest.mark.asyncio +async def test_a_failing_health_probe_leaves_the_deployment_serving_requests(monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "failure_callback", []) + monkeypatch.setattr(litellm, "_async_failure_callback", []) + monkeypatch.setattr(litellm, "success_callback", []) + monkeypatch.setattr(litellm, "_async_success_callback", []) + litellm.in_memory_llm_clients_cache.flush_cache() + deployment: Final = { + "model_name": "probe-group", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "test-key", + "api_base": "https://probe.example/v1", + }, + "model_info": {"id": "probe-deployment", "mode": "chat"}, + } + router: Final = litellm.Router(model_list=[deployment]) + + with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.post("https://probe.example/v1/chat/completions").mock( + side_effect=( + httpx.Response(404, json={"error": {"message": "model not found", "type": "invalid_request_error"}}), + httpx.Response(200, json={**_MANTLE_GPT_CHAT_REPLY, "model": "gpt-4o-mini"}), + ) + ) + probe: Final = await hc_module._run_model_health_check(copy.deepcopy(deployment)) + reply: Final = await router.acompletion(model="probe-group", messages=[{"role": "user", "content": "Say ok"}]) + + assert "error" in probe + assert reply.choices[0].message.content == "ok" diff --git a/tests/unit/proxy/test_proxy_token_counter.py b/tests/unit/proxy/test_proxy_token_counter.py index f7d1970fdae..720739a730b 100644 --- a/tests/unit/proxy/test_proxy_token_counter.py +++ b/tests/unit/proxy/test_proxy_token_counter.py @@ -2,15 +2,32 @@ # 1. Generate a Key, and use it to make a call +import asyncio +import base64 +import hashlib +import itertools import json -from collections.abc import Callable, Mapping -from typing import Final +import logging +import threading +import time +import traceback +from collections.abc import Callable, Coroutine, Mapping +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final, NoReturn from unittest.mock import AsyncMock, MagicMock, patch +import anyio import httpx import pytest +import tiktoken from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict, JsonValue +from tokenizers import Tokenizer +from tokenizers.models import WordLevel +from tokenizers.pre_tokenizers import Whitespace +from tokenizers.processors import TemplateProcessing load_dotenv() @@ -22,10 +39,20 @@ from fastapi.responses import JSONResponse import litellm from litellm import Router +from litellm._logging import verbose_proxy_logger from litellm.llms.anthropic.count_tokens import handler as anthropic_count_tokens_handler +from litellm.llms.base_llm.base_utils import BaseTokenCounter from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter from litellm.llms.bedrock.count_tokens.handler import BedrockCountTokensHandler +from litellm.llms.bedrock.count_tokens.kimi_k3 import ( + KIMI_K3_RANKS_SHA256, + KimiK3Encodings, + KimiK3TokenizerSource, + kimi_k3_template_tokens, + kimi_k3_tokens, + load_kimi_k3_encoding, +) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.gemini.count_tokens import handler as gemini_count_tokens_handler from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import VertexAIPartnerModelsTokenCounter @@ -33,8 +60,39 @@ from litellm.proxy._types import ProxyException, TokenCountRequest from litellm.proxy.anthropic_endpoints.endpoints import ( count_tokens as anthropic_count_tokens, ) -from litellm.proxy.proxy_server import token_counter -from litellm.types.utils import TokenCountResponse +from litellm.proxy.common_utils.token_count_settings import ( + ProviderCountFailure, + TokenCountModel, + TokenCountSettings, + cost_map_entry, +) +from litellm.proxy.google_endpoints.endpoints import _google_count_tokens_request +from litellm.proxy.proxy_server import ( + _estimate_or_provider_error, + _provider_token_count, + _try_provider_token_count, + count_request_tokens, + token_counter, +) +from litellm.rust_bridge.fork_guard import ForkedAfterNativeRuntimeStarted +from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem +from litellm.types.utils import SelectTokenizerResponse, TokenCountMessagesFormat, TokenCountResponse + + +def _hugging_face_offline() -> NoReturn: + raise OSError("unit tests never download the Kimi K3 tokenizer") + + +@pytest.fixture(autouse=True) +def _kimi_k3_tokenizer_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + litellm.proxy.proxy_server, + "_KIMI_K3_ENCODINGS", + KimiK3Encodings( + source=KimiK3TokenizerSource(ranks_path=_hugging_face_offline, sha256=KIMI_K3_RANKS_SHA256), + retry_after_seconds=60, + ), + ) @pytest.mark.asyncio @@ -1217,6 +1275,1282 @@ async def test_anthropic_endpoint_429_rate_limit_error_format(): proxy_server.count_request_tokens = original_token_counter +class _ScriptedCounter(BaseTokenCounter): + def __init__(self, replies: dict[str, TokenCountResponse], burst: int = 1) -> None: + self._replies = replies + self._burst = burst + self._burst_arrived = asyncio.Event() + self.calls: list[tuple[str, object, object, object]] = [] + self.regions: list[object] = [] + + def should_use_token_counting_api(self, custom_llm_provider: str | None = None) -> bool: + return True + + async def count_tokens( + self, + model_to_use: str, + messages: object, + contents: object, + deployment: object = None, + request_model: str = "", + tools: object = None, + system: object = None, + messages_format: TokenCountMessagesFormat = "openai", + output_format: JsonValue = None, + context_management: object = None, + ) -> TokenCountResponse | None: + self.calls.append((model_to_use, messages, tools, system)) + self.regions.append(None if deployment is None else deployment["litellm_params"].get("aws_region_name")) + if len(self.calls) >= self._burst: + self._burst_arrived.set() + await self._burst_arrived.wait() + return self._replies[model_to_use] + + +def _provider_reply( + model: str, total_tokens: int = 0, status_code: int | None = None, rejected_before_provider_call: bool = False +) -> TokenCountResponse: + return TokenCountResponse( + total_tokens=total_tokens, + request_model="sonnet-5-5", + model_used=model, + tokenizer_type="bedrock_api", + error=status_code is not None, + error_message=None if status_code is None else f"scripted {status_code} for {model}", + status_code=status_code, + rejected_before_provider_call=rejected_before_provider_call, + ) + + +_COUNT_REQUEST = TokenCountRequest( + model="sonnet-5-5", + messages=[{"role": "user", "content": "hello"}], + tools=[{"name": "get_quote", "input_schema": {"type": "object"}}], + system="be brief", +) + + +_SONNET_DEPLOYMENT: Final = MappingProxyType( + {"litellm_params": {"model": "bedrock/us.anthropic.claude-sonnet-5-5", "aws_region_name": "us-west-2"}} +) +_OPUS_IN_US_EAST_1: Final = TokenCountModel(model="us.anthropic.claude-opus-5-5", region="us-east-1") + + +async def _ladder( + counter: _ScriptedCounter, settings: TokenCountSettings, local_estimate_allowed: bool = True +) -> TokenCountResponse | None: + return await _provider_token_count( + count_with=partial( + _try_provider_token_count, + counter, + "bedrock", + messages=_COUNT_REQUEST.messages, + contents=_COUNT_REQUEST.contents, + deployment=_SONNET_DEPLOYMENT, + request_model=_COUNT_REQUEST.model, + tools=_COUNT_REQUEST.tools, + system=_COUNT_REQUEST.system, + ), + model_to_use="us.anthropic.claude-sonnet-5-5", + settings=settings, + local_estimate_allowed=local_estimate_allowed, + ) + + +@pytest.mark.asyncio +async def test_model_without_token_count_model_counts_itself_in_the_deployment_region(): + counter: Final = _ScriptedCounter({"us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", total_tokens=447)}) + + result: Final = await _ladder(counter, TokenCountSettings(framing=10, count_model=None)) + + assert result is not None + assert (result.total_tokens, result.count_source) == (447, "provider") + assert ([call[0] for call in counter.calls], counter.regions) == (["us.anthropic.claude-sonnet-5-5"], ["us-west-2"]) + + +@pytest.mark.parametrize( + ("count_model", "region"), + [ + (_OPUS_IN_US_EAST_1, "us-east-1"), + (TokenCountModel(model="us.anthropic.claude-opus-5-5", region=None), "us-west-2"), + ], +) +@pytest.mark.asyncio +async def test_token_count_model_counts_the_same_request_in_its_own_region_without_trying_the_model( + count_model: TokenCountModel, region: str +): + counter: Final = _ScriptedCounter({"us.anthropic.claude-opus-5-5": _provider_reply("opus", total_tokens=447)}) + + result: Final = await _ladder(counter, TokenCountSettings(framing=None, count_model=count_model)) + + assert result is not None + assert (result.total_tokens, result.count_source, result.model_used) == ( + 447, + "token_count_model", + "us.anthropic.claude-opus-5-5", + ) + assert ([call[0] for call in counter.calls], counter.regions) == (["us.anthropic.claude-opus-5-5"], [region]) + assert counter.calls[0][1:] == (_COUNT_REQUEST.messages, _COUNT_REQUEST.tools, _COUNT_REQUEST.system) + assert _SONNET_DEPLOYMENT["litellm_params"]["aws_region_name"] == "us-west-2" + + +@pytest.mark.parametrize("status_code", [401, 403, 429, 500]) +@pytest.mark.asyncio +async def test_errors_other_than_cannot_count_raise_even_with_framing(status_code: int): + counter: Final = _ScriptedCounter( + {"us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", status_code=status_code)} + ) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=10, count_model=None)) + + assert (raised.value.code, raised.value.message) == (str(status_code), f"scripted {status_code} for sonnet") + + +@pytest.mark.parametrize("status_code", [400, 403, 404, 500]) +@pytest.mark.asyncio +async def test_token_count_model_failure_names_its_region_so_the_user_can_enable_it(status_code: int): + counter: Final = _ScriptedCounter( + {"us.anthropic.claude-opus-5-5": _provider_reply("opus", status_code=status_code)} + ) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=None, count_model=_OPUS_IN_US_EAST_1)) + + assert raised.value.code == str(status_code) + assert raised.value.message.startswith(f"scripted {status_code} for opus") + assert "us.anthropic.claude-opus-5-5 in us-east-1" in raised.value.message + assert len(counter.calls) == 1 + + +@pytest.mark.asyncio +async def test_token_count_model_without_a_region_answers_its_error_unchanged(): + counter: Final = _ScriptedCounter({"us.anthropic.claude-opus-5-5": _provider_reply("opus", status_code=404)}) + + with pytest.raises(ProxyException) as raised: + await _ladder( + counter, + TokenCountSettings( + framing=None, count_model=TokenCountModel(model="us.anthropic.claude-opus-5-5", region=None) + ), + ) + + assert (raised.value.code, raised.value.message) == ("404", "scripted 404 for opus") + + +@pytest.mark.asyncio +async def test_uncountable_model_without_framing_raises(): + counter: Final = _ScriptedCounter({"us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", status_code=404)}) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=None, count_model=None)) + + assert raised.value.code == "404" + + +@pytest.mark.asyncio +async def test_uncountable_model_with_framing_falls_through_to_the_local_estimate(): + counter: Final = _ScriptedCounter({"us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", status_code=400)}) + + result: Final = await _ladder(counter, TokenCountSettings(framing=0, count_model=None)) + + assert isinstance(result, ProviderCountFailure) + assert len(counter.calls) == 1 + + +@pytest.mark.parametrize("status_code", [400, 404]) +@pytest.mark.asyncio +async def test_token_count_model_error_stands_even_with_framing(status_code: int): + counter: Final = _ScriptedCounter( + {"us.anthropic.claude-opus-5-5": _provider_reply("opus", status_code=status_code)} + ) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=78, count_model=_OPUS_IN_US_EAST_1)) + + assert raised.value.code == str(status_code) + assert "us.anthropic.claude-opus-5-5 in us-east-1" in raised.value.message + + +@pytest.mark.asyncio +async def test_uncountable_model_with_framing_raises_when_local_counting_is_disabled(): + counter: Final = _ScriptedCounter({"us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", status_code=400)}) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=10, count_model=None), local_estimate_allowed=False) + + assert raised.value.code == "400" + + +@pytest.mark.asyncio +async def test_request_the_counter_rejects_before_calling_the_provider_answers_400_without_framing(): + counter: Final = _ScriptedCounter( + { + "us.anthropic.claude-sonnet-5-5": _provider_reply( + "sonnet", status_code=400, rejected_before_provider_call=True + ) + } + ) + + with pytest.raises(ProxyException) as raised: + await _ladder(counter, TokenCountSettings(framing=100, count_model=None)) + + assert (raised.value.code, raised.value.message) == ("400", "scripted 400 for sonnet") + + +@pytest.mark.parametrize("model", ["openai/gpt-5.4-mini", "gemini/gemini-3.8-flash"]) +@pytest.mark.asyncio +async def test_framing_keeps_the_400_for_blocks_the_provider_counter_cannot_convert( + monkeypatch: pytest.MonkeyPatch, model: str +): + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "framed", + "litellm_params": {"model": model, "api_key": "sk-unused-framed"}, + "model_info": {"token_count_framing": 100}, + } + ] + ), + ) + search_result: Final = {"type": "search_result", "source": "https://example.com", "title": "Notes", "content": []} + + with pytest.raises(ProxyException) as raised: + await count_request_tokens( + request=TokenCountRequest( + model="framed", + messages=[{"role": "user", "content": [{"type": "text", "text": "Summarize."}, search_result]}], + ), + call_endpoint=True, + messages_format="anthropic", + ) + + assert raised.value.code == "400" + assert "search_result" in raised.value.message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "unreadable_body", + [ + pytest.param( + { + "messages": [ + {"role": "user", "content": "What is new?"}, + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_01", + "name": "web_search", + "input": {"query": "new"}, + }, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_01", "content": []}, + ], + }, + ] + }, + id="web_search_blocks", + ), + pytest.param( + { + "messages": [{"role": "user", "content": "What is new?"}], + "tools": [{"name": "search", "input_schema": {"type": "object", "properties": {"query": True}}}], + }, + id="boolean_subschema_tool", + ), + pytest.param( + { + "messages": [{"role": "user", "content": "What is new?"}], + "tools": [ + {"name": "search", "input_schema": {"type": "object", "properties": {"tags": {"type": "array"}}}} + ], + }, + id="array_schema_without_items", + ), + pytest.param( + {"messages": [], "contents": [{"role": "user", "parts": [{"text": "What is new?"}]}]}, + id="google_contents", + ), + pytest.param( + { + "messages": [{"role": "user", "content": "Describe this"}], + "contents": [ + { + "role": "user", + "parts": [ + {"text": "Describe this"}, + {"fileData": {"mimeType": "image/png", "fileUri": "gs://b/c.png"}}, + ], + } + ], + }, + id="google_contents_with_media_the_translation_dropped", + ), + pytest.param( + { + "messages": [ + {"role": "user", "content": "What is new?"}, + {"role": "assistant", "content": "Weighing the release notes"}, + ], + "contents": [ + {"role": "user", "parts": [{"text": "What is new?"}]}, + {"role": "model", "parts": [{"text": "Weighing the release notes", "thought": True}]}, + ], + }, + id="google_contents_with_a_thought_the_translation_counts_as_text", + ), + ], +) +async def test_framed_estimate_that_cannot_read_the_body_answers_the_provider_error( + monkeypatch: pytest.MonkeyPatch, unreadable_body: dict[str, list[dict[str, object]]] +): + counter: Final = _ScriptedCounter({"us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router(model_list=[{"model_name": "kimi", "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}}]), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + with pytest.raises(ProxyException) as raised: + await token_counter(request=TokenCountRequest(model="kimi", **unreadable_body), call_endpoint=True) + readable: Final = await token_counter( + request=TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "What is new?"}]), + call_endpoint=True, + ) + + assert (raised.value.code, raised.value.message) == ("400", "scripted 400 for kimi") + assert readable.count_source == "local_estimate" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "contents", + [ + pytest.param([{"role": "user", "parts": [{"text": "What is new in the release notes?"}]}], id="text"), + pytest.param( + [ + {"role": "user", "parts": [{"text": "Weather in Paris?"}]}, + { + "role": "model", + "parts": [ + {"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}, "thoughtSignature": "c2ln"} + ], + }, + { + "role": "user", + "parts": [{"functionResponse": {"name": "get_weather", "response": {"forecast": "Sun"}}}], + }, + ], + id="sdk_agent_loop", + ), + pytest.param( + [ + {"role": "user", "parts": [{"text": "Hi"}]}, + {"role": "model", "parts": [{"text": "Hello!", "thoughtSignature": "c2ln"}]}, + {"role": "user", "parts": [{"text": "What is new?"}]}, + ], + id="sdk_chat_with_a_thought_signature", + ), + pytest.param([], id="empty"), + ], +) +async def test_framed_estimate_counts_google_contents_like_the_same_conversation_sent_as_messages( + monkeypatch: pytest.MonkeyPatch, contents: list[dict[str, object]] +): + _framed_kimi(monkeypatch) + google_request: Final = _google_count_tokens_request("kimi", {"contents": contents}) + + from_google: Final = await token_counter(request=google_request, call_endpoint=True) + from_messages: Final = await token_counter( + request=TokenCountRequest(model="kimi", messages=google_request.messages), call_endpoint=True + ) + + assert from_google.count_source == "local_estimate" + assert from_google.total_tokens == from_messages.total_tokens + + +@pytest.mark.asyncio +async def test_framed_deployment_whose_custom_tokenizer_cannot_load_answers_the_provider_error( + monkeypatch: pytest.MonkeyPatch, +): + counter: Final = _ScriptedCounter({"us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "kimi", + "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}, + "model_info": {"custom_tokenizer": {"identifier": "moonshotai/kimi-k3", "revision": "main"}}, + } + ] + ), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + with pytest.raises(ProxyException) as raised: + await token_counter( + request=TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "What is new?"}]), + call_endpoint=True, + ) + + assert (raised.value.code, raised.value.message) == ("400", "scripted 400 for kimi") + + +def _framed_kimis_with_custom_tokenizers( + monkeypatch: pytest.MonkeyPatch, tokenizers: Mapping[str, tuple[str, str]], burst: int = 1 +) -> None: + counter: Final = _ScriptedCounter( + { + "us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400), + "us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", total_tokens=12), + }, + burst=burst, + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "sonnet", + "litellm_params": {"model": "bedrock/us.anthropic.claude-sonnet-5-5"}, + "model_info": {"token_count_model": {}}, + }, + *( + { + "model_name": name, + "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}, + "model_info": { + "custom_tokenizer": {"identifier": identifier, "revision": revision, "auth_token": None} + }, + } + for name, (identifier, revision) in tokenizers.items() + ), + ] + ), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + +def _framed_kimi_with_custom_tokenizer(monkeypatch: pytest.MonkeyPatch, identifier: str) -> None: + _framed_kimis_with_custom_tokenizers(monkeypatch, {"kimi": (identifier, "main")}) + + +async def _count_status(model: str = "kimi") -> str: + try: + response: Final = await token_counter( + request=TokenCountRequest(model=model, messages=[{"role": "user", "content": "What is new?"}]), + call_endpoint=True, + ) + except ProxyException as e: + return e.code + return response.count_source or "" + + +class _SkippableClockLoop(asyncio.SelectorEventLoop): + skipped_seconds: float = 0.0 + + def time(self) -> float: + return super().time() + self.skipped_seconds + + def skip(self, seconds: float) -> None: + self.skipped_seconds += seconds + + +def _run_on_skippable_clock( + scenario: Callable[[_SkippableClockLoop], Coroutine[object, object, tuple[str, ...]]], +) -> tuple[str, ...]: + loop: Final = _SkippableClockLoop() + try: + return loop.run_until_complete(scenario(loop)) + finally: + loop.run_until_complete(loop.shutdown_default_executor()) + loop.close() + + +async def _until_finished(tasks: tuple[asyncio.Future[str], ...], count: int) -> None: + for finished in itertools.islice(asyncio.as_completed(tasks), count): + await finished + + +def _tokenizer_that_panics_while_encoding() -> SelectTokenizerResponse: + template: Final = Tokenizer(WordLevel({"[UNK]": 0, "": 1}, unk_token="[UNK]")) + template.pre_tokenizer = Whitespace() + template.post_processor = TemplateProcessing(single=" $A", special_tokens=[("", 1)]) + spec: Final = json.loads(template.to_str()) + return litellm.utils.create_tokenizer( + json.dumps({**spec, "post_processor": {**spec["post_processor"], "special_tokens": {}}}) + ) + + +def test_hanging_tokenizer_download_answers_the_provider_error_after_the_wait_with_one_download_while_it_runs( + monkeypatch: pytest.MonkeyPatch, +): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/hanging-tokenizer") + download_started: Final = threading.Event() + hub_released: Final = threading.Event() + downloads: Final[list[str]] = [] + + def hanging_download(model: str, custom_tokenizer: object = None) -> NoReturn: + downloads.append(model) + download_started.set() + hub_released.wait(10) + raise OSError("hub unreachable") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", hanging_download) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_WAIT_SECONDS", 30) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", 1e-9) + + async def burst_past_the_wait_then_one_more(loop: _SkippableClockLoop) -> tuple[str, ...]: + try: + burst: Final = asyncio.gather(*(_count_status() for _ in range(60))) + await asyncio.to_thread(download_started.wait, 10) + loop.skip(31) + answered: Final = await asyncio.wait_for(burst, 8) + later: Final = await asyncio.wait_for(_count_status(), 8) + finally: + hub_released.set() + return (*answered, later) + + assert set(_run_on_skippable_clock(burst_past_the_wait_then_one_more)) == {"400"} + assert len(downloads) == 1 + + +@pytest.mark.asyncio +async def test_framed_count_waits_for_a_tokenizer_download_that_finishes_within_the_wait( + monkeypatch: pytest.MonkeyPatch, +): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/slow-tokenizer") + real_select_tokenizer: Final = litellm.utils.select_tokenizer + download_started: Final = threading.Event() + hub_released: Final = threading.Event() + + def slow_download(model: str, custom_tokenizer: object = None): + download_started.set() + hub_released.wait(10) + return real_select_tokenizer(model="gpt-5.4-mini") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", slow_download) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_WAIT_SECONDS", 30) + count: Final = asyncio.create_task(_count_status()) + try: + await asyncio.to_thread(download_started.wait, 10) + finally: + hub_released.set() + + assert await asyncio.wait_for(count, 10) == "local_estimate" + + +@pytest.mark.asyncio +async def test_failed_tokenizer_download_is_reused_until_the_retry_window_passes(monkeypatch: pytest.MonkeyPatch): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/flaky-tokenizer") + real_select_tokenizer: Final = litellm.utils.select_tokenizer + downloads: Final[list[str]] = [] + + def hub_down_once(model: str, custom_tokenizer: object = None): + downloads.append(model) + if len(downloads) == 1: + raise OSError("hub unreachable") + return real_select_tokenizer(model="gpt-5.4-mini") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", hub_down_once) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", 1e9) + within_window: Final = [await _count_status() for _ in range(3)] + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", 1e-9) + after_window: Final = await _count_status() + + assert within_window == ["400", "400", "400"] + assert after_window == "local_estimate" + assert len(downloads) == 2 + + +def test_failed_tokenizer_load_is_reused_for_the_window_after_it_fails(monkeypatch: pytest.MonkeyPatch): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/slow-failing-tokenizer") + download_started: Final = threading.Event() + hub_released: Final = threading.Event() + downloads: Final[list[str]] = [] + + def slow_failing_download(model: str, custom_tokenizer: object = None) -> NoReturn: + downloads.append(model) + download_started.set() + hub_released.wait(10) + raise OSError("hub unreachable") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", slow_failing_download) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_WAIT_SECONDS", 1000) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", 60) + + async def fail_after_longer_than_the_window_then_retry(loop: _SkippableClockLoop) -> tuple[str, ...]: + first: Final = asyncio.ensure_future(_count_status()) + try: + await asyncio.to_thread(download_started.wait, 10) + loop.skip(120) + finally: + hub_released.set() + return await asyncio.wait_for(first, 8), await asyncio.wait_for(_count_status(), 8) + + assert _run_on_skippable_clock(fail_after_longer_than_the_window_then_retry) == ("400", "400") + assert len(downloads) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "second_tokenizer", + [("lit-tests/tokenizer-b", "main"), ("lit-tests/tokenizer-a", "v2")], + ids=["identifier", "revision"], +) +async def test_framed_deployments_with_different_custom_tokenizers_each_load_their_own( + monkeypatch: pytest.MonkeyPatch, second_tokenizer: tuple[str, str] +): + _framed_kimis_with_custom_tokenizers( + monkeypatch, {"kimi-a": ("lit-tests/tokenizer-a", "main"), "kimi-b": second_tokenizer} + ) + real_select_tokenizer: Final = litellm.utils.select_tokenizer + loaded: Final[list[object]] = [] + + def load(model: str, custom_tokenizer: object = None) -> SelectTokenizerResponse: + loaded.append(custom_tokenizer) + return real_select_tokenizer(model="gpt-5.4-mini") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", load) + + statuses: Final = [await _count_status(name) for name in ("kimi-a", "kimi-b")] + + assert statuses == ["local_estimate", "local_estimate"] + assert loaded == [ + {"identifier": "lit-tests/tokenizer-a", "revision": "main", "auth_token": None}, + {"identifier": second_tokenizer[0], "revision": second_tokenizer[1], "auth_token": None}, + ] + + +@pytest.mark.asyncio +async def test_tokenizer_loads_past_the_cap_answer_the_provider_error_without_stalling_other_counts( + monkeypatch: pytest.MonkeyPatch, +): + tokenizers: Final = {f"kimi-{n}": (f"lit-tests/unreachable-tokenizer-{n}", "main") for n in range(5)} + _framed_kimis_with_custom_tokenizers(monkeypatch, tokenizers, burst=len(tokenizers)) + real_select_tokenizer: Final = litellm.utils.select_tokenizer + hub_released: Final = threading.Event() + downloads: Final[list[object]] = [] + + def held_download(model: str, custom_tokenizer: object = None) -> SelectTokenizerResponse: + downloads.append(custom_tokenizer) + hub_released.wait(10) + return real_select_tokenizer(model="gpt-5.4-mini") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", held_download) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_MAX_RUNNING", 2) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_WAIT_SECONDS", 30) + monkeypatch.setattr(anyio.to_thread.current_default_thread_limiter(), "total_tokens", 2) + framed: Final = tuple(asyncio.ensure_future(_count_status(name)) for name in tokenizers) + try: + await asyncio.wait_for(_until_finished(framed, 3), 8) + unrelated: Final = await asyncio.wait_for(_count_status("sonnet"), 5) + finally: + hub_released.set() + statuses: Final = await asyncio.wait_for(asyncio.gather(*framed), 8) + + assert sorted(statuses) == ["400"] * 3 + ["local_estimate"] * 2 + assert unrelated == "provider" + assert len(downloads) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("panics_while", ["loading", "counting"]) +async def test_framed_count_answers_the_provider_error_when_the_tokenizer_panics( + monkeypatch: pytest.MonkeyPatch, panics_while: str +): + _framed_kimi_with_custom_tokenizer(monkeypatch, f"lit-tests/panicking-tokenizer-{panics_while}") + panicking: Final = _tokenizer_that_panics_while_encoding() + + def load(model: str, custom_tokenizer: object = None) -> SelectTokenizerResponse: + if panics_while == "loading": + panicking["tokenizer"].encode("hello world") + return panicking + + monkeypatch.setattr(litellm.utils, "select_tokenizer", load) + + assert await _count_status() == "400" + + +def _framed_kimi(monkeypatch: pytest.MonkeyPatch) -> None: + counter: Final = _ScriptedCounter({"us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router(model_list=[{"model_name": "kimi", "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}}]), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + +@pytest.mark.asyncio +async def test_local_count_loads_the_tokenizer_again_after_a_failed_load(monkeypatch: pytest.MonkeyPatch): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/local-route-tokenizer") + real_select_tokenizer: Final = litellm.utils.select_tokenizer + downloads: Final[list[str]] = [] + + def hub_down_once(model: str, custom_tokenizer: object = None): + downloads.append(model) + if len(downloads) == 1: + raise OSError("hub unreachable") + return real_select_tokenizer(model="gpt-5.4-mini") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", hub_down_once) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", 1e9) + request: Final = TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "What is new?"}]) + + with pytest.raises(OSError, match="hub unreachable"): + await token_counter(request=request) + retried: Final = await token_counter(request=request) + + assert retried.count_source == "local_estimate" and retried.total_tokens > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("reuse_seconds", "tokenizer_types"), + [(1e9, ("openai_tokenizer", "openai_tokenizer")), (1e-9, ("openai_tokenizer", "huggingface_tokenizer"))], + ids=["within_the_window", "after_the_window"], +) +async def test_framed_count_loads_the_tokenizer_again_only_after_the_window_of_a_load_that_fell_back( + monkeypatch: pytest.MonkeyPatch, reuse_seconds: float, tokenizer_types: tuple[str, str] +): + _framed_kimi(monkeypatch) + real_select_tokenizer: Final = litellm.utils.select_tokenizer + hub_tokenizer: Final = litellm.utils.create_tokenizer( + Tokenizer(WordLevel({"[UNK]": 0}, unk_token="[UNK]")).to_str() + ) + loads: Final[list[str]] = [] + + def hub_up_on_the_second_load(model: str, custom_tokenizer: object = None): + loads.append(model) + return real_select_tokenizer(model="gpt-5.4-mini") if len(loads) == 1 else hub_tokenizer + + monkeypatch.setattr(litellm.utils, "select_tokenizer", hub_up_on_the_second_load) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_REUSE_SECONDS", reuse_seconds) + monkeypatch.setattr(litellm.proxy.proxy_server, "TOKENIZER_LOAD_MAX_RUNNING", 1) + request: Final = TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "What is new?"}]) + + first: Final = await token_counter(request=request, call_endpoint=True) + second: Final = await token_counter(request=request, call_endpoint=True) + + assert (first.tokenizer_type, second.tokenizer_type) == tokenizer_types + + +async def _fork_guard_traceback_frames() -> int: + with pytest.raises(ForkedAfterNativeRuntimeStarted) as raised: + await token_counter( + request=TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "What is new?"}]), + call_endpoint=True, + ) + return len(traceback.extract_tb(raised.value.__traceback__)) + + +@pytest.mark.asyncio +async def test_fork_guard_error_while_loading_the_tokenizer_is_raised_to_every_count_sharing_the_load( + monkeypatch: pytest.MonkeyPatch, +): + _framed_kimi(monkeypatch) + + def forked_load(model: str, custom_tokenizer: object = None) -> NoReturn: + raise ForkedAfterNativeRuntimeStarted("native runtime started before fork") + + monkeypatch.setattr(litellm.utils, "select_tokenizer", forked_load) + + frames: Final = [await _fork_guard_traceback_frames() for _ in range(3)] + + assert len(set(frames)) == 1 + + +@pytest.mark.asyncio +async def test_swallowed_estimate_error_is_logged_by_type_without_the_request_content( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + counter: Final = _ScriptedCounter({"us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router(model_list=[{"model_name": "kimi", "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}}]), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + verbose_proxy_logger.addHandler(caplog.handler) + try: + with caplog.at_level(logging.WARNING, logger=verbose_proxy_logger.name), pytest.raises(ProxyException): + await token_counter( + request=TokenCountRequest( + model="kimi", + messages=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok", "tool_calls": "private-marker-in-the-prompt"}, + ], + ), + call_endpoint=True, + ) + finally: + verbose_proxy_logger.removeHandler(caplog.handler) + + warnings: Final = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert any("count error 400" in message and "ValueError" in message for message in warnings) + assert not any("private-marker-in-the-prompt" in message for message in warnings) + + +@pytest.mark.asyncio +async def test_fork_guard_error_in_the_estimate_is_never_answered_as_the_provider_error(): + async def forked_estimate() -> NoReturn: + raise ForkedAfterNativeRuntimeStarted("native runtime started before fork") + + with pytest.raises(ForkedAfterNativeRuntimeStarted): + await _estimate_or_provider_error( + forked_estimate(), provider_failure=ProviderCountFailure(status_code=400, message="scripted 400") + ) + + +@pytest.mark.asyncio +async def test_local_estimate_adds_the_configured_framing_once_per_request(monkeypatch: pytest.MonkeyPatch): + def deployment(name: str, model_info: dict[str, object]) -> dict[str, object]: + return { + "model_name": name, + "litellm_params": {"model": "openai/gpt-5.4-mini", "api_key": f"sk-unused-{name}"}, + "model_info": model_info, + } + + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router(model_list=[deployment("framed", {"token_count_framing": 100}), deployment("plain", {})]), + ) + messages: Final = [{"role": "system", "content": "be brief"}, {"role": "user", "content": "hello there"}] + + plain: Final = await token_counter(request=TokenCountRequest(model="plain", messages=messages)) + framed: Final = await token_counter(request=TokenCountRequest(model="framed", messages=messages)) + framed_prompt: Final = await token_counter(request=TokenCountRequest(model="framed", prompt="hello there")) + + assert plain.total_tokens == litellm.token_counter(model="gpt-5.4-mini", messages=messages) + assert framed.total_tokens == plain.total_tokens + 100 + assert (plain.count_source, framed.count_source) == ("local_estimate", "local_estimate") + assert framed_prompt.total_tokens == litellm.token_counter(model="gpt-5.4-mini", text="hello there") + + +@pytest.mark.asyncio +async def test_framing_comes_from_the_row_the_deployment_provider_or_base_model_names(monkeypatch: pytest.MonkeyPatch): + def deployment(name: str, litellm_params: dict[str, object], model_info: dict[str, object]) -> dict[str, object]: + return { + "model_name": name, + "litellm_params": {**litellm_params, "api_key": "unused", "api_base": "https://unused.invalid"}, + "model_info": model_info, + } + + kimi: Final = "databricks/system.ai.kimi-k3" + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + deployment("prefixed", {"model": kimi}, {}), + deployment("split", {"model": "system.ai.kimi-k3", "custom_llm_provider": "databricks"}, {}), + deployment("aliased", {"model": "gpt-5.4-mini", "custom_llm_provider": "openai"}, {"base_model": kimi}), + ] + ), + ) + messages: Final = [{"role": "user", "content": "hello there"}] + framing: Final = cost_map_entry(kimi)["token_count_framing"] + + prefixed: Final = await token_counter(request=TokenCountRequest(model="prefixed", messages=messages)) + split: Final = await token_counter(request=TokenCountRequest(model="split", messages=messages)) + aliased: Final = await token_counter(request=TokenCountRequest(model="aliased", messages=messages)) + + assert prefixed.total_tokens == litellm.token_counter(model="system.ai.kimi-k3", messages=messages) + framing + assert split.total_tokens == prefixed.total_tokens + assert aliased.total_tokens == litellm.token_counter(model="gpt-5.4-mini", messages=messages) + framing + + +_KIMI_WEATHER: Final = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, +} + + +def _byte_level_kimi_k3(path: Path) -> KimiK3TokenizerSource: + path.write_bytes(b"\n".join(base64.b64encode(bytes((value,))) + b" %d" % value for value in range(256))) + return KimiK3TokenizerSource(ranks_path=lambda: str(path), sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + + +def _available_kimi_k3(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tiktoken.Encoding: + source: Final = _byte_level_kimi_k3(tmp_path / "tiktoken.model") + monkeypatch.setattr( + litellm.proxy.proxy_server, "_KIMI_K3_ENCODINGS", KimiK3Encodings(source=source, retry_after_seconds=60) + ) + encoding: Final = load_kimi_k3_encoding(source) + assert encoding is not None + return encoding + + +def _shipped_kimi_k3_framing() -> int: + framing: Final = cost_map_entry("bedrock/us.moonshotai.kimi-k3")["token_count_framing"] + assert isinstance(framing, int) + return framing + + +@pytest.mark.asyncio +async def test_bedrock_kimi_k3_is_counted_with_its_tokenizer_plus_the_shipped_framing_in_both_request_formats( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _framed_kimi(monkeypatch) + encoding: Final = _available_kimi_k3(monkeypatch, tmp_path) + question: Final = {"role": "user", "content": "Weather in Paris?"} + openai_messages: Final = [ + {"role": "system", "content": "Be terse."}, + question, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "c1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}} + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "Sun"}, + ] + + openai: Final = await count_request_tokens( + request=TokenCountRequest(model="kimi", messages=openai_messages, tools=[_KIMI_WEATHER]), call_endpoint=True + ) + anthropic: Final = await count_request_tokens( + request=TokenCountRequest( + model="kimi", + system="Be terse.", + messages=[ + question, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "c1", "name": "get_weather", "input": {"city": "Paris"}}], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "c1", "content": "Sun"}]}, + ], + tools=[ + { + "name": "get_weather", + "description": "Get the weather", + "input_schema": _KIMI_WEATHER["function"]["parameters"], + } + ], + ), + call_endpoint=True, + messages_format="anthropic", + ) + exact: Final = kimi_k3_tokens(encoding, "us.moonshotai.kimi-k3", openai_messages, [_KIMI_WEATHER]) + + assert exact is not None + assert (openai.total_tokens, openai.tokenizer_type, openai.count_source) == ( + exact + _shipped_kimi_k3_framing(), + "huggingface_tokenizer", + "local_estimate", + ) + assert (anthropic.total_tokens, anthropic.tokenizer_type) == (openai.total_tokens, "huggingface_tokenizer") + + +@pytest.mark.asyncio +async def test_bedrock_kimi_k3_deployment_with_a_custom_tokenizer_keeps_counting_with_it( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _framed_kimi_with_custom_tokenizer(monkeypatch, "lit-tests/kimi-custom-tokenizer") + _available_kimi_k3(monkeypatch, tmp_path) + custom: Final = litellm.utils.select_tokenizer(model="gpt-5.4-mini") + monkeypatch.setattr(litellm.utils, "select_tokenizer", lambda model, custom_tokenizer=None: custom) + messages: Final = [{"role": "user", "content": "What is new?"}] + + counted: Final = await token_counter(request=TokenCountRequest(model="kimi", messages=messages), call_endpoint=True) + + assert counted.total_tokens == ( + litellm.token_counter(model=counted.model_used, messages=messages, custom_tokenizer=custom) + + kimi_k3_template_tokens(len(messages), False) + + _shipped_kimi_k3_framing() + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("messages", "tools", "template_tokens"), + [ + pytest.param([{"role": "user", "content": "What is new?"}], None, 14, id="one_message"), + pytest.param( + [ + {"role": "system", "content": "Be terse."}, + {"role": "user", "content": "Weather in Paris?"}, + {"role": "assistant", "content": "Sunny."}, + {"role": "user", "content": "And Oslo?"}, + ], + [_KIMI_WEATHER], + 14 + 3 * 13 + 40, + id="turns_and_tools", + ), + ], +) +async def test_bedrock_kimi_k3_without_its_tokenizer_adds_the_template_offsets_to_the_local_estimate( + monkeypatch: pytest.MonkeyPatch, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None, + template_tokens: int, +): + _framed_kimi(monkeypatch) + + counted: Final = await token_counter( + request=TokenCountRequest(model="kimi", messages=messages, tools=tools), call_endpoint=True + ) + estimate: Final = litellm.token_counter( + model=counted.model_used, + messages=messages, + tools=tools, + custom_tokenizer=litellm.utils.select_tokenizer(model=counted.model_used), + ) + + assert counted.total_tokens == estimate + template_tokens + _shipped_kimi_k3_framing() + + +@pytest.mark.asyncio +async def test_slow_cost_map_lookup_does_not_stall_other_requests_on_the_worker(monkeypatch: pytest.MonkeyPatch): + real_get_model_info: Final = litellm.get_model_info + lookup_started: Final = threading.Event() + other_request_ran: Final = threading.Event() + released_by_the_other_request: Final[list[bool]] = [] + + def slow_get_model_info(model: str, *args: object, **kwargs: object): + if model == "slow-lookup-model": + lookup_started.set() + released_by_the_other_request.append(other_request_ran.wait(5)) + return real_get_model_info(model, *args, **kwargs) + + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", None) + monkeypatch.setattr(litellm, "get_model_info", slow_get_model_info) + counting: Final = asyncio.create_task( + token_counter( + request=TokenCountRequest(model="slow-lookup-model", messages=[{"role": "user", "content": "hi"}]) + ) + ) + await asyncio.to_thread(lookup_started.wait, 5) + other_request_ran.set() + await counting + + assert set(released_by_the_other_request) == {True} + + +@pytest.mark.asyncio +async def test_uncountable_model_keeps_the_provider_error_when_local_counting_is_disabled( + monkeypatch: pytest.MonkeyPatch, +): + counter: Final = _ScriptedCounter({"us.moonshotai.kimi-k3": _provider_reply("kimi", status_code=400)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "kimi", + "litellm_params": {"model": "bedrock/us.moonshotai.kimi-k3"}, + "model_info": {"token_count_framing": 78}, + } + ] + ), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + monkeypatch.setattr(litellm, "disable_token_counter", True) + + with pytest.raises(ProxyException) as raised: + await token_counter( + request=TokenCountRequest(model="kimi", messages=[{"role": "user", "content": "hi"}]), call_endpoint=True + ) + + assert raised.value.code == "400" + assert "scripted 400 for kimi" in raised.value.message + + +@pytest.mark.parametrize( + ("model_info", "counted_with"), + [ + ({}, ("us.anthropic.claude-opus-5-5", "us-east-1", 447)), + ( + {"token_count_model": {"model": "us.anthropic.claude-opus-5-5", "region": "ap-southeast-4"}}, + ("us.anthropic.claude-opus-5-5", "ap-southeast-4", 447), + ), + ({"token_count_model": {}}, ("us.anthropic.claude-sonnet-5-5", "us-west-2", 300)), + ], +) +@pytest.mark.asyncio +async def test_configured_token_count_model_overrides_the_shipped_one_and_an_empty_one_counts_the_model_itself( + monkeypatch: pytest.MonkeyPatch, model_info: dict[str, object], counted_with: tuple[str, str, int] +): + counter: Final = _ScriptedCounter( + { + "us.anthropic.claude-sonnet-5-5": _provider_reply("sonnet", total_tokens=300), + "us.anthropic.claude-opus-5-5": _provider_reply("opus", total_tokens=447), + } + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "sonnet", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-sonnet-5-5", + "aws_region_name": "us-west-2", + }, + "model_info": model_info, + } + ] + ), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + counted: Final = await token_counter( + request=TokenCountRequest(model="sonnet", messages=[{"role": "user", "content": "hi"}]), call_endpoint=True + ) + + assert (counter.calls[0][0], counter.regions[0], counted.total_tokens) == counted_with + assert len(counter.calls) == 1 + + +@pytest.mark.parametrize( + ("aws_region_name", "counted_in"), + [("eu-central-1", "eu-central-1"), ("us-west-2", "us-east-1")], +) +@pytest.mark.asyncio +async def test_shipped_token_count_model_keeps_the_prompt_in_the_deployment_geography( + monkeypatch: pytest.MonkeyPatch, aws_region_name: str, counted_in: str +): + counter: Final = _ScriptedCounter({"anthropic.claude-opus-5-5": _provider_reply("opus", total_tokens=447)}) + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + { + "model_name": "sonnet", + "litellm_params": { + "model": "bedrock/anthropic.claude-sonnet-5-5", + "aws_region_name": aws_region_name, + }, + } + ] + ), + ) + monkeypatch.setattr( + litellm.proxy.proxy_server, "_get_provider_token_counter", lambda deployment, model: (counter, model, "bedrock") + ) + + await token_counter( + request=TokenCountRequest(model="sonnet", messages=[{"role": "user", "content": "hi"}]), call_endpoint=True + ) + + assert (counter.calls[0][0], counter.regions[0]) == ("anthropic.claude-opus-5-5", counted_in) + + +class _SlowVault: + def sync_read_secret(self, secret_name: str) -> None: + time.sleep(0.5) + + +async def _longest_stall(done: asyncio.Event) -> float: + if done.is_set(): + return 0.0 + started: Final = time.monotonic() + await asyncio.sleep(0.01) + return max(time.monotonic() - started, await _longest_stall(done)) + + +@pytest.mark.asyncio +async def test_region_lookup_for_a_shipped_count_model_leaves_the_event_loop_free(monkeypatch: pytest.MonkeyPatch): + from litellm.proxy import proxy_server + + counter: Final = _ScriptedCounter({"anthropic.claude-opus-5-5": _provider_reply("opus", total_tokens=447)}) + + def bedrock_counter(deployment: object, model: str) -> tuple[_ScriptedCounter, str, str]: + return counter, model, "bedrock" + + monkeypatch.setattr( + proxy_server, + "llm_router", + Router( + model_list=[{"model_name": "sonnet", "litellm_params": {"model": "bedrock/anthropic.claude-sonnet-5-5"}}] + ), + ) + monkeypatch.setattr(proxy_server, "_get_provider_token_counter", bedrock_counter) + monkeypatch.setattr(litellm, "secret_manager_client", _SlowVault()) + monkeypatch.setattr(litellm, "_key_management_system", KeyManagementSystem.HASHICORP_VAULT) + monkeypatch.setattr(litellm, "_key_management_settings", KeyManagementSettings(access_mode="read_only")) + done: Final = asyncio.Event() + + async def count() -> TokenCountResponse: + try: + return await token_counter( + request=TokenCountRequest(model="sonnet", messages=[{"role": "user", "content": "hi"}]), + call_endpoint=True, + ) + finally: + done.set() + + started: Final = time.monotonic() + stall, response = await asyncio.gather(_longest_stall(done), count()) + + assert time.monotonic() - started >= 1.0 + assert stall < 0.4 + assert response.total_tokens == 447 + + +@pytest.mark.asyncio +async def test_framing_still_applies_while_the_deployment_is_cooling_down(monkeypatch: pytest.MonkeyPatch): + router: Final = Router( + model_list=[ + { + "model_name": "framed", + "litellm_params": {"model": "openai/gpt-5.4-mini", "api_key": "sk-unused-framed"}, + "model_info": {"id": "framed-deployment", "token_count_framing": 100}, + } + ] + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", router) + messages: Final = [{"role": "user", "content": "hello there"}] + warm: Final = await token_counter(request=TokenCountRequest(model="framed", messages=messages)) + + router.cooldown_cache.add_deployment_to_cooldown( + model_id="framed-deployment", + original_exception=Exception("rate limited"), + exception_status=429, + cooldown_time=60, + ) + cooling: Final = await token_counter(request=TokenCountRequest(model="framed", messages=messages)) + + assert warm.total_tokens == litellm.token_counter(model="gpt-5.4-mini", messages=messages) + 100 + assert cooling.total_tokens - 100 == litellm.token_counter(model="framed", messages=messages) + assert cooling.count_source == "local_estimate" + + _GEMINI_TOOL_OUTPUT: Final = "line of a long file that the agent read earlier. " * 180 @@ -1360,7 +2694,7 @@ async def test_gemini_messages_count_tokens_applies_the_context_edits_inference_ router, {**request_options, "model": requested_model} ) - assert response == {"input_tokens": 41} + assert response == {"input_tokens": 41, "litellm_count_source": "provider"} assert len(sent_bodies) == 1 assert sent_bodies[0].decode().count(_GEMINI_TOOL_OUTPUT) == expected_tool_outputs_sent @@ -1426,7 +2760,7 @@ async def test_gemini_messages_count_tokens_keeps_tool_results_when_a_routable_d Router(model_list=model_list), {"model": requested_model} ) - assert response == {"input_tokens": 41} + assert response == {"input_tokens": 41, "litellm_count_source": "provider"} assert sent_bodies[0].decode().count(_GEMINI_TOOL_OUTPUT) == 3 @@ -1493,7 +2827,7 @@ async def test_gemini_messages_count_tokens_reads_the_opt_out_of_the_deployment_ make_router(), {**request_options, "model": requested_model} ) - assert response == {"input_tokens": 41} + assert response == {"input_tokens": 41, "litellm_count_source": "provider"} assert sent_bodies[0].decode().count(_GEMINI_TOOL_OUTPUT) == expected_tool_outputs_sent @@ -1517,7 +2851,7 @@ async def test_gemini_messages_count_tokens_reads_the_opt_out_of_the_completion_ with patch.object(proxy_server, "general_settings", {"completion_model": completion_model}): response, sent_bodies = await _count_through_the_messages_endpoint(router, {"model": requested_model}) - assert response == {"input_tokens": 41} + assert response == {"input_tokens": 41, "litellm_count_source": "provider"} assert sent_bodies[0].decode().count(_GEMINI_TOOL_OUTPUT) == expected_tool_outputs_sent @@ -1591,7 +2925,7 @@ async def test_gemini_messages_count_tokens_counts_the_unedited_body_when_drop_p router, {**request_options, "model": "gemini-agent", "context_management": _INVALID_COMPACT_EDIT} ) - assert response == {"input_tokens": 41} + assert response == {"input_tokens": 41, "litellm_count_source": "provider"} assert sent_bodies[0].decode().count(_GEMINI_TOOL_OUTPUT) == 3 diff --git a/tests/unit/proxy/test_response_model_sanitization.py b/tests/unit/proxy/test_response_model_sanitization.py index ba66d27609b..f52736453c1 100644 --- a/tests/unit/proxy/test_response_model_sanitization.py +++ b/tests/unit/proxy/test_response_model_sanitization.py @@ -68,6 +68,19 @@ def _decode_sse_chunk(chunk) -> str: return chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk +def _make_response_created_event(model: str | None): + from litellm.types.llms.openai import ( + ResponseCreatedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + return ResponseCreatedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_CREATED, + response=ResponsesAPIResponse(id="resp_test", created_at=0, output=[], model=model), + ) + + def test_restamp_streaming_chunk_skips_matching_model(): from litellm.proxy.proxy_server import _restamp_streaming_chunk_model @@ -117,19 +130,13 @@ def test_fast_serialize_returns_none_when_model_field_is_missing(): # produce a serialized result via the slow-path fallback, and it must # not contain ``"model": null``. serialized = _serialize_streaming_chunk(chunk) - payload_str = ( - serialized.decode("utf-8") if isinstance(serialized, bytes) else serialized - ) + payload_str = serialized.decode("utf-8") if isinstance(serialized, bytes) else serialized assert '"model": null' not in payload_str assert '"model":null' not in payload_str - assert json.loads(payload_str) == json.loads( - chunk.model_dump_json(exclude_none=True, exclude_unset=True) - ) + assert json.loads(payload_str) == json.loads(chunk.model_dump_json(exclude_none=True, exclude_unset=True)) -def test_proxy_chat_completion_does_not_return_provider_prefixed_model( - tmp_path, monkeypatch -): +def test_proxy_chat_completion_does_not_return_provider_prefixed_model(tmp_path, monkeypatch): """ Regression test: @@ -159,15 +166,11 @@ def test_proxy_chat_completion_does_not_return_provider_prefixed_model( monkeypatch.setattr( proxy_server.llm_router, # type: ignore[arg-type] "acompletion", - AsyncMock( - return_value=_make_minimal_chat_completion_response(model=internal_model) - ), + AsyncMock(return_value=_make_minimal_chat_completion_response(model=internal_model)), ) # Also no-op proxy logging hooks to keep this test focused and deterministic. - monkeypatch.setattr( - proxy_server.proxy_logging_obj, "during_call_hook", AsyncMock(return_value=None) - ) + monkeypatch.setattr(proxy_server.proxy_logging_obj, "during_call_hook", AsyncMock(return_value=None)) monkeypatch.setattr( proxy_server.proxy_logging_obj, "update_request_status", @@ -482,3 +485,385 @@ async def test_proxy_streaming_fastest_response_preserves_winning_model(monkeypa payload = json.loads(first[len("data: ") :].strip()) assert payload["model"] == winning_model assert payload["model"] != comma_separated_models + + +def test_restamp_streaming_chunk_rewrites_nested_response_model(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = _make_response_created_event("hosted_vllm/some-model") + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.response.model == "client-model" + assert chunk.response.model == "hosted_vllm/some-model" + + +def _make_response_completed_event(model: str): + from litellm.types.llms.openai import ( + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + return ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=ResponsesAPIResponse(id="resp_test", created_at=0, output=[], model=model), + ) + + +def test_restamp_streaming_chunk_rewrites_nested_model_on_completed_event(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = _make_response_completed_event("hosted_vllm/some-model") + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.response.model == "client-model" + assert chunk.response.model == "hosted_vllm/some-model", "the priced original must not change" + + +@pytest.mark.parametrize("event_type", ["response.in_progress", "response.failed", "response.incomplete"]) +def test_restamp_streaming_chunk_rewrites_nested_model_on_other_lifecycle_events(event_type): + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + payload = { + "type": event_type, + "sequence_number": 1, + "response": {"id": "resp_test", "object": "response", "created_at": 0, "output": [], "model": "gpt-backend"}, + } + chunk = OpenAIResponsesAPIConfig.get_event_model_class(event_type).model_validate(payload) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.response.model == "client-model" + assert chunk.response.model == "gpt-backend", "the priced original must not change" + + +def test_restamp_streaming_chunk_leaves_nested_none_model_untouched(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = _make_response_created_event(None) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.response.model is None + + +@pytest.mark.parametrize("event_type", ["response.output_text.delta", "response.custom_event"]) +def test_restamp_streaming_chunk_does_not_add_nested_response_to_other_events(event_type): + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = OpenAIResponsesAPIConfig.get_event_model_class(event_type).model_validate( + { + "type": event_type, + "sequence_number": 3, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "hi", + } + ) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert "response" not in result.model_dump() + assert result.delta == "hi" + + +def test_restamp_streaming_chunk_keeps_nested_model_when_raw_name_requested(): + from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = _make_response_created_event("hosted_vllm/some-model") + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"metadata": {RETURN_RAW_MODEL_NAME_METADATA_KEY: True}}, + model_mismatch_logged=False, + ) + + assert result.response.model == "hosted_vllm/some-model" + + +def test_restamp_streaming_chunk_uses_fallback_model_for_nested_response(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = _make_response_created_event("hosted_vllm/some-model") + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + fallback_was_attempted=True, + fallback_model_from_metadata="fallback-public", + ) + + assert result.response.model == "fallback-public" + + +@pytest.mark.asyncio +async def test_proxy_responses_stream_reports_requested_model_in_nested_response(monkeypatch): + """ + End-to-end regression for /v1/responses streaming: + + The nested `response.model` on lifecycle events reaching the wire must report the + client-requested model, not the provider-prefixed deployment name. + """ + client_model = "vllm-model" + internal_model = f"hosted_vllm/{client_model}" + + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + + async def _iterator_hook( + user_api_key_dict: UserAPIKeyAuth, + response: AsyncGenerator, + request_data: dict, + ): + yield _make_response_created_event(model=internal_model) + + monkeypatch.setattr( + proxy_server.proxy_logging_obj, + "async_post_call_streaming_iterator_hook", + _iterator_hook, + ) + monkeypatch.setattr( + proxy_server.proxy_logging_obj, + "async_post_call_streaming_hook", + AsyncMock(side_effect=lambda **kwargs: kwargs["response"]), + ) + for name in ("has_streaming_callbacks", "needs_iterator_wrap", "needs_per_chunk_streaming_hook"): + monkeypatch.setattr(proxy_server.proxy_logging_obj, name, MagicMock(return_value=True)) + + gen = proxy_server.async_data_generator( + response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(api_key=MASTER_KEY), + request_data={"model": client_model}, + responses_stream_errors=True, + ) + + payloads = [] + async for item in gen: + text = _decode_sse_chunk(item) + for line in text.splitlines(): + if line.startswith("data: ") and line.strip() != "data: [DONE]": + payloads.append(json.loads(line[len("data: ") :].strip())) + + created = next(p for p in payloads if p.get("type") == "response.created") + assert created["response"]["model"] == client_model + assert not created["response"]["model"].startswith("hosted_vllm/") + + +def test_restamp_streaming_chunk_rewrites_nested_model_on_queued_event(): + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + queued_payload = { + "type": "response.queued", + "sequence_number": 0, + "response": {"id": "resp_test", "object": "response", "status": "queued", "model": "gpt-backend-2026-01-01"}, + } + chunk = OpenAIResponsesAPIConfig.get_event_model_class("response.queued").model_validate(queued_payload) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.model_dump()["response"] == {**queued_payload["response"], "model": "client-model"} + assert chunk.model_dump()["response"]["model"] == "gpt-backend-2026-01-01", "the priced original must not change" + + +def test_restamp_streaming_chunk_rewrites_nested_model_when_lifecycle_event_kept_raw_response(): + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + partial_response = {"id": "resp_test", "object": "response", "status": "in_progress", "model": "gpt-backend-2026-01-01"} + chunk = OpenAIResponsesAPIConfig().transform_streaming_response( + model="gpt-backend", + parsed_chunk={"type": "response.created", "sequence_number": 0, "response": partial_response}, + logging_obj=MagicMock(), + ) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result.response == {**partial_response, "model": "client-model"} + assert chunk.response["model"] == "gpt-backend-2026-01-01", "the priced original must not change" + + +def test_restamp_streaming_chunk_leaves_generic_event_without_nested_response_untouched(): + from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + shell_payload = {"type": "response.shell_call.in_progress", "sequence_number": 3, "item_id": "sh_1"} + chunk = OpenAIResponsesAPIConfig.get_event_model_class(shell_payload["type"]).model_validate(shell_payload) + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert "response" not in result.model_dump() + + +@pytest.mark.parametrize("stream_started", [False, True]) +def test_restamp_streaming_chunk_rewrites_nested_model_in_guardrail_block_frames(stream_started): + from litellm.exceptions import ModifyResponseException + from litellm.llms.openai.responses.guardrail_translation.handler import OpenAIResponsesHandler + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + sent_before_block = ( + [{"type": "response.created", "response": {"id": "resp_live", "model": "gpt-backend-2026-01-01"}}] + if stream_started + else [] + ) + block = ModifyResponseException( + message="Blocked by policy.", + model="gpt-backend-2026-01-01", + request_data={}, + original_response=sent_before_block, + ) + frames = OpenAIResponsesHandler().build_block_sse_chunks( + block, stream_started=stream_started, responses_so_far=sent_before_block + ) + client_frames = [ + _restamp_streaming_chunk_model( + chunk=frame, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + )[0] + for frame in frames + ] + events = [json.loads(frame.decode().removeprefix("data: ")) for frame in client_frames] + + assert all(frame.endswith(b"\n\n") for frame in client_frames) + assert events[-1]["type"] == "response.completed" + assert events[-1]["response"]["output"][0]["content"][0]["text"] == "Blocked by policy." + assert {event["response"]["model"] for event in events if "response" in event} == {"client-model"} + assert [frame for frame, event in zip(client_frames, events) if "response" not in event] == [ + frame for frame in frames if b'"response"' not in frame + ] + + +def test_restamp_streaming_chunk_rewrites_nested_model_on_dict_lifecycle_events(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + chunk = {"type": "response.completed", "response": {"id": "resp_test", "model": "gpt-backend-2026-01-01"}} + result, _ = _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + assert result["response"] == {"id": "resp_test", "model": "client-model"} + assert chunk["response"]["model"] == "gpt-backend-2026-01-01", "the priced original must not change" + + +_BACKEND_COMPLETED_EVENT = b'{"type":"response.completed","response":{"id":"resp_1","model":"gpt-backend-2026-01-01"}}' + + +@pytest.mark.parametrize( + "frame", + [ + pytest.param(b"event: response.completed\ndata: " + _BACKEND_COMPLETED_EVENT + b"\n\n", id="event_line_first"), + pytest.param(b"data:" + _BACKEND_COMPLETED_EVENT + b"\n\n", id="no_space_after_data"), + pytest.param( + b"data: " + _BACKEND_COMPLETED_EVENT + b"\n\ndata: " + _BACKEND_COMPLETED_EVENT + b"\r\n\r\n", + id="coalesced_frames", + ), + ], +) +def test_restamp_streaming_chunk_rewrites_nested_model_on_every_sse_data_line(frame): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + result, _ = _restamp_streaming_chunk_model( + chunk=frame, + requested_model_from_client="client-model", + request_data={"litellm_call_id": "test-call-id"}, + model_mismatch_logged=False, + ) + + def is_data_line(line: bytes) -> bool: + return line.startswith(b"data:") + + result_lines = result.splitlines(keepends=True) + frame_lines = frame.splitlines(keepends=True) + data_events = [json.loads(line.removeprefix(b"data:")) for line in result_lines if is_data_line(line)] + assert len(data_events) == len([line for line in frame_lines if is_data_line(line)]) + assert {event["response"]["model"] for event in data_events} == {"client-model"} + assert [line for line in result_lines if not is_data_line(line)] == [ + line for line in frame_lines if not is_data_line(line) + ] + assert [line[len(line.rstrip(b"\r\n")) :] for line in result_lines] == [ + line[len(line.rstrip(b"\r\n")) :] for line in frame_lines + ] + + +_SKIPPED_RESTAMPS = { + "azure_model_router": ("azure_ai/model_router", {}), + "fastest_response": ("openai/gpt-4o,gemini/gemini-2.5-flash", {"fastest_response": True}), +} + + +@pytest.mark.parametrize("skip", _SKIPPED_RESTAMPS.keys()) +def test_restamp_streaming_chunk_keeps_nested_model_where_the_top_level_model_is_kept(skip): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + requested_model, request_data = _SKIPPED_RESTAMPS[skip] + frame = b"data: " + _BACKEND_COMPLETED_EVENT + b"\n\n" + results = [ + _restamp_streaming_chunk_model( + chunk=chunk, + requested_model_from_client=requested_model, + request_data=request_data, + model_mismatch_logged=False, + )[0] + for chunk in (_make_response_created_event("gpt-backend-2026-01-01"), frame) + ] + + assert results[0].response.model == "gpt-backend-2026-01-01" + assert results[1] == frame + + +def test_restamp_streaming_chunk_leaves_raw_sse_passthrough_frames_untouched(): + from litellm.proxy.proxy_server import _restamp_streaming_chunk_model + + frame = b"data: " + _BACKEND_COMPLETED_EVENT + b"\n\n" + result, _ = _restamp_streaming_chunk_model( + chunk=frame, + requested_model_from_client="client-model", + request_data={"_litellm_raw_sse_stream": True}, + model_mismatch_logged=False, + ) + + assert result == frame diff --git a/tests/unit/proxy/test_route_llm_request.py b/tests/unit/proxy/test_route_llm_request.py index 67f29220cb6..4e4e2f23950 100644 --- a/tests/unit/proxy/test_route_llm_request.py +++ b/tests/unit/proxy/test_route_llm_request.py @@ -1,4 +1,7 @@ +import httpx +import litellm import pytest +import respx from types import SimpleNamespace @@ -1890,3 +1893,92 @@ async def test_null_user_config_is_treated_as_absent() -> None: ) assert response.choices[0].message.content == "from the main router" + + +def _registered_bedrock_kb_search(team_id: str) -> dict[str, object]: + """The request a team key's /v1/vector_stores/{id}/search carries once the registry row is merged in.""" + return { + "model": None, + "vector_store_id": "KB12345678", + "query": "litellm proxy", + "custom_llm_provider": "bedrock", + "aws_region_name": "us-east-1", + "aws_access_key_id": "AKIAREGISTRYKEY00000", + "aws_secret_access_key": "registry-secret", + "metadata": {"user_api_key_team_id": team_id}, + } + + +def _team_a_router(*team_models: tuple[str, dict[str, object]]) -> litellm.Router: + return litellm.Router( + model_list=[ + { + "model_name": f"internal-team-a-{public_name}", + "litellm_params": litellm_params, + "model_info": { + "id": f"team-a-{public_name}", + "team_id": "team-a", + "team_public_model_name": public_name, + }, + } + for public_name, litellm_params in team_models + ] + ) + + +_LANGGRAPH_AGENT_PARAMS: Final[dict[str, object]] = { + "model": "langgraph/repro-agent", + "api_base": "http://127.0.0.1:2024", +} +_BEDROCK_CLAUDE_PARAMS: Final[dict[str, object]] = { + "model": "bedrock/anthropic.claude-sonnet-4-6", + "aws_region_name": "us-east-1", +} + + +@pytest.mark.parametrize( + "team_models", + [ + pytest.param( + [("a-langgraph-agent", _LANGGRAPH_AGENT_PARAMS), ("a-bedrock-claude", _BEDROCK_CLAUDE_PARAMS)], + id="langgraph-first", + ), + pytest.param( + [("a-bedrock-claude", _BEDROCK_CLAUDE_PARAMS), ("a-langgraph-agent", _LANGGRAPH_AGENT_PARAMS)], + id="bedrock-first", + ), + pytest.param([("a-langgraph-agent", _LANGGRAPH_AGENT_PARAMS)], id="no-bedrock-team-model"), + ], +) +@pytest.mark.asyncio +async def test_team_key_search_of_registered_bedrock_store_reaches_bedrock_with_registry_credentials( + monkeypatch: pytest.MonkeyPatch, + team_models: list[tuple[str, dict[str, object]]], +) -> None: + """A team key's model-less search of a registered Bedrock store was routed through the team's first + deployment, so a langgraph team model turned it into "Vector store search is not supported for langgraph".""" + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + with respx.mock(assert_all_called=True) as respx_mock: + route: Final = respx_mock.post( + "https://bedrock-agent-runtime.us-east-1.amazonaws.com/knowledgebases/KB12345678/retrieve" + ).mock(return_value=httpx.Response(200, json={"retrievalResults": []})) + response: Final = await ( + await route_request( + _registered_bedrock_kb_search("team-a"), + _team_a_router(*team_models), + None, + "avector_store_search", + ) + ) + + request: Final = route.calls.last.request + posted_url: Final = str(request.url) + signed_auth: Final = request.headers["Authorization"] + assert response["object"] == "vector_store.search_results.page", response + assert route.call_count == 1 + assert posted_url == "https://bedrock-agent-runtime.us-east-1.amazonaws.com/knowledgebases/KB12345678/retrieve", ( + posted_url + ) + assert "Credential=AKIAREGISTRYKEY00000/" in signed_auth, signed_auth diff --git a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index ae0ac32c62d..25f1301dd9d 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -16,12 +16,14 @@ from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( LATEST_RELEASE_CACHE_TTL_SECONDS, LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS, LATEST_RELEASE_URL, + RELEASE_BY_TAG_URL, LatestReleaseInfo, LatestReleaseUnavailable, _default_cache, _default_client, _default_fetch_lock, count_release_bullets, + earlier_patch_tags, get_latest_release_info, ) from tests._master_key import MASTER_KEY @@ -233,6 +235,124 @@ class TestGetLatestReleaseInfo: assert len(client.calls) == 1 +def _release(tag: str, body: str | None, *, prerelease: bool = False) -> dict[str, object]: + return { + "tag_name": tag, + "html_url": f"https://github.com/BerriAI/litellm/releases/tag/{tag}", + "body": body, + "prerelease": prerelease, + "draft": False, + } + + +def _tag_url(tag: str) -> str: + return RELEASE_BY_TAG_URL.format(tag=tag) + + +class _GitHubByUrl: + """Answers each GitHub URL from a fixed map; unknown URLs are 404, like a tag with no release.""" + + def __init__(self, routes: dict[str, httpx.Response | Exception]) -> None: + self._routes = routes + self.urls: list[str] = [] + + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.urls.append(url) + outcome = self._routes.get(url, httpx.Response(404, content=b"{}")) + if isinstance(outcome, Exception): + raise outcome + return outcome + + +PATCH_RELEASE: Final = _release("v1.102.2", "* fix(proxy): second backport by @k in https://x/pull/10\n") +FIRST_PATCH: Final = _release("v1.102.1", "* fix(proxy): first backport by @k in https://x/pull/9\n") + + +def _minor_line(**overrides: httpx.Response | Exception) -> dict[str, httpx.Response | Exception]: + routes: dict[str, httpx.Response | Exception] = { + LATEST_RELEASE_URL: _github_response(payload=PATCH_RELEASE), + _tag_url("v1.102.0"): _github_response(payload=SAMPLE_RELEASE), + _tag_url("v1.102.1"): _github_response(payload=FIRST_PATCH), + } + routes.update({_tag_url(tag): outcome for tag, outcome in overrides.items()}) + return routes + + +async def _info(client: _GitHubByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable: + return await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) + + +class TestPatchReleaseAggregatesMinorLine: + def test_earlier_patch_tags_cover_the_whole_line(self): + assert earlier_patch_tags((1, 105, 2)) == ("v1.105.0", "v1.105.1") + assert earlier_patch_tags((1, 105, 0)) == () + + @pytest.mark.asyncio + async def test_patch_release_counts_include_every_earlier_patch_on_its_line(self): + client = _GitHubByUrl(_minor_line()) + result = await _info(client) + assert isinstance(result, LatestReleaseInfo) + assert result.model_dump() == { + "version": "1.102.2", + "new_features": 2, + "bug_fixes": 4, + "other_updates": 4, + "release_url": PATCH_RELEASE["html_url"], + } + assert sorted(client.urls) == sorted([LATEST_RELEASE_URL, _tag_url("v1.102.0"), _tag_url("v1.102.1")]) + + @pytest.mark.asyncio + async def test_minor_release_makes_a_single_request(self): + client = _RecordingClient([_github_response()]) + await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) + assert client.calls == [(LATEST_RELEASE_URL, 5)] + + @pytest.mark.asyncio + async def test_non_semver_latest_tag_counts_only_its_own_notes(self): + client = _GitHubByUrl({LATEST_RELEASE_URL: _github_response(payload=_release("v1.102.1-hotfix", "* fix: x\n"))}) + result = await _info(client) + assert isinstance(result, LatestReleaseInfo) + assert (result.new_features, result.bug_fixes, result.other_updates) == (0, 1, 0) + assert client.urls == [LATEST_RELEASE_URL] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "skipped", + [ + httpx.Response(404, content=b"{}"), + _github_response(payload=_release("v1.102.1", "* feat: rc only\n", prerelease=True)), + _github_response(payload=_release("v1.102.1", None)), + ], + ids=["never_published", "prerelease", "null_body"], + ) + async def test_missing_prerelease_or_empty_patch_adds_nothing(self, skipped: httpx.Response): + result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.1": skipped}))) + assert isinstance(result, LatestReleaseInfo) + assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4) + + @pytest.mark.asyncio + async def test_latest_release_with_null_body_still_counts_its_line(self): + routes = _minor_line() + routes[LATEST_RELEASE_URL] = _github_response(payload=_release("v1.102.2", None)) + result = await _info(_GitHubByUrl(routes)) + assert isinstance(result, LatestReleaseInfo) + assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "outcome", + [ + httpx.ConnectError("boom"), + _github_response(status=403, payload={"message": "rate limited"}), + httpx.Response(200, content=b"not json"), + ], + ids=["unreachable", "rate_limited", "not_json"], + ) + async def test_earlier_patch_failure_is_unavailable(self, outcome: httpx.Response | Exception): + result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.0": outcome}))) + assert isinstance(result, LatestReleaseUnavailable) + + class TestLatestReleaseInfoEndpoint: def test_returns_release_stats_for_authenticated_user(self, http_client): _override_dependencies(_RecordingClient([_github_response()]), _fresh_cache(), LitellmUserRoles.INTERNAL_USER) diff --git a/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 2f70c1f5188..48642a9b50d 100644 --- a/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/unit/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -6,6 +6,7 @@ import pytest from fastapi import HTTPException, Request import litellm +from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( LiteLLM_ManagedVectorStore, ) @@ -3256,3 +3257,159 @@ class TestConfigOwnedVectorStores: assert response["status"] == "success", response prisma.db.litellm_managedvectorstorestable.delete.assert_awaited_once_with(where={"vector_store_id": self.DB_ID}) assert registry.get_litellm_managed_vector_store_from_registry(self.DB_ID) is None + + +CALLERS_METADATA = {"team": "llmproxy", "endpoint": "ingest", "trace_id": "callers-trace"} + + +class _PreCallBodies(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.logged_bodies = [] + + def log_pre_api_call(self, model, messages, kwargs): + self.logged_bodies.append(kwargs["litellm_params"]["proxy_server_request"]["body"]) + + +@pytest.mark.parametrize("operation", ["create", "update"]) +@pytest.mark.parametrize("model", [None, "gpt-5"]) +@pytest.mark.parametrize( + ("metadata_field", "expected_metadata"), + [ + ({"metadata": CALLERS_METADATA}, CALLERS_METADATA), + ({"metadata": {}}, {}), + ({"metadata": json.dumps(CALLERS_METADATA)}, CALLERS_METADATA), + ({"metadata": None}, None), + ({}, None), + ], +) +def test_proxy_vector_store_writes_send_the_provider_exactly_the_callers_metadata( + monkeypatch, operation, model, metadata_field, expected_metadata +): + import httpx + import respx + from fastapi.testclient import TestClient + + from litellm.constants import VECTOR_STORE_CALLER_METADATA_KWARG + from litellm.proxy import proxy_server + from litellm.proxy.proxy_server import app + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + monkeypatch.setattr(proxy_server, "master_key", "sk-master-test") + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[{"model_name": "gpt-5", "litellm_params": {"model": "openai/gpt-5", "api_key": "sk-test"}}] + ), + ) + pre_call_bodies = _PreCallBodies() + monkeypatch.setattr(litellm, "callbacks", [pre_call_bodies]) + sent_bodies = [] + + def echo(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + sent_bodies.append(body) + return httpx.Response( + 200, + json={ + "id": "vs_1", + "object": "vector_store", + "created_at": 0, + "name": "x", + "usage_bytes": 0, + "file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0}, + "status": "completed", + "metadata": body.get("metadata"), + }, + ) + + path = "/v1/vector_stores" if operation == "create" else "/v1/vector_stores/vs_1" + routing = {} if model is None else {"model": model} + with respx.mock(assert_all_called=False) as upstream: + upstream.post(url__regex=r"https://api\.openai\.com/v1/vector_stores.*").mock(side_effect=echo) + response = TestClient(app).post( + path, + json={"name": "x", **routing, **metadata_field}, + headers={ + "Authorization": "Bearer sk-master-test", + "x-litellm-tags": "header-tag", + "x-litellm-trace-id": "header-trace", + "x-litellm-session-id": "header-session", + }, + ) + + assert response.status_code == 200, response.text + assert len(sent_bodies) == 1 + assert sent_bodies[0].get("metadata") == expected_metadata + assert len(pre_call_bodies.logged_bodies) == 1 + assert "name" in pre_call_bodies.logged_bodies[0] + assert VECTOR_STORE_CALLER_METADATA_KWARG not in pre_call_bodies.logged_bodies[0] + + +@pytest.mark.parametrize("operation", ["create", "update"]) +@pytest.mark.parametrize( + "request_body", + [ + {"data": {"name": "x", "metadata": json.dumps(CALLERS_METADATA)}}, + { + "content": json.dumps({"name": "x", "metadata": CALLERS_METADATA})[:-1] + + ', "nested": ' + + "[" * 300 + + "]" * 300 + + "}", + "headers": {"Content-Type": "application/json"}, + }, + ], + ids=["form", "json-too-deep-to-reparse"], +) +def test_proxy_vector_store_writes_send_only_the_callers_metadata_when_the_raw_body_cannot_be_reparsed( + monkeypatch, operation, request_body +): + import httpx + import respx + from fastapi.testclient import TestClient + + from litellm.proxy import proxy_server + from litellm.proxy.proxy_server import app + + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + monkeypatch.setattr(proxy_server, "master_key", "sk-master-test") + monkeypatch.setattr(proxy_server, "llm_router", litellm.Router(model_list=[])) + sent_bodies = [] + + def echo(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + sent_bodies.append(body) + return httpx.Response( + 200, + json={ + "id": "vs_1", + "object": "vector_store", + "created_at": 0, + "name": "x", + "usage_bytes": 0, + "file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0}, + "status": "completed", + "metadata": body.get("metadata"), + }, + ) + + path = "/v1/vector_stores" if operation == "create" else "/v1/vector_stores/vs_1" + headers = { + "Authorization": "Bearer sk-master-test", + "x-litellm-trace-id": "header-trace", + **request_body.get("headers", {}), + } + body_kwargs = {key: value for key, value in request_body.items() if key != "headers"} + with respx.mock(assert_all_called=False) as upstream: + upstream.post(url__regex=r"https://api\.openai\.com/v1/vector_stores.*").mock(side_effect=echo) + response = TestClient(app).post(path, headers=headers, **body_kwargs) + + assert response.status_code == 200, response.text + assert len(sent_bodies) == 1 + assert sent_bodies[0].get("metadata") == CALLERS_METADATA diff --git a/tests/unit/rag/ingestion/test_base_ingestion.py b/tests/unit/rag/ingestion/test_base_ingestion.py index d20b46d356b..63a0f0d7da5 100644 --- a/tests/unit/rag/ingestion/test_base_ingestion.py +++ b/tests/unit/rag/ingestion/test_base_ingestion.py @@ -1,4 +1,5 @@ from collections import UserString +from types import SimpleNamespace from typing import Final import httpx @@ -6,10 +7,18 @@ import pytest import respx import litellm +from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion from litellm.rag.ingestion.gemini_ingestion import GeminiRAGIngestion +from litellm.rag.ingestion.openai_ingestion import OpenAIRAGIngestion +from litellm.rag.ingestion.s3_vectors_ingestion import S3VectorsRAGIngestion +from litellm.rag.ingestion.vertex_ai_ingestion import VertexAIRAGIngestion from litellm.types.utils import CredentialItem _FILE_URL: Final = "https://files.example/docs/report.pdf" +_GEMINI_API_BASE: Final = "https://gemini.example" +_GEMINI_STORE: Final = "fileSearchStores/docs-1" +_GEMINI_UPLOAD_SESSION_URL: Final = "https://gemini.example/upload/session-1" +_TEXT_FILE: Final = ("report.txt", b"plain text", "text/plain") _STORED_CREDENTIAL: Final = CredentialItem( credential_name="gemini-prod", credential_info={}, @@ -17,6 +26,32 @@ _STORED_CREDENTIAL: Final = CredentialItem( ) +class _UnauthorizedIngestionError(Exception): + status_code: Final = 401 + + +class _FailingStoreIngestion(BaseRAGIngestion): + def __init__(self, failure: Exception) -> None: + super().__init__(ingest_options={"vector_store": {}}) + self._failure = failure + + async def store( + self, + file_content: bytes | None, + filename: str | None, + content_type: str | None, + chunks: list[str], + embeddings: list[list[float]] | None, + existing_file_id: str | None = None, + ) -> tuple[str | None, str | None]: + raise self._failure + + +class _EmbeddingRouter: + async def aembedding(self, model: str, input: list[str]) -> SimpleNamespace: + return SimpleNamespace(data=[{"embedding": [0.1, 0.2]} for _ in input]) + + def _vector_store_options(litellm_credential_name: object) -> dict[str, object]: return { "custom_llm_provider": "gemini", @@ -99,3 +134,132 @@ async def test_upload_from_a_url_takes_the_content_type_from_the_response_header uploaded: Final = await ingestion.upload(file_url=_FILE_URL) assert uploaded == ("report.pdf", b"%PDF-1.7", expected_content_type, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("failure", "expected_status_code"), + [ + (_UnauthorizedIngestionError("unauthorized"), 401), + (RuntimeError("ingestion failed"), None), + ], +) +async def test_ingest_response_preserves_integer_exception_status_code( + failure: Exception, + expected_status_code: int | None, +) -> None: + response: Final = await _FailingStoreIngestion(failure).ingest( + file_data=("report.txt", b"plain text", "text/plain") + ) + + assert response["error_status_code"] == expected_status_code + + +async def test_an_existing_file_id_on_a_store_that_cannot_take_one_fails_as_a_bad_request() -> None: + response: Final = await _FailingStoreIngestion(RuntimeError("store must not run")).ingest(file_id="file-abc") + + assert response["status"] == "failed" + assert response["error_status_code"] == 400 + assert "does not support ingesting an existing file_id" in str(response["error"]) + + +@pytest.mark.parametrize( + ("ingestion_class", "ingest_kwargs"), + [ + (OpenAIRAGIngestion, {"file_id": "file-abc"}), + (VertexAIRAGIngestion, {"file_data": ("report.txt", b"plain text", "text/plain")}), + ], +) +async def test_a_vector_store_config_missing_a_caller_field_fails_as_a_bad_request( + ingestion_class: type[BaseRAGIngestion], + ingest_kwargs: dict[str, object], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VERTEXAI_PROJECT", raising=False) + ingestion: Final = ingestion_class(ingest_options={"vector_store": {"api_key": "caller-key"}}) + + response: Final = await ingestion.ingest(**ingest_kwargs) + + assert response["status"] == "failed" + assert response["error_status_code"] == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("vector_store_id", "upstream_statuses", "expected_status_code"), + [ + (None, {"create_store": 401}, 401), + (_GEMINI_STORE, {"start_upload": 429}, 429), + (_GEMINI_STORE, {"upload": 503}, 503), + ], +) +async def test_a_gemini_upstream_error_keeps_its_status_on_the_failed_ingest( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + vector_store_id: str | None, + upstream_statuses: dict[str, int], + expected_status_code: int, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + respx_mock.post(f"{_GEMINI_API_BASE}/v1beta/fileSearchStores").mock( + return_value=httpx.Response(upstream_statuses.get("create_store", 200), json={"name": _GEMINI_STORE}) + ) + respx_mock.post(f"{_GEMINI_API_BASE}/upload/v1beta/{_GEMINI_STORE}:uploadToFileSearchStore").mock( + return_value=httpx.Response( + upstream_statuses.get("start_upload", 200), headers={"x-goog-upload-url": _GEMINI_UPLOAD_SESSION_URL} + ) + ) + respx_mock.put(_GEMINI_UPLOAD_SESSION_URL).mock( + return_value=httpx.Response(upstream_statuses.get("upload", 200), json={"name": f"{_GEMINI_STORE}/documents/d"}) + ) + vector_store: Final = { + "custom_llm_provider": "gemini", + "api_key": "test-key", + "api_base": _GEMINI_API_BASE, + **({"vector_store_id": vector_store_id} if vector_store_id else {}), + } + + response: Final = await GeminiRAGIngestion(ingest_options={"vector_store": vector_store}).ingest( + file_data=_TEXT_FILE + ) + + assert response["status"] == "failed" + assert response["error_status_code"] == expected_status_code + + +@pytest.mark.asyncio +async def test_a_gemini_store_with_no_api_key_anywhere_fails_without_a_caller_error_status(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + ingestion: Final = GeminiRAGIngestion(ingest_options={"vector_store": {"custom_llm_provider": "gemini"}}) + + response: Final = await ingestion.ingest(file_data=_TEXT_FILE) + + assert response["status"] == "failed" + assert response.get("error_status_code") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "vector_bucket_name", + ["ab", "bucket_with_underscores"], +) +async def test_an_invalid_s3_vectors_bucket_name_fails_as_a_bad_request(vector_bucket_name: str) -> None: + ingestion: Final = S3VectorsRAGIngestion( + ingest_options={ + "embedding": {"model": "text-embedding-3-small"}, + "vector_store": { + "custom_llm_provider": "s3_vectors", + "aws_region_name": "us-west-2", + "vector_bucket_name": vector_bucket_name, + "dimension": 2, + }, + }, + router=_EmbeddingRouter(), + ) + + response: Final = await ingestion.ingest(file_data=_TEXT_FILE) + + assert response["status"] == "failed" + assert response["error_status_code"] == 400 diff --git a/tests/unit/responses/litellm_completion_transformation/test_session_handler_with_cold_storage.py b/tests/unit/responses/litellm_completion_transformation/test_session_handler_with_cold_storage.py index f2fbcda59a4..1565820cbbd 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_session_handler_with_cold_storage.py +++ b/tests/unit/responses/litellm_completion_transformation/test_session_handler_with_cold_storage.py @@ -10,20 +10,18 @@ Tests for the changes to integrate cold storage handling across different compon """ import json -from datetime import datetime, timezone -from typing import Optional -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from litellm.integrations.s3_v2 import S3Logger -from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload +from litellm.proxy._types import SpendLogsMetadata from litellm.proxy.spend_tracking.cold_storage_handler import ColdStorageHandler from litellm.proxy.spend_tracking.spend_tracking_utils import _get_spend_logs_metadata from litellm.responses.litellm_completion_transformation.session_handler import ( ResponsesSessionHandler, ) -from litellm.types.utils import StandardLoggingMetadata, StandardLoggingPayload +from litellm.types.utils import StandardLoggingMetadata class TestColdStorageObjectKeyIntegration: @@ -57,7 +55,9 @@ class TestColdStorageObjectKeyIntegration: """ # Create a SpendLogsMetadata instance with cold_storage_object_key metadata = SpendLogsMetadata( - user_api_key="test_key", cold_storage_object_key="test/path/to/object.json" + user_api_key="test_key", + user_api_key_user_email=None, + cold_storage_object_key="test/path/to/object.json", ) # Verify the field can be set and accessed diff --git a/tests/unit/responses/test_responses_api_bridge_flag.py b/tests/unit/responses/test_responses_api_bridge_flag.py index 699144a31bb..63204114501 100644 --- a/tests/unit/responses/test_responses_api_bridge_flag.py +++ b/tests/unit/responses/test_responses_api_bridge_flag.py @@ -6,18 +6,28 @@ Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. """ +import copy import json +from collections.abc import Mapping from importlib import import_module -from typing import Final +from typing import Final, cast from unittest.mock import MagicMock, patch import httpx import openai import pytest import respx +from pydantic import JsonValue import litellm +from litellm import Router from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.responses.bridge_check import ( + backend_serves_chat_completions, + bridge_reasoning_effort, + deployment_routing_mode, + responses_api_bridge_check, +) from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse from litellm.types.utils import Choices, Message, ModelResponse, Usage @@ -48,12 +58,8 @@ class TestUseResponsesApiBridgeFlag: @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - def test_bridge_used_when_use_chat_completions_api_true( - self, mock_get_config, mock_bridge_handler - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + def test_bridge_used_when_use_chat_completions_api_true(self, mock_get_config, mock_bridge_handler): """When use_chat_completions_api=True, the bridge handler should be called.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_bridge_handler.return_value = MagicMock() @@ -70,9 +76,7 @@ class TestUseResponsesApiBridgeFlag: @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") def test_provider_affinity_header_is_forwarded_through_bridge(self, mock_get_config, mock_bridge_handler): mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_bridge_handler.return_value = MagicMock() @@ -94,12 +98,8 @@ class TestUseResponsesApiBridgeFlag: @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - def test_bridge_used_when_model_uses_chat_completions_prefix( - self, mock_get_config, mock_bridge_handler - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + def test_bridge_used_when_model_uses_chat_completions_prefix(self, mock_get_config, mock_bridge_handler): """`openai/chat_completions/` normalizes to `openai/` and uses the bridge.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_bridge_handler.return_value = MagicMock() @@ -115,12 +115,8 @@ class TestUseResponsesApiBridgeFlag: assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model") @patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler") - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - def test_native_forwarding_when_flag_absent( - self, mock_get_config, mock_native_handler - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + def test_native_forwarding_when_flag_absent(self, mock_get_config, mock_native_handler): """When use_chat_completions_api is not set, openai/ models should use native responses API forwarding (existing behavior).""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() @@ -137,9 +133,7 @@ class TestUseResponsesApiBridgeFlag: @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): """use_chat_completions_api should be popped and not passed to the bridge handler.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() @@ -159,12 +153,8 @@ class TestUseResponsesApiBridgeFlag: @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - def test_bridge_used_when_provider_config_none( - self, mock_get_config, mock_bridge_handler - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + def test_bridge_used_when_provider_config_none(self, mock_get_config, mock_bridge_handler): """When the provider has no native responses API config (returns None), the bridge should be used regardless of the flag (existing behavior).""" mock_get_config.return_value = None @@ -179,12 +169,8 @@ class TestUseResponsesApiBridgeFlag: mock_bridge_handler.assert_called_once() @patch("litellm.acompletion") - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - async def test_allowed_openai_params_forwarded_through_bridge( - self, mock_get_config, mock_acompletion - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + async def test_allowed_openai_params_forwarded_through_bridge(self, mock_get_config, mock_acompletion): """allowed_openai_params is a named param of responses(), so it must be explicitly forwarded to the bridge; otherwise litellm.acompletion raises UnsupportedParamsError for params the caller explicitly allowed.""" @@ -212,9 +198,7 @@ class TestUseResponsesApiBridgeFlag: ) mock_acompletion.assert_called_once() - assert mock_acompletion.call_args.kwargs.get("allowed_openai_params") == [ - "reasoning_effort" - ] + assert mock_acompletion.call_args.kwargs.get("allowed_openai_params") == ["reasoning_effort"] @pytest.mark.parametrize( ("model", "upstream_url", "use_chat_completions_api", "allowed_openai_params", "expected_chat_template_kwargs"), @@ -287,9 +271,7 @@ class TestUseResponsesApiBridgeFlag: assert request_body["messages"] == [{"role": "user", "content": "Hello"}] assert response.output[0].content[0].text == "Answer" - def test_bridge_drops_client_metadata_even_when_allowed_openai_params_names_it( - self, respx_mock: respx.MockRouter - ): + def test_bridge_drops_client_metadata_even_when_allowed_openai_params_names_it(self, respx_mock: respx.MockRouter): upstream: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock( return_value=httpx.Response( status_code=200, @@ -441,12 +423,8 @@ class TestUseResponsesApiBridgeFlag: assert "chat_template_kwargs" not in json.loads(request.read()) @patch.object(import_module("litellm.responses.file_search.emulated_handler"), "_call_aresponses") - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - async def test_bridge_flag_forwarded_to_file_search_emulation( - self, mock_get_config, mock_call_aresponses - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + async def test_bridge_flag_forwarded_to_file_search_emulation(self, mock_get_config, mock_call_aresponses): """When use_chat_completions_api=True and file_search tool is present, the flag should be forwarded to the inner aresponses call in the file_search emulation path.""" @@ -458,12 +436,8 @@ class TestUseResponsesApiBridgeFlag: id="resp_123", model="openai/my-custom-model", created_at=1234567890, - output=[ - {"type": "message", "content": [{"type": "text", "text": "Answer"}]} - ], - usage=ResponseAPIUsage( - input_tokens=10, output_tokens=5, total_tokens=15 - ), + output=[{"type": "message", "content": [{"type": "text", "text": "Answer"}]}], + usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15), ) mock_call_aresponses.return_value = mock_response @@ -478,17 +452,15 @@ class TestUseResponsesApiBridgeFlag: # Verify _call_aresponses was called with use_chat_completions_api=True mock_call_aresponses.assert_called_once() call_kwargs = mock_call_aresponses.call_args.kwargs - assert ( - call_kwargs.get("use_chat_completions_api") is True - ), "use_chat_completions_api should be forwarded to inner aresponses call" + assert call_kwargs.get("use_chat_completions_api") is True, ( + "use_chat_completions_api should be forwarded to inner aresponses call" + ) @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) @patch("litellm.vector_stores.main.asearch") - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") async def test_bridge_flag_prevents_native_responses_endpoint_call( self, mock_get_config, mock_asearch, mock_bridge_handler ): @@ -512,9 +484,7 @@ class TestUseResponsesApiBridgeFlag: "arguments": '{"queries": ["test query"]}', } ], - usage=ResponseAPIUsage( - input_tokens=10, output_tokens=5, total_tokens=15 - ), + usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15), ) second_response = ResponsesAPIResponse( id="resp_second", @@ -526,9 +496,7 @@ class TestUseResponsesApiBridgeFlag: "content": [{"type": "text", "text": "Final answer"}], } ], - usage=ResponseAPIUsage( - input_tokens=20, output_tokens=10, total_tokens=30 - ), + usage=ResponseAPIUsage(input_tokens=20, output_tokens=10, total_tokens=30), ) mock_bridge_handler.side_effect = [first_response, second_response] @@ -547,8 +515,7 @@ class TestUseResponsesApiBridgeFlag: ) assert mock_bridge_handler.call_count == 2, ( - "Bridge handler should be called twice: initial function-tool call " - "and follow-up with tool results" + "Bridge handler should be called twice: initial function-tool call and follow-up with tool results" ) for call in mock_bridge_handler.call_args_list: all_kwargs = call.kwargs if call.kwargs else {} @@ -558,12 +525,8 @@ class TestUseResponsesApiBridgeFlag: @patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler") @patch("litellm.vector_stores.main.asearch") - @patch.object( - import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" - ) - async def test_without_bridge_flag_uses_native_endpoint( - self, mock_get_config, mock_asearch, mock_native_handler - ): + @patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config") + async def test_without_bridge_flag_uses_native_endpoint(self, mock_get_config, mock_asearch, mock_native_handler): """Without the bridge flag, openai/ with native config uses the native handler.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_asearch.return_value = [] @@ -577,9 +540,7 @@ class TestUseResponsesApiBridgeFlag: "content": [{"type": "text", "text": "Native response"}], } ], - usage=ResponseAPIUsage( - input_tokens=10, output_tokens=5, total_tokens=15 - ), + usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15), ) result = await litellm.aresponses( @@ -607,3 +568,287 @@ class TestUseResponsesApiBridgeFlag: assert exc_info.value.param == "stream_chunk_size" send.assert_not_called() + + +# Deployment-pinned routing mode (deployment_routing_mode / responses_api_bridge_check) +_CHAT: Final[Mapping[str, JsonValue]] = {"id": "dep-1", "mode": "chat"} +_RESPONSES: Final[Mapping[str, JsonValue]] = {"id": "dep-1", "mode": "responses"} + + +def _pin_shared_mode(monkeypatch: pytest.MonkeyPatch, model: str, mode: str) -> None: + cost_map: Final = cast(dict[str, dict[str, object]], litellm.model_cost) + monkeypatch.setitem(cost_map, model, {**cost_map.get(model, {}), "mode": mode}) + + +@pytest.mark.parametrize( + "deployment_model_info, custom_llm_provider, expected", + [ + (_CHAT, "bedrock_mantle", "chat"), + (_RESPONSES, "bedrock_mantle", "responses"), + (_CHAT, "openai", "chat"), + (_CHAT, "chatgpt", None), + (_CHAT, "github_copilot", None), + ({"id": "dep-1", "mode": "embedding"}, "bedrock_mantle", None), + ({"id": "dep-1"}, "bedrock_mantle", None), + (None, "bedrock_mantle", None), + ], +) +def test_deployment_routing_mode_pins_chat_or_responses_except_on_responses_only_backends( + deployment_model_info: Mapping[str, JsonValue] | None, custom_llm_provider: str, expected: str | None +) -> None: + assert deployment_routing_mode(deployment_model_info, custom_llm_provider) == expected + + +def test_a_chat_pinned_mantle_deployment_stays_on_chat_completions_while_the_shared_row_says_responses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model: Final = "bedrock_mantle/openai.gpt-5.6-sol" + _pin_shared_mode(monkeypatch, model, "responses") + shared_info, _ = responses_api_bridge_check(model=model, custom_llm_provider="bedrock_mantle") + pinned_info, pinned_model = responses_api_bridge_check( + model=model, custom_llm_provider="bedrock_mantle", deployment_model_info=_CHAT + ) + assert shared_info["mode"] == "responses" + assert (pinned_info["mode"], pinned_model) == ("chat", model) + + +def test_a_chat_pinned_chatgpt_alias_still_bridges_to_the_responses_api(monkeypatch: pytest.MonkeyPatch) -> None: + _pin_shared_mode(monkeypatch, "chatgpt/gpt-5.4", "responses") + info, _ = responses_api_bridge_check(model="gpt-5.4", custom_llm_provider="chatgpt", deployment_model_info=_CHAT) + assert info["mode"] == "responses" + + +_FUNCTION_TOOL: Final[dict[str, JsonValue]] = { + "type": "function", + "function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}}, +} + + +@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"]) +def test_a_chat_pin_cannot_pull_gpt_5_4_function_tool_calls_off_the_responses_api(custom_llm_provider: str) -> None: + """Chat Completions rejects function tools while reasoning is on for gpt-5.4+, so the capability rule + must still bridge the request even when the deployment pins ``mode: chat``.""" + info, model = responses_api_bridge_check( + model="gpt-5.4", + custom_llm_provider=custom_llm_provider, + tools=[_FUNCTION_TOOL], + api_base="https://example.openai.azure.com" if custom_llm_provider == "azure" else None, + deployment_model_info=_CHAT, + ) + assert (info["mode"], model) == ("responses", "gpt-5.4") + + +@pytest.mark.parametrize("model", ["openai.gpt-5.6-terra", "us-east-1/openai.gpt-5.6-terra"]) +@pytest.mark.parametrize("reasoning_effort", [None, "low"]) +def test_a_chat_pinned_mantle_deployment_sends_function_tools_to_the_responses_api( + model: str, reasoning_effort: str | None +) -> None: + """Mantle Chat Completions rejects function tools on gpt-5.4+ unless reasoning is off, so a ``mode: chat`` + pin cannot keep a tool call there.""" + info, routed_model = responses_api_bridge_check( + model=model, + custom_llm_provider="bedrock_mantle", + tools=[_FUNCTION_TOOL], + reasoning_effort=reasoning_effort, + deployment_model_info=_CHAT, + ) + assert (info["mode"], routed_model) == ("responses", model) + + +@pytest.mark.parametrize( + "reasoning_effort, reasoning_summary", + [("low", "auto"), ({"effort": "low", "summary": "auto"}, None)], +) +def test_a_chat_pinned_mantle_deployment_sends_a_reasoning_summary_to_the_responses_api( + reasoning_effort: str | Mapping[str, JsonValue], reasoning_summary: str | None +) -> None: + info, _ = responses_api_bridge_check( + model="openai.gpt-5.6-terra", + custom_llm_provider="bedrock_mantle", + reasoning_effort=reasoning_effort, + reasoning_summary=reasoning_summary, + deployment_model_info=_CHAT, + ) + assert info["mode"] == "responses" + + +def test_a_chat_pinned_mantle_deployment_sends_web_search_to_the_responses_api() -> None: + info, model = responses_api_bridge_check( + model="openai.gpt-5.6-terra", + custom_llm_provider="bedrock_mantle", + web_search_options={"search_context_size": "medium"}, + deployment_model_info=_CHAT, + ) + assert (info["mode"], model) == ("responses", "openai.gpt-5.6-terra") + + +@pytest.mark.parametrize( + "model, reasoning_effort", + [ + ("openai.gpt-5.6-terra", "none"), + ("openai.gpt-oss-120b", "low"), + ("openai.gpt-oss-safeguard-120b", "low"), + ], +) +def test_a_chat_pinned_mantle_tool_call_stays_on_chat_completions_when_chat_accepts_it( + model: str, reasoning_effort: str +) -> None: + info, routed_model = responses_api_bridge_check( + model=model, + custom_llm_provider="bedrock_mantle", + tools=[_FUNCTION_TOOL], + reasoning_effort=reasoning_effort, + deployment_model_info=_CHAT, + ) + assert (info["mode"], routed_model) == ("chat", model) + + +def test_a_chat_pin_cannot_pull_xai_web_search_off_the_responses_api() -> None: + info, model = responses_api_bridge_check( + model="grok-4", + custom_llm_provider="xai", + web_search_options={"search_context_size": "medium"}, + deployment_model_info=_CHAT, + ) + assert (info["mode"], model) == ("responses", "grok-4") + + +@pytest.mark.parametrize( + "model, custom_llm_provider, api_base", + [ + ("responses/my-azure-deployment", "azure", "https://example.openai.azure.com"), + ("responses/gpt-5.4", "openai", None), + ], +) +def test_a_chat_pin_does_not_override_an_explicit_responses_prefix( + model: str, custom_llm_provider: str, api_base: str | None +) -> None: + info, sent_model = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + deployment_model_info=_CHAT, + ) + assert (info["mode"], sent_model) == ("responses", model.removeprefix("responses/")) + + +def test_a_chat_pinned_responses_prefixed_router_deployment_reaches_the_responses_api( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + """The router registers a ``mode: chat`` row under the deployment's own ``responses/gpt-5.6`` name, + so the prefix has to win over that row too, or the request goes to Chat Completions as ``responses/gpt-5.6``.""" + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + responses_route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock( + return_value=httpx.Response( + 200, + json={ + "id": "resp_1", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-5.6", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + }, + ) + ) + router: Final = Router( + model_list=[ + { + "model_name": "prefixed-chat", + "litellm_params": { + "model": "openai/responses/gpt-5.6", + "custom_llm_provider": "openai", + "api_key": "sk-test", + }, + "model_info": {"id": "prefixed-chat-id", "mode": "chat"}, + } + ] + ) + + response: Final = router.completion(model="prefixed-chat", messages=[{"role": "user", "content": "Say ok"}]) + + assert json.loads(responses_route.calls[0].request.read())["model"] == "gpt-5.6" + assert response.choices[0].message.content == "ok" + + +def test_a_chat_pin_keeps_a_plain_gpt_5_4_chat_request_on_chat_completions() -> None: + """Without tools or a reasoning summary nothing forces the Responses API, so the deployment's own + mode decides and the shared ``responses`` row is left alone.""" + shared_mode = litellm.model_cost["gpt-5.4"]["mode"] + info, model = responses_api_bridge_check(model="gpt-5.4", custom_llm_provider="openai", deployment_model_info=_CHAT) + assert (info["mode"], model) == ("chat", "gpt-5.4") + assert litellm.model_cost["gpt-5.4"]["mode"] == shared_mode + + +@pytest.mark.parametrize( + "model, custom_llm_provider", + [ + ("gpt-5.1-codex", "azure"), + ("o3-pro", "openai"), + ("gpt-5.4-pro", "openai"), + ("eu/gpt-5-codex", "azure"), + ("us/gpt-5.4-pro", "azure"), + ("databricks-gpt-5-3-codex", "databricks"), + ], +) +def test_a_chat_pin_cannot_pull_a_responses_only_model_off_the_responses_api( + model: str, custom_llm_provider: str +) -> None: + """These rows list only ``/v1/responses`` in ``supported_endpoints``, or are ``responses`` rows with no + endpoint list at all, so a deployment pin to chat has nowhere to send the request and the bridge keeps it.""" + cost_map: Final = cast(dict[str, dict[str, object]], litellm.model_cost) + assert not backend_serves_chat_completions( + cost_map[model if custom_llm_provider == "openai" else f"{custom_llm_provider}/{model}"] + ) + info, routed_model = responses_api_bridge_check( + model=model, + custom_llm_provider=custom_llm_provider, + api_base="https://example.openai.azure.com" if custom_llm_provider == "azure" else None, + deployment_model_info=_CHAT, + ) + assert (info["mode"], routed_model) == ("responses", model) + + +@pytest.mark.parametrize( + "shared_model_info, expected", + [ + ({}, True), + ({"mode": "chat"}, True), + ({"mode": "responses"}, False), + ({"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]}, True), + ({"supported_endpoints": ["/v1/responses", "/v1/batch"]}, False), + ({"supported_endpoints": "/v1/chat/completions"}, True), + ], +) +def test_backend_serves_chat_completions_reads_the_shared_row( + shared_model_info: Mapping[str, JsonValue], expected: bool +) -> None: + assert backend_serves_chat_completions(shared_model_info) is expected + + +@pytest.mark.parametrize( + "extra_body, expected", + [ + ({"reasoning_effort": {"effort": "low", "summary": "auto"}}, {"effort": "low", "summary": "auto"}), + ({"reasoning_effort": "low"}, "low"), + ({"reasoning_effort": 5}, None), + ({"reasoning_effort": ["low"]}, None), + ({}, None), + ("reasoning_effort=low", None), + (None, None), + ], + ids=["dict", "string", "int", "list", "absent", "not-a-mapping", "no-extra-body"], +) +def test_bridge_reasoning_effort_reads_only_a_string_or_mapping_effort_nested_in_extra_body( + extra_body: JsonValue, expected: JsonValue +) -> None: + assert bridge_reasoning_effort(None, extra_body) == expected diff --git a/tests/unit/responses/test_responses_api_request_body.py b/tests/unit/responses/test_responses_api_request_body.py index 8c8b347be82..4a2fc0627ab 100644 --- a/tests/unit/responses/test_responses_api_request_body.py +++ b/tests/unit/responses/test_responses_api_request_body.py @@ -16,7 +16,12 @@ import pytest import respx import litellm -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from tests.unit.llms.bedrock.slow_upstream import ( + STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_sync_client, +) def _expected_dir() -> Path: @@ -1035,3 +1040,39 @@ async def test_a_native_responses_provider_places_every_point_itself(): assert user_only == [0, 2, 4, 6] assert with_unmatchable_system == user_only + + +def test_non_streamed_response_ignores_a_stream_timeout_it_never_uses() -> None: + answer: Final = json.dumps(_minimal_responses_api_payload("resp_unstreamed", "gpt-5.6")).encode() + client: Final = HTTPHandler( + client=httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, content=answer))) + ) + + response: Final = litellm.responses( + model="openai/gpt-5.6", input="hi", stream_timeout="unset", api_key="fake-key", client=client + ) + + assert response.output[0].content[0].text == "Done." + + +def test_faked_stream_accepts_a_text_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + litellm.model_cost, + "slow-faked-responses-model", + {"litellm_provider": "openai", "mode": "responses", "supports_native_streaming": False}, + ) + answer: Final = json.dumps(_minimal_responses_api_payload("resp_faked", "slow-faked-responses-model")).encode() + + response: Final = litellm.responses( + model="openai/slow-faked-responses-model", + input="hi", + stream=True, + timeout=str(UPSTREAM_FIRST_BYTE_SECONDS * 2), + stream_timeout=STREAM_TIMEOUT_SECONDS, + api_key="fake-key", + client=slow_upstream_sync_client(answer), + ) + + assert [event.response.output[0].content[0].text for event in response if event.type == "response.completed"] == [ + "Done." + ] diff --git a/tests/unit/responses/test_responses_websocket_all_providers.py b/tests/unit/responses/test_responses_websocket_all_providers.py index 2b78aa98b04..b38808fb57c 100644 --- a/tests/unit/responses/test_responses_websocket_all_providers.py +++ b/tests/unit/responses/test_responses_websocket_all_providers.py @@ -285,6 +285,151 @@ class TestManagedWebSocketHandlerIntegration: assert captured["model"] == "bedrock_mantle/openai.gpt-5.5" + @pytest.mark.asyncio + async def test_managed_socket_runs_the_frame_guard_on_every_turn(self, monkeypatch): + import json + from unittest.mock import AsyncMock, MagicMock + + import litellm + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ManagedResponsesWebSocketHandler + + async def guard(frame: str) -> GuardedResponsesWsFrame: + body = json.loads(frame) + if "forbidden" in body["input"]: + raise ValueError("blocked by guardrail") + return GuardedResponsesWsFrame( + frame=json.dumps({**body, "input": body["input"].upper()}), + guardrail_information=({"guardrail_name": "g", "guardrail_cost": 0.5},), + ) + + async def empty_stream(): + return + yield {} + + aresponses: Final = AsyncMock(side_effect=lambda **kwargs: empty_stream()) + monkeypatch.setattr(litellm, "aresponses", aresponses) + websocket: Final = MagicMock() + websocket.send_text = AsyncMock() + handler: Final = ManagedResponsesWebSocketHandler( + websocket=websocket, + model="anthropic/claude-sonnet-4-5", + logging_obj=Logging( + model="anthropic/claude-sonnet-4-5", + messages=[], + stream=True, + call_type="aresponses", + start_time=0, + litellm_call_id="test-id", + function_id="test-func", + ), + litellm_metadata={"user_api_key_hash": "k1"}, + frame_guard=guard, + ) + + await handler._process_response_create(json.dumps({"type": "response.create", "input": "say forbidden"})) + await handler._process_response_create(json.dumps({"type": "response.create", "input": "hello"})) + + aresponses.assert_awaited_once() + error_event: Final = json.loads(websocket.send_text.await_args_list[0].args[0]) + assert error_event["error"] == {"type": "invalid_request_error", "message": "blocked by guardrail"} + sent: Final = aresponses.await_args.kwargs + assert sent["input"] == "HELLO" + assert sent["litellm_metadata"]["user_api_key_hash"] == "k1" + assert sent["litellm_metadata"]["standard_logging_guardrail_information"] == [ + {"guardrail_name": "g", "guardrail_cost": 0.5} + ] + + @pytest.mark.asyncio + async def test_managed_socket_unmasks_the_reply_with_the_frame_guard_s_pii_tokens(self, monkeypatch): + import json + from unittest.mock import AsyncMock, MagicMock + + import litellm + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ManagedResponsesWebSocketHandler + + presidio = _FakeWSGuardrail() + + async def guard(frame: str) -> GuardedResponsesWsFrame: + body = json.loads(frame) + masked = await presidio.check_pii(body["input"], True, None, walk := {}) + return GuardedResponsesWsFrame( + frame=json.dumps({**body, "input": masked}), pii_tokens=walk["metadata"]["pii_tokens"] + ) + + provider_saw: dict = {} + + async def fake_aresponses(**kwargs): + provider_saw.update(kwargs) + + async def stream(): + yield {"type": "response.output_text.delta", "delta": "Sent to "} + yield { + "type": "response.completed", + "response": { + "id": "resp_1", + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "Sent to "}]} + ], + }, + } + + return stream() + + monkeypatch.setattr(litellm, "aresponses", fake_aresponses) + websocket: Final = MagicMock() + websocket.send_text = AsyncMock() + handler: Final = ManagedResponsesWebSocketHandler( + websocket=websocket, + model="anthropic/claude-sonnet-4-5", + logging_obj=Logging( + model="anthropic/claude-sonnet-4-5", + messages=[], + stream=True, + call_type="aresponses", + start_time=0, + litellm_call_id="test-id", + function_id="test-func", + ), + frame_guard=guard, + guardrail_callbacks=[presidio], + ) + + await handler._process_response_create( + json.dumps({"type": "response.create", "input": "email alice@example.com please"}) + ) + + assert provider_saw["input"] == "email please" + sent: Final = [json.loads(call.args[0]) for call in websocket.send_text.await_args_list] + assert sent[0]["delta"] == "Sent to alice@example.com" + assert sent[1]["response"]["output"][0]["content"][0]["text"] == "Sent to alice@example.com" + + @pytest.mark.asyncio + async def test_http_handler_hands_the_frame_guard_to_a_managed_socket(self): + import json + from unittest.mock import AsyncMock, MagicMock + + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + guard: Final = AsyncMock(side_effect=ValueError("blocked by guardrail")) + websocket: Final = MagicMock() + websocket.send_text = AsyncMock() + websocket.receive_text = AsyncMock(side_effect=ConnectionError("client left")) + + await BaseLLMHTTPHandler().async_responses_websocket( + model="anthropic/claude-sonnet-4-5", + websocket=websocket, + logging_obj=MagicMock(), + responses_api_provider_config=None, + first_message=json.dumps({"type": "response.create", "input": "say forbidden"}), + frame_guard=guard, + ) + + guard.assert_awaited_once() + error_event: Final = json.loads(websocket.send_text.await_args.args[0]) + assert error_event["error"]["message"] == "blocked by guardrail" + @pytest.mark.parametrize("terminal_type", ["response.completed", "response.incomplete"]) @pytest.mark.asyncio async def test_truncated_turn_is_kept_as_history_for_the_next_turn(self, monkeypatch, terminal_type: str): @@ -1319,6 +1464,97 @@ class TestWebSocketProjectQuotaEnforcement: mock_websocket.send_text.assert_called_once() assert "rate_limit_exceeded" in mock_websocket.send_text.call_args[0][0] + @pytest.mark.asyncio + async def test_native_handler_runs_the_frame_guard_on_every_turn_and_bills_each_turn_its_own_guardrail_records( + self, + ): + from unittest.mock import AsyncMock, MagicMock + + from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ResponsesWebSocketStreaming + + def connection_logging(): + logging_obj = MagicMock() + logging_obj.litellm_params = { + "metadata": { + "standard_logging_guardrail_information": [{"guardrail_name": "admission", "guardrail_cost": 9.0}] + } + } + logging_obj.model_call_details = {} + logging_obj.copy_for_new_call.side_effect = lambda **_: connection_logging() + return logging_obj + + async def guard(frame: str) -> GuardedResponsesWsFrame: + body = json.loads(frame) + return GuardedResponsesWsFrame( + frame=json.dumps({**body, "input": body["input"] + " [guarded]"}), + guardrail_information=({"guardrail_name": "turn", "guardrail_cost": 0.1 * len(body["input"])},), + ) + + frames = [json.dumps({"type": "response.create", "input": text}) for text in ("hi", "hello")] + mock_backend_ws = MagicMock() + mock_backend_ws.send = AsyncMock() + mock_websocket = MagicMock() + mock_websocket.send_text = AsyncMock() + mock_websocket.receive_text = AsyncMock(side_effect=[frames[1], RuntimeError("client closed")]) + + handler = ResponsesWebSocketStreaming( + websocket=mock_websocket, + backend_ws=mock_backend_ws, + logging_obj=connection_logging(), + first_message=frames[0], + frame_guard=guard, + ) + await handler.client_to_backend() + + sent = [json.loads(call.args[0]) for call in mock_backend_ws.send.call_args_list] + assert [frame["input"] for frame in sent] == ["hi [guarded]", "hello [guarded]"] + assert [ + turn.logging_obj.litellm_params["metadata"]["standard_logging_guardrail_information"] + for turn in handler._open_turns + ] == [ + [{"guardrail_name": "turn", "guardrail_cost": pytest.approx(0.2)}], + [{"guardrail_name": "turn", "guardrail_cost": pytest.approx(0.5)}], + ] + + @pytest.mark.asyncio + async def test_native_handler_drops_a_frame_the_guard_rejects_and_keeps_the_socket_open(self): + from unittest.mock import AsyncMock, MagicMock + + from litellm.responses.streaming_iterator import GuardedResponsesWsFrame, ResponsesWebSocketStreaming + + async def guard(frame: str) -> GuardedResponsesWsFrame: + if "forbidden" in frame: + raise ValueError("turn guardrail blocked the request") + return GuardedResponsesWsFrame(frame=frame) + + blocked = json.dumps({"type": "response.create", "input": "forbidden"}) + allowed = json.dumps({"type": "response.create", "input": "fine"}) + mock_backend_ws = MagicMock() + mock_backend_ws.send = AsyncMock() + mock_websocket = MagicMock() + mock_websocket.send_text = AsyncMock() + mock_websocket.receive_text = AsyncMock(side_effect=[allowed, RuntimeError("client closed")]) + logging_obj = MagicMock() + logging_obj.copy_for_new_call.return_value = logging_obj + + handler = ResponsesWebSocketStreaming( + websocket=mock_websocket, + backend_ws=mock_backend_ws, + logging_obj=logging_obj, + first_message=blocked, + frame_guard=guard, + ) + await handler.client_to_backend() + + error_event = json.loads(mock_websocket.send_text.call_args[0][0]) + assert error_event["type"] == "error" + assert error_event["error"] == { + "type": "invalid_request_error", + "message": "turn guardrail blocked the request", + } + assert [json.loads(call.args[0])["input"] for call in mock_backend_ws.send.call_args_list] == ["fine"] + assert len(handler._open_turns) == 1 + @pytest.mark.asyncio async def test_native_handler_forwards_frame_allowed_by_quota_callback(self): from unittest.mock import AsyncMock, MagicMock @@ -1905,6 +2141,34 @@ def _make_streaming(**kwargs): class TestNativeWebSocketGuardrailMasking: """Exercises the input/output PII masking hooks on ResponsesWebSocketStreaming.""" + @pytest.mark.asyncio + async def test_a_reply_to_a_frame_the_guard_masked_is_unmasked_with_the_guard_s_tokens(self): + from unittest.mock import AsyncMock, MagicMock + + from litellm.responses.streaming_iterator import GuardedResponsesWsFrame + + guardrail = _FakeWSGuardrail() + + async def guard(frame: str) -> GuardedResponsesWsFrame: + masked = await guardrail.check_pii(json.loads(frame)["input"], True, None, walk := {}) + return GuardedResponsesWsFrame( + frame=json.dumps({"type": "response.create", "input": masked}), + pii_tokens=walk["metadata"]["pii_tokens"], + ) + + backend_ws = MagicMock() + backend_ws.send = AsyncMock() + handler = _make_streaming( + backend_ws=backend_ws, request_data={}, guardrail_callbacks=[guardrail], frame_guard=guard + ) + handler.logging_obj.copy_for_new_call.return_value = handler.logging_obj + + await handler._forward_guarded_frame(json.dumps({"type": "response.create", "input": "email alice@example.com"})) + + assert json.loads(backend_ws.send.await_args.args[0])["input"] == "email " + reply = json.dumps({"type": "response.output_text.delta", "delta": "Sent to "}) + assert json.loads(handler._unmask_response_event(reply))["delta"] == "Sent to alice@example.com" + @pytest.mark.asyncio async def test_mask_response_create_flat_string_input(self): guardrail = _FakeWSGuardrail() diff --git a/tests/unit/responses/test_streaming_iterator_error_events.py b/tests/unit/responses/test_streaming_iterator_error_events.py index 001be7e1fbd..04d2b9ddf81 100644 --- a/tests/unit/responses/test_streaming_iterator_error_events.py +++ b/tests/unit/responses/test_streaming_iterator_error_events.py @@ -19,6 +19,7 @@ Also covers: ErrorEventError.param must accept dict payloads without raising a Pydantic ValidationError (previously typed as Optional[str]). """ +import asyncio import json from datetime import datetime from importlib import import_module @@ -31,15 +32,18 @@ import pytest import litellm from litellm.exceptions import MidStreamFallbackError from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.litellm_core_utils.litellm_logging import _get_provider_request_id from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.responses.streaming_iterator import ( _ERROR_CODE_HTTP_STATUS, BaseResponsesAPIStreamingIterator, ResponsesAPIStreamingIterator, + ResponsesWebSocketStreaming, SyncResponsesAPIStreamingIterator, _status_code_for_error_fields, ) +from litellm.utils import ProviderConfigManager from litellm.types.llms.openai import ( ErrorEvent, ErrorEventError, @@ -658,3 +662,140 @@ def test_openai_top_level_error_event_keeps_the_provider_code_and_message( assert exc_info.value.status_code == status_code assert type(exc_info.value.original_exception) is exception_type assert f"upstream said {code}" in str(exc_info.value.original_exception) + + +_AZURE_RESPONSES_API_BASE: Final = "https://responses-mid-stream.openai.azure.com" +_AZURE_RESPONSES_REQUEST_URL: Final = f"{_AZURE_RESPONSES_API_BASE}/openai/responses?api-version=2025-04-01-preview" +_AZURE_RESPONSES_FAILED_EVENT: Final = { + "type": "response.failed", + "sequence_number": 2, + "response": { + "id": "resp_mid_stream", + "object": "response", + "created_at": 1790000000, + "status": "failed", + "model": "gpt-5.4-mini", + "output": [], + "error": {"code": "server_error", "message": "upstream failed mid-stream"}, + }, +} +_AZURE_RESPONSES_WS_URL: Final = "wss://responses-mid-stream.openai.azure.com/openai/v1/responses?api-version=preview" + + +def _azure_responses_logging_obj(logged_api_base: str, routed_api_base: str | None) -> LiteLLMLoggingObj: + logging_obj: Final = LiteLLMLoggingObj( + model="gpt-5.4-mini", + messages=[], + stream=True, + call_type="aresponses", + start_time=datetime(2026, 1, 1), + litellm_call_id="responses-mid-stream", + function_id="responses-mid-stream", + ) + logging_obj.update_environment_variables( + model="gpt-5.4-mini", + optional_params={}, + litellm_params=get_litellm_params( + api_base=logged_api_base, + custom_llm_provider="azure", + metadata={ + "model_group": "gpt-5.4-mini-azure", + "deployment": "azure/gpt-5.4-mini", + "api_base": routed_api_base, + }, + ), + ) + return logging_obj + + +def _assert_names_the_azure_deployment(exception: BaseException, api_base: str = _AZURE_RESPONSES_API_BASE) -> None: + debug_info: Final = getattr(exception, "litellm_debug_info", None) + assert isinstance(debug_info, str) + assert f"API Base: `{api_base}`" in debug_info + assert "model_group: `gpt-5.4-mini-azure`" in debug_info + assert "deployment: `azure/gpt-5.4-mini`" in debug_info + assert "api.openai.com" not in debug_info + + +@pytest.mark.asyncio +async def test_response_failed_after_output_reports_the_deployment_it_was_sent_to(): + delta: Final = { + "type": "response.output_text.delta", + "sequence_number": 1, + "item_id": "msg_mid_stream", + "output_index": 0, + "content_index": 0, + "delta": "1", + } + body: Final = "".join(f"data: {json.dumps(event)}\n\n" for event in (delta, _AZURE_RESPONSES_FAILED_EVENT)) + iterator: Final = ResponsesAPIStreamingIterator( + response=httpx.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode()), + model="gpt-5.4-mini", + responses_api_provider_config=ProviderConfigManager.get_provider_responses_api_config( + provider="azure", model="gpt-5.4-mini" + ), + logging_obj=_azure_responses_logging_obj(_AZURE_RESPONSES_REQUEST_URL, _AZURE_RESPONSES_API_BASE), + custom_llm_provider="azure", + ) + stream: Final = aiter(iterator) + assert getattr(await anext(stream), "delta", None) == "1" + + with pytest.raises(MidStreamFallbackError) as exc_info: + await anext(stream) + assert exc_info.value.is_pre_first_chunk is False + _assert_names_the_azure_deployment(exc_info.value.original_exception) + + +class _ScriptedAzureSocket: + def __init__(self, events: tuple[str, ...]) -> None: + self._events = iter(events) + self._drained = asyncio.Event() + + async def recv(self, decode: bool = False) -> str: + event: Final = next(self._events, None) + if event is None: + self._drained.set() + raise ConnectionError("backend closed") + return event + + async def send(self, message: str) -> None: + return None + + async def close(self) -> None: + return None + + async def send_text(self, data: str) -> None: + return None + + async def receive_text(self) -> str: + await self._drained.wait() + raise ConnectionError("client closed") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("routed_api_base", "reported_api_base"), + [ + (_AZURE_RESPONSES_API_BASE, _AZURE_RESPONSES_API_BASE), + (None, "wss://responses-mid-stream.openai.azure.com/openai/v1/responses"), + ], + ids=["router", "no_router"], +) +async def test_responses_websocket_turn_failure_reports_the_deployment_it_was_sent_to( + routed_api_base: str | None, reported_api_base: str +): + connection_logging_obj: Final = _azure_responses_logging_obj(_AZURE_RESPONSES_WS_URL, routed_api_base) + connection_logging_obj.pre_call(input=None, api_key="", additional_args={"api_base": _AZURE_RESPONSES_WS_URL}) + socket: Final = _ScriptedAzureSocket((json.dumps(_AZURE_RESPONSES_FAILED_EVENT),)) + streaming: Final = ResponsesWebSocketStreaming( + websocket=socket, + backend_ws=socket, + logging_obj=connection_logging_obj, + authorized_model="gpt-5.4-mini", + custom_llm_provider="azure", + ) + + failure: Final = await streaming.bidirectional_forward() + + assert failure is not None + _assert_names_the_azure_deployment(failure, reported_api_base) diff --git a/tests/unit/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/unit/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index e5565a3df16..5f018ab5dda 100644 --- a/tests/unit/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/unit/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -143,6 +143,48 @@ def test_bedrock_application_profile_uses_its_base_model_for_one_hour_ttl() -> N ) is True +def test_bedrock_application_profile_cache_hit_reads_base_model_from_metadata_model_info() -> None: + standard_logging_object: Final = cast( + StandardLoggingPayload, + { + "custom_llm_provider": "bedrock", + "model": "bedrock/converse/arn:aws:bedrock:us-east-1:000000000000:application-inference-profile/pinprofile", + }, + ) + assert ( + _honors_one_hour_cache_ttl( + standard_logging_object, + {"metadata": {"model_info": {"base_model": "bedrock/us.anthropic.claude-sonnet-5-5"}}}, + ) + is True + ) + + +def test_bedrock_application_profile_top_level_base_model_wins_over_metadata_model_info() -> None: + standard_logging_object: Final = cast( + StandardLoggingPayload, + { + "custom_llm_provider": "bedrock", + "model": "bedrock/converse/arn:aws:bedrock:us-east-1:000000000000:application-inference-profile/pinprofile", + }, + ) + top_level: Final = "bedrock/anthropic.claude-3-haiku-20240307-v1:0" + in_metadata: Final = "bedrock/us.anthropic.claude-sonnet-5-5" + top_level_alone: Final = _honors_one_hour_cache_ttl(standard_logging_object, {"base_model": top_level}) + metadata_alone: Final = _honors_one_hour_cache_ttl( + standard_logging_object, {"metadata": {"model_info": {"base_model": in_metadata}}} + ) + assert top_level_alone != metadata_alone + + assert ( + _honors_one_hour_cache_ttl( + standard_logging_object, + {"base_model": top_level, "metadata": {"model_info": {"base_model": in_metadata}}}, + ) + is top_level_alone + ) + + @pytest.mark.asyncio async def test_async_filter_deployments_does_not_narrow_prompt_below_model_minimum(): """ diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index 56184fa299a..637fdfa6873 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -14,6 +14,7 @@ import respx import litellm from litellm import Router from litellm.integrations.custom_logger import CustomLogger +from litellm.llms.anthropic.pass_through.messages.fake_stream_iterator import FakeAnthropicMessagesStreamIterator from litellm.litellm_core_utils import get_llm_provider_logic from litellm.router_utils.cooldown_handlers import mark_advisor_orchestration_failure from litellm.router_utils.fallback_event_handlers import ( @@ -32,6 +33,7 @@ from litellm.router_utils.fallback_event_handlers import ( is_bad_request_without_fallback, log_failure_fallback_event, log_success_fallback_event, + mid_stream_fallback_hop_kwargs, mid_stream_fallback_snapshot_kwargs, mid_stream_retry_kwargs, record_pre_routing_selection, @@ -1865,6 +1867,41 @@ async def test_streaming_refusal_without_fallback_row_passes_frames_through(): assert len(fake.calls) == 1 +@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") +@pytest.mark.asyncio +async def test_refused_stream_built_from_a_finished_agentic_turn_is_delivered_without_a_fallback(): + """Websearch, MCP and advisor loops run their tools before streaming the final reply, so a + fallback would run every tool call again for a reply the client then gets from another model.""" + fake: Final = FakeAnthropicUpstream() + router: Final = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) + + with fake.install(): + stream: Final = await router._aanthropic_messages_streaming_iterator( + response=FakeAnthropicMessagesStreamIterator(REFUSAL_RESPONSE), + initial_kwargs=mid_stream_fallback_hop_kwargs( + model="fable-tier", + original_generic_function=litellm.anthropic_messages, + controls=None, + kwargs={ + "model": "fable-tier", + "max_tokens": 16, + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + }, + ), + ) + events: Final = [ + json.loads(line[len(b"data: ") :]) + for line in (await _collect(stream)).splitlines() + if line.startswith(b"data: ") + ] + + assert fake.calls == [] + message_delta: Final = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" + assert message_delta["delta"]["stop_details"] == REFUSAL_RESPONSE["stop_details"] + + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_inbound_metadata(): diff --git a/tests/unit/test_circleci_path_filter.py b/tests/unit/test_circleci_path_filter.py index 0856115e39b..f94b2fa863a 100644 --- a/tests/unit/test_circleci_path_filter.py +++ b/tests/unit/test_circleci_path_filter.py @@ -52,7 +52,7 @@ CI = [".github/workflows/test-unit.yml"] ("mcp-dependencies", ["pyproject.toml"], "run"), ("mcp-dependencies", ["uv.lock"], "run"), ("mcp-dependencies", ["litellm/experimental_mcp_client/client.py"], "run"), - ("mcp-dependencies", ["tests/e2e/mcp/oauth_chat_client.py"], "run"), + ("mcp-dependencies", ["tests/e2e/mcp/datadog_mcp.py"], "run"), ("mcp-dependencies", ["litellm-proxy-extras/pyproject.toml"], "run"), ("mcp-dependencies", ["scripts/check_mcp_sdk_install.py"], "run"), ("mcp-dependencies", [".github/workflows/test-mcp-dependency-resolution.yml"], "run"), diff --git a/tests/unit/test_completion_timeout_resolution.py b/tests/unit/test_completion_timeout_resolution.py index 77166d6e947..37b5cceef5a 100644 --- a/tests/unit/test_completion_timeout_resolution.py +++ b/tests/unit/test_completion_timeout_resolution.py @@ -10,7 +10,7 @@ from openai import Timeout as SDKTimeout sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) -from litellm.litellm_core_utils.completion_timeout import CompletionTimeout +from litellm.litellm_core_utils.completion_timeout import CompletionTimeout, StreamKind from litellm.utils import supports_httpx_timeout @@ -22,6 +22,7 @@ def test_explicit_timeout_wins(): "openai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 12.5 ) @@ -35,6 +36,7 @@ def test_kwargs_timeout_when_param_none(): "azure_ai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 21.0 ) @@ -48,6 +50,7 @@ def test_request_timeout_alias_in_kwargs(): "bedrock", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 33.0 ) @@ -61,6 +64,7 @@ def test_global_timeout_from_litellm_settings(): "vertex_ai", global_timeout=360.0, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 360.0 ) @@ -76,6 +80,7 @@ def test_explicit_global_timeout_6000_is_preserved(): "openai", global_timeout=6000.0, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 6000.0 ) @@ -90,6 +95,7 @@ def test_explicit_request_timeout_6000_preserved(): "openai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 6000.0 ) @@ -103,6 +109,7 @@ def test_explicit_model_timeout_6000_preserved(): "openai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 6000.0 ) @@ -116,6 +123,7 @@ def test_fallback_600_when_no_global_timeout(): "azure_ai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == 600.0 ) @@ -129,6 +137,7 @@ def test_httpx_timeout_coerced_for_provider_without_httpx_timeout_support(): "azure_ai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) assert out == 50.0 assert not isinstance(out, httpx.Timeout) @@ -142,6 +151,7 @@ def test_httpx_timeout_preserved_for_openai(): "openai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) assert out is t assert isinstance(out, httpx.Timeout) @@ -158,6 +168,7 @@ def test_sdk_timeout_preserves_components_for_httpx_providers( "openai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) assert isinstance(resolved, httpx.Timeout) assert resolved.as_dict() == timeout.as_dict() @@ -174,6 +185,49 @@ def test_sdk_timeout_coerces_read_timeout_for_providers_without_httpx_support( "azure_ai", global_timeout=None, supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.NOT_STREAMED, ) == expected ) + + +@pytest.mark.parametrize( + "stream_kind, timeout, stream_timeout, expected", + ( + (StreamKind.NOT_STREAMED, 120.0, 15.0, 120.0), + (StreamKind.LIVE, 120.0, 15.0, 15.0), + (StreamKind.LIVE, 1.0, 15.0, 15.0), + (StreamKind.FAKED, 120.0, 15.0, 120.0), + (StreamKind.FAKED, 1.0, 15.0, 15.0), + (StreamKind.FAKED, httpx.Timeout(5.0, read=None), 15.0, httpx.Timeout(15.0, read=None)), + (StreamKind.FAKED, httpx.Timeout(1.0, read=120.0), 15.0, httpx.Timeout(15.0, read=120.0)), + ), +) +def test_stream_timeout_bounds_a_live_stream_and_never_shortens_a_fake_one( + stream_kind: StreamKind, timeout: float | httpx.Timeout, stream_timeout: float, expected: float | httpx.Timeout +) -> None: + assert ( + CompletionTimeout.resolve( + timeout, + {"stream_timeout": stream_timeout}, + "openai", + global_timeout=None, + supports_httpx_timeout=supports_httpx_timeout, + stream_kind=stream_kind, + ) + == expected + ) + + +def test_fake_stream_with_only_a_stream_timeout_keeps_the_global_request_timeout() -> None: + assert ( + CompletionTimeout.resolve( + None, + {"stream_timeout": 15.0}, + "bedrock", + global_timeout=None, + supports_httpx_timeout=supports_httpx_timeout, + stream_kind=StreamKind.FAKED, + ) + == 600.0 + ) diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 03b822411ac..cb4d3f82611 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -89,6 +89,40 @@ def test_json_mode_emits_one_record_per_logger(capfd): assert "timestamp" in obj, "`timestamp` key missing" +@pytest.mark.parametrize( + ("litellm_log", "want_info_lines"), + [("INFO", 2), ("DEBUG", 2), (None, 0)], +) +def test_json_mode_root_logger_honours_litellm_log(monkeypatch, capfd, litellm_log, want_info_lines): + root: Final = logging.getLogger() + saved_level: Final = root.level + saved_handlers: Final = list(root.handlers) + saved_propagate: Final = root.propagate + if litellm_log is None: + monkeypatch.delenv("LITELLM_LOG", raising=False) + else: + monkeypatch.setenv("LITELLM_LOG", litellm_log) + try: + turn_on_json() + logging.getLogger("some.third.party").info("library info line") + logging.info("root info line") + logging.getLogger("some.third.party").warning("library warning line") + out, err = capfd.readouterr() + finally: + root.handlers.clear() + root.handlers.extend(saved_handlers) + root.setLevel(saved_level) + root.propagate = saved_propagate + + records = [json.loads(line) for line in (out + err).splitlines() if line.strip()] + info_records = [r for r in records if r["level"] == "INFO"] + assert len(info_records) == want_info_lines, records + if want_info_lines: + assert {r["message"] for r in info_records} == {"library info line", "root info line"} + assert {r["component"] for r in info_records} == {"some.third.party", "root"} + assert [r["message"] for r in records if r["level"] == "WARNING"] == ["library warning line"] + + def test_json_formatter_parses_embedded_json_message(): """ Test that JsonFormatter parses embedded JSON in the message field and promotes diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 0293af05c84..7c292ea8f08 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -48,6 +48,11 @@ from litellm.types.litellm_params import ControlOptions from litellm.types.llms.openai import AllMessageValues, HttpxBinaryResponseContent from litellm.types.prompts.init_prompts import PromptSpec from litellm.types.utils import Delta, ModelResponseStream, StandardCallbackDynamicParams, StreamingChoices, Usage +from tests.unit.llms.bedrock.slow_upstream import ( + STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_sync_client, +) @pytest.fixture(autouse=True) @@ -1937,6 +1942,60 @@ def test_responses_bridge_preserves_reasoning_effort_with_drop_params( assert request_body["reasoning"] == {"effort": "high"} +_BRIDGED_RESPONSE_ANSWER: Final = json.dumps( + { + "id": "resp_slow", + "object": "response", + "created_at": 1734366691, + "status": "completed", + "model": "slow-responses-model", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + } + ], + "parallel_tool_calls": True, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "tool_choice": "auto", + "tools": [], + } +).encode() + + +def test_responses_bridge_fake_stream_is_bounded_by_the_request_timeout_not_the_stream_timeout( + restore_model_registry: None, +) -> None: + model: Final = "openai/slow-responses-model" + litellm.register_model( + { + model: { + "litellm_provider": "openai", + "mode": "responses", + "supports_native_streaming": False, + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + } + }, + persist_across_reloads=False, + ) + + response: Final = litellm.completion( + model=model, + messages=[{"role": "user", "content": "hi"}], + stream=True, + stream_timeout=STREAM_TIMEOUT_SECONDS, + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + api_key="fake-key", + client=slow_upstream_sync_client(_BRIDGED_RESPONSE_ANSWER), + ) + + assert "".join(chunk.choices[0].delta.content or "" for chunk in response) == "hi" + + _FOUNDRY_RESPONSES_FUNCTION_CALL_BODY: Final = { "id": "resp_foundry", "object": "response", @@ -6978,6 +7037,23 @@ def test_mock_request_with_mock_timeout_raises_litellm_timeout(): assert error.value.model == "gpt-5.6" +@pytest.mark.asyncio +async def test_async_mock_timeout_on_a_stream_waits_only_for_the_stream_timeout() -> None: + with pytest.raises(litellm.Timeout): + await asyncio.wait_for( + litellm.acompletion( + model="gpt-5.6", + messages=[{"role": "user", "content": "hello"}], + stream=True, + timeout=30.0, + stream_timeout=0.01, + mock_timeout=True, + num_retries=0, + ), + timeout=5, + ) + + def test_router_mock_request_with_mock_timeout_raises_litellm_timeout(): router: Final = litellm.Router( model_list=[ diff --git a/tests/unit/test_register_model_custom_pricing.py b/tests/unit/test_register_model_custom_pricing.py index 02c90e0d97b..35fbd28eae7 100644 --- a/tests/unit/test_register_model_custom_pricing.py +++ b/tests/unit/test_register_model_custom_pricing.py @@ -10,13 +10,16 @@ calculations for DB-sourced models with prompt caching pricing. """ import copy +import itertools import os -from typing import Final +from collections.abc import Callable, Mapping +from typing import Final, NoReturn import pytest import litellm +from litellm.litellm_core_utils.get_model_cost_map import _expand_model_aliases, adopt_model_cost_map from litellm.main import _build_custom_pricing_entry from litellm.utils import _invalidate_model_cost_lowercase_map @@ -796,6 +799,609 @@ def test_embedding_direct_sdk_custom_pricing_still_registers_shared_key(): _invalidate_model_cost_lowercase_map() +@pytest.mark.parametrize("route", ["", "converse/", "invoke/"]) +def test_register_model_prices_a_geo_alias_from_its_builtin_when_the_lookup_fails( + monkeypatch: pytest.MonkeyPatch, route: str +) -> None: + from litellm.types.utils import ModelResponse, Usage + + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + _invalidate_model_cost_lowercase_map() + canonical_key: Final = "au.anthropic.claude-sonnet-5" + alias: Final = f"bedrock/{route}{canonical_key}" + canonical: Final = litellm.model_cost[canonical_key] + + def lookup_tripped_by_a_concurrent_registration(model: str, custom_llm_provider: str | None = None) -> NoReturn: + raise RuntimeError("dictionary changed size during iteration") + + with pytest.MonkeyPatch.context() as lookup_patch: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup_tripped_by_a_concurrent_registration) + litellm.register_model( + {alias: {"litellm_provider": "bedrock", "mode": "chat", "supports_vision": True}}, + persist_across_reloads=False, + ) + + usage: Final = Usage(prompt_tokens=1000, completion_tokens=100, total_tokens=1100) + alias_cost: Final = litellm.completion_cost( + completion_response=ModelResponse(model=alias, usage=usage), model=alias, custom_llm_provider="bedrock" + ) + canonical_cost: Final = litellm.completion_cost( + completion_response=ModelResponse(model=canonical_key, usage=usage), + model=canonical_key, + custom_llm_provider="bedrock", + ) + try: + assert alias_cost == canonical_cost > 0 + assert litellm.get_model_info(alias)["input_cost_per_token"] == canonical["input_cost_per_token"] + assert litellm.get_model_info(alias)["supports_vision"] is True + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_keeps_a_catalog_rows_own_prices_when_the_lookup_fails(monkeypatch: pytest.MonkeyPatch) -> None: + geo_key: Final = "au.anthropic.lit-5853-geo-model" + geo_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + "cache_read_input_token_cost": 3.3e-07, + } + sibling_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + } + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.get_model_cost_map(url=""), + geo_key: dict(geo_row), + "anthropic.lit-5853-geo-model": sibling_row, + }, + ) + _invalidate_model_cost_lowercase_map() + + def lookup_tripped_by_a_concurrent_registration(model: str, custom_llm_provider: str | None = None) -> NoReturn: + raise RuntimeError("dictionary changed size during iteration") + + with pytest.MonkeyPatch.context() as lookup_patch: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup_tripped_by_a_concurrent_registration) + litellm.register_model({geo_key: {"mode": "chat"}}, persist_across_reloads=False) + + try: + for field in ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost"): + assert litellm.model_cost[geo_key][field] == geo_row[field] + assert litellm.get_model_info(geo_key)[field] == geo_row[field] + assert litellm.model_cost[geo_key]["litellm_provider"] == geo_row["litellm_provider"] + finally: + _invalidate_model_cost_lowercase_map() + + +@pytest.mark.parametrize("route", ["", "converse/", "invoke/"]) +def test_register_model_leaves_no_shadow_key_when_the_lookup_trips_once( + monkeypatch: pytest.MonkeyPatch, route: str +) -> None: + catalog_key: Final = "au.anthropic.lit-5853-geo-model" + alias: Final = f"bedrock/{route}{catalog_key}" + repriced_input_cost: Final = 9e-06 + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.get_model_cost_map(url=""), + catalog_key: { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + }, + }, + ) + _invalidate_model_cost_lowercase_map() + real_lookup: Final = litellm.utils.get_model_info + lookups: Final = itertools.count() + + def lookup_tripped_once_by_a_concurrent_registration(model: str, custom_llm_provider: str | None = None) -> object: + if next(lookups) == 0: + raise RuntimeError("dictionary changed size during iteration") + return real_lookup(model=model, custom_llm_provider=custom_llm_provider) + + with pytest.MonkeyPatch.context() as lookup_patch: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup_tripped_once_by_a_concurrent_registration) + litellm.register_model({alias: {"litellm_provider": "bedrock", "mode": "chat"}}, persist_across_reloads=False) + litellm.register_model({catalog_key: {"input_cost_per_token": repriced_input_cost}}, persist_across_reloads=False) + + try: + assert litellm.get_model_info(alias)["key"] == catalog_key + assert litellm.get_model_info(alias)["input_cost_per_token"] == repriced_input_cost + finally: + _invalidate_model_cost_lowercase_map() + + +@pytest.mark.parametrize("key_prefix", ["", "bedrock/", "bedrock/converse/"]) +def test_register_model_bills_a_catalog_rows_tier_rates_the_same_when_the_lookup_fails( + monkeypatch: pytest.MonkeyPatch, key_prefix: str +) -> None: + from litellm.types.utils import ModelResponse, Usage + + catalog_key: Final = "au.anthropic.lit-5853-tiered-geo-model" + registered_key: Final = f"{key_prefix}{catalog_key}" + catalog_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + "input_cost_per_token_above_200k_tokens": 6.6e-06, + "output_cost_per_token_above_200k_tokens": 2.475e-05, + } + builtin_cost_map: Final = litellm.get_model_cost_map(url="") + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + + def lookup_tripped_by_a_concurrent_registration(model: str, custom_llm_provider: str | None = None) -> NoReturn: + raise RuntimeError("dictionary changed size during iteration") + + def long_prompt_cost_after_registering_with(lookup: Callable[..., object]) -> float: + monkeypatch.setattr(litellm, "model_cost", {**builtin_cost_map, catalog_key: dict(catalog_row)}) + _invalidate_model_cost_lowercase_map() + with pytest.MonkeyPatch.context() as lookup_patch: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup) + litellm.register_model( + { + registered_key: { + "litellm_provider": "bedrock", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + }, + persist_across_reloads=False, + ) + return litellm.completion_cost( + completion_response=ModelResponse( + model=registered_key, + usage=Usage(prompt_tokens=250_000, completion_tokens=1_000, total_tokens=251_000), + ), + model=registered_key, + custom_llm_provider="bedrock", + ) + + try: + assert long_prompt_cost_after_registering_with(lookup_tripped_by_a_concurrent_registration) == pytest.approx( + long_prompt_cost_after_registering_with(litellm.utils.get_model_info) + ) + finally: + _invalidate_model_cost_lowercase_map() + + +@pytest.mark.parametrize("lookup_trips", [False, True]) +def test_register_model_prices_a_provider_less_azure_ai_key_without_repricing_the_openai_row( + monkeypatch: pytest.MonkeyPatch, lookup_trips: bool +) -> None: + openai_key: Final = "lit-5853-openai-model" + azure_ai_key: Final = f"azure_ai/{openai_key}" + openai_row: Final = { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 5e-06, + "output_cost_per_token": 1.5e-05, + } + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setenv("AZURE_AI_API_BASE", "https://lit-5853.openai.azure.com") + monkeypatch.setattr(litellm, "model_cost", {**litellm.get_model_cost_map(url=""), openai_key: dict(openai_row)}) + monkeypatch.setattr( + litellm, "open_ai_chat_completion_models", {*litellm.open_ai_chat_completion_models, openai_key} + ) + _invalidate_model_cost_lowercase_map() + + def lookup_tripped_by_a_concurrent_registration(model: str, custom_llm_provider: str | None = None) -> NoReturn: + raise RuntimeError("dictionary changed size during iteration") + + with pytest.MonkeyPatch.context() as lookup_patch: + if lookup_trips: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup_tripped_by_a_concurrent_registration) + litellm.register_model( + {azure_ai_key: {"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}}, + persist_across_reloads=False, + ) + + try: + assert litellm.model_cost[openai_key] == openai_row + assert litellm.cost_per_token(model=azure_ai_key, prompt_tokens=1000, completion_tokens=1000) == pytest.approx( + (1e-03, 2e-03) + ) + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_keeps_the_prices_a_reload_adopts_while_it_registers(monkeypatch: pytest.MonkeyPatch) -> None: + catalog_key: Final = "au.anthropic.lit-5853-reloaded-model" + deployment_key: Final = f"bedrock/{catalog_key}" + catalog_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + } + reloaded_input_cost: Final = 6.6e-06 + builtin_cost_map: Final = litellm.get_model_cost_map(url="") + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", {**builtin_cost_map, catalog_key: dict(catalog_row)}) + _invalidate_model_cost_lowercase_map() + reloaded_cost_map: Final = { + **builtin_cost_map, + catalog_key: {**catalog_row, "input_cost_per_token": reloaded_input_cost}, + } + real_lookup: Final = litellm.utils.get_model_info + + def lookup_racing_a_price_data_reload(model: str, custom_llm_provider: str | None = None) -> object: + info: Final = real_lookup(model=model, custom_llm_provider=custom_llm_provider) + if litellm.model_cost is not reloaded_cost_map: + adopt_model_cost_map(reloaded_cost_map) + return info + + with pytest.MonkeyPatch.context() as lookup_patch: + lookup_patch.setattr(litellm.utils, "get_model_info", lookup_racing_a_price_data_reload) + litellm.register_model({deployment_key: {"litellm_provider": "bedrock"}}, persist_across_reloads=False) + + try: + assert litellm.model_cost[catalog_key]["input_cost_per_token"] == reloaded_input_cost + assert litellm.get_model_info(deployment_key)["input_cost_per_token"] == reloaded_input_cost + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_keeps_its_price_when_a_reload_adopts_a_map_as_it_writes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalog_key: Final = "au.anthropic.lit-5853-reloaded-as-written-model" + deployment_key: Final = f"bedrock/{catalog_key}" + catalog_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3.3e-06, + "output_cost_per_token": 1.65e-05, + } + reloaded_input_cost: Final = 6.6e-06 + registered_output_cost: Final = 2e-05 + builtin_cost_map: Final = litellm.get_model_cost_map(url="") + reloaded_cost_map: Final = { + **builtin_cost_map, + catalog_key: {**catalog_row, "input_cost_per_token": reloaded_input_cost}, + } + + class CostMapAReloadReplacesAsItIsWritten(dict[str, dict[str, object]]): + def update(self, other: Mapping[str, dict[str, object]], /) -> None: # pyright: ignore[reportIncompatibleMethodOverride] # registration writes pass one mapping + if litellm.model_cost is self: + adopt_model_cost_map(reloaded_cost_map) + super().update(other) + + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + CostMapAReloadReplacesAsItIsWritten({**builtin_cost_map, catalog_key: dict(catalog_row)}), + ) + _invalidate_model_cost_lowercase_map() + + litellm.register_model( + {deployment_key: {"litellm_provider": "bedrock", "output_cost_per_token": registered_output_cost}}, + persist_across_reloads=False, + ) + + try: + assert litellm.model_cost is reloaded_cost_map + assert litellm.get_model_info(deployment_key)["input_cost_per_token"] == reloaded_input_cost + assert litellm.get_model_info(deployment_key)["output_cost_per_token"] == registered_output_cost + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_leaves_the_entry_a_lookup_is_walking_intact(monkeypatch: pytest.MonkeyPatch) -> None: + catalog_key: Final = "lit-5853-live-entry-model" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.get_model_cost_map(url=""), + catalog_key: {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": 2e-06}, + }, + ) + _invalidate_model_cost_lowercase_map() + walk: Final = iter(litellm.model_cost[catalog_key].items()) + first_field: Final = next(walk) + + try: + litellm.register_model( + {catalog_key: {"litellm_provider": "openai", "input_cost_per_token": 1e-06, "supports_vision": True}}, + persist_across_reloads=False, + ) + assert dict((first_field, *walk)) == { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 2e-06, + } + assert litellm.get_model_info(catalog_key)["input_cost_per_token"] == 1e-06 + assert litellm.get_model_info(catalog_key)["supports_vision"] is True + finally: + _invalidate_model_cost_lowercase_map() + + +_CANONICAL_KEY: Final = "lit-5853-canonical-model" +_ALIAS_KEY: Final = "lit-5853-alias-model" + + +def _install_aliased_cost_map(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + _expand_model_aliases( + { + **litellm.get_model_cost_map(url=""), + _CANONICAL_KEY: { + "aliases": [_ALIAS_KEY], + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 2e-06, + "output_cost_per_token": 8e-06, + }, + } + ), + ) + _invalidate_model_cost_lowercase_map() + + +def _token_prices(name: str) -> tuple[float | None, float | None]: + info: Final = litellm.get_model_info(name) + return info["input_cost_per_token"], info["output_cost_per_token"] + + +@pytest.mark.parametrize("registered_key", [_CANONICAL_KEY, _ALIAS_KEY]) +def test_register_model_prices_every_alias_of_the_registered_entry( + monkeypatch: pytest.MonkeyPatch, registered_key: str +) -> None: + _install_aliased_cost_map(monkeypatch) + + try: + litellm.register_model( + {registered_key: {"litellm_provider": "openai", "input_cost_per_token": 1e-06}}, + persist_across_reloads=False, + ) + litellm.register_model( + {registered_key: {"litellm_provider": "openai", "output_cost_per_token": 4e-06}}, + persist_across_reloads=False, + ) + assert {name: _token_prices(name) for name in (_CANONICAL_KEY, _ALIAS_KEY)} == { + _CANONICAL_KEY: (1e-06, 4e-06), + _ALIAS_KEY: (1e-06, 4e-06), + } + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_leaves_an_alias_repointed_at_its_own_entry(monkeypatch: pytest.MonkeyPatch) -> None: + _install_aliased_cost_map(monkeypatch) + litellm.model_cost[_ALIAS_KEY] = { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 5e-06, + "output_cost_per_token": 6e-06, + } + + try: + litellm.register_model( + {_CANONICAL_KEY: {"litellm_provider": "openai", "input_cost_per_token": 1e-06}}, + persist_across_reloads=False, + ) + assert {name: _token_prices(name) for name in (_CANONICAL_KEY, _ALIAS_KEY)} == { + _CANONICAL_KEY: (1e-06, 8e-06), + _ALIAS_KEY: (5e-06, 6e-06), + } + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_inherits_builtin_token_pricing_for_unmapped_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + _invalidate_model_cost_lowercase_map() + builtin_key: Final = "us.anthropic.claude-sonnet-4-6" + registered_key: Final = f"bedrock/bedrock/bedrock/{builtin_key}" + builtin: Final = litellm.model_cost[builtin_key] + + try: + litellm.register_model( + {registered_key: {"litellm_provider": "bedrock", "mode": "chat"}}, persist_across_reloads=False + ) + info: Final = litellm.get_model_info(registered_key) + assert info["input_cost_per_token"] == builtin["input_cost_per_token"] > 0 + assert info["output_cost_per_token"] == builtin["output_cost_per_token"] > 0 + assert info["cache_read_input_token_cost"] == builtin["cache_read_input_token_cost"] + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_keeps_the_capabilities_an_unregistered_region_alias_resolves_to( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.litellm_core_utils.fallback_generalizations import ( + get_fallback_generalization_rules, + set_fallback_generalizations, + ) + + sibling_key: Final = "anthropic.lit-5853-flagless-model" + region_alias: Final = f"bedrock/ap-northeast-1.{sibling_key}" + sibling_row: Final = { + "litellm_provider": "bedrock_converse", + "mode": "chat", + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + } + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", {**litellm.get_model_cost_map(url=""), sibling_key: sibling_row}) + _invalidate_model_cost_lowercase_map() + previous_rules: Final = list(get_fallback_generalization_rules()) + set_fallback_generalizations( + [ + { + "name": "lit-5853-flagless", + "pattern": "lit-5853-flagless", + "model_info": {"supports_system_messages": True, "supports_parallel_function_calling": True}, + } + ] + ) + + try: + unregistered: Final = litellm.get_model_info(region_alias) + litellm.register_model( + {region_alias: {"litellm_provider": "bedrock", "mode": "chat"}}, persist_across_reloads=False + ) + registered: Final = litellm.get_model_info(region_alias) + assert registered["supports_system_messages"] is unregistered["supports_system_messages"] is True + assert ( + registered["supports_parallel_function_calling"] + is unregistered["supports_parallel_function_calling"] + is True + ) + assert registered["input_cost_per_token"] == sibling_row["input_cost_per_token"] + finally: + set_fallback_generalizations(previous_rules) + _invalidate_model_cost_lowercase_map() + + +@pytest.mark.parametrize( + ("builtin_prices", "custom_prices", "usage", "expected_cost"), + [ + pytest.param( + { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + }, + {"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06}, + {"prompt_tokens": 250_000, "completion_tokens": 1_000}, + 250_000 * 1e-06 + 1_000 * 2e-06, + id="above-200k-tier", + ), + pytest.param( + { + "tiered_pricing": [ + {"input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07, "range": [0, 256_000]}, + {"input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "range": [256_000, 1_000_000]}, + ] + }, + {"input_cost_per_token": 1e-06}, + {"prompt_tokens": 300_000, "completion_tokens": 1_000}, + 300_000 * 1e-06 + 1_000 * 2e-06, + id="tiered-pricing-input-only", + ), + pytest.param( + {"input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, "output_cost_per_reasoning_token": 3e-05}, + {"output_cost_per_token": 2e-06}, + {"prompt_tokens": 100, "completion_tokens": 1_000, "reasoning_tokens": 600}, + 100 * 3e-06 + 1_000 * 2e-06, + id="reasoning-rate-output-only", + ), + pytest.param( + { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "off_peak_pricing": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "hours_utc": "00:00-00:00", + }, + }, + {"output_cost_per_token": 2e-06}, + {"prompt_tokens": 100, "completion_tokens": 1_000}, + 100 * 1e-06 + 1_000 * 2e-06, + id="off-peak-output-only", + ), + ], +) +def test_register_model_keeps_custom_flat_prices_over_inherited_rate_variants( + monkeypatch: pytest.MonkeyPatch, + builtin_prices: dict[str, object], + custom_prices: dict[str, float], + usage: dict[str, int], + expected_cost: float, +) -> None: + from litellm.types.utils import CompletionTokensDetailsWrapper, ModelResponse, Usage + + builtin_key: Final = "anthropic.lit-5853-tiered-model-v1:0" + alias: Final = f"bedrock/ap-northeast-1.{builtin_key}" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.get_model_cost_map(url=""), + builtin_key: {"litellm_provider": "bedrock", "mode": "chat", **builtin_prices}, + }, + ) + _invalidate_model_cost_lowercase_map() + + try: + litellm.register_model( + {alias: {"litellm_provider": "bedrock", **custom_prices}}, + persist_across_reloads=False, + ) + response_usage: Final = Usage( + prompt_tokens=usage["prompt_tokens"], + completion_tokens=usage["completion_tokens"], + total_tokens=usage["prompt_tokens"] + usage["completion_tokens"], + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=usage.get("reasoning_tokens", 0)), + ) + cost: Final = litellm.completion_cost( + completion_response=ModelResponse(model=alias, usage=response_usage), + model=alias, + custom_llm_provider="bedrock", + ) + assert cost == pytest.approx(expected_cost) + finally: + _invalidate_model_cost_lowercase_map() + + +def test_register_model_never_inherits_pricing_across_providers(monkeypatch: pytest.MonkeyPatch) -> None: + openai_key: Final = "lit-5853-openai-only-model" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr( + litellm, + "model_cost", + { + **litellm.get_model_cost_map(url=""), + openai_key: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-06, + }, + }, + ) + _invalidate_model_cost_lowercase_map() + registered_key: Final = f"bedrock/{openai_key}" + + try: + litellm.register_model( + {registered_key: {"litellm_provider": "bedrock", "mode": "chat"}}, persist_across_reloads=False + ) + registered: Final = litellm.model_cost[registered_key] + assert "cache_read_input_token_cost" not in registered + assert "input_cost_per_token" not in registered + assert "output_cost_per_token" not in registered + finally: + _invalidate_model_cost_lowercase_map() + + def test_update_dictionary_merges_nested_dicts_without_aliasing(): """A nested dict must be merged copy-on-write: the pre-existing nested dict object stays untouched, and the caller's incoming nested dict is never diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 50a9d98385d..1dec4de45f4 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -2023,7 +2023,9 @@ async def test_ageneric_api_call_deployment_model_overrides_alias(): ] ) - def inject_alias_into_kwargs(deployment, kwargs, function_name=None): + def inject_alias_into_kwargs( + deployment: dict, kwargs: dict, function_name: str | None = None, splits_stream_timeout: bool = False + ) -> None: # Simulate the alias leaking into kwargs (as happens when # _ageneric_api_call_with_fallbacks sets kwargs["model"] = alias before # calling the helper through async_function_with_fallbacks). @@ -23912,6 +23914,105 @@ def test_map_team_model_should_not_iterate_aliases_for_non_alias_team_model_name result = router.map_team_model(team_model_name="team-model", team_id="team-1") assert result == "team-model", f"Expected public name 'team-model', got {result}" + +def _team_deployment( + public_name: str, + litellm_model: str, + team_id: str = "team-a", + deployment_id: str | None = None, +) -> DeploymentTypedDict: + return { + "model_name": f"internal-{team_id}-{public_name}", + "litellm_params": {"model": litellm_model}, + "model_info": { + "id": deployment_id or f"{team_id}-{public_name}", + "team_id": team_id, + "team_public_model_name": public_name, + }, + } + + +_LANGGRAPH_AGENT: Final[DeploymentTypedDict] = _team_deployment("a-langgraph-agent", "langgraph/repro-agent") +_BEDROCK_CLAUDE: Final[DeploymentTypedDict] = _team_deployment( + "a-bedrock-claude", "bedrock/anthropic.claude-sonnet-4-6" +) +_OPENAI_GPT: Final[DeploymentTypedDict] = _team_deployment("a-gpt", "openai/gpt-4o-mini") + + +@pytest.mark.parametrize( + "team_models, custom_llm_provider, expected", + [ + pytest.param([_LANGGRAPH_AGENT, _BEDROCK_CLAUDE], "bedrock", "a-bedrock-claude", id="match-is-second"), + pytest.param([_BEDROCK_CLAUDE, _LANGGRAPH_AGENT], "bedrock", "a-bedrock-claude", id="match-is-first"), + pytest.param([_LANGGRAPH_AGENT, _OPENAI_GPT], "bedrock", None, id="no-match-returns-none"), + pytest.param([_LANGGRAPH_AGENT, _BEDROCK_CLAUDE], None, "a-langgraph-agent", id="no-provider-keeps-first"), + pytest.param( + [_team_deployment("b-bedrock-claude", "bedrock/anthropic.claude-sonnet-4-6", team_id="team-b")], + "bedrock", + None, + id="other-teams-match-ignored", + ), + pytest.param( + [ + _team_deployment("shared", "langgraph/repro-agent", deployment_id="shared-langgraph"), + _team_deployment( + "shared", + "bedrock/anthropic.claude-sonnet-4-6", + deployment_id="shared-bedrock", + ), + ], + "bedrock", + "shared-bedrock", + id="mixed-public-name-langgraph-first", + ), + pytest.param( + [ + _team_deployment( + "shared", + "bedrock/anthropic.claude-sonnet-4-6", + deployment_id="shared-bedrock", + ), + _team_deployment("shared", "langgraph/repro-agent", deployment_id="shared-langgraph"), + ], + "bedrock", + "shared-bedrock", + id="mixed-public-name-bedrock-first", + ), + pytest.param( + [ + _team_deployment("shared", "bedrock/anthropic.claude-sonnet-4-6", deployment_id="bedrock-one"), + _team_deployment("shared", "bedrock/anthropic.claude-sonnet-4-6", deployment_id="bedrock-two"), + ], + "bedrock", + "shared", + id="same-provider-public-name-keeps-load-balancing", + ), + ], +) +def test_map_team_model_without_model_picks_deployment_serving_the_requested_provider( + team_models: list[DeploymentTypedDict], custom_llm_provider: str | None, expected: str | None +) -> None: + """A model-less vector store call routed through the team's first deployment, so a langgraph + team model made a Bedrock vector store search fail with "not supported for langgraph".""" + router = Router(model_list=team_models) + + result = router.map_team_model(None, "team-a", custom_llm_provider=custom_llm_provider) + + assert result == expected, ( + f"team models {[m['litellm_params']['model'] for m in team_models]}, provider {custom_llm_provider}" + ) + + +def test_map_team_model_with_explicit_model_ignores_requested_provider() -> None: + router = Router(model_list=[_LANGGRAPH_AGENT, _BEDROCK_CLAUDE]) + + result = router.map_team_model("a-langgraph-agent", "team-a", custom_llm_provider="bedrock") + + assert result == "a-langgraph-agent", ( + "an explicit model is the caller's choice and must not be re-picked by provider" + ) + + @pytest.mark.usefixtures("_vcr_outcome_gate_router_unit", "setup_and_teardown_router_unit") class TestPreCallChecksOptimization: """ diff --git a/tests/unit/test_router/test_router_endpoints.py b/tests/unit/test_router/test_router_endpoints.py index 74a1bfefeee..815b32b33f7 100644 --- a/tests/unit/test_router/test_router_endpoints.py +++ b/tests/unit/test_router/test_router_endpoints.py @@ -1,10 +1,14 @@ import asyncio +import json import os +from typing import Final from unittest.mock import AsyncMock, MagicMock, Mock, patch +import httpx import litellm import pytest from litellm import Router +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler @pytest.fixture @@ -573,6 +577,39 @@ async def test_init_vector_store_api_endpoints(): mock_original_function.assert_called_once_with(vector_store_id="test-store", custom_llm_provider="openai") +@pytest.mark.asyncio +async def test_avector_store_create_sends_only_the_callers_metadata_to_the_provider(): + def echo_vector_store(request: httpx.Request) -> httpx.Response: + body: Final = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "vs_echo", + "object": "vector_store", + "created_at": 0, + "name": body["name"], + "usage_bytes": 0, + "file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0}, + "status": "completed", + "metadata": body.get("metadata"), + }, + ) + + router: Final = Router( + model_list=[{"model_name": "gpt-5", "litellm_params": {"model": "openai/gpt-5", "api_key": "sk-test"}}] + ) + + response: Final = await router.avector_store_create( + model="gpt-5", + name="x", + metadata={"team": "llmproxy"}, + client=AsyncHTTPHandler(transport=httpx.MockTransport(echo_vector_store)), + ) + + assert response["id"] == "vs_echo" + assert response["metadata"] == {"team": "llmproxy"} + + def test_apply_default_settings(): """ Test the apply_default_settings method. diff --git a/tests/unit/test_router/test_router_timeout.py b/tests/unit/test_router/test_router_timeout.py index 3c3f0692043..3265179a513 100644 --- a/tests/unit/test_router/test_router_timeout.py +++ b/tests/unit/test_router/test_router_timeout.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import json import os +from datetime import datetime, timedelta +from collections.abc import Awaitable, Callable, Mapping from typing import Final from unittest.mock import AsyncMock, Mock @@ -13,9 +15,22 @@ import respx import litellm from litellm import Router +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +from litellm.router_utils.cooldown_handlers import is_caller_timeout_408 +from litellm.types.integrations.custom_logger import WEBSEARCH_INTERCEPTION_PREFIX, as_converted_stream +from litellm.types.llms.openai import AllMessageValues import time from unittest.mock import patch, MagicMock +from tests.unit.llms.bedrock.slow_upstream import ( + CONVERSE_ANSWER, + STREAM_TIMEOUT_SECONDS, + UPSTREAM_FIRST_BYTE_SECONDS, + slow_upstream_answer, + slow_upstream_async_client, +) + @pytest.fixture def restore_request_timeout(monkeypatch: pytest.MonkeyPatch) -> None: @@ -138,6 +153,276 @@ def test_unit_test_streaming_timeout(stream): assert stream_timeout_val == normal_timeout +async def _drain_responses_stream(router: Router) -> None: + response: Final = await router.aresponses(model="gpt", input="hi", stream=True) + _ = [event async for event in response] + + +@pytest.mark.asyncio +async def test_live_responses_stream_through_the_router_fails_at_the_stream_timeout( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post("https://api.openai.com/v1/responses").mock(side_effect=slow_upstream_answer()) + router: Final = Router( + model_list=[{"model_name": "gpt", "litellm_params": {"model": "openai/gpt-4.1", "api_key": "fake"}}], + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + stream_timeout=STREAM_TIMEOUT_SECONDS, + num_retries=0, + ) + + with pytest.raises(litellm.Timeout): + await _drain_responses_stream(router) + + +@pytest.mark.asyncio +async def test_prompt_managed_stream_keeps_the_target_deployments_own_stream_timeout() -> None: + router: Final = Router( + model_list=[ + { + "model_name": "claude-agent", + "litellm_params": {"model": "litellm_agent/claude-target"}, + }, + { + "model_name": "claude-target", + "litellm_params": { + "model": "bedrock/anthropic.claude-sonnet-4-5-v1:0", + "aws_access_key_id": "fake", + "aws_secret_access_key": "fake", + "aws_region_name": "us-east-1", + "stream_timeout": STREAM_TIMEOUT_SECONDS, + }, + }, + ], + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + stream_timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + num_retries=0, + ) + messages: Final[list[AllMessageValues]] = [{"role": "user", "content": "hi"}] + + with pytest.raises(litellm.Timeout): + await router.acompletion( + model="claude-agent", + messages=messages, + stream=True, + client=slow_upstream_async_client(), + litellm_logging_obj=LiteLLMLogging( + model="claude-agent", + messages=messages, + stream=True, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="prompt-managed-stream", + function_id="f", + ), + ) + + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("agent_params", "target_params", "request_params"), + [ + ({}, {"timeout": UPSTREAM_FIRST_BYTE_SECONDS * 2}, {}), + ({"timeout": STREAM_TIMEOUT_SECONDS}, {}, {"request_timeout": UPSTREAM_FIRST_BYTE_SECONDS * 2}), + ], + ids=["target_deployment_timeout", "caller_request_timeout"], +) +async def test_prompt_managed_fake_stream_keeps_the_timeout_its_caller_or_target_set( + agent_params: Mapping[str, float], target_params: Mapping[str, float], request_params: Mapping[str, float] +) -> None: + router: Final = Router( + model_list=[ + { + "model_name": "jamba-agent", + "litellm_params": {"model": "litellm_agent/jamba-target", **agent_params}, + }, + { + "model_name": "jamba-target", + "litellm_params": { + "model": "bedrock/converse/ai21.jamba-1-5-large-v1:0", + "aws_access_key_id": "fake", + "aws_secret_access_key": "fake", + "aws_region_name": "us-east-1", + **target_params, + }, + }, + ], + timeout=STREAM_TIMEOUT_SECONDS, + stream_timeout=STREAM_TIMEOUT_SECONDS, + num_retries=0, + ) + messages: Final[list[AllMessageValues]] = [{"role": "user", "content": "hi"}] + + response: Final = await router.acompletion( + model="jamba-agent", + messages=messages, + stream=True, + client=slow_upstream_async_client(CONVERSE_ANSWER), + **request_params, + litellm_logging_obj=LiteLLMLogging( + model="jamba-agent", + messages=messages, + stream=True, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="prompt-managed-fake-stream", + function_id="f", + ), + ) + + assert isinstance(response, litellm.CustomStreamWrapper) + assert "".join([chunk.choices[0].delta.content or "" async for chunk in response]) == "hi" + + +class _AnswersStreamsInOnePiece(CustomLogger): + async def async_pre_call_deployment_hook(self, kwargs: dict, call_type: object) -> dict: + return as_converted_stream(kwargs, WEBSEARCH_INTERCEPTION_PREFIX) if kwargs.get("stream") else kwargs + + +_INVOKE_ANSWER: Final = json.dumps( + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + } +).encode() + + +async def _streamed_chat_text(router: Router) -> str: + response: Final = await router.acompletion(model="claude", messages=[{"role": "user", "content": "hi"}], stream=True) + return "".join([chunk.choices[0].delta.content or "" async for chunk in response]) + + +async def _streamed_messages_text(router: Router) -> str: + response: Final = await router.aanthropic_messages( + model="claude", messages=[{"role": "user", "content": "hi"}], max_tokens=10, stream=True + ) + events: Final = [ + json.loads(line.removeprefix(b"data: ")) + for line in b"".join([chunk async for chunk in response]).splitlines() + if line.startswith(b"data: ") + ] + return "".join(event["delta"]["text"] for event in events if event["type"] == "content_block_delta") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model", "answer", "streamed_text"), + [ + ("bedrock/converse/anthropic.claude-sonnet-4-5-v1:0", CONVERSE_ANSWER, _streamed_chat_text), + ("bedrock/invoke/anthropic.claude-sonnet-4-5-v1:0", _INVOKE_ANSWER, _streamed_messages_text), + ], + ids=["chat_completions", "anthropic_messages"], +) +async def test_stream_a_deployment_hook_answers_in_one_piece_gets_the_longer_limit( + model: str, + answer: bytes, + streamed_text: Callable[[Router], Awaitable[str]], + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "callbacks", [_AnswersStreamsInOnePiece()]) + respx_mock.post(url__regex=r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/.*").mock( + side_effect=slow_upstream_answer(answer) + ) + router: Final = Router( + model_list=[ + { + "model_name": "claude", + "litellm_params": { + "model": model, + "aws_access_key_id": "fake", + "aws_secret_access_key": "fake", + "aws_region_name": "us-east-1", + "timeout": STREAM_TIMEOUT_SECONDS, + "stream_timeout": UPSTREAM_FIRST_BYTE_SECONDS * 2, + }, + } + ], + num_retries=0, + ) + + assert await streamed_text(router) == "hi" + + +class _FailureDetails(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.logged: Final = asyncio.Event() + self.details: Mapping[str, object] = {} + + async def async_log_failure_event( + self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object + ) -> None: + self.details = kwargs + self.logged.set() + + +async def _drain_messages_stream(router: Router) -> None: + response: Final = await router.aanthropic_messages( + model="native", messages=[{"role": "user", "content": "hi"}], max_tokens=10, stream=True, client_side_timeout=True + ) + _ = [chunk async for chunk in response] + + +async def _drain_native_responses_stream(router: Router) -> None: + response: Final = await router.aresponses(model="native", input="hi", stream=True, client_side_timeout=True) + _ = [event async for event in response] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model", "drain"), + [ + ("bedrock/invoke/anthropic.claude-sonnet-4-5-v1:0", _drain_messages_stream), + ("openai/gpt-4.1", _drain_native_responses_stream), + ], + ids=["anthropic_messages", "responses"], +) +async def test_native_live_stream_cut_at_the_stream_timeout_is_not_blamed_on_the_deployment( + model: str, + drain: Callable[[Router], Awaitable[None]], + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + failure_details: Final = _FailureDetails() + monkeypatch.setattr(litellm, "callbacks", [failure_details]) + respx_mock.post(url__regex=r"https://(bedrock-runtime\.us-east-1\.amazonaws\.com|api\.openai\.com)/.*").mock( + side_effect=slow_upstream_answer() + ) + router: Final = Router( + model_list=[ + { + "model_name": "native", + "litellm_params": { + "model": model, + "api_key": "fake", + "aws_access_key_id": "fake", + "aws_secret_access_key": "fake", + "aws_region_name": "us-east-1", + }, + } + ], + timeout=UPSTREAM_FIRST_BYTE_SECONDS * 2, + stream_timeout=STREAM_TIMEOUT_SECONDS, + num_retries=0, + ) + + with pytest.raises(litellm.Timeout): + await drain(router) + await asyncio.wait_for(failure_details.logged.wait(), timeout=10) + + started: Final = failure_details.details.get("api_call_start_time") + assert isinstance(started, datetime) + assert is_caller_timeout_408(failure_details.details, 408, ended=started + timedelta(seconds=STREAM_TIMEOUT_SECONDS)) + @pytest.mark.asyncio async def test_bedrock_timeout_preserves_the_provider_timeout_error( respx_mock: respx.MockRouter, diff --git a/tests/unit/test_router_model_cost_isolation.py b/tests/unit/test_router_model_cost_isolation.py index d8625ff9da3..094a13c37b2 100644 --- a/tests/unit/test_router_model_cost_isolation.py +++ b/tests/unit/test_router_model_cost_isolation.py @@ -9,14 +9,22 @@ should still use the built-in pricing. import asyncio import copy +import itertools +import json import logging import os +import pathlib +import queue import re -from typing import Final +import sys +import threading +from types import MappingProxyType +from typing import Final, cast from unittest.mock import Mock, patch import httpx import pytest +import respx import litellm from litellm import Router @@ -39,6 +47,9 @@ from litellm.utils import ( ) +_COPILOT_TOKEN_EXPIRES_AT: Final = 4_102_444_800 + + def _simulate_price_data_reload(fetched_catalog): """Drive what a price data reload does to this process's litellm state. @@ -573,6 +584,40 @@ def test_capability_flag_stays_on_the_deployment_that_set_it(flag: str) -> None: _restore_model_cost_entries(model_keys) +def test_token_count_settings_stay_on_the_deployment_that_set_them() -> None: + """A deployment's ``token_count_model`` and ``token_count_framing`` must not land on + the shared ``{provider}/{model}`` key, or a sibling deployment of that model would count with them.""" + from litellm.proxy.common_utils.token_count_settings import cost_map_entry + + backend_model: Final = "bedrock/us.anthropic.claude-sonnet-5-5" + deploy_id: Final = "sonnet-deploy-keep-token-count" + configured: Final = { + "token_count_model": {"model": "anthropic.claude-opus-5-5", "region": "us-west-2"}, + "token_count_framing": 7, + } + builtin: Final = {field: cost_map_entry(backend_model).get(field) for field in configured} + assert all(builtin[field] != value for field, value in configured.items()), builtin + model_keys: Final = { + key: copy.deepcopy(litellm.model_cost.get(key)) + for key in (deploy_id, backend_model, backend_model.removeprefix("bedrock/")) + } + try: + Router( + model_list=[ + { + "model_name": "sonnet-keep-token-count", + "litellm_params": {"model": backend_model}, + "model_info": {"id": deploy_id, **configured}, + } + ], + ) + + assert {field: litellm.model_cost[deploy_id][field] for field in configured} == configured + assert {field: cost_map_entry(backend_model).get(field) for field in configured} == builtin + finally: + _restore_model_cost_entries(model_keys) + + def test_should_store_full_pricing_under_deployment_model_id(): """ Per-deployment pricing (including zero) should be stored and @@ -2125,6 +2170,398 @@ def test_backend_cost_map_keys_matches_what_registration_writes(model, custom_ll assert keys[0] == (model if custom_llm_provider is None else f"{custom_llm_provider}/{model}") +def test_mantle_chat_deployment_does_not_rewrite_the_shared_responses_row(): + """ + A Bedrock Mantle deployment that pins ``mode: chat`` routes its own chat + requests to Chat Completions, while the shared cost-map row keeps + ``mode: responses`` for every other deployment and plain SDK calls. + """ + from litellm.main import responses_api_bridge_check + + backend_model = "bedrock_mantle/openai.gpt-5.6-sol" + cost_map: Final = cast(dict[str, dict[str, object]], litellm.model_cost) + model_keys = { + key: copy.deepcopy(cost_map.get(key)) + for key in (backend_model, f"bedrock_mantle/{backend_model}", "mantle-chat-pinned") + } + + try: + litellm.model_cost[backend_model] = { + **(model_keys[backend_model] or {}), + "litellm_provider": "bedrock_mantle", + "mode": "responses", + } + _invalidate_model_cost_lowercase_map() + + Router( + model_list=[ + { + "model_name": "mantle-chat", + "litellm_params": { + "model": backend_model, + "custom_llm_provider": "bedrock_mantle", + "api_key": "test-bearer", + }, + "model_info": {"id": "mantle-chat-pinned", "mode": "chat"}, + } + ] + ) + + assert litellm.model_cost[backend_model]["mode"] == "responses" + + shared_info, _ = responses_api_bridge_check(model=backend_model, custom_llm_provider="bedrock_mantle") + assert shared_info["mode"] == "responses" + pinned_info, _ = responses_api_bridge_check( + model=backend_model, + custom_llm_provider="bedrock_mantle", + deployment_model_info={"id": "mantle-chat-pinned", "mode": "chat"}, + ) + assert pinned_info["mode"] == "chat" + finally: + _restore_model_cost_entries(model_keys) + + +def _register_routing_test_row(monkeypatch: pytest.MonkeyPatch, mode: str) -> None: + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + litellm.model_cost["routing-test-model"] = { + "litellm_provider": "openai", + "mode": mode, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "max_input_tokens": 1000, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + } + _invalidate_model_cost_lowercase_map() + + +@pytest.mark.parametrize("registered_mode, pinned_mode", [("responses", "chat"), ("chat", "responses")]) +@pytest.mark.parametrize( + "model, custom_llm_provider", + [ + ("openai/routing-test-model", "openai"), + ("openai/routing-test-model", None), + ("routing-test-model", "openai"), + ("openai/responses/routing-test-model", None), + ], +) +def test_a_deployment_mode_never_rewrites_the_shared_row( + monkeypatch: pytest.MonkeyPatch, + registered_mode: str, + pinned_mode: str, + model: str, + custom_llm_provider: str | None, +) -> None: + """A ``mode`` pin routes only its own deployment. Every key registration merges into the shared + ```` row, the ``responses/``-stripped twin included, has to keep the mode the SDK and the + sibling deployments route by, whichever way the pin points.""" + from litellm.main import responses_api_bridge_check + + _register_routing_test_row(monkeypatch, registered_mode) + litellm_params: Final = {"model": model, "api_key": "sk-test"} | ( + {"custom_llm_provider": custom_llm_provider} if custom_llm_provider else {} + ) + + Router( + model_list=[ + { + "model_name": "pinned", + "litellm_params": litellm_params, + "model_info": {"id": "pinned-id", "mode": pinned_mode}, + }, + {"model_name": "sibling", "litellm_params": {"model": "openai/routing-test-model", "api_key": "sk-test"}}, + ] + ) + + assert litellm.model_cost["routing-test-model"]["mode"] == registered_mode + shared_info, _ = responses_api_bridge_check(model="routing-test-model", custom_llm_provider="openai") + assert shared_info["mode"] == registered_mode + + +def test_a_custom_priced_chat_pin_request_leaves_its_unpinned_sibling_on_responses( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + _register_routing_test_row(monkeypatch, "responses") + chat_route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "routing-test-model", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + ) + responses_route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock( + return_value=httpx.Response( + 200, + json={ + "id": "resp_1", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "routing-test-model", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + }, + ) + ) + router: Final = Router( + model_list=[ + { + "model_name": "priced-chat-pin", + "litellm_params": { + "model": "openai/routing-test-model", + "api_key": "sk-test", + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4e-06, + }, + "model_info": {"id": "priced-chat-pin-id", "mode": "chat"}, + }, + {"model_name": "sibling", "litellm_params": {"model": "openai/routing-test-model", "api_key": "sk-test"}}, + ] + ) + + router.completion(model="priced-chat-pin", messages=[{"role": "user", "content": "Say ok"}]) + router.completion(model="sibling", messages=[{"role": "user", "content": "Say ok"}]) + + assert (chat_route.call_count, responses_route.call_count) == (1, 1) + + +def test_a_prefixed_deployment_override_leaves_the_shared_row_limits_alone(monkeypatch: pytest.MonkeyPatch) -> None: + """A deployment that names its provider twice, in ``custom_llm_provider`` and in the model, registers + under its own key, so its ``max_input_tokens`` override stays off the row its siblings read.""" + _register_routing_test_row(monkeypatch, "responses") + shared_before: Final = copy.deepcopy(litellm.model_cost["routing-test-model"]) + + Router( + model_list=[ + { + "model_name": "small-window", + "litellm_params": { + "model": "openai/routing-test-model", + "custom_llm_provider": "openai", + "api_key": "sk-test", + }, + "model_info": {"id": "small-window-id", "max_input_tokens": 10}, + } + ] + ) + + assert litellm.model_cost["routing-test-model"] == shared_before + + +@pytest.mark.parametrize("pins", [("chat", "responses"), ("responses", "chat")], ids=["chat first", "responses first"]) +def test_an_unmapped_model_routes_the_same_whichever_pin_registers_first( + monkeypatch: pytest.MonkeyPatch, pins: tuple[str, str] +) -> None: + """A model missing from the cost map gets its shared row from the router, so conflicting pins must not + leave a row that depends on deployment order, and a ``responses`` pin keeps opting the model in.""" + from litellm.main import responses_api_bridge_check + + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + Router( + model_list=[ + { + "model_name": f"pinned-{mode}", + "litellm_params": {"model": "openai/unmapped-routing-test-model", "api_key": "sk-test"}, + "model_info": {"id": f"pinned-{mode}", "mode": mode}, + } + for mode in pins + ] + ) + + shared_info, _ = responses_api_bridge_check(model="unmapped-routing-test-model", custom_llm_provider="openai") + assert shared_info.get("mode") == "responses" + + +_FINE_TUNE: Final = "openai/ft:gpt-4o-mini-2024-07-18:routing-test::abc123" + + +def _fine_tune_routing( + monkeypatch: pytest.MonkeyPatch, pristine_cost_map: dict[str, object], order: tuple[str | None, ...] +) -> tuple[object, ...]: + from litellm.main import responses_api_bridge_check + + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(pristine_cost_map)) + Router( + model_list=[ + { + "model_name": f"ft-{mode}", + "litellm_params": {"model": _FINE_TUNE, "api_key": "sk-test"}, + "model_info": {"id": f"ft-{mode}"} if mode is None else {"id": f"ft-{mode}", "mode": mode}, + } + for mode in order + ] + ) + return tuple( + responses_api_bridge_check( + model=_FINE_TUNE.removeprefix("openai/"), + custom_llm_provider="openai", + deployment_model_info=None if mode is None else {"mode": mode}, + )[0].get("mode") + for mode in (None, "chat", "responses") + ) + + +@pytest.mark.parametrize("order", list(itertools.permutations(("chat", "responses", None))), ids=str) +def test_a_fine_tune_routes_the_same_whichever_deployment_registers_first( + monkeypatch: pytest.MonkeyPatch, order: tuple[str | None, ...] +) -> None: + """A fine-tune missing from the cost map inherits its base model's row on its first registration, so a + ``responses`` pin must not opt the shared row in only when it happens to register before its siblings.""" + pristine_cost_map: Final = litellm.model_cost + + assert _fine_tune_routing(monkeypatch, pristine_cost_map, order) == _fine_tune_routing( + monkeypatch, pristine_cost_map, ("responses", "chat", None) + ) + + +def test_a_non_routing_mode_pin_still_reaches_the_shared_row(monkeypatch: pytest.MonkeyPatch) -> None: + """Only ``chat`` and ``responses`` pins are per-deployment routing. Other modes, such as a realtime + model pinned to ``audio_transcription``, are still read from the shared row by name.""" + _register_routing_test_row(monkeypatch, "chat") + + Router( + model_list=[ + { + "model_name": "transcriber", + "litellm_params": {"model": "openai/routing-test-model", "api_key": "sk-test"}, + "model_info": {"id": "transcriber-id", "mode": "audio_transcription"}, + } + ] + ) + + assert litellm.get_model_info(model="routing-test-model", custom_llm_provider="openai")["mode"] == ( + "audio_transcription" + ) + + +def test_a_non_routing_mode_pin_leaves_a_responses_row_on_the_responses_api(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.main import responses_api_bridge_check + + _register_routing_test_row(monkeypatch, "responses") + + Router( + model_list=[ + { + "model_name": "transcriber", + "litellm_params": {"model": "openai/routing-test-model", "api_key": "sk-test"}, + "model_info": {"id": "transcriber-id", "mode": "audio_transcription"}, + } + ] + ) + + shared_info, _ = responses_api_bridge_check(model="routing-test-model", custom_llm_provider="openai") + assert shared_info["mode"] == "responses" + + +@pytest.mark.parametrize("registered_mode", ["responses", "chat"]) +@pytest.mark.parametrize("malformed_mode", [5, ["chat"], {"mode": "chat"}], ids=["int", "list", "object"]) +def test_a_malformed_mode_never_reaches_the_shared_row( + monkeypatch: pytest.MonkeyPatch, registered_mode: str, malformed_mode: object +) -> None: + from litellm.main import responses_api_bridge_check + + _register_routing_test_row(monkeypatch, registered_mode) + + Router( + model_list=[ + { + "model_name": "malformed", + "litellm_params": {"model": "openai/routing-test-model", "api_key": "sk-test"}, + "model_info": {"id": "malformed-id", "mode": malformed_mode}, + } + ] + ) + + assert litellm.model_cost["routing-test-model"]["mode"] == registered_mode + shared_info, _ = responses_api_bridge_check(model="routing-test-model", custom_llm_provider="openai") + assert shared_info["mode"] == registered_mode + + +def test_a_github_copilot_chat_pin_opts_a_dual_endpoint_model_out_of_the_responses_api( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """``github_copilot`` sends a model listing both endpoints to ``/v1/responses`` unless its row says ``chat``, + so a ``chat`` pin has to reach the row to keep that model on chat completions.""" + from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api + + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT})) + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + litellm.model_cost["github_copilot/routing-test-model"] = { + "litellm_provider": "github_copilot", + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "max_input_tokens": 1000, + } + _invalidate_model_cost_lowercase_map() + assert github_copilot_supports_responses_api("github_copilot/routing-test-model") is True + + Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": {"model": "github_copilot/routing-test-model"}, + "model_info": {"id": "copilot-chat-pinned", "mode": "chat"}, + } + ] + ) + + assert github_copilot_supports_responses_api("github_copilot/routing-test-model") is False + + +@pytest.mark.parametrize("registered_mode, pinned_mode", [("chat", "responses"), ("responses", "chat")]) +@pytest.mark.parametrize( + "model, custom_llm_provider", [("github_copilot/routing-test-model", None), ("routing-test-model", "github_copilot")] +) +def test_a_github_copilot_responses_pin_still_opts_the_model_into_the_responses_api( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + registered_mode: str, + pinned_mode: str, + model: str, + custom_llm_provider: str | None, +) -> None: + """``github_copilot`` dispatches ``/v1/responses`` natively by the shared row's mode, and a ``responses`` + pin is its documented opt-in, while a ``chat`` pin must not move a Responses-only row off it.""" + from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api + + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT})) + monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) + litellm.model_cost["github_copilot/routing-test-model"] = { + "litellm_provider": "github_copilot", + "mode": registered_mode, + "supported_endpoints": ["/v1/responses"] if registered_mode == "responses" else ["/v1/chat/completions"], + "max_input_tokens": 1000, + } + _invalidate_model_cost_lowercase_map() + + Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": {"model": model} + | ({"custom_llm_provider": custom_llm_provider} if custom_llm_provider else {}), + "model_info": {"id": "copilot-pinned", "mode": pinned_mode}, + } + ] + ) + + assert github_copilot_supports_responses_api("github_copilot/routing-test-model") is True + + def test_a_discarded_router_stops_contributing_to_later_reloads(monkeypatch): """ `_route_user_config_request` builds a Router per request from caller-supplied @@ -3241,6 +3678,76 @@ def test_price_data_reload_refreshes_the_cached_model_group_and_deployment_info( assert router.cached_deployment_model_info("dep-a", "openai/gpt-4o")["input_cost_per_token"] == new_price +def test_price_data_reload_keeps_geo_deployments_priced_while_requests_register_pricing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + catalog: Final = litellm.get_model_cost_map(url="") + monkeypatch.setattr(litellm, "model_cost", dict(catalog)) + _invalidate_model_cost_lowercase_map() + backends: Final = tuple( + f"bedrock/au.anthropic.{model}" + for model in ("claude-sonnet-5", "claude-opus-5", "claude-haiku-4-5-20251001-v1:0", "claude-sonnet-4-6") + ) + canonical_prices: Final = MappingProxyType( + {backend: catalog[backend.removeprefix("bedrock/")]["input_cost_per_token"] for backend in backends} + ) + assert all(price > 0 for price in canonical_prices.values()) + router: Final = Router( + model_list=[ + { + "model_name": backend.rsplit(".", 1)[-1], + "litellm_params": {"model": backend, "aws_region_name": "ap-southeast-2"}, + "model_info": {"id": f"lit-5853-dep-{index}"}, + } + for index, backend in enumerate(backends) + ] + ) + stop: Final = threading.Event() + writer_outcome: Final[queue.SimpleQueue[int | Exception]] = queue.SimpleQueue() + + def register_until_stopped() -> None: + try: + for i in itertools.count(): + if stop.is_set(): + writer_outcome.put(i) + return + litellm.register_model( + { + f"lit-5853-writer-{i}": { + "litellm_provider": "lit-5853-writer", + "mode": "chat", + "input_cost_per_token": 1e-07, + "output_cost_per_token": 4e-07, + "cache_read_input_token_cost": 1e-08, + } + }, + persist_across_reloads=False, + ) + except Exception as failure: + writer_outcome.put(failure) + + writer: Final = threading.Thread(target=register_until_stopped, daemon=True) + saved_switch_interval: Final = sys.getswitchinterval() + sys.setswitchinterval(1e-4) + writer.start() + try: + for _ in range(10): + _simulate_price_data_reload_with_provider_sets(monkeypatch, dict(catalog)) + finally: + stop.set() + writer.join() + sys.setswitchinterval(saved_switch_interval) + + registrations_landed: Final = writer_outcome.get(timeout=5) + try: + assert isinstance(registrations_landed, int) and registrations_landed > 0, registrations_landed + assert tuple(backend for backend in backends if backend in litellm.model_cost) == () + for backend in backends: + assert litellm.get_model_info(backend)["input_cost_per_token"] == canonical_prices[backend] + assert tuple(router.get_model_names()) == tuple(backend.rsplit(".", 1)[-1] for backend in backends) + finally: + _invalidate_model_cost_lowercase_map() + + _COLLIDING_BUILTIN_KEY: Final = "baseten/zai-org/GLM-5.2" _COLLIDING_SHARED_OPENAI_KEY: Final = "openai/zai-org/GLM-5.2" _COLLIDING_MODEL_INFO: Final = { @@ -3411,9 +3918,7 @@ def test_router_capacity_widens_to_a_larger_base_model_window( ) _invalidate_model_cost_lowercase_map() router: Final = _capacity_test_router(route, base, base_location, explicit_limit) - expected: Final = ( - explicit_limit if explicit_limit is not None else (1024 if base_location is None else 8192) - ) + expected: Final = explicit_limit if explicit_limit is not None else (1024 if base_location is None else 8192) assert _effective_max_input_tokens(router) == expected assert _deployments_allowed_for_2048_input_tokens(router) == (1 if expected >= 2048 else 0) _invalidate_model_cost_lowercase_map() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 87f9d58e247..b4150aad1c7 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -14,7 +14,7 @@ from collections.abc import Callable, Iterator, Mapping from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime, timedelta, timezone from pathlib import Path, PurePath -from typing import Final, cast +from typing import Final, SupportsIndex, cast from unittest import mock from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -1138,6 +1138,13 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_box_questions": {"type": "boolean"}, "supports_vision": {"type": "boolean"}, "supports_web_search": {"type": "boolean"}, + "token_count_framing": {"type": "integer"}, + "token_count_model": { + "type": "object", + "properties": {"model": {"type": "string"}, "region": {"type": "string"}}, + "required": ["model"], + }, + "token_count_tokenizer": {"type": "string", "enum": ["kimi_k3"]}, "supports_bedrock_runtime_chat_completions_tools_with_reasoning": {"type": "boolean"}, "supports_bedrock_runtime_chat_completions_response_format": {"type": "boolean"}, "supports_bedrock_runtime_chat_completions_inline_reasoning": {"type": "boolean"}, @@ -4887,6 +4894,104 @@ def test_reapply_runtime_registrations_drops_request_scoped_registrations(monkey _invalidate_model_cost_lowercase_map() +def test_model_cost_walks_survive_a_registration_landing_mid_walk(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.utils import _invalidate_model_cost_lowercase_map, _rebuild_model_cost_lowercase_map + + class _KeyRegisteredMidWalk(str): + def lower(self) -> str: + litellm.model_cost.setdefault("lit-5853-registered-during-the-rebuild", {"mode": "chat"}) + return super().lower() + + class _EntryRegisteredMidWalk(dict): + def get(self, field: str, default: object = None) -> object: + litellm.model_cost.setdefault("lit-5853-registered-during-add-known-models", {"mode": "chat"}) + return super().get(field, default) + + monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) + litellm.model_cost[_KeyRegisteredMidWalk("LIT-5853-WALKED-LAST")] = _EntryRegisteredMidWalk(mode="chat") + try: + assert _rebuild_model_cost_lowercase_map()["lit-5853-walked-last"] == "LIT-5853-WALKED-LAST" + litellm.add_known_models(model_cost_map=litellm.model_cost) + assert "lit-5853-registered-during-the-rebuild" in litellm.model_cost + assert "lit-5853-registered-during-add-known-models" in litellm.model_cost + finally: + _invalidate_model_cost_lowercase_map() + + +class _KeyWhoseLoweringLosesTheRace(str): + """Lowering the key is the one Python-level step between the lookup map's rebuild and + its read, so another thread's registration invalidating the map lands right there.""" + + def lower(self) -> str: + from litellm.utils import _invalidate_model_cost_lowercase_map + + _invalidate_model_cost_lowercase_map() + return super().lower() + + +def test_case_insensitive_model_cost_lookup_survives_an_invalidation_mid_lookup() -> None: + from litellm.utils import _get_model_cost_key, _invalidate_model_cost_lowercase_map + + _invalidate_model_cost_lowercase_map() + try: + assert _get_model_cost_key(_KeyWhoseLoweringLosesTheRace("GPT-4O")) == "gpt-4o" + finally: + _invalidate_model_cost_lowercase_map() + + +def _register_a_model_mid_walk() -> None: + litellm.model_cost.setdefault("lit-5853-registered-mid-walk", {"mode": "chat"}) + + +class _KeyReadDuringARegistration(str): + def startswith( + self, + prefix: str | tuple[str, ...], + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> bool: + _register_a_model_mid_walk() + return super().startswith(prefix, start, end) + + def lower(self) -> str: + _register_a_model_mid_walk() + return super().lower() + + +class _EntryReadDuringARegistration(dict[str, object]): + def __contains__(self, key: object) -> bool: + _register_a_model_mid_walk() + return super().__contains__(key) + + +@pytest.mark.parametrize("walk", ["fal_ai", "fireworks_ai", "ollama", "gpt-5", "case-insensitive scan"]) +def test_request_path_cost_map_walks_survive_a_registration_mid_walk(monkeypatch: pytest.MonkeyPatch, walk: str) -> None: + from litellm.llms.fal_ai.cost_calculator import _keyed_rows + from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig + from litellm.llms.ollama.common_utils import OllamaModelInfo + from litellm.llms.openai.chat.gpt_5_transformation import _catalogue_declares_default_effort + from litellm.utils import _handle_new_key_with_scan, _invalidate_model_cost_lowercase_map + + walks: Final[Mapping[str, Callable[[], object]]] = { + "fal_ai": lambda: _keyed_rows("lit-5853-model", "standard"), + "fireworks_ai": FireworksAIConfig._get_fireworks_index, + "ollama": lambda: OllamaModelInfo._is_static_ollama_model("ollama/lit-5853-model"), + "gpt-5": _catalogue_declares_default_effort, + "case-insensitive scan": lambda: _handle_new_key_with_scan("lit-5853-model"), + } + monkeypatch.setattr( + litellm, + "model_cost", + {_KeyReadDuringARegistration("lit-5853-catalog-model"): _EntryReadDuringARegistration(mode="chat")}, + ) + + try: + walks[walk]() + assert "lit-5853-registered-mid-walk" in litellm.model_cost + finally: + _invalidate_model_cost_lowercase_map() + + class _JsonCapture(logging.Handler): def __init__(self): super().__init__() @@ -7091,6 +7196,25 @@ def test_get_model_info_gemini(monkeypatch): assert info.get("rpm") is not None, f"{model} does not have rpm" +def test_register_model_without_a_provider_never_inherits_another_providers_entry(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.utils import _invalidate_model_cost_lowercase_map + + key: Final = "azure/claude-sonnet-4-6" + monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) + litellm.model_cost.pop(key, None) + try: + litellm.register_model( + {key: {"input_cost_per_token": 1e-07, "output_cost_per_token": 4e-07}}, persist_across_reloads=False + ) + assert litellm.model_cost[key].get("litellm_provider") is None + info: Final = litellm.get_model_info(key) + assert info["litellm_provider"] == "azure" + assert info["input_cost_per_token"] == 1e-07 + assert info["output_cost_per_token"] == 4e-07 + finally: + _invalidate_model_cost_lowercase_map() + + @pytest.mark.parametrize( "model, litellm_params, expects_native_config", [ diff --git a/tests/unit/test_xai_responses_auto_routing.py b/tests/unit/test_xai_responses_auto_routing.py index c31f599dd79..89b93df1891 100644 --- a/tests/unit/test_xai_responses_auto_routing.py +++ b/tests/unit/test_xai_responses_auto_routing.py @@ -4,14 +4,16 @@ Test automatic routing to xAI Responses API when tools are present import json from collections.abc import Mapping -from typing import Final +from typing import Final, cast from unittest.mock import MagicMock, patch import httpx import pytest + import litellm from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.main import responses_api_bridge_check +from litellm.types.llms.openai import OpenAIWebSearchOptions class _RecordingResponsesHandler: @@ -46,7 +48,6 @@ class TestXAIResponsesAutoRouting: assert model_info.get("mode") != "responses" assert updated_model == model - def test_responses_api_bridge_check_with_empty_tools(self): """Test that with empty tools list, xAI does not route to Responses API""" model = "grok-3" @@ -106,9 +107,6 @@ class TestXAIResponsesAutoRouting: assert model_info.get("mode") == "responses" assert updated_model == "grok-3" # prefix removed - - - def test_responses_api_bridge_check_with_web_search_options(self): """Test auto-routing with web_search_options""" model = "grok-4-1-fast" @@ -119,7 +117,7 @@ class TestXAIResponsesAutoRouting: model_info, updated_model = responses_api_bridge_check( model=model, custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, + web_search_options=cast(OpenAIWebSearchOptions | None, web_search_options), ) # Should auto-route with web_search_options @@ -136,7 +134,7 @@ class TestXAIResponsesAutoRouting: model_info, updated_model = responses_api_bridge_check( model=model, custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, + web_search_options=cast(OpenAIWebSearchOptions | None, web_search_options), ) # Should auto-route with both present @@ -155,9 +153,7 @@ class TestXAIResponsesAutoRouting: assert updated_model == "grok-not-in-cost-map" @patch("litellm.completion_extras.responses_api_bridge.completion") - def test_completion_with_tools_routes_to_responses_api( - self, mock_responses_completion - ): + def test_completion_with_tools_routes_to_responses_api(self, mock_responses_completion): """Test that completion() with tools routes to Responses API""" # Mock the responses_api_bridge.completion to avoid actual API calls mock_responses_completion.return_value = MagicMock() diff --git a/tests/unit/types/test_completion.py b/tests/unit/types/test_completion.py index 28d13b7da4f..d6e5801bc0b 100644 --- a/tests/unit/types/test_completion.py +++ b/tests/unit/types/test_completion.py @@ -179,6 +179,7 @@ def _build_dispatch_context() -> CompletionDispatchContext: model="gpt-4o", model_response=None, # type: ignore[arg-type] optional_params={}, + optional_params_for_model_info=lambda model_info: {}, organization=None, provider_config=None, request_params={}, @@ -187,6 +188,7 @@ def _build_dispatch_context() -> CompletionDispatchContext: temperature=None, text_completion=False, timeout=None, + timeout_for_optional_params=lambda optional_params: 600.0, top_p=None, ) diff --git a/tests/unit/types/test_guardrails.py b/tests/unit/types/test_guardrails.py index 5c652a10e16..97bab8295f4 100644 --- a/tests/unit/types/test_guardrails.py +++ b/tests/unit/types/test_guardrails.py @@ -7,6 +7,7 @@ from typing import Any, List, Optional, Tuple from unittest.mock import MagicMock, patch import pytest +from pydantic import ValidationError import litellm import litellm.litellm_core_utils @@ -178,3 +179,16 @@ def test_litellm_params_treats_blank_values_as_unset(blank: str) -> None: assert with_blank_values.model_dump() == omitted.model_dump() assert BaseLitellmParams(fail_on_error=blank).model_dump() == BaseLitellmParams().model_dump() + + +@pytest.mark.parametrize(("configured", "loaded"), [("2", 2), (2, 2), ("0", 0), (5, 5), ("", None)]) +def test_num_retries_loads_the_dashboard_string_and_blank_means_unset(configured: object, loaded: int | None) -> None: + assert ( + LitellmParams(guardrail="generic_guardrail_api", mode="pre_call", num_retries=configured).num_retries == loaded + ) + + +@pytest.mark.parametrize("configured", [-1, 6, "1.5", "twice"]) +def test_num_retries_rejects_values_that_are_not_a_retry_count(configured: object) -> None: + with pytest.raises(ValidationError, match="num_retries"): + LitellmParams(guardrail="generic_guardrail_api", mode="pre_call", num_retries=configured) diff --git a/tests/unit/vector_stores/test_main.py b/tests/unit/vector_stores/test_main.py index 1c968126c42..25a378270b1 100644 --- a/tests/unit/vector_stores/test_main.py +++ b/tests/unit/vector_stores/test_main.py @@ -8,16 +8,19 @@ model_dump() it (the #19550 serialization trap). """ import json +from collections.abc import Mapping +from typing import Final from unittest.mock import MagicMock, patch +import httpx import pytest import litellm.vector_stores.main as vector_stores_main from litellm.llms.base_llm.vector_store.transformation import ( RouterVectorStoreEmbeddingExecutor, ) -from litellm.llms.custom_httpx.http_handler import HTTPHandler -from litellm.vector_stores.main import search +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.vector_stores.main import acreate, aupdate, search MOCK_SEARCH_RESPONSE = { "object": "vector_store.search_results.page", @@ -114,3 +117,86 @@ def test_search_forwards_top_level_user_context_to_bedrock_retrieve(): posted = json.loads(client.post.call_args.kwargs["data"]) assert posted["userContext"] == {"userId": "alice@example.com"} assert posted["retrievalQuery"] == {"text": "q"} + + +PROXY_STAMPED_METADATA: Final = { + "team": "llmproxy", + "user_api_key": "hashed-key", + "user_api_key_hash": "hashed-key", + "user_api_key_team_id": "team-1", + "requester_ip_address": "127.0.0.1", + "user_agent": "curl/8.7.1", + "endpoint": "http://localhost:4000/v1/vector_stores", + "litellm_api_version": "1.0.0", + "deployment": "openai/gpt-5", + "deployment_model_name": "gpt-5", + "api_base": "https://api.openai.com/v1", + "agent_id": "agent-1", + "actor_agent_id": "agent-2", + "target_agent_id": "agent-3", + "billing_agent_id": "agent-4", + "agent_execution_mode": "autonomous", + "verified_human_user_id": "user-1", + "litellm_caller_session_id": "session-1", + "user_api_keyboard": "qwerty", + 1: "a", +} + + +def _copy(metadata: Mapping[object, object] | None) -> dict[object, object] | None: + return None if metadata is None else dict(metadata) + + +async def _metadata_sent_to_provider( + operation: str, metadata: Mapping[object, object] | None, **kwargs: object +) -> list[object]: + sent_metadata: Final[list[object]] = [] + + def echo_vector_store(request: httpx.Request) -> httpx.Response: + body: Final = json.loads(request.content) + sent_metadata.append(body.get("metadata")) + return httpx.Response( + 200, + json={ + "id": "vs_1", + "object": "vector_store", + "created_at": 0, + "name": "x", + "usage_bytes": 0, + "file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0}, + "status": "completed", + "metadata": body.get("metadata"), + }, + ) + + client: Final = AsyncHTTPHandler(transport=httpx.MockTransport(echo_vector_store)) + if operation == "create": + await acreate(name="x", metadata=_copy(metadata), api_key="sk-test", client=client, **kwargs) + else: + await aupdate(vector_store_id="vs_1", metadata=_copy(metadata), api_key="sk-test", client=client, **kwargs) + return sent_metadata + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["create", "update"]) +async def test_proxy_vector_store_writes_send_only_the_callers_metadata_to_the_provider(operation: str): + sent_metadata: Final = await _metadata_sent_to_provider( + operation, + PROXY_STAMPED_METADATA, + proxy_server_request={"url": "http://localhost:4000/v1/vector_stores", "method": "POST", "body": {}}, + ) + + assert sent_metadata == [{"team": "llmproxy", "user_api_keyboard": "qwerty", "1": "a"}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["create", "update"]) +@pytest.mark.parametrize( + "callers_metadata", [{"team": "llmproxy", "endpoint": "ingest", "deployment": "prod", "agent_id": "bot-7"}, None] +) +async def test_direct_sdk_vector_store_writes_send_the_callers_metadata_unchanged( + operation: str, callers_metadata: Mapping[object, object] | None +): + sent_metadata: Final = await _metadata_sent_to_provider(operation, callers_metadata) + + assert sent_metadata == [callers_metadata] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx index 67ac770a65f..53ce661baaf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -10,6 +10,7 @@ import { chooseSelectOption, renderWithProviders as render } from "../../../../. vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn() }, createAgentCall: vi.fn(), + getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"), getAgentCreateMetadata: vi.fn(), getAgentsList: vi.fn(), keyCreateForAgentCall: vi.fn(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx index 5e8ba145396..5b8cb652510 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx @@ -8,6 +8,7 @@ import type { AgentCreateInfo } from "@/components/networking"; vi.mock("@/components/networking", () => ({ createAgentCall: vi.fn(), + getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"), getAgentCreateMetadata: vi.fn(), getAgentsList: vi.fn(), keyCreateForAgentCall: vi.fn(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.ts index eceaae9d422..a7367069014 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.ts @@ -3,7 +3,7 @@ type ListParams = { page?: number; limit?: number; - filters?: Record; + filters?: Record; }; /** diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/currentUser/useCurrentUser.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/currentUser/useCurrentUser.ts new file mode 100644 index 00000000000..6b688ec2668 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/currentUser/useCurrentUser.ts @@ -0,0 +1,28 @@ +import { skipToken, useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { fetchClient } from "@/lib/http/api"; +import { getAuthHeaderName } from "@/lib/http/runtime"; +import type { components } from "@/lib/http/schema"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +export type CurrentUser = components["schemas"]["CurrentUserResponse"]; + +const currentUserKeys = createQueryKeys("currentUser"); + +const getCurrentUser = async (accessToken: string, authHeaderName: string): Promise => { + const { data } = await fetchClient.GET("/management/v1/users/me", { + headers: { [authHeaderName]: `Bearer ${accessToken}` }, + }); + if (!data) throw new Error("Current user response is empty"); + return data; +}; + +export const useCurrentUser = (accessToken: string | null, authHeaderName?: string, canFetch = true) => { + const options: UseQueryOptions = { + queryKey: currentUserKeys.detail(accessToken ?? ""), + queryFn: + accessToken && canFetch ? () => getCurrentUser(accessToken, authHeaderName ?? getAuthHeaderName()) : skipToken, + staleTime: Infinity, + retry: 1, + }; + return useQuery(options); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/redirectToLogin.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/redirectToLogin.ts new file mode 100644 index 00000000000..ec53589a8b2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/redirectToLogin.ts @@ -0,0 +1,8 @@ +import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking"; +import { buildLoginUrlWithReturn, getLoginUrl, storeReturnUrl } from "@/utils/returnUrlUtils"; + +export const redirectToLogin = () => { + storeReturnUrl(); + const baseLoginUrl = getUiPageUrl(getLoginUrl(getProxyBaseUrl())); + window.location.replace(buildLoginUrlWithReturn(baseLoginUrl)); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index 1ca0581a391..5d7825b310a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -1,9 +1,11 @@ /* @vitest-environment jsdom */ import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import useAuthorized from "./useAuthorized"; +import { useUIConfig } from "./uiConfig/useUIConfig"; +import { useCurrentUser } from "./currentUser/useCurrentUser"; // Unmock useAuthorized to test the actual implementation vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); @@ -60,6 +62,32 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => { storeReturnUrl: vi.fn(), }; }); +const fetchMock = vi.fn<(request: Request) => Promise>(); + +const CURRENT_USER_PATH = "/management/v1/users/me"; + +const currentUserRequests = () => + fetchMock.mock.calls + .map(([request]) => request) + .filter((request) => new URL(request.url).pathname === CURRENT_USER_PATH); + +type CurrentUserBody = { user_id: string; user_email: string | null; user_role: string | null }; + +const serveCurrentUser = (reply: CurrentUserBody | ((request: Request) => CurrentUserBody)) => { + fetchMock.mockImplementation(async (request) => Response.json(typeof reply === "function" ? reply(request) : reply)); + vi.stubGlobal("fetch", fetchMock); +}; + +const stallCurrentUser = () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); +}; + +const failCurrentUser = (status: number) => { + fetchMock.mockImplementation(async () => Response.json({ detail: "rejected" }, { status })); + vi.stubGlobal("fetch", fetchMock); +}; + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -114,10 +142,12 @@ describe("useAuthorized", () => { decodeTokenMock.mockReset(); checkTokenValidityMock.mockReset(); buildLoginUrlWithReturnMock.mockClear(); + vi.unstubAllGlobals(); + fetchMock.mockReset(); clearCookie(); }); - it("should decode the token and expose user details", async () => { + it("should take user details from the server and the rest from the token", async () => { getUiConfigMock.mockResolvedValue({ server_root_path: "/", proxy_base_url: null, @@ -138,6 +168,7 @@ describe("useAuthorized", () => { decodeTokenMock.mockReturnValue(decodedPayload); checkTokenValidityMock.mockReturnValue(true); + serveCurrentUser({ user_id: "user-1", user_email: "user@example.com", user_role: "proxy_admin" }); const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; @@ -145,9 +176,13 @@ describe("useAuthorized", () => { const { result } = renderHook(() => useAuthorized(), { wrapper }); await waitFor(() => { - expect(result.current.token).toBe(token); + expect(result.current.userId).toBe("user-1"); }); + expect(currentUserRequests().map((request) => request.headers.get("Authorization"))).toEqual([ + "Bearer api-key-123", + ]); + expect(result.current.token).toBe(token); expect(result.current.accessToken).toBe("api-key-123"); expect(result.current.userId).toBe("user-1"); expect(result.current.userEmail).toBe("user@example.com"); @@ -161,6 +196,39 @@ describe("useAuthorized", () => { expect(clearTokenCookiesMock).not.toHaveBeenCalled(); }); + it("sends the first lookup on the auth header named in the session cookie", async () => { + const uiConfig = { + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + sso_configured: false, + }; + getUiConfigMock.mockResolvedValue(uiConfig); + + const decodedPayload = { + key: "api-key-custom-header", + user_id: "user-3", + user_role: "internal_user", + auth_header_name: "X-Litellm-Key", + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(true); + serveCurrentUser({ user_id: "user-3", user_email: null, user_role: "internal_user" }); + document.cookie = `token=${createJwt(decodedPayload)}; path=/;`; + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.userId).toBe("user-3"); + }); + + expect(currentUserRequests().map((request) => request.headers.get("X-Litellm-Key"))).toEqual([ + "Bearer api-key-custom-header", + ]); + }); + it("should present proxy_admin_viewer as Admin while flagging it view-only", async () => { getUiConfigMock.mockResolvedValue({ server_root_path: "/", @@ -182,6 +250,11 @@ describe("useAuthorized", () => { decodeTokenMock.mockReturnValue(decodedPayload); checkTokenValidityMock.mockReturnValue(true); + serveCurrentUser({ + user_id: "user-2", + user_email: "viewer@example.com", + user_role: "proxy_admin_viewer", + }); const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; @@ -189,7 +262,7 @@ describe("useAuthorized", () => { const { result } = renderHook(() => useAuthorized(), { wrapper }); await waitFor(() => { - expect(result.current.token).toBe(token); + expect(result.current.userId).toBe("user-2"); }); expect(result.current.userRole).toBe("Admin"); @@ -222,6 +295,7 @@ describe("useAuthorized", () => { expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login/"); expect(result.current.accessToken).toBeNull(); expect(result.current.userRole).toBe("Undefined Role"); + expect(currentUserRequests()).toHaveLength(0); }); it("should redirect even with valid token if admin_ui_disabled is true", async () => { @@ -245,6 +319,7 @@ describe("useAuthorized", () => { decodeTokenMock.mockReturnValue(decodedPayload); checkTokenValidityMock.mockReturnValue(true); + serveCurrentUser({ user_id: "user-1", user_email: "user@example.com", user_role: "proxy_admin" }); const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; @@ -256,8 +331,6 @@ describe("useAuthorized", () => { }); expect(result.current.accessToken).toBe("api-key-123"); - expect(result.current.userId).toBe("user-1"); - expect(result.current.userEmail).toBe("user@example.com"); }); it("should redirect when token is missing", async () => { @@ -313,5 +386,193 @@ describe("useAuthorized", () => { expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login/"); expect(checkTokenValidityMock).toHaveBeenCalledWith(token); + expect(currentUserRequests()).toHaveLength(0); + }); + + describe("identity from the server", () => { + const uiConfig = { + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + sso_configured: false, + }; + + const setSessionCookie = (claims: Record) => { + decodeTokenMock.mockReturnValue(claims); + checkTokenValidityMock.mockReturnValue(true); + const token = createJwt(claims); + document.cookie = `token=${token}; path=/;`; + return token; + }; + + it("uses the role the server resolved, not the role claimed in the cookie", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: "internal-key", user_id: "internal-1", user_role: "proxy_admin" }); + serveCurrentUser({ + user_id: "internal-1", + user_email: "internal@example.com", + user_role: "internal_user", + }); + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.userRole).toBe("Internal User"); + }); + expect(result.current.userRoleLabel).toBe("Internal User"); + expect(result.current.isViewOnly).toBe(false); + expect(result.current.userEmail).toBe("internal@example.com"); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("stays loading with no role until the server answers", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: "admin-claim-key", user_id: "u-1", user_role: "proxy_admin" }); + stallCurrentUser(); + + const { result } = renderHook(() => ({ auth: useAuthorized(), uiConfig: useUIConfig() }), { wrapper }); + + await waitFor(() => { + expect(result.current.uiConfig.isSuccess).toBe(true); + }); + expect(result.current.auth.isLoading).toBe(true); + expect(result.current.auth.userRole).toBe("Undefined Role"); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("waits for backend discovery before looking up the current user", async () => { + let finishDiscovery!: (config: typeof uiConfig) => void; + getUiConfigMock.mockReturnValue(new Promise((resolve) => (finishDiscovery = resolve))); + setSessionCookie({ key: "discovery-key", user_id: "u-9", user_role: "internal_user" }); + serveCurrentUser({ user_id: "u-9", user_email: null, user_role: "internal_user" }); + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => expect(getUiConfigMock).toHaveBeenCalled()); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(currentUserRequests()).toHaveLength(0); + expect(result.current.isLoading).toBe(true); + + await act(async () => finishDiscovery(uiConfig)); + + await waitFor(() => expect(result.current.userId).toBe("u-9")); + expect(currentUserRequests()).toHaveLength(1); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("keeps an identity the page already loaded while backend discovery reruns", async () => { + getUiConfigMock.mockReturnValue(new Promise(() => {})); + setSessionCookie({ key: "cached-key", user_id: "u-7", user_role: "proxy_admin" }); + serveCurrentUser({ user_id: "u-7", user_email: null, user_role: "internal_user" }); + const queryClient = createQueryClient(); + const sharedWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result: layoutLookup } = renderHook(() => useCurrentUser("cached-key"), { wrapper: sharedWrapper }); + await waitFor(() => expect(layoutLookup.current.isSuccess).toBe(true)); + + const { result } = renderHook(() => useAuthorized(), { wrapper: sharedWrapper }); + + expect(result.current.userId).toBe("u-7"); + expect(result.current.userRole).toBe("Internal User"); + expect(currentUserRequests()).toHaveLength(1); + }); + + it("clears the cookie and redirects to login when the current user cannot be loaded", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: "orphan-key", user_id: "u-1", user_role: "internal_user" }); + failCurrentUser(404); + + renderHook(() => useAuthorized(), { wrapper }); + + await waitFor( + () => { + expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login/"); + }, + { timeout: 4000 }, + ); + expect(clearTokenCookiesMock).toHaveBeenCalled(); + expect(currentUserRequests()).toHaveLength(2); + }); + + it("refetches the identity when a different session key logs in", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + serveCurrentUser((request) => + request.headers.get("Authorization") === "Bearer first-key" + ? { user_id: "first", user_email: null, user_role: "proxy_admin" } + : { user_id: "second", user_email: null, user_role: "internal_user" }, + ); + const queryClient = createQueryClient(); + const sharedClientWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + setSessionCookie({ key: "first-key", user_id: "first" }); + + const { result, rerender } = renderHook(() => useAuthorized(), { wrapper: sharedClientWrapper }); + await waitFor(() => { + expect(result.current.userId).toBe("first"); + }); + + setSessionCookie({ key: "second-key", user_id: "second" }); + act(() => rerender()); + + await waitFor(() => { + expect(result.current.userId).toBe("second"); + }); + expect(result.current.userRole).toBe("Internal User"); + }); + + it.each([ + { cookieRole: "internal_user", serverRole: "proxy_admin_viewer", isViewOnly: true }, + { cookieRole: "proxy_admin_viewer", serverRole: "proxy_admin", isViewOnly: false }, + ])("derives view-only from the server role ($serverRole), not the cookie ($cookieRole)", async (scenario) => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: `view-only-${scenario.serverRole}`, user_id: "u-1", user_role: scenario.cookieRole }); + serveCurrentUser({ user_id: "u-1", user_email: null, user_role: scenario.serverRole }); + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.userId).toBe("u-1"); + }); + expect(result.current.isViewOnly).toBe(scenario.isViewOnly); + }); + + it("does not refetch the identity when a consumer mounts after it has loaded", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: "late-key", user_id: "u-1" }); + serveCurrentUser({ user_id: "u-1", user_email: null, user_role: "internal_user" }); + const queryClient = createQueryClient(); + const sharedClientWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const first = renderHook(() => useAuthorized(), { wrapper: sharedClientWrapper }); + await waitFor(() => { + expect(first.result.current.userId).toBe("u-1"); + }); + const second = renderHook(() => useAuthorized(), { wrapper: sharedClientWrapper }); + + expect(second.result.current.userId).toBe("u-1"); + await act(async () => {}); + expect(currentUserRequests()).toHaveLength(1); + }); + + it("fetches the identity once for many consumers on the page", async () => { + getUiConfigMock.mockResolvedValue(uiConfig); + setSessionCookie({ key: "shared-key", user_id: "u-1" }); + serveCurrentUser({ user_id: "u-1", user_email: null, user_role: "internal_user" }); + const queryClient = createQueryClient(); + const sharedClientWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + const { result } = renderHook(() => [useAuthorized(), useAuthorized(), useAuthorized()], { + wrapper: sharedClientWrapper, + }); + + await waitFor(() => { + expect(result.current.map((auth) => auth.userId)).toEqual(["u-1", "u-1", "u-1"]); + }); + expect(currentUserRequests()).toHaveLength(1); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 0d240c02f45..47716fccfeb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -1,11 +1,11 @@ "use client"; -import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; -import { buildLoginUrlWithReturn, getLoginUrl, storeReturnUrl } from "@/utils/returnUrlUtils"; -import { useCallback, useEffect, useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole } from "@/utils/roles"; +import { useCurrentUser } from "./currentUser/useCurrentUser"; +import { redirectToLogin } from "./redirectToLogin"; import { useUIConfig } from "./uiConfig/useUIConfig"; const useAuthorized = () => { @@ -15,16 +15,14 @@ const useAuthorized = () => { const decoded = useMemo(() => decodeToken(token), [token]); const isTokenValid = useMemo(() => checkTokenValidity(token), [token]); - const isLoading = isUIConfigLoading; - const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled; - - // Helper function to redirect to login while preserving the current URL - const redirectToLogin = useCallback(() => { - storeReturnUrl(); - const baseLoginUrl = getUiPageUrl(getLoginUrl(getProxyBaseUrl())); - const loginUrlWithReturn = buildLoginUrlWithReturn(baseLoginUrl); - window.location.replace(loginUrlWithReturn); - }, []); + const { + data: currentUser, + isLoading: isCurrentUserLoading, + isError: isCurrentUserError, + } = useCurrentUser(isTokenValid ? decoded?.key ?? null : null, decoded?.auth_header_name, !isUIConfigLoading); + const serverRole = currentUser?.user_role ?? ""; + const isLoading = isUIConfigLoading || isCurrentUserLoading; + const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled && !isCurrentUserError; // Single useEffect for all redirect logic useEffect(() => { @@ -36,18 +34,18 @@ const useAuthorized = () => { } redirectToLogin(); } - }, [isLoading, isAuthorized, token, redirectToLogin]); + }, [isLoading, isAuthorized, token]); return { isLoading, isAuthorized, token: isAuthorized ? token : null, accessToken: decoded?.key ?? null, - userId: decoded?.user_id ?? null, - userEmail: decoded?.user_email ?? null, - userRole: effectiveSessionRole(decoded?.user_role), - userRoleLabel: formatUserRole(decoded?.user_role), - isViewOnly: isViewOnlySessionRole(decoded?.user_role), + userId: currentUser?.user_id ?? null, + userEmail: currentUser?.user_email ?? null, + userRole: effectiveSessionRole(serverRole), + userRoleLabel: formatUserRole(serverRole), + isViewOnly: isViewOnlySessionRole(serverRole), premiumUser: decoded?.premium_user ?? null, disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, loginMethod: decoded?.login_method ?? null, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx index aa2069eb74e..c4c32d5f7f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.integration.test.tsx @@ -81,6 +81,8 @@ beforeEach(() => { }; return Response.json(usage); } + case "/management/v1/users/me": + return Response.json({ user_id: "layout-admin", user_role: "proxy_admin", user_email: null }); case "/sso/get/ui_settings": return Response.json({}); case "/get/user_banner": diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 154f72719e2..0fdbd047786 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -7,6 +7,17 @@ import Layout from "./layout"; const { replaceMock } = vi.hoisted(() => ({ replaceMock: vi.fn() })); +const CURRENT_USER_PATH = "/management/v1/users/me"; +const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise>(); +const pathOf = (input: RequestInfo | URL) => + new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname; +let currentUserReply: () => Promise; + +const currentUserRequests = () => + fetchMock.mock.calls + .map(([input]) => input) + .filter((input): input is Request => input instanceof Request && pathOf(input) === CURRENT_USER_PATH); + let searchParamsValue = new URLSearchParams(); vi.mock("@/lib/telemetry/uiEvents", () => ({ @@ -79,6 +90,17 @@ describe("(dashboard) Layout", () => { pendingUiConfig = createDeferred(); searchParamsValue = new URLSearchParams(); vi.mocked(usePathname).mockReturnValue("/ui/guardrails"); + currentUserReply = async () => + Response.json({ user_id: "normal-user", user_role: "internal_user", user_email: null }); + fetchMock.mockReset(); + fetchMock.mockImplementation((input) => + pathOf(input) === CURRENT_USER_PATH ? currentUserReply() : Promise.resolve(Response.json({}, { status: 404 })), + ); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it("starts mobile navigation closed, opens a modal drawer and closes it after choosing a page", async () => { @@ -220,6 +242,7 @@ describe("(dashboard) Layout", () => { afterEach(() => { document.cookie = "token=; Max-Age=0; Path=/"; + document.cookie = "litellm_return_url=; Max-Age=0; Path=/"; }); it("routes a session flagged password_reset_required to the change-password page", async () => { @@ -264,5 +287,99 @@ describe("(dashboard) Layout", () => { expect(await screen.findByTestId("page-content")).toBeInTheDocument(); expect(replaceMock).not.toHaveBeenCalled(); }); + + it("shows the loading screen instead of the shell until the current user resolves", async () => { + let resolveCurrentUser!: (response: Response) => void; + currentUserReply = () => new Promise((resolve) => (resolveCurrentUser = resolve)); + document.cookie = `token=${sessionCookie({ user_id: "normal-user", key: "sk-pending-session", user_role: "proxy_admin" })}; Path=/`; + + render( + + +
+ + , + ); + pendingUiConfig.resolve(); + + await waitFor(() => + expect(currentUserRequests().map((request) => request.headers.get("Authorization"))).toEqual([ + "Bearer sk-pending-session", + ]), + ); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + + resolveCurrentUser(Response.json({ user_id: "normal-user", user_role: "internal_user", user_email: null })); + + expect(await screen.findByTestId("page-content")).toBeInTheDocument(); + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + }); + + it("signs out instead of mounting the shell when the current user cannot be loaded", async () => { + const originalLocation = window.location; + const locationReplace = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...originalLocation, replace: locationReplace }, + writable: true, + }); + currentUserReply = async () => Response.json({ detail: "rejected" }, { status: 401 }); + document.cookie = `token=${sessionCookie({ user_id: "gone-user", key: "sk-rejected-session" })}; Path=/`; + + try { + render( + + +
+ + , + ); + pendingUiConfig.resolve(); + + await waitFor(() => expect(locationReplace).toHaveBeenCalledWith(expect.stringContaining("/login")), { + timeout: 4000, + }); + expect(document.cookie).not.toContain("token="); + expect(document.cookie).toContain("litellm_return_url="); + expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(currentUserRequests()).toHaveLength(2); + } finally { + Object.defineProperty(window, "location", { value: originalLocation, writable: true }); + } + }); + + it("keeps the session of an invitation link whose current user cannot be loaded", async () => { + const originalLocation = window.location; + const locationReplace = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...originalLocation, replace: locationReplace }, + writable: true, + }); + searchParamsValue = new URLSearchParams("invitation_id=abc123"); + currentUserReply = async () => Response.json({ detail: "rejected" }, { status: 401 }); + document.cookie = `token=${sessionCookie({ user_id: "invited-user", key: "sk-invited-session" })}; Path=/`; + + try { + render( + + +
+ + , + ); + pendingUiConfig.resolve(); + + await waitFor(() => expect(currentUserRequests()).toHaveLength(2), { timeout: 4000 }); + await waitFor(() => + expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/onboarding?invitation_id=abc123")), + ); + expect(locationReplace).not.toHaveBeenCalled(); + expect(document.cookie).toContain("token="); + } finally { + Object.defineProperty(window, "location", { value: originalLocation, writable: true }); + } + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 083118bc65d..92c8806df07 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -7,6 +7,10 @@ import Navbar from "@/components/navbar"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { useAuth } from "@/contexts/AuthContext"; +import { useCurrentUser } from "@/app/(dashboard)/hooks/currentUser/useCurrentUser"; +import { redirectToLogin } from "@/app/(dashboard)/hooks/redirectToLogin"; +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { decodeToken } from "@/utils/jwtUtils"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -218,8 +222,14 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); - const { accessToken, authLoading, passwordResetRequired } = useAuth(); + const { accessToken, token, authLoading, passwordResetRequired } = useAuth(); + const session = decodeToken(token); + const { isLoading: currentUserLoading, isError: currentUserFailed } = useCurrentUser( + session?.key ?? null, + session?.auth_header_name, + ); const isInvitationFlow = Boolean(searchParams.get("invitation_id")); + const identityPending = currentUserLoading || currentUserFailed; // Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own // /onboarding route. Redirect once ui-config has loaded so uiHref resolves the SERVER_ROOT_PATH base. @@ -237,7 +247,13 @@ function LayoutContent({ children }: { children: React.ReactNode }) { } }, [authLoading, passwordResetRequired, pathname, router]); - if (authLoading || isInvitationFlow) { + useEffect(() => { + if (!currentUserFailed || isInvitationFlow) return; + clearTokenCookies(); + redirectToLogin(); + }, [currentUserFailed, isInvitationFlow]); + + if (authLoading || identityPending || isInvitationFlow) { return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 478ddcd2458..92890031ddd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -337,7 +337,7 @@ const AllModelsTab = ({ availableModelGroups={availableModelGroups} availableModelAccessGroups={availableModelAccessGroups} userRole={userRole} - userID={userId} + userID={userId ?? ""} isViewOnly={isViewOnly} onModelIdClick={setSelectedModelId} onTeamIdClick={setSelectedTeamId} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index b43bcba7bd0..0d1fc4f7aba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -247,7 +247,7 @@ const AddPolicyForm: React.FC = ({ }, [visible, accessToken]); const loadAvailableModels = async () => { - if (!accessToken) return; + if (!accessToken || !userId) return; try { const response = await modelAvailableCall(accessToken, userId, userRole); if (response?.data) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx index ff0706b24cb..9b7e60aa12a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import CreateVectorStore from "./CreateVectorStore"; import * as networking from "@/components/networking"; +import { toast } from "@/lib/toast"; // Mock the networking module vi.mock("@/components/networking", () => ({ @@ -215,6 +216,44 @@ describe("CreateVectorStore", () => { }); }); + it("should report the created store and the failed file when a later file fails to ingest", async () => { + const firstIngest = { id: "ingest-1", status: "completed", vector_store_id: "vs_123", file_id: "file_1" }; + vi.mocked(networking.ragIngestCall) + .mockResolvedValueOnce(firstIngest) + .mockRejectedValueOnce(new Error("upstream rejected the file")); + const onSuccess = vi.fn(); + render(); + + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(uploadInput, { + target: { + files: [ + new File(["first"], "first.txt", { type: "text/plain" }), + new File(["second"], "second.txt", { type: "text/plain" }), + ], + }, + }); + }); + await waitFor(() => { + expect(screen.getByText("Uploaded Documents (2)")).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Create Vector Store/i })); + }); + + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledWith( + "Vector store vs_123 was created with 1 document(s), but second.txt failed to ingest", + { description: "upstream rejected the file" }, + ); + }); + expect(toast.fromError).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith("vs_123"); + expect(screen.getByText("Vector Store Created Successfully")).toBeInTheDocument(); + }); + it("should exclude valkey from the provider dropdown since it has no RAG ingestion", async () => { render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index 8f24e47340c..2422e83355d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -175,6 +175,7 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu setIsCreating(true); const results: RAGIngestResponse[] = []; let vectorStoreId: string | undefined; + let failure: { name: string; error: unknown } | undefined; try { // Ingest each document @@ -208,19 +209,35 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu console.error(`Error ingesting ${doc.name}:`, error); // Update document status to error setDocuments((prev) => prev.map((d) => (d.uid === doc.uid ? { ...d, status: "error" as const } : d))); - throw error; // Stop processing on first error + failure = { name: doc.name, error }; + break; } } + if (failure && !vectorStoreId) { + throw failure.error; + } + setIngestResults(results); - toast.success( - `Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`, - ); + if (failure) { + toast.warning( + `Vector store ${vectorStoreId} was created with ${results.length} document(s), but ${failure.name} failed to ingest`, + { description: failure.error instanceof Error ? failure.error.message : String(failure.error) }, + ); + } else { + toast.success( + `Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`, + ); + } if (onSuccess && vectorStoreId) { onSuccess(vectorStoreId); } + if (failure) { + return; + } + // Clear documents after successful creation setTimeout(() => { setDocuments([]); diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx index ac4fe2068fb..d455b84afa3 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx @@ -7,6 +7,8 @@ const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = const state = { enableChatUI: false, isUISettingsLoading: false, + isIdentityLoading: false, + isAuthorized: true, }; return { state, @@ -18,6 +20,8 @@ const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = userId: "user-1", userEmail: "user@example.com", premiumUser: false, + isLoading: state.isIdentityLoading, + isAuthorized: state.isAuthorized, })), mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } }, @@ -47,6 +51,8 @@ describe("ChatLayout", () => { afterEach(() => { state.enableChatUI = false; state.isUISettingsLoading = false; + state.isIdentityLoading = false; + state.isAuthorized = true; mockReplace.mockClear(); mockUiHref.mockClear(); }); @@ -84,4 +90,28 @@ describe("ChatLayout", () => { expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); }); + + it("renders nothing until the signed-in user's identity has loaded", () => { + state.enableChatUI = true; + state.isIdentityLoading = true; + render( + +
+ , + ); + expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + }); + + it("renders nothing when the signed-in user's identity could not be confirmed", () => { + state.enableChatUI = true; + state.isAuthorized = false; + render( + +
+ , + ); + expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx index 0bed5bda4a8..bfe494d2234 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.tsx @@ -12,7 +12,15 @@ import { uiHref } from "@/utils/uiHref"; // The nuqs Next adapter uses useSearchParams, so keep the chat tree behind Suspense. function ChatLayoutContent({ children }: { children: React.ReactNode }) { - const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized(); + const { + accessToken, + userRole, + userId, + userEmail, + premiumUser, + isLoading: isIdentityLoading, + isAuthorized, + } = useAuthorized(); const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); const router = useRouter(); @@ -23,7 +31,9 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) { if (blocked) router.replace(uiHref("")); }, [blocked, router]); - if (isUISettingsLoading || blocked) return null; + const identityReady = !isIdentityLoading && isAuthorized; + + if (!identityReady || isUISettingsLoading || blocked) return null; return ( diff --git a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx index 1b4d96c352d..87c34165fd9 100644 --- a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx @@ -36,7 +36,10 @@ const snoozedBy = (user: string, until: string): DeploymentWarning["ignore"] => ignored_until: until, }); +let signedInRole = "proxy_admin"; + const signIn = (userRole: string) => { + signedInRole = userRole; const claims = { key: "sk-health-test", user_id: "admin-1", user_role: userRole, exp: NOW.getTime() / 1000 + 3600 }; const token = `${btoa(JSON.stringify({ alg: "none" }))}.${btoa(JSON.stringify(claims))}.test`; document.cookie = `token=${token}; Path=/`; @@ -47,6 +50,9 @@ const handleRequest = async (request: Request): Promise => { if (pathname === "/litellm/.well-known/litellm-ui-config") { return Response.json({ server_root_path: "", proxy_base_url: "", admin_ui_disabled: false }); } + if (pathname === "/management/v1/users/me") { + return Response.json({ user_id: "admin-1", user_role: signedInRole, user_email: null }); + } if (pathname === "/health/deployment_warnings" && request.method === "GET") { proxy.gets += 1; return Response.json({ warnings: proxy.warnings, ignore_available: proxy.ignoreAvailable }); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 7e90991523c..beab81c16a2 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -202,7 +202,7 @@ const AddModelForm: React.FC = ({ : null; const isAdmin = all_admin_roles.includes(userRole); - const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId); + const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId ?? ""); // Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about // who has to name a team. This form is only reachable when creation is allowed at all. const createScope = modelCreationScope( diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx index 61c3dbbc1f1..b0ff9871570 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx @@ -64,15 +64,18 @@ function deferred() { return { promise, resolve }; } +let signedInUser = { user_id: "first-admin", user_role: "proxy_admin" }; + function session( role = "proxy_admin", user = "first-admin", license: { premium_user?: boolean | null | string } = { premium_user: true }, + key = `sk-session-${user}`, ) { const encode = (value: object) => btoa(JSON.stringify(value)).replaceAll("=", "").replaceAll("+", "-").replaceAll("/", "_"); const claims = { - key: `sk-session-${user}`, + key, user_id: user, user_role: role, ...license, @@ -81,6 +84,7 @@ function session( }; const token = `${encode({ alg: "none" })}.${encode(claims)}.test`; document.cookie = `token=${token}; Path=/`; + signedInUser = { user_id: user, user_role: role }; return token; } @@ -134,6 +138,7 @@ function gateway(replies: (ModelReply | Promise)[], options: Gateway const recorded = { url: request.url, headers: request.headers, body, signal: request.signal }; const path = new URL(request.url).pathname; if (path.endsWith("/ui/litellm-runtime-config.json")) return json({}); + if (path.endsWith("/management/v1/users/me")) return json({ ...signedInUser, user_email: null }); requests.push(recorded); if (path.endsWith("/litellm-ui-config")) return json({ proxy_base_url: MANAGEMENT, server_root_path: "", admin_ui_disabled: false }); @@ -1018,7 +1023,7 @@ describe("LiteAdmin in the gateway", () => { settings.target = `${INFERENCE}/updated`; await act(async () => view.client.invalidateQueries({ queryKey: ["proxySettings"] })); } else { - session("proxy_admin_viewer"); + session("proxy_admin_viewer", "first-admin", { premium_user: true }, "sk-session-first-admin-viewer"); view.refresh(); expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument(); expect(screen.queryByRole("region", { name: "Ask LiteAdmin", hidden: true })).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx index 6d4a6e789b4..2f2246a3878 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.test.tsx @@ -147,6 +147,41 @@ describe("Internal User column", () => { expect(await screen.findByText("alice@example.com (106514937785257944828)")).toBeInTheDocument(); }); + it("shows the email stored in the log metadata when the user list has no entry for that user", async () => { + const user = userEvent.setup(); + renderRows( + [ + logEntry({ + request_id: "req-sso-user", + user: "okta|abc123", + metadata: { user_api_key_user_email: "sso-user@example.com" }, + }), + ], + deps, + ); + + const emailCell = screen.getByText("sso-user@example.com"); + expect(screen.queryByText("okta|abc123")).not.toBeInTheDocument(); + await user.hover(emailCell); + expect(await screen.findByText("sso-user@example.com (okta|abc123)")).toBeInTheDocument(); + }); + + it("prefers the user list email over the one stored in the log metadata", () => { + renderRows( + [ + logEntry({ + request_id: "req-renamed-user", + user: "106514937785257944828", + metadata: { user_api_key_user_email: "old@example.com" }, + }), + ], + deps, + ); + + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(screen.queryByText("old@example.com")).not.toBeInTheDocument(); + }); + it("falls back to the raw id when no email is known for the user", () => { renderRows([logEntry({ request_id: "req-unknown-user", user: "unknown-user-id" })], deps); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx index 5d5918e4657..b3d44361d0a 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTableColumns.tsx @@ -365,7 +365,10 @@ export const getRequestLogsTableColumns = ({ cell: ({ row }) => ( ), }, diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index dc582da6833..e7a55870345 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -975,6 +975,30 @@ describe("modelCostMap", () => { }); }); +describe("ragIngestCall", () => { + let currentFetch: typeof global.fetch; + + beforeEach(() => { + currentFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = currentFetch; + }); + + it("rejects with the proxy's error text when the detail is an object", async () => { + const reason = "Error code: 404 - No vector store found with id 'vs_missing'"; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ detail: { error: reason } }), + } as any) as any; + + await expect( + Networking.ragIngestCall("sk-test", new File(["text"], "doc.txt"), "openai", "vs_missing"), + ).rejects.toThrow(reason); + }); +}); + describe("getUiConfig runtime backend discovery", () => { const discoveryConfig = () => ({ server_root_path: "/", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0f7d76a7156..4df34275923 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5786,7 +5786,7 @@ export const ragIngestCall = async ( if (!response.ok) { const error = await response.json(); - throw new Error(error.error?.message || error.detail || "Failed to ingest document"); + throw new Error(deriveErrorMessage(error)); } return await response.json(); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index a15f6c6e31c..30ff097a87c 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -479,7 +479,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const response = endpoint === "service_account" ? await keyCreateServiceAccountCall(accessToken, payload) - : await keyCreateCall(accessToken, userID, payload); + : await keyCreateCall(accessToken, userID ?? "", payload); // Add the data to the state in the parent component // Also directly update the keys list in VirtualKeysTable without an API call @@ -1902,7 +1902,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp Create New User = ({ team, teams, data, addKey, autoOp {apiKey && ( !open && handleCancel()}> - +
Save your Key {apiKey != null ? ( diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx index 0e10d32f8d1..310f69d28bc 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx @@ -1,13 +1,43 @@ +import { cloneElement, type MouseEventHandler, type ReactElement, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import CreatedKeyDisplay from "./CreatedKeyDisplay"; +import CreatedKeyDisplay, { buildAgentInstructions } from "./CreatedKeyDisplay"; import { toast } from "@/lib/toast"; +const mockCopy = vi.hoisted(() => vi.fn<(text: string) => void>()); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => "http://gw.test:4000/"), +})); + +vi.mock("react-copy-to-clipboard", () => ({ + CopyToClipboard: ({ + text, + onCopy, + children, + }: { + text: string; + onCopy?: (text: string, result: boolean) => void; + children: ReactNode; + }) => { + const child = children as ReactElement<{ onClick?: MouseEventHandler }>; + return cloneElement(child, { + onClick: (event) => { + child.props.onClick?.(event); + mockCopy(text); + onCopy?.(text, true); + }, + }); + }, +})); + describe("CreatedKeyDisplay", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + mockCopy.mockReset(); + vi.mocked(toast.success).mockClear(); }); afterEach(() => { @@ -69,4 +99,87 @@ describe("CreatedKeyDisplay", () => { expect(screen.getByRole("button", { name: /copy virtual key/i })).toBeInTheDocument(); }); + + it("shows the gateway URL without its trailing slash and copies it", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + expect(screen.getByText("http://gw.test:4000")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Copy URL" })); + + expect(mockCopy).toHaveBeenCalledWith("http://gw.test:4000"); + expect(toast.success).toHaveBeenCalledWith("Gateway URL copied to clipboard"); + }); + + it("shows one shared instruction card with both agent logos and expands on demand", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + expect(screen.getByRole("img", { name: "Claude Code" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Codex" })).toBeInTheDocument(); + expect(screen.getByText("Agent instructions")).toBeInTheDocument(); + expect(screen.queryByText("Set up your agent")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Claude Code" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Codex" })).not.toBeInTheDocument(); + + const showButton = screen.getByRole("button", { name: "Show" }); + expect(showButton).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).not.toBeInTheDocument(); + + await user.click(showButton); + + expect(screen.getByRole("button", { name: "Hide" })).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).toBeInTheDocument(); + expect(screen.getByText(/ANTHROPIC_BASE_URL=http:\/\/gw\.test:4000/)).toBeInTheDocument(); + expect(screen.getByText(/OPENAI_BASE_URL=http:\/\/gw\.test:4000\/v1/)).toBeInTheDocument(); + }); + + it("copies complete shared instructions while the card is collapsed", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + expect(screen.getByRole("button", { name: "Show" })).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText(/POST http:\/\/gw\.test:4000\/v1\/responses/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Copy instructions" })); + + const copiedInstructions = mockCopy.mock.calls.at(-1)?.[0] ?? ""; + expect(copiedInstructions).toContain("Gateway URL: http://gw.test:4000"); + expect(copiedInstructions).toContain("Virtual key: sk-test-123"); + expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/chat/completions"); + expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/responses"); + expect(copiedInstructions).toContain("POST http://gw.test:4000/v1/messages"); + expect(copiedInstructions).toContain("GET http://gw.test:4000/v1/models"); + expect(copiedInstructions).toContain( + "To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=http://gw.test:4000 and ANTHROPIC_AUTH_TOKEN=sk-test-123.", + ); + expect(copiedInstructions).toContain( + "To run Codex through this gateway, set OPENAI_BASE_URL=http://gw.test:4000/v1 and OPENAI_API_KEY=sk-test-123.", + ); + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); + expect(toast.success).toHaveBeenCalledWith("Instructions copied"); + }); +}); + +describe("buildAgentInstructions", () => { + it("builds shared instructions with both agent settings and a normalized URL", () => { + expect(buildAgentInstructions("http://gw.test:4000/", "sk-test-123")).toBe( + [ + "Use this LiteLLM AI gateway for model inference.", + "", + "Gateway URL: http://gw.test:4000", + "Virtual key: sk-test-123", + "", + "LiteLLM is a gateway that exposes OpenAI and Anthropic compatible APIs. Send the virtual key as a Bearer token (Authorization: Bearer sk-test-123) to:", + "- POST http://gw.test:4000/v1/chat/completions (OpenAI Chat Completions)", + "- POST http://gw.test:4000/v1/responses (OpenAI Responses)", + "- POST http://gw.test:4000/v1/messages (Anthropic Messages)", + "- GET http://gw.test:4000/v1/models (models this key can use)", + "", + "To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=http://gw.test:4000 and ANTHROPIC_AUTH_TOKEN=sk-test-123.", + "To run Codex through this gateway, set OPENAI_BASE_URL=http://gw.test:4000/v1 and OPENAI_API_KEY=sk-test-123.", + "", + "Docs for everything else LiteLLM supports: https://docs.litellm.ai/docs/", + ].join("\n"), + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index 5dbc17b13bf..d58a1864575 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -1,5 +1,9 @@ import React, { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import anthropicLogo from "../../../public/assets/logos/anthropic.svg"; +import openaiLogo from "../../../public/assets/logos/openai_small.svg"; +import { getProxyBaseUrl } from "@/components/networking"; import { Button } from "@/components/ui/button"; import { toast } from "@/lib/toast"; @@ -7,12 +11,41 @@ interface CreatedKeyDisplayProps { apiKey: string; } +export const buildAgentInstructions = (baseUrl: string, apiKey: string): string => { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + const endpointIntroduction = + "LiteLLM is a gateway that exposes OpenAI and Anthropic compatible APIs. " + + `Send the virtual key as a Bearer token (Authorization: Bearer ${apiKey}) to:`; + + return [ + "Use this LiteLLM AI gateway for model inference.", + "", + `Gateway URL: ${normalizedBaseUrl}`, + `Virtual key: ${apiKey}`, + "", + endpointIntroduction, + `- POST ${normalizedBaseUrl}/v1/chat/completions (OpenAI Chat Completions)`, + `- POST ${normalizedBaseUrl}/v1/responses (OpenAI Responses)`, + `- POST ${normalizedBaseUrl}/v1/messages (Anthropic Messages)`, + `- GET ${normalizedBaseUrl}/v1/models (models this key can use)`, + "", + `To run Claude Code through this gateway, set ANTHROPIC_BASE_URL=${normalizedBaseUrl} and ANTHROPIC_AUTH_TOKEN=${apiKey}.`, + `To run Codex through this gateway, set OPENAI_BASE_URL=${normalizedBaseUrl}/v1 and OPENAI_API_KEY=${apiKey}.`, + "", + "Docs for everything else LiteLLM supports: https://docs.litellm.ai/docs/", + ].join("\n"); +}; + /** * Shared component for displaying a newly-created virtual key. * Used on the Virtual Keys page and in the Add Agent wizard. */ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { const [copied, setCopied] = useState(false); + const [instructionsCopied, setInstructionsCopied] = useState(false); + const [showInstructions, setShowInstructions] = useState(false); + const baseUrl = getProxyBaseUrl().replace(/\/+$/, ""); + const instructions = buildAgentInstructions(baseUrl, apiKey); const handleCopy = () => { setCopied(true); @@ -20,6 +53,12 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { setTimeout(() => setCopied(false), 2000); }; + const handleCopyInstructions = () => { + setInstructionsCopied(true); + toast.success("Instructions copied"); + setTimeout(() => setInstructionsCopied(false), 2000); + }; + return (

@@ -36,6 +75,47 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { + +

Gateway URL:

+
+
{baseUrl}
+ toast.success("Gateway URL copied to clipboard")}> + + +
+ +
+
+
+

Agent instructions

+ Claude Code + Codex +
+
+ + + + +
+
+ {showInstructions && ( +
{instructions}
+ )} +
); }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7fae3437f22..c33ef86af72 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -5832,7 +5832,8 @@ export interface paths { }; /** * Latest Release Info - * @description Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. + * @description Latest stable LiteLLM GitHub release with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) + * split into new features, bug fixes and other updates. * Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render. */ get: operations["latest_release_info_get_latest_release_info_get"]; @@ -9472,6 +9473,23 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Current User */ + get: operations["get_current_user_management_v1_users_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp": { parameters: { query?: never; @@ -27052,6 +27070,11 @@ export interface components { * @description Optional field if guardrail requires a 'model' parameter */ model?: string | null; + /** + * Num Retries + * @description How many more times to send a guardrail check whose connection timed out waiting for the answer or was dropped after the request went out. Each retry opens a fresh connection and gets the full timeout. Connect failures, HTTP error statuses and BLOCKED verdicts are not retried. Unset or 0 sends each check once, and the most allowed is 5. Implemented by guardrail='generic_guardrail_api', other guardrails ignore it. + */ + num_retries?: number | null; /** * On Sensitive Data * @description Action to take when sensitive data is detected. 'block' raises an exception (default behavior). 'route' reroutes the request to the model specified in sensitive_data_route_to_model. @@ -31061,6 +31084,14 @@ export interface components { */ source: "db" | "config"; }; + /** CurrentUserResponse */ + CurrentUserResponse: { + /** User Email */ + user_email: string | null; + /** User Id */ + user_id: string; + user_role: components["schemas"]["LitellmUserRoles"] | null; + }; /** CustomDimension */ CustomDimension: { /** @@ -36638,6 +36669,11 @@ export interface components { * @description If True, logs violations without blocking. Defaults to False if not provided */ monitor_mode?: boolean | null; + /** + * Num Retries + * @description How many more times to send a guardrail check whose connection timed out waiting for the answer or was dropped after the request went out. Each retry opens a fresh connection and gets the full timeout. Connect failures, HTTP error statuses and BLOCKED verdicts are not retried. Unset or 0 sends each check once, and the most allowed is 5. Implemented by guardrail='generic_guardrail_api', other guardrails ignore it. + */ + num_retries?: number | null; /** * On Disallowed Action * @description Choose whether disallowed tools block the request or get rewritten out of the payload @@ -46811,9 +46847,17 @@ export interface components { * @description Response structure for token count details with modality breakdown. * * Example: - * {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}]} + * {'totalTokens': 12, 'promptTokensDetails': [{'modality': 'TEXT', 'tokenCount': 12}], + * 'litellm_count_source': 'provider'} */ TokenCountDetailsResponse: { + /** Litellm Count Model */ + litellm_count_model?: string; + /** + * Litellm Count Source + * @enum {string} + */ + litellm_count_source?: "provider" | "token_count_model" | "local_estimate"; /** Prompttokensdetails */ promptTokensDetails: components["schemas"]["PromptTokensDetails"][]; /** Totaltokens */ @@ -46842,6 +46886,8 @@ export interface components { }; /** TokenCountResponse */ TokenCountResponse: { + /** Count Source */ + count_source?: ("provider" | "token_count_model" | "local_estimate") | null; /** * Error * @default false @@ -63723,6 +63769,26 @@ export interface operations { }; }; }; + get_current_user_management_v1_users_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CurrentUserResponse"]; + }; + }; + }; + }; aggregate_mcp_route_mcp_get: { parameters: { query?: never;