diff --git a/.circleci/config.yml b/.circleci/config.yml index 63012ce3fc0..dfc539fb80e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -112,10 +112,10 @@ commands: node --version npm --version install_rust: - description: "Install pinned rustup (1.28.2) and Rust toolchain (1.97.1) with checksum verification. Adds ~/.cargo/bin to PATH. Run this before any `uv sync` or `uv build` of the workspace: the root package builds litellm-rust through maturin, and on an image without cargo maturin fetches an unpinned rustup and a floating toolchain by itself." + description: "Install pinned rustup (1.28.2) and Rust toolchain (1.98.0) with checksum verification. Adds ~/.cargo/bin to PATH. Run this before any `uv sync` or `uv build` of the workspace: the root package builds litellm-rust through maturin, and on an image without cargo maturin fetches an unpinned rustup and a floating toolchain by itself." steps: - run: - name: Install Rust (rustup 1.28.2, toolchain 1.97.1) + name: Install Rust (rustup 1.28.2, toolchain 1.98.0) command: | case "$(uname -m)" in x86_64) @@ -135,7 +135,7 @@ commands: "https://static.rust-lang.org/rustup/archive/1.28.2/${RUSTUP_TRIPLE}/rustup-init" echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - chmod +x /tmp/rustup-init - /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.97.1 + /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.98.0 rm -f /tmp/rustup-init echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$BASH_ENV" export PATH="$HOME/.cargo/bin:$PATH" @@ -300,7 +300,7 @@ jobs: if ($rustupActual -ne $rustupExpected) { throw "rustup installer hash mismatch: expected $rustupExpected got $rustupActual" } - & $rustupInit -y --profile minimal --default-toolchain stable + & $rustupInit -y --profile minimal --default-toolchain 1.98.0 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -1483,7 +1483,7 @@ jobs: - run: name: Run tests command: | - uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not legacy_resolver" + uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not v2_resolver" installing_litellm_on_python_3_13: docker: @@ -1507,9 +1507,9 @@ jobs: - run: name: Run tests command: | - uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not legacy_resolver" + uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not v2_resolver" - installing_litellm_on_python_legacy_migration_resolver: + installing_litellm_on_python_v2_migration_resolver: docker: - *python312_image - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 @@ -1536,10 +1536,10 @@ jobs: url: tcp://localhost:5432 timeout: "60" - run: - name: Run legacy migration resolver proxy smoke test + name: Run v2 migration resolver proxy smoke test command: | uv run --no-sync python -m pytest -vv \ - tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_legacy_resolver + tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_v2_resolver helm_chart_testing: machine: @@ -2879,8 +2879,7 @@ jobs: command: | if grep -q "Error: P1001: Can't reach database server at" docker_output.log && \ (grep -q "Database setup failed after multiple retries" docker_output.log || \ - grep -q "ERROR: Application startup failed. Exiting." docker_output.log || \ - grep -q "Database migration cannot proceed" docker_output.log); then + grep -q "ERROR: Application startup failed. Exiting." docker_output.log); then echo "Expected error found. Test passed." else echo "Expected error not found. Test failed." @@ -3012,7 +3011,7 @@ workflows: filters: *main_branches - installing_litellm_on_python_3_13: filters: *main_branches - - installing_litellm_on_python_legacy_migration_resolver: + - installing_litellm_on_python_v2_migration_resolver: filters: *main_branches - helm_chart_testing: requires: diff --git a/.github/scripts/smoke_test_native_wheel.py b/.github/scripts/smoke_test_native_wheel.py new file mode 100644 index 00000000000..577bb32fcf0 --- /dev/null +++ b/.github/scripts/smoke_test_native_wheel.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path +from typing import Final + +CHILD_SCRIPT: Final = """ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + +native_path = Path(sys.argv[1]) +spec = spec_from_file_location("litellm.rust_bridge._native", native_path) +if spec is None or spec.loader is None: + raise RuntimeError("cannot create native extension import specification") +module = module_from_spec(spec) +spec.loader.exec_module(module) + +before = module.gil_stats() +if not isinstance(before.get("releases"), int): + raise AssertionError(f"unexpected gil_stats result: {before!r}") + +try: + module._panic_for_test() +except BaseException as error: + if type(error).__name__ != "PanicException": + raise AssertionError(f"expected PanicException, got {type(error).__name__}") from error +else: + raise AssertionError("Rust panic returned without raising") + +after = module.gil_stats() +if not isinstance(after.get("releases"), int): + raise AssertionError(f"native module unusable after panic: {after!r}") +""" + + +def main() -> int: + if len(sys.argv) != 2: + sys.stderr.write(f"usage: {Path(sys.argv[0]).name} WHEEL\n") + return 2 + + wheel: Final = Path(sys.argv[1]) + with tempfile.TemporaryDirectory() as temporary_directory, zipfile.ZipFile(wheel) as archive: + native_members: Final = tuple( + member + for member in archive.infolist() + if member.filename.startswith("litellm/rust_bridge/_native.") and member.filename.endswith(".so") + ) + if len(native_members) != 1: + sys.stderr.write(f"expected one native extension, found {len(native_members)}\n") + return 1 + + native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name + native_path.write_bytes(archive.read(native_members[0])) + result: Final = subprocess.run((sys.executable, "-c", CHILD_SCRIPT, str(native_path)), check=False) + + if result.returncode != 0: + sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/verify_linux_native_wheel.py b/.github/scripts/verify_linux_native_wheel.py new file mode 100644 index 00000000000..899e2a211c0 --- /dev/null +++ b/.github/scripts/verify_linux_native_wheel.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import importlib.util +import os +import re +import subprocess +import sys +import zipfile +from collections.abc import Callable, Mapping, Sequence +from itertools import product +from pathlib import Path, PurePosixPath +from types import MappingProxyType, ModuleType +from typing import Final, Protocol + +EXPECTED_PYTHON_TAG: Final = "cp310" +EXPECTED_ABI_TAG: Final = "abi3" +EXPECTED_PLATFORM_TAG: Final = "linux_x86_64" + + +class CommandRunner(Protocol): + def __call__( + self, + command: tuple[str, ...], + *, + check: bool, + capture_output: bool, + text: bool, + ) -> subprocess.CompletedProcess[str]: ... + + +def _run_command( + command: tuple[str, ...], + *, + check: bool, + capture_output: bool, + text: bool, +) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=check, capture_output=capture_output, text=text) + + +def _dist_info_directory(member: zipfile.ZipInfo) -> str | None: + parts: Final = PurePosixPath(member.filename).parts + if not parts or not parts[0].endswith(".dist-info"): + return None + return parts[0] + + +def _wheel_metadata_tags(archive: zipfile.ZipFile, members: tuple[zipfile.ZipInfo, ...]) -> tuple[str, ...]: + if len(members) != 1: + return () + lines: Final = archive.read(members[0]).splitlines() + return tuple(line.removeprefix(b"Tag:").strip().decode("ascii") for line in lines if line.startswith(b"Tag:")) + + +def _load_native_module(native_path: Path) -> ModuleType | None: + module_spec: Final = importlib.util.spec_from_file_location("litellm.rust_bridge._native", native_path) + if module_spec is None or module_spec.loader is None: + return None + try: + native_module: Final = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(native_module) + except Exception as error: # noqa: BLE001 # native module initialization can raise arbitrary exceptions + sys.stderr.write(f"native module load failed: {error}\n") + return None + return native_module + + +def main( + argv: Sequence[str] | None = None, + environment: Mapping[str, str] | None = None, + load_native_module: Callable[[Path], ModuleType | None] = _load_native_module, + run_command: CommandRunner = _run_command, +) -> int: + arguments: Final = tuple(sys.argv if argv is None else argv) + resolved_environment: Final = os.environ if environment is None else environment + if len(arguments) != 2: + sys.stderr.write(f"usage: {Path(arguments[0]).name} WHEEL\n") + return 2 + + wheel: Final = Path(arguments[1]) + wheel_tags: Final = wheel.stem.rsplit("-", maxsplit=3) + if len(wheel_tags) != 4: + sys.stderr.write(f"cannot parse wheel tags from {wheel.name}\n") + return 1 + + wheel_identity: Final = wheel_tags[0].split("-") + if len(wheel_identity) != 2 or wheel_identity[0] != "litellm" or not wheel_identity[1]: + sys.stderr.write(f"unexpected wheel identity: {wheel_tags[0]}\n") + return 1 + + expected_dist_info_directory: Final = f"{wheel_tags[0]}.dist-info" + expected_dist_info_directories: Final = frozenset((expected_dist_info_directory,)) + python_tag: Final = wheel_tags[1] + abi_tag: Final = wheel_tags[2] + platform_tag: Final = wheel_tags[3] + expanded_filename_tags: Final = frozenset( + "-".join(tag) for tag in product(python_tag.split("."), abi_tag.split("."), platform_tag.split(".")) + ) + + with zipfile.ZipFile(wheel) as archive: + wheel_members: Final = archive.infolist() + dist_info_directories: Final = frozenset( + directory for member in wheel_members if (directory := _dist_info_directory(member)) is not None + ) + required_dist_info_files: Final = ("METADATA", "RECORD", "WHEEL") + dist_info_file_counts: Final = MappingProxyType( + { + filename: sum( + member.filename == f"{expected_dist_info_directory}/{filename}" for member in wheel_members + ) + for filename in required_dist_info_files + } + ) + wheel_metadata_members: Final = tuple( + member for member in wheel_members if member.filename == f"{expected_dist_info_directory}/WHEEL" + ) + wheel_metadata_tags: Final = _wheel_metadata_tags(archive, wheel_metadata_members) + native_members: Final = tuple( + member + for member in wheel_members + if member.filename.startswith("litellm/rust_bridge/_native.") and member.filename.endswith(".so") + ) + if len(native_members) != 1: + sys.stderr.write(f"expected one native extension, found {len(native_members)}\n") + return 1 + + unexpected_members: Final = tuple( + member.filename + for member in wheel_members + if member.filename.endswith((".pdb", ".dwp", ".rlib", ".rmeta", "Cargo.toml", "Cargo.lock")) + or any(part.endswith(".dSYM") for part in PurePosixPath(member.filename).parts) + ) + native_member: Final = native_members[0] + uncompressed_wheel_size: Final = sum(member.file_size for member in wheel_members) + native_path: Final = wheel.parent / "native" / Path(native_member.filename).name + native_path.parent.mkdir(parents=True, exist_ok=True) + native_path.write_bytes(archive.read(native_member)) + + wheel_metadata_tags_match: Final = ( + len(wheel_metadata_tags) == len(expanded_filename_tags) + and frozenset(wheel_metadata_tags) == expanded_filename_tags + ) + commit_sha: Final = resolved_environment.get( + "RELEASE_WHEEL_COMMIT_SHA", resolved_environment.get("GITHUB_SHA", "unknown") + ) + rustc_version: Final = run_command( + ("rustc", "--version"), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + pyproject: Final = (Path(__file__).parents[2] / "pyproject.toml").read_text() + maturin_match: Final = re.search(r'"maturin==([^";]+)', pyproject) + if maturin_match is None: + sys.stderr.write("build-system does not pin an exact Maturin version\n") + return 1 + + maturin_version: Final = maturin_match.group(1) + native_percentage: Final = native_member.file_size / uncompressed_wheel_size * 100 + size_report: Final = "\n".join( + ( + "## Native wheel build report", + "", + "| Build | Value |", + "| --- | --- |", + f"| Commit | `{commit_sha}` |", + f"| Platform | `{platform_tag}` |", + f"| Python ABI | `{python_tag}-{abi_tag}` |", + f"| Rust compiler | `{rustc_version}` |", + f"| Maturin | `{maturin_version}` |", + "| Cargo profile | `release` |", + "", + "| Artifact | Size |", + "| --- | ---: |", + f"| Compressed wheel | {wheel.stat().st_size / 1_000_000:.2f} MB |", + f"| Uncompressed wheel | {uncompressed_wheel_size / 1_000_000:.2f} MB |", + f"| Native extension | {native_member.file_size / 1_000_000:.2f} MB |", + f"| Native share | {native_percentage:.2f}% |", + "", + ) + ) + summary_path: Final = resolved_environment.get("GITHUB_STEP_SUMMARY") + if summary_path is None: + sys.stdout.write(size_report) + else: + Path(summary_path).write_text(size_report) + + sections: Final = run_command( + ("readelf", "--sections", "--wide", str(native_path)), + check=True, + capture_output=True, + text=True, + ).stdout + debug_sections: Final = tuple(section for section in (".debug_", ".zdebug_") if section in sections) + debug_sections_absent: Final = not debug_sections + static_symbol_table_absent: Final = ".symtab" not in sections + + dynamic_symbols: Final = run_command( + ("readelf", "--dyn-syms", "--wide", str(native_path)), + check=True, + capture_output=True, + text=True, + ).stdout + extension_entry_point_present: Final = "PyInit__native" in dynamic_symbols + native_module: Final = load_native_module(native_path) + native_module_loads: Final = native_module is not None + panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test") + native_size_limit: Final = 20_000_000 + native_size_within_limit: Final = native_member.file_size <= native_size_limit + validations: Final = ( + (f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG), + (f"ABI tag is {EXPECTED_ABI_TAG}", abi_tag == EXPECTED_ABI_TAG), + (f"Platform tag is {EXPECTED_PLATFORM_TAG}", platform_tag == EXPECTED_PLATFORM_TAG), + ("Wheel dist-info directory matches the filename", dist_info_directories == expected_dist_info_directories), + ( + "Required dist-info files are present exactly once", + all(count == 1 for count in dist_info_file_counts.values()), + ), + ("Wheel metadata tags match the filename", wheel_metadata_tags_match), + ("Debug sections are absent", debug_sections_absent), + ("Static symbol table is absent", static_symbol_table_absent), + ("Python extension entry point is present", extension_entry_point_present), + ("Native module loads", native_module_loads), + ("Production module omits the panic test hook", panic_test_hook_absent), + ("Native extension does not exceed 20 MB", native_size_within_limit), + ("Wheel contents are valid", not unexpected_members), + ) + + verified_report: Final = size_report + "\n".join( + ("", "| Validation | Expected | Result |", "| --- | --- | :---: |") + + tuple(f"| {label} | Yes | {'O' if passed else 'X'} |" for label, passed in validations) + + ("",) + ) + if summary_path is not None: + Path(summary_path).write_text(verified_report) + + invalid_dist_info_files: Final = any(count != 1 for count in dist_info_file_counts.values()) + validation_errors: Final = tuple( + message + for failed, message in ( + (bool(debug_sections), f"{native_member.filename} contains debug sections: {', '.join(debug_sections)}"), + (not static_symbol_table_absent, f"{native_member.filename} contains a static symbol table"), + (not extension_entry_point_present, "native extension does not export PyInit__native"), + ( + python_tag != EXPECTED_PYTHON_TAG, + f"unexpected Python tag: expected {EXPECTED_PYTHON_TAG}, found {python_tag}", + ), + (abi_tag != EXPECTED_ABI_TAG, f"unexpected ABI tag: expected {EXPECTED_ABI_TAG}, found {abi_tag}"), + ( + platform_tag != EXPECTED_PLATFORM_TAG, + f"unexpected platform tag: expected {EXPECTED_PLATFORM_TAG}, found {platform_tag}", + ), + ( + dist_info_directories != expected_dist_info_directories, + f"unexpected dist-info directories: expected {expected_dist_info_directory}, " + f"found {', '.join(sorted(dist_info_directories))}", + ), + (invalid_dist_info_files, f"required dist-info file counts are invalid: {dist_info_file_counts}"), + ( + not invalid_dist_info_files and not wheel_metadata_tags_match, + f"WHEEL tags do not match filename: expected {', '.join(sorted(expanded_filename_tags))}, " + f"found {', '.join(sorted(wheel_metadata_tags))}", + ), + ( + native_module is not None and not panic_test_hook_absent, + "production native module exposes _panic_for_test", + ), + ( + not native_size_within_limit, + f"native extension exceeds 20 MB: {native_member.file_size / 1_000_000:.2f} MB", + ), + (bool(unexpected_members), f"wheel contains unexpected build artifacts: {', '.join(unexpected_members)}"), + ) + if failed + ) + sys.stderr.write("".join(f"{message}\n" for message in validation_errors)) + + return 0 if all(passed for _, passed in validations) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index bb04563c1a8..206bb809e0c 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -80,7 +80,7 @@ jobs: LITELLM_IMAGE: litellm-image-scan:${{ github.sha }} run: | python -m pip install "pytest==9.0.3" - python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v + python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v # Scans the whole shipped artifact: OS/apk plus every language package # baked into the image, including ones no lockfile declares (e.g. prisma's @@ -124,7 +124,7 @@ jobs: LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }} run: | python -m pip install "pytest==9.0.3" - python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v + python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v migrations-image: name: migrations-image @@ -185,7 +185,7 @@ jobs: LITELLM_COMPONENT_PORT: "4000" run: | python -m pip install "pytest==9.0.3" - python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v + python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v ui-image: name: ui-image diff --git a/.github/workflows/report-rust-release-wheel.yml b/.github/workflows/report-rust-release-wheel.yml new file mode 100644 index 00000000000..1d93b56f77f --- /dev/null +++ b/.github/workflows/report-rust-release-wheel.yml @@ -0,0 +1,130 @@ +name: Report LiteLLM Rust release wheel + +on: # zizmor: ignore[dangerous-triggers] reporter executes no PR code and consumes no PR artifacts or outputs + workflow_run: + workflows: + - LiteLLM Rust + types: + - completed + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + report-release-wheel: + name: report release wheel + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.path == '.github/workflows/test-rust.yml' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.pull_requests[0].number != null + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write # PR comments use the issues API + pull-requests: read # Current-head validation rejects stale workflow runs + + steps: + - name: Link release wheel report on PR + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + COMMENT_MARKER: "" + with: + script: | + const marker = process.env.COMMENT_MARKER; + const workflowRun = context.payload.workflow_run; + const allowedConclusions = new Set([ + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "startup_failure", + "success", + "timed_out", + ]); + if ( + !allowedConclusions.has(workflowRun.conclusion) || + workflowRun.event !== "pull_request" || + workflowRun.path !== ".github/workflows/test-rust.yml" || + workflowRun.head_repository?.full_name !== + `${context.repo.owner}/${context.repo.repo}` || + workflowRun.pull_requests?.length !== 1 + ) { + throw new Error("unexpected source workflow"); + } + const pullRequest = workflowRun.pull_requests[0]; + const pullRequestNumber = pullRequest.number; + const headSha = workflowRun.head_sha; + const runId = workflowRun.id; + if ( + !Number.isSafeInteger(pullRequestNumber) || + pullRequestNumber <= 0 || + !Number.isSafeInteger(runId) || + runId <= 0 || + !/^[0-9a-f]{40}$/.test(headSha) || + pullRequest.head?.sha !== headSha + ) { + throw new Error("invalid source workflow metadata"); + } + const runUrl = + `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` + + `/actions/runs/${runId}`; + const result = + workflowRun.conclusion === "success" + ? "successfully" + : `with \`${workflowRun.conclusion}\``; + const body = [ + marker, + "## LiteLLM Rust workflow", + "", + `Workflow completed ${result} for \`${headSha}\``, + "", + `[View workflow run](${runUrl})`, + ].join("\n"); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.startsWith(marker), + ); + const currentPullRequest = ( + await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequestNumber, + }) + ).data; + if ( + currentPullRequest.state !== "open" || + currentPullRequest.head.repo?.full_name !== + `${context.repo.owner}/${context.repo.repo}` || + currentPullRequest.head.sha !== headSha + ) { + core.info("source workflow no longer matches the current pull request head"); + return; + } + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + body, + }); + } diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 21e1bcb90c6..1b71232bc2e 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -4,6 +4,12 @@ on: push: paths: - "litellm-rust/**" + - ".cargo/**" + - "pyproject.toml" + - "rust-toolchain.toml" + - ".github/scripts/smoke_test_native_wheel.py" + - ".github/scripts/verify_linux_native_wheel.py" + - "tests/test_litellm/rust_bridge/native_route_wheel_test.py" - ".github/workflows/test-rust.yml" pull_request: branches: @@ -13,6 +19,12 @@ on: - "litellm_**" paths: - "litellm-rust/**" + - ".cargo/**" + - "pyproject.toml" + - "rust-toolchain.toml" + - ".github/scripts/smoke_test_native_wheel.py" + - ".github/scripts/verify_linux_native_wheel.py" + - "tests/test_litellm/rust_bridge/native_route_wheel_test.py" - ".github/workflows/test-rust.yml" permissions: @@ -40,9 +52,7 @@ jobs: persist-credentials: false - name: Set up Rust - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt - rustup default stable + run: rustup toolchain install - name: Cache Cargo registry and target uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 @@ -51,7 +61,7 @@ jobs: ~/.cargo/registry ~/.cargo/git litellm-rust/target - key: ${{ runner.os }}-cargo-${{ hashFiles('litellm-rust/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ hashFiles('rust-toolchain.toml', 'litellm-rust/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- @@ -69,3 +79,50 @@ jobs: - name: Run core tests with Bedrock auth run: cargo test -p litellm-core --features bedrock-auth --locked + + release-wheel: + name: release wheel + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: ./.github/actions/setup-uv-with-retries + with: + version: "0.10.9" + + - name: Set up Rust + run: rustup toolchain install + + - name: Build release wheel + run: uv build --wheel --out-dir dist + + - name: Build panic contract wheel + run: >- + uv build --wheel --out-dir panic-dist + --config-setting "maturin.build-args=--features panic-test,extension-module" + + - name: Smoke-test native panic unwinding + run: python .github/scripts/smoke_test_native_wheel.py panic-dist/*.whl + + - name: Verify stripped native extension + env: + RELEASE_WHEEL_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: python .github/scripts/verify_linux_native_wheel.py dist/*.whl + + - name: Test native route wheel + run: python tests/test_litellm/rust_bridge/native_route_wheel_test.py dist/*.whl diff --git a/CLAUDE.md b/CLAUDE.md index 6af8390b1af..d9e9e8f1586 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank -Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR +Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y: - don't use emojis diff --git a/Dockerfile b/Dockerfile index b3ee85e9ed1..0a92aa9a68c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 # Copy full source tree @@ -87,6 +88,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \ --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ @@ -101,6 +103,12 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root +# The base image only configures Chainguard's authenticated apk repo, which +# requires an enterprise subscription. Add the public Wolfi repo so `apk add` +# also works for anyone installing extra packages into a running container. +# https://github.com/BerriAI/litellm/issues/33518 +RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories + # node (without npm) is required by the prisma CLI at runtime RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile diff --git a/backend/Dockerfile b/backend/Dockerfile index aa01b9fba8b..622fedcd70d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -46,6 +46,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ + --extra saml \ --python python3.13 # Stage 2 — copy source and install the project + workspace members. @@ -57,6 +58,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ --extra proxy-runtime \ --extra extra_proxy \ --extra semantic-router \ + --extra saml \ --python python3.13 RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index df52069e71f..da788bf1ce3 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -117,7 +117,7 @@ "limit": 111 }, "reportUnnecessaryComparison": { - "limit": 695 + "limit": 692 }, "reportUnnecessaryContains": { "limit": 5 diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index c1348f68231..e9ad2849bb2 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -64,6 +64,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 # Copy full source tree @@ -85,6 +86,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \ --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 2221435a83a..edf20e8bbff 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -70,6 +70,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 # Copy full source tree @@ -97,6 +98,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13 \ --no-sources-package litellm-proxy-extras; \ else \ @@ -106,6 +108,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ --extra extra_proxy \ --extra semantic-router \ --extra saml \ + --extra bedrock-realtime \ --python python3.13; \ fi diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md index b242373de5d..bf4089404db 100644 --- a/helm/litellm-helm/README.md +++ b/helm/litellm-helm/README.md @@ -26,7 +26,7 @@ If `db.useStackgresOperator` is used (not yet implemented): | `replicaCount` | The number of LiteLLM Proxy pods to be deployed | `1` | | `masterkeySecretName` | The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A | | `masterkeySecretKey` | The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use `masterkey` as the key. | N/A | -| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated. | N/A | +| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated on first install and reused on upgrades. | N/A | | `environmentSecrets` | An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | | `environmentConfigMaps` | An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | | `image.repository` | LiteLLM Proxy image repository | `ghcr.io/berriai/litellm` | @@ -212,6 +212,8 @@ service, the **Proxy Endpoint** should be set to `http://-litellm:4000` The **Proxy Key** is the value specified for `masterkey` or, if a `masterkey` was not provided to the helm command line, the `masterkey` is a randomly generated string in the `sk-...` format stored in the `-litellm-masterkey` Kubernetes Secret. +The key is generated once on the first install; later `helm upgrade` runs reuse the +value already in that Secret, so upgrading never rotates the master key. ```bash kubectl -n litellm get secret -litellm-masterkey -o jsonpath="{.data.masterkey}" diff --git a/helm/litellm-helm/templates/secret-masterkey.yaml b/helm/litellm-helm/templates/secret-masterkey.yaml index 7c8560cc2cc..60ab4e74c6b 100644 --- a/helm/litellm-helm/templates/secret-masterkey.yaml +++ b/helm/litellm-helm/templates/secret-masterkey.yaml @@ -1,9 +1,11 @@ {{- if not .Values.masterkeySecretName }} -{{ $masterkey := (.Values.masterkey | default (printf "sk-%s" (randAlphaNum 18))) }} +{{- $secretName := printf "%s-masterkey" (include "litellm.fullname" .) }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +{{- $masterkey := .Values.masterkey | default (dig "data" "masterkey" "" $existing | b64dec) | default (printf "sk-%s" (randAlphaNum 18)) }} apiVersion: v1 kind: Secret metadata: - name: {{ include "litellm.fullname" . }}-masterkey + name: {{ $secretName }} data: masterkey: {{ $masterkey | b64enc }} type: Opaque diff --git a/helm/litellm-helm/tests/masterkey-secret_tests.yaml b/helm/litellm-helm/tests/masterkey-secret_tests.yaml index bbbade9d802..296f26755b8 100644 --- a/helm/litellm-helm/tests/masterkey-secret_tests.yaml +++ b/helm/litellm-helm/tests/masterkey-secret_tests.yaml @@ -15,6 +15,53 @@ tests: # Note: The masterkey is generated as "sk-<18-random-chars>" in plain text, # but stored as base64 encoded in Kubernetes secret (requirement). # "sk-" base64 encodes to "c2st", so we check for "^c2st" pattern. + - it: should reuse the master key already stored in the cluster instead of generating a new one on upgrade + template: secret-masterkey.yaml + set: + masterkeySecretName: "" + kubernetesProvider: + scheme: + "v1/Secret": + gvr: + version: "v1" + resource: "secrets" + namespaced: true + objects: + - kind: Secret + apiVersion: v1 + metadata: + name: RELEASE-NAME-litellm-masterkey + namespace: NAMESPACE + data: + masterkey: c2stZXhpc3Rpbmcta2V5 + asserts: + - equal: + path: data.masterkey + value: c2stZXhpc3Rpbmcta2V5 + - it: should let an explicit masterkey value override the one already stored in the cluster + template: secret-masterkey.yaml + set: + masterkeySecretName: "" + masterkey: sk-explicit + kubernetesProvider: + scheme: + "v1/Secret": + gvr: + version: "v1" + resource: "secrets" + namespaced: true + objects: + - kind: Secret + apiVersion: v1 + metadata: + name: RELEASE-NAME-litellm-masterkey + namespace: NAMESPACE + data: + masterkey: c2stZXhpc3Rpbmcta2V5 + asserts: + - equal: + path: data.masterkey + value: c2stZXhwbGljaXQ= - it: should not create a secret if masterkeySecretName is set template: secret-masterkey.yaml set: diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index c088609dad7..b8032dd0d28 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -7,8 +7,7 @@ import subprocess import tempfile import time from pathlib import Path -from types import MappingProxyType -from typing import Final, Optional +from typing import Optional from litellm_proxy_extras._logging import logger from litellm_proxy_extras.replica_identity import ( @@ -51,17 +50,6 @@ _SPEND_LOGS_PK_CLAUSE_RE = re.compile( re.IGNORECASE, ) -_PRISMA_ATTEMPTS: Final = 4 - -_TRANSIENT_PRISMA_FAILURES: Final = MappingProxyType( - { - "deadlock detected": "a deadlock on the migration advisory lock (a concurrent migrate deploy)", - "P1001": "an unreachable database server", - "P1002": "a database server that timed out", - } -) - - PARTITIONED_SPEND_LOGS_PUSH_ERROR = ( "LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), " "so its primary key must include the partition key (\"startTime\"). `prisma db push` " @@ -286,23 +274,6 @@ class ProxyExtrasDBManager: env=prisma_env, ) - @staticmethod - def _transient_prisma_failure(stderr: str) -> str | None: - """Why a failed prisma command is worth retrying, or None. - - v1 retried every failure, so it absorbed a database that was not up yet - or another instance holding the migration lock. v2 fails fast, which is - right for a broken migration and wrong for these. - """ - return next( - ( - reason - for marker, reason in _TRANSIENT_PRISMA_FAILURES.items() - if marker in stderr - ), - None, - ) - @staticmethod def _is_permission_error(error_message: str) -> bool: """ @@ -684,7 +655,7 @@ class ProxyExtrasDBManager: @staticmethod def _setup_database_v2(use_migrate: bool) -> bool: """ - v2 migration resolver (what the proxy CLI selects by default). + v2 migration resolver (opt-in via --use_v2_migration_resolver). Runs `prisma migrate deploy` and handles standard recovery paths (P3005 baseline, P3009/P3018 idempotent errors). Critically, it does @@ -705,46 +676,20 @@ class ProxyExtrasDBManager: original_dir = os.getcwd() os.chdir(migrations_dir) try: - for attempt in range(_PRISMA_ATTEMPTS): - try: - subprocess.run( - [_get_prisma_command(), "db", "push", "--accept-data-loss"], - timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, - env=_get_prisma_env(), - ) - return True - except subprocess.TimeoutExpired: - logger.info( - "prisma db push attempt %s timed out, retrying", - attempt + 1, - ) - time.sleep(random.randrange(5, 15)) - except subprocess.CalledProcessError as e: - stderr = e.stderr or "" - transient = ProxyExtrasDBManager._transient_prisma_failure( - stderr - ) - # Re-raise as RuntimeError so proxy_cli.py's - # `except RuntimeError` catches it and exits cleanly. - if transient is None or attempt == _PRISMA_ATTEMPTS - 1: - raise RuntimeError( - f"prisma db push failed.\n\nDetail: {e}" - f"\n\nPrisma error:\n{stderr}" - ) from e - logger.info( - "prisma db push attempt %s failed on %s, retrying. " - "Prisma error:\n%s", - attempt + 1, - transient, - stderr, - ) - time.sleep(random.randrange(5, 15)) - raise RuntimeError( - f"prisma db push failed after {_PRISMA_ATTEMPTS} attempts." + subprocess.run( + [_get_prisma_command(), "db", "push", "--accept-data-loss"], + timeout=prisma_command_timeout(), + check=True, + env=_get_prisma_env(), ) + return True + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as e: + # Re-raise as RuntimeError so proxy_cli.py's + # `except RuntimeError` catches it and exits cleanly. + raise RuntimeError(f"prisma db push failed.\n\nDetail: {e}") from e finally: os.chdir(original_dir) @@ -754,7 +699,7 @@ class ProxyExtrasDBManager: original_dir = os.getcwd() os.chdir(migrations_dir) try: - for attempt in range(_PRISMA_ATTEMPTS): + for attempt in range(4): try: result = subprocess.run( [_get_prisma_command(), "migrate", "deploy"], @@ -869,36 +814,16 @@ class ProxyExtrasDBManager: f"Manual intervention required.\n\nPrisma error:\n{stderr}" ) from e - transient = ProxyExtrasDBManager._transient_prisma_failure(stderr) - if transient is None: - raise RuntimeError( - "Database migration failed and cannot be auto-recovered. " - f"Manual intervention required.\n\nPrisma error:\n{stderr}" - ) from e - - if attempt == _PRISMA_ATTEMPTS - 1: - raise RuntimeError( - f"Database migration failed after " - f"{_PRISMA_ATTEMPTS} attempts on {transient}. " - "Check database connectivity and load." - f"\n\nPrisma error:\n{stderr}" - ) from e - - logger.info( - "prisma migrate deploy attempt %s failed on %s, retrying. " - "Prisma error:\n%s", - attempt + 1, - transient, - stderr, - ) - time.sleep(random.randrange(5, 15)) - continue + raise RuntimeError( + "Database migration failed and cannot be auto-recovered. " + f"Manual intervention required.\n\nPrisma error:\n{stderr}" + ) from e raise RuntimeError( - f"Database migration failed after {_PRISMA_ATTEMPTS} " - "attempts (retry loop exhausted by timeouts or repeated " - "idempotent-recovery continues). Check database connectivity, " - "load, and _prisma_migrations ledger state." + "Database migration failed after 4 attempts (retry loop " + "exhausted by timeouts or repeated idempotent-recovery " + "continues). Check database connectivity, load, and " + "_prisma_migrations ledger state." ) finally: os.chdir(original_dir) @@ -946,11 +871,10 @@ class ProxyExtrasDBManager: Args: use_migrate: Whether to use prisma migrate instead of db push - use_v2_resolver: Run the v2 migration resolver (safer during + use_v2_resolver: Opt into the v2 migration resolver (safer during rolling deploys; does not run the diff-and-force recovery - that causes schema thrashing). Defaults to False here so - direct callers keep the old behavior; the proxy CLI passes - True, so the proxy's runtime default is v2. + that causes schema thrashing). Defaults to False for + backwards compatibility. Returns: bool: True if setup was successful, False otherwise @@ -968,7 +892,7 @@ class ProxyExtrasDBManager: @staticmethod def _run_migrations(use_migrate: bool, use_v2_resolver: bool) -> bool: if use_v2_resolver: - logger.info("Using v2 migration resolver") + logger.info("Using v2 migration resolver (--use_v2_migration_resolver)") return ProxyExtrasDBManager._setup_database_v2(use_migrate=use_migrate) schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma" diff --git a/litellm-proxy-extras/tests/__init__.py b/litellm-proxy-extras/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm-proxy-extras/tests/test_setup_database_fail_fast.py b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py new file mode 100644 index 00000000000..8d66bf872de --- /dev/null +++ b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py @@ -0,0 +1,242 @@ +"""Regression tests for ProxyExtrasDBManager v2 migration resolver. + +The v2 resolver is opt-in via `--use_v2_migration_resolver` / the +`use_v2_resolver=True` kwarg. These tests exercise the v2 path; the v1 +(default) behavior is unchanged from pre-fix. +""" + +import subprocess +from unittest.mock import patch + +import pytest + +from litellm_proxy_extras.utils import ( + ProxyExtrasDBManager, + _max_migration_timestamp, + _migration_timestamp, +) + + +def _fake_migrate_deploy_failure(returncode: int, stderr: str): + def _run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=returncode, + cmd=args[0], + stderr=stderr, + output="", + ) + + return _run + + +def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path): + """v2: a permission failure during migrate deploy raises RuntimeError.""" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = ( + "Error: P3018\nMigration name: 20250326162113_baseline\n" + "Database error code: 42501\npermission denied for schema public" + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="permission"): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path): + """v2: a non-idempotent migration failure raises (no silent recovery).""" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = ( + "Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n" + 'Reason: syntax error at or near "BRKN" LINE 42' + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="cannot be auto-recovered"): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_strip_prisma_query_params_removes_connection_limit(): + """DATABASE_URLs with Prisma-specific params should be parseable by psycopg.""" + url = "postgresql://u:p@h:5432/db?connection_limit=100&pool_timeout=60&sslmode=require" + stripped = ProxyExtrasDBManager._strip_prisma_query_params(url) + assert "connection_limit" not in stripped + assert "pool_timeout" not in stripped + assert "sslmode=require" in stripped + + +def test_strip_prisma_query_params_passthrough_no_query(): + """URLs without query strings are returned unchanged.""" + url = "postgresql://u:p@h:5432/db" + assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url + + +def test_migration_timestamp_extracts_leading_digits(): + assert _migration_timestamp("20260101000000_add_foo") == 20260101000000 + assert _migration_timestamp("20250326162113_baseline") == 20250326162113 + + +def test_migration_timestamp_returns_zero_on_malformed(): + assert _migration_timestamp("0_init") == 0 + assert _migration_timestamp("not_a_migration") == 0 + + +def test_max_migration_timestamp(): + names = {"20250326000000_a", "20260415000000_b", "20251115000000_c"} + assert _max_migration_timestamp(names) == 20260415000000 + + +def test_max_migration_timestamp_empty_set(): + assert _max_migration_timestamp(set()) == 0 + + +def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path): + """v1 (default) continues to call _resolve_all_migrations on the happy path. + + This is the existing buggy behavior — we're not fixing it in v1, only + offering v2 as opt-in. This test pins the default so that a future + inadvertent default flip is caught. + """ + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + # Stub `prisma migrate deploy` to claim success with pending migrations + # applied, which is the code path that triggers the legacy post-migration + # sanity check (a call to _resolve_all_migrations). + class FakeResult: + stdout = "Applied migration.\n" + stderr = "" + + def fake_run(cmd, *args, **kwargs): + return FakeResult() + + resolve_called = {"n": 0} + + def fake_resolve(*args, **kwargs): + resolve_called["n"] += 1 + + monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve) + + ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set + assert ok is True + assert resolve_called["n"] == 1, "v1 default should still invoke the legacy path" + + +def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_path): + """v2: a failing `prisma db push` must raise RuntimeError, not leak + CalledProcessError past proxy_cli.py's `except RuntimeError`.""" + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = "db push error" + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="prisma db push failed"): + ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) + + +def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path): + """_warn_if_db_ahead_of_head must never raise — it's informational. + + Non-connection DB errors (e.g. InsufficientPrivilege from a user + without SELECT on _prisma_migrations) must be caught, not propagated. + """ + import psycopg + + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + class _FakeConn: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def execute(self, *a, **kw): + # Simulate an InsufficientPrivilege (subclass of DatabaseError). + raise psycopg.errors.InsufficientPrivilege("permission denied") + + def _fake_connect(*a, **kw): + return _FakeConn() + + monkeypatch.setattr("psycopg.connect", _fake_connect) + + # Must not raise. + ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path)) + + +def test_v2_resolve_specific_migration_failure_raises_runtime_error( + monkeypatch, tmp_path +): + """If marking a migration as applied fails inside P3009 idempotent + recovery, the subprocess error must be re-raised as RuntimeError so + proxy_cli.py catches it cleanly (instead of leaking CalledProcessError).""" + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + monkeypatch.setattr( + ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None + ) + + # First call: migrate deploy -> P3009 idempotent error. + # Recovery path tries _resolve_specific_migration; that also raises. + def _failing_resolve(*a, **kw): + raise subprocess.CalledProcessError( + returncode=1, + cmd="prisma migrate resolve --applied", + stderr="resolve failed", + output="", + ) + + monkeypatch.setattr( + ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve + ) + + stderr = ( + "Error: P3009\nMigration `20260101000000_some_migration` failed\n" + "relation already exists" + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises( + RuntimeError, match="Failed to mark migration .* as applied" + ): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path): + """v2 must never call _resolve_all_migrations — that's the bug it fixes.""" + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + class FakeResult: + stdout = "Applied migration.\n" + stderr = "" + + monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult()) + + resolve_called = {"n": 0} + monkeypatch.setattr( + ProxyExtrasDBManager, + "_resolve_all_migrations", + lambda *a, **kw: resolve_called.__setitem__("n", resolve_called["n"] + 1), + ) + + ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + assert ok is True + assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery" diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md index 36a5ad5a8f4..b8b6291283d 100644 --- a/litellm-rust/AGENTS.md +++ b/litellm-rust/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -litellm-rust has exactly THREE crates. A crate is a LAYER, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are MODULES inside the layers. +litellm-rust has four crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers. ## Crates @@ -8,9 +8,10 @@ litellm-rust has exactly THREE crates. A crate is a LAYER, not a route. Routes ( |-------|------| | litellm-core | The LiteLLM SDK in Rust. One public entrypoint per top-level call (`messages::messages()`), owning types, transforms, provider resolution, auth, and the provider HTTP call. Call it, get a typed response. | | litellm-ai-gateway | The axum server (behind the `server` feature) plus the WebSocket hosts. Translates HTTP/WS to core entrypoints; owns no provider logic and no handlers. | -| litellm-python-bridge | PyO3 cdylib exposing Rust to the litellm Python SDK — marshals Python objects and calls core entrypoints. | +| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. | +| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. | -Dependency direction (acyclic): litellm-core ← litellm-ai-gateway ← litellm-python-bridge. +Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate. ## Where a route lives @@ -28,7 +29,7 @@ core/src/messages/ Handlers never live in `ai-gateway`. `ocr`, `audio_transcription`, and `realtime` are still hosted there from before this rule; they move to `core` as they are touched. -Adding a crate: default to a MODULE. New crate ONLY on a real trigger — separate artifact (binary/cdylib), proc-macro, shared foundation, or publishable standalone. A new provider or route is none of these. +Adding a crate: default to a module. A new crate requires a real trigger: separate artifact (binary/cdylib), proc-macro, shared foundation, or publishable standalone. A new provider or route is none of these. Adding a crate fails crates/core/tests/workspace_crate_allowlist.rs until you update its allowlist and this file — intentional. diff --git a/litellm-rust/CLAUDE.md b/litellm-rust/CLAUDE.md index fe6ceedbb86..3dcf1853efc 100644 --- a/litellm-rust/CLAUDE.md +++ b/litellm-rust/CLAUDE.md @@ -21,12 +21,13 @@ variants of it. The test for a good abstraction is that adding the next provider is a few declarative lines, not a new file of duplicated flow. Only diverge from the base when behavior is genuinely different, and say so explicitly in the PR. -## Crates (exactly three — see AGENTS.md) +## Crates (see AGENTS.md) `litellm-core` **is** the LiteLLM SDK in Rust: it makes the LLM call. `litellm-ai-gateway` is an HTTP/WebSocket server in front of it, and -`litellm-python-bridge` exposes it to the Python SDK. A crate is a **layer**, not -a route — add modules, not crates. +`litellm-python-bridge` exposes it to the Python SDK. `litellm-python-interop` +holds domain-neutral PyO3 primitives shared by Python-facing Rust code. A crate +is a layer or shared foundation, not a route; add modules, not crates. ## Core Boundary @@ -175,7 +176,7 @@ cd litellm-rust cargo fmt --check # the ai-gateway binary + server code is behind the `server` feature cargo clippy -p litellm-ai-gateway --all-targets --features server -- -D warnings -cargo clippy -p litellm-core -p litellm-python-bridge --all-targets -- -D warnings +cargo clippy -p litellm-core -p litellm-python-interop -p litellm-python-bridge --all-targets -- -D warnings cargo test --workspace ``` diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 4388e561026..95dd09282e0 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -919,6 +919,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.33" @@ -972,6 +978,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "h2" version = "0.3.27" @@ -1380,6 +1392,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -1416,6 +1434,10 @@ dependencies = [ "aws-sigv4", "aws-smithy-runtime-api", "aws-types", + "base64", + "bytes", + "futures-util", + "mime", "rand 0.8.7", "reqwest", "serde", @@ -1423,6 +1445,8 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.19", "tokio", + "tracing", + "url", ] [[package]] @@ -1430,14 +1454,29 @@ name = "litellm-python-bridge" version = "0.1.0" dependencies = [ "criterion", + "futures-util", "litellm-ai-gateway", "litellm-core", + "litellm-python-interop", "pyo3", "pyo3-async-runtimes", - "pythonize", "serde", "serde_json", "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "litellm-python-interop" +version = "0.1.0" +dependencies = [ + "pyo3", + "pythonize", + "rstest", + "serde", + "serde_json", ] [[package]] @@ -1627,6 +1666,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1638,9 +1686,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "libc", "once_cell", @@ -1666,18 +1714,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -1685,9 +1733,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -1697,9 +1745,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", @@ -1899,6 +1947,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.28" @@ -1956,6 +2010,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2210,6 +2293,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2348,6 +2440,15 @@ dependencies = [ "syn 3.0.0", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.53" @@ -2488,6 +2589,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -2566,6 +2697,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2620,6 +2762,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2903,6 +3046,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index afade0f5ca2..4886b484942 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/core", "crates/ai-gateway", + "crates/python-interop", "crates/python-bridge", ] resolver = "2" @@ -13,14 +14,18 @@ license = "MIT" repository = "https://github.com/BerriAI/litellm" [workspace.dependencies] +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } litellm-core = { path = "crates/core" } litellm-ai-gateway = { path = "crates/ai-gateway", default-features = false } +litellm-python-interop = { path = "crates/python-interop" } axum = "0.7" -pyo3 = "0.29.0" +pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } pythonize = "0.29.0" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } +rstest = "0.26.1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } sha2 = "0.10" @@ -30,6 +35,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"] tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } base64 = "0.22" +bytes = "1" +mime = "0.3" +url = { version = "2", features = ["serde"] } [profile.release] opt-level = 3 diff --git a/litellm-rust/README.md b/litellm-rust/README.md index bcccf93300b..a0d79c6f0a5 100644 --- a/litellm-rust/README.md +++ b/litellm-rust/README.md @@ -26,9 +26,10 @@ coverage and production evidence. |-------|------| | litellm-core | The SDK. Per-route entrypoints (`messages::messages()`), types, provider transforms (modules under `providers/`), provider resolution, auth, the provider HTTP call, and the router. | | litellm-ai-gateway | The axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. | -| litellm-python-bridge | PyO3 cdylib exposing Rust to the litellm Python SDK — marshals Python objects and calls core entrypoints. | +| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. | +| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. | -Dependency direction (acyclic): litellm-core ← litellm-ai-gateway ← litellm-python-bridge. +Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate. ## Layout @@ -38,7 +39,8 @@ crates/ src/messages/ mod.rs (entrypoint), types, transformation, prepare, handler, client src/providers/anthropic/messages/transformation.rs ai-gateway/ Axum server + WebSocket hosts; calls core entrypoints. - python-bridge/ PyO3 bridge for Python LiteLLM. + python-interop/ Domain-neutral PyO3 conversion and GIL primitives. + python-bridge/ PyO3 API adapter for Python LiteLLM. ``` The folder shape follows the Python provider tree: diff --git a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md index a1860d8a9c9..4a689cb9579 100644 --- a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md +++ b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md @@ -54,6 +54,6 @@ Rules for adding or changing an LLM provider/route in `litellm-rust`. `messages` cd litellm-rust cargo fmt --check cargo clippy -p litellm-ai-gateway --all-targets --features server -- -D warnings - cargo clippy -p litellm-core -p litellm-python-bridge --all-targets -- -D warnings + cargo clippy -p litellm-core -p litellm-python-interop -p litellm-python-bridge --all-targets -- -D warnings cargo test --workspace ``` diff --git a/litellm-rust/crates/ai-gateway/README.md b/litellm-rust/crates/ai-gateway/README.md index 7a6c620ee84..5cbb47220be 100644 --- a/litellm-rust/crates/ai-gateway/README.md +++ b/litellm-rust/crates/ai-gateway/README.md @@ -6,15 +6,16 @@ dials OpenAI upstream, and splices the two sockets frame-by-frame. ## Crates -`litellm-rust` is exactly three crates (a crate is a **layer**, not a route): +`litellm-rust` has four crates. A crate is a layer or shared foundation, not a route: | Crate | Role | |-------|------| | litellm-core | The LiteLLM SDK in Rust — per-route entrypoints (`messages::messages()`) that resolve the provider, transform, and make the call; plus types, provider transforms, and the router. | | litellm-ai-gateway | The Axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. | -| litellm-python-bridge | PyO3 cdylib exposing Rust to the litellm Python SDK — marshals Python objects and calls core entrypoints. | +| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. | +| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. | -Dependency direction (acyclic): litellm-core ← litellm-ai-gateway ← litellm-python-bridge. +Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate. - **Client endpoint:** `wss:///v1/realtime?model=` (WebSocket) - **Auth:** `Authorization: Bearer $LITELLM_MASTER_KEY` (fails closed if unset) diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/common_utils.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/common_utils.rs deleted file mode 100644 index 270d5c2d97a..00000000000 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/common_utils.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::collections::BTreeMap; - -use litellm_core::CoreResult; -use litellm_core::audio_transcription::transformation::AudioTranscriptionProviderConfig; -use litellm_core::error::CoreError; -use litellm_core::providers::bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG; -use serde_json::{Map, Value}; - -pub(super) fn audio_transcription_provider_config( - provider: &str, -) -> Option<&'static dyn AudioTranscriptionProviderConfig> { - match provider { - "bedrock" => Some(&BEDROCK_AUDIO_TRANSCRIPTION_CONFIG), - _ => None, - } -} - -pub(super) fn string_headers( - headers: Option>, -) -> CoreResult> { - headers - .unwrap_or_default() - .into_iter() - .map(|(key, value)| { - value - .as_str() - .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| { - CoreError::InvalidRequest(format!( - "audio transcription extra_headers.{key} must be a string" - )) - }) - }) - .collect() -} - -pub(super) fn has_header(headers: &BTreeMap, name: &str) -> bool { - headers.keys().any(|key| key.eq_ignore_ascii_case(name)) -} - -pub(super) fn truncate_error_body(body: &str) -> String { - let truncated: String = body.chars().take(256).collect(); - if truncated.chars().count() == body.chars().count() { - truncated - } else { - format!("{truncated}... (truncated)") - } -} diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/handler.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/handler.rs deleted file mode 100644 index 33c13550f58..00000000000 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/handler.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::time::SystemTime; - -use litellm_core::CoreResult; -use litellm_core::audio_transcription::transformation::AudioTranscriptionAuth; -use litellm_core::error::CoreError; -use litellm_core::providers::bedrock::audio_transcription::aws_auth_config; -use litellm_core::providers::bedrock::aws_base::{resolve_credentials, sign_bedrock_post}; -use serde_json::Value; - -use super::common_utils::truncate_error_body; -use super::types::ProviderAudioTranscriptionRequest; -use crate::client::http_client; - -pub(crate) async fn execute_audio_transcription_provider_call( - request: ProviderAudioTranscriptionRequest, -) -> CoreResult { - let body = serde_json::to_vec(&request.body).map_err(|error| { - CoreError::InvalidRequest(format!("invalid audio request body: {error}")) - })?; - let mut request_builder = http_client().post(&request.url).body(body.clone()); - for (key, value) in &request.upstream_headers { - request_builder = request_builder.header(key, value); - } - if let Some(duration) = request.timeout { - request_builder = request_builder.timeout(duration); - } - let response = request_builder - .send() - .await - .map_err(|error| CoreError::Network(error.to_string()))?; - let status = response.status(); - let text = response - .text() - .await - .map_err(|error| CoreError::Network(error.to_string()))?; - if !status.is_success() { - return Err(CoreError::Http { - status: status.as_u16(), - body: truncate_error_body(&text), - }); - } - let response_json: Value = serde_json::from_str(&text).map_err(|error| { - CoreError::InvalidResponse(format!("invalid audio response JSON: {error}")) - })?; - Ok(request - .config - .transform_transcription_response(&request.model, response_json)? - .into_json()) -} - -pub(crate) async fn sign_request( - request: &ProviderAudioTranscriptionRequest, - optional_params: &serde_json::Map, -) -> CoreResult { - let env_lookup = environment_lookup; - let auth = request - .config - .auth_strategy(&request.model, optional_params, &env_lookup)?; - let body = serde_json::to_vec(&request.body).map_err(|error| { - CoreError::InvalidRequest(format!("invalid audio request body: {error}")) - })?; - let mut headers = super::common_utils::string_headers(None)?; - headers.insert("Content-Type".to_string(), "application/json".to_string()); - headers.extend(request.upstream_headers.iter().cloned()); - match auth { - AudioTranscriptionAuth::Bearer => {} - AudioTranscriptionAuth::AwsSigV4 { region, .. } => { - let credentials = - resolve_credentials(aws_auth_config(optional_params, &env_lookup), &env_lookup) - .await?; - headers.extend(sign_bedrock_post( - &request.url, - &body, - &headers, - ®ion, - &credentials, - SystemTime::now(), - )?); - } - } - Ok(ProviderAudioTranscriptionRequest { - upstream_headers: headers.into_iter().collect(), - ..request.clone() - }) -} - -pub(super) fn environment_lookup(key: &str) -> Option { - std::env::var(key).ok() -} diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs index 0c9faeda6e7..42ffd4cbcff 100644 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs +++ b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs @@ -1,15 +1,14 @@ +use litellm_core::audio_transcription::{ + AudioTranscriptionRequest as CoreAudioTranscriptionRequest, ProviderAudioTranscriptionRequest, + prepare_audio_transcription_provider_call, +}; +use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; +use litellm_core::error::Error; +use serde_json::{Map, Value, json}; use std::future::Future; use std::pin::Pin; -use litellm_core::CoreResult; -use litellm_core::audio_transcription::transformation::AudioTranscriptionAuth; -use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; -use litellm_core::error::CoreError; -use serde_json::{Map, Value, json}; - -use super::common_utils::{audio_transcription_provider_config, has_header, string_headers}; -use super::handler::sign_request; -use super::types::{PreparedAudioTranscriptionRequest, ProviderAudioTranscriptionRequest}; +use super::types::PreparedAudioTranscriptionRequest; use crate::integrations::custom_guardrail::{ CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest, }; @@ -26,7 +25,7 @@ pub(crate) struct AudioTranscriptionLifecycleHooks { request_metadata: RequestMetadata, } -type AudioFuture<'a, T> = Pin> + Send + 'a>>; +type AudioFuture<'a, T> = Pin> + Send + 'a>>; type AudioLogFuture<'a> = Pin + Send + 'a>>; impl AudioTranscriptionLifecycleHooks { @@ -45,7 +44,7 @@ impl AudioTranscriptionLifecycleHooks { async fn run_pre_call_guardrails( &self, request: PreparedAudioTranscriptionRequest, - ) -> CoreResult { + ) -> Result { if self.guardrail_runner.is_empty() { return Ok(request); } @@ -63,17 +62,17 @@ impl AudioTranscriptionLifecycleHooks { .await .map_err(guardrail_error_to_core_error)?; let Value::Object(mut data) = guardrail_request.data else { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "audio transcription pre_call guardrail must return an object".to_string(), )); }; let audio = data.remove("audio").ok_or_else(|| { - CoreError::InvalidRequest("audio transcription guardrail removed audio".to_string()) + Error::InvalidRequest("audio transcription guardrail removed audio".to_string()) })?; let optional_params = match data.remove("optional_params") { Some(Value::Object(value)) => value, Some(_) => { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "audio transcription optional_params must be an object".to_string(), )); } @@ -89,53 +88,36 @@ impl AudioTranscriptionLifecycleHooks { async fn prepare_provider_request( &self, request: PreparedAudioTranscriptionRequest, - ) -> CoreResult { - let config = audio_transcription_provider_config(&request.custom_llm_provider) - .ok_or_else(|| CoreError::InvalidProvider(request.custom_llm_provider.clone()))?; - let env_lookup = super::handler::environment_lookup; - let headers = string_headers(request.extra_headers)?; - let url = config.complete_url( - request.api_base.as_deref(), - &request.model, - &request.optional_params, - &env_lookup, - )?; - let filtered_params = config.map_transcription_params(&request.optional_params); - let body = config.transform_transcription_request( - &request.model, - request.audio, - filtered_params, - )?; - let auth = config.auth_strategy(&request.model, &request.optional_params, &env_lookup)?; - let mut upstream_headers = headers.into_iter().collect::>(); - if matches!(auth, AudioTranscriptionAuth::Bearer) - && !has_header( - &upstream_headers - .iter() - .cloned() - .collect::>(), - "authorization", - ) - && let Some(api_key) = request.api_key.as_deref() - { - upstream_headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); - } - let provider_request = ProviderAudioTranscriptionRequest { - model: request.model, - config, - url, - body: body.body, - upstream_headers, - timeout: request.timeout, - }; - let provider_request = self.run_during_call_guardrails(provider_request).await?; - sign_request(&provider_request, &request.optional_params).await + ) -> Result { + let PreparedAudioTranscriptionRequest { + model, + custom_llm_provider, + audio, + api_key, + api_base, + extra_headers, + optional_params, + timeout, + .. + } = request; + let provider_request = + prepare_audio_transcription_provider_call(CoreAudioTranscriptionRequest { + model: &model, + audio, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: Some(&custom_llm_provider), + extra_headers, + optional_params, + timeout, + })?; + self.run_during_call_guardrails(provider_request).await } async fn run_during_call_guardrails( &self, request: ProviderAudioTranscriptionRequest, - ) -> CoreResult { + ) -> Result { if self.guardrail_runner.is_empty() { return Ok(request); } @@ -144,23 +126,23 @@ impl AudioTranscriptionLifecycleHooks { .run_during_call( &guardrail_context(&self.request_metadata), GuardrailRequest::new(json!({ - "model": request.model, - "custom_llm_provider": "bedrock", - "url": request.url, - "body": request.body, + "model": request.model(), + "custom_llm_provider": request.custom_llm_provider(), + "url": request.url(), + "body": request.body(), })), ) .await .map_err(guardrail_error_to_core_error)?; let Value::Object(mut data) = guardrail_request.data else { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "audio transcription during_call guardrail must return an object".to_string(), )); }; let body = data.remove("body").ok_or_else(|| { - CoreError::InvalidRequest("audio transcription guardrail removed body".to_string()) + Error::InvalidRequest("audio transcription guardrail removed body".to_string()) })?; - Ok(ProviderAudioTranscriptionRequest { body, ..request }) + Ok(request.with_body(body)) } fn logging_payload( @@ -241,7 +223,7 @@ impl CallLifecycleHooks( &'a self, context: &'a CallLifecycleContext, - error: &'a CoreError, + error: &'a Error, timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a> { Box::pin(async move { @@ -281,22 +263,21 @@ fn guardrail_context(metadata: &RequestMetadata) -> GuardrailContext { } } -fn guardrail_error_to_core_error(error: GuardrailError) -> CoreError { - CoreError::InvalidRequest(format!("{}: {}", error.kind, error.message)) +fn guardrail_error_to_core_error(error: GuardrailError) -> Error { + Error::InvalidRequest(format!("{}: {}", error.kind, error.message)) } -fn core_error_kind(error: &CoreError) -> &'static str { +fn core_error_kind(error: &Error) -> &'static str { match error { - CoreError::Auth(_) => "AuthError", - CoreError::InvalidProvider(_) => "InvalidProvider", - CoreError::InvalidRequest(_) => "InvalidRequest", - CoreError::InvalidType { .. } => "InvalidType", - CoreError::MissingField(_) => "MissingField", - CoreError::Http { .. } => "HttpError", - CoreError::InvalidResponse(_) => "InvalidResponse", - CoreError::Network(_) => "NetworkError", - CoreError::Connect(_) => "ConnectError", - CoreError::Routing(_) => "RoutingError", - CoreError::Unsupported(_) => "UnsupportedRequest", + Error::Auth(_) => "AuthError", + Error::InvalidProvider(_) => "InvalidProvider", + Error::InvalidRequest(_) => "InvalidRequest", + Error::InvalidType { .. } => "InvalidType", + Error::MissingField(_) => "MissingField", + Error::Http { .. } => "HttpError", + Error::InvalidResponse(_) => "InvalidResponse", + Error::Network(_) => "NetworkError", + Error::Routing(_) => "RoutingError", + Error::Unsupported(_) => "UnsupportedRequest", } } diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/mod.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/mod.rs index 5d33d912c40..03d621b8414 100644 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/audio_transcription/mod.rs @@ -1,19 +1,17 @@ -use litellm_core::CoreResult; +use litellm_core::Error; +use litellm_core::audio_transcription::execute_audio_transcription_provider_call; use litellm_core::call_lifecycle::CallLifecycle; use serde_json::Value; -mod common_utils; -mod handler; mod hooks; mod prepare; mod types; pub use types::AudioTranscriptionRequest; -use handler::execute_audio_transcription_provider_call; use prepare::{PreparedAudioTranscriptionCall, prepare_audio_transcription_call}; -pub async fn audio_transcription(request: AudioTranscriptionRequest<'_>) -> CoreResult { +pub async fn audio_transcription(request: AudioTranscriptionRequest<'_>) -> Result { let PreparedAudioTranscriptionCall { request, hooks } = prepare_audio_transcription_call(request); CallLifecycle::default() diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/types.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/types.rs index 9697aa98b0a..b470638264e 100644 --- a/litellm-rust/crates/ai-gateway/src/audio_transcription/types.rs +++ b/litellm-rust/crates/ai-gateway/src/audio_transcription/types.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use std::time::Duration; -use litellm_core::audio_transcription::transformation::AudioTranscriptionProviderConfig; use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleRequest}; use serde_json::{Map, Value}; @@ -46,13 +45,3 @@ impl CallLifecycleRequest for PreparedAudioTranscriptionRequest { ) } } - -#[derive(Clone)] -pub(crate) struct ProviderAudioTranscriptionRequest { - pub(crate) model: String, - pub(crate) config: &'static dyn AudioTranscriptionProviderConfig, - pub(crate) url: String, - pub(crate) body: Value, - pub(crate) upstream_headers: Vec<(String, String)>, - pub(crate) timeout: Option, -} diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime.rs b/litellm-rust/crates/ai-gateway/src/io/realtime.rs index 845e7bf9527..662f7328982 100644 --- a/litellm-rust/crates/ai-gateway/src/io/realtime.rs +++ b/litellm-rust/crates/ai-gateway/src/io/realtime.rs @@ -15,8 +15,7 @@ use std::time::Duration; use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{Sink, SinkExt, Stream, StreamExt}; -use litellm_core::CoreResult; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::realtime::transformation::RealtimeProviderConfig; use litellm_core::realtime::types::RealtimeEvent; use tokio::net::TcpStream; @@ -48,7 +47,7 @@ pub(crate) type UpstreamRx = SplitStream; /// Resolve the OpenAI API key from the explicit param or the environment. /// /// Blank/whitespace values are treated as absent (guard at resolution time). -pub(crate) fn resolve_api_key(api_key: Option<&str>) -> CoreResult { +pub(crate) fn resolve_api_key(api_key: Option<&str>) -> Result { api_key .map(str::trim) .filter(|key| !key.is_empty()) @@ -58,7 +57,7 @@ pub(crate) fn resolve_api_key(api_key: Option<&str>) -> CoreResult { .ok() .filter(|key| !key.trim().is_empty()) }) - .ok_or_else(|| CoreError::Auth(MISSING_KEY_MESSAGE.to_string())) + .ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string())) } /// Open the upstream WebSocket to OpenAI for `(model, api_key, api_base)`. @@ -70,24 +69,24 @@ pub(crate) async fn dial_upstream( model: &str, api_key: &str, api_base: Option<&str>, -) -> CoreResult { +) -> Result { let url = OPENAI_REALTIME_CONFIG.complete_url(api_base, model); let mut request = url .as_str() .into_client_request() - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; // GA realtime: only Authorization. The legacy OpenAI-Beta header triggers // beta_api_shape_disabled, so we do not send it. request.headers_mut().insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {api_key}")) - .map_err(|err| CoreError::Auth(err.to_string()))?, + .map_err(|err| Error::Auth(err.to_string()))?, ); let (upstream, _response) = connect_async(request) .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; Ok(upstream) } @@ -96,22 +95,22 @@ pub(crate) async fn dial_upstream( /// Used by the pool to pre-read OpenAI's unprompted `session.created`. Returns an /// error on a non-text frame, a closed socket, or undecodable JSON so the pool can /// discard a misbehaving socket rather than warm it. -pub(crate) async fn read_event(upstream_rx: &mut UpstreamRx) -> CoreResult { +pub(crate) async fn read_event(upstream_rx: &mut UpstreamRx) -> Result { loop { let message = upstream_rx .next() .await - .ok_or_else(|| CoreError::Network("upstream closed before first event".to_string()))? - .map_err(|err| CoreError::Network(err.to_string()))?; + .ok_or_else(|| Error::Network("upstream closed before first event".to_string()))? + .map_err(|err| Error::Network(err.to_string()))?; match message { Message::Text(text) => { return serde_json::from_str(&text) - .map_err(|err| CoreError::InvalidResponse(err.to_string())); + .map_err(|err| Error::InvalidResponse(err.to_string())); } // Ignore protocol frames (ping/pong) while waiting for the first event. Message::Ping(_) | Message::Pong(_) => continue, Message::Close(_) => { - return Err(CoreError::Network( + return Err(Error::Network( "upstream closed before first event".to_string(), )); } @@ -139,7 +138,7 @@ pub(crate) async fn splice( mut observe: impl FnMut(&RealtimeEvent) + Send, mut client_in: In, mut client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -154,7 +153,7 @@ where client_out .send(outbound) .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; } } @@ -175,26 +174,26 @@ where // inflate its own spend log. Logging observes upstream events only. for outbound in config.transform_realtime_request(&event, model)?.events { let payload = serde_json::to_string(&outbound) - .map_err(|err| CoreError::InvalidResponse(err.to_string()))?; + .map_err(|err| Error::InvalidResponse(err.to_string()))?; upstream_tx .send(Message::Text(payload)) .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; } } // upstream -> client upstream_message = upstream_rx.next() => { let Some(message) = upstream_message else { break }; // upstream closed - match message.map_err(|err| CoreError::Network(err.to_string()))? { + match message.map_err(|err| Error::Network(err.to_string()))? { Message::Text(text) => { let event: RealtimeEvent = serde_json::from_str(&text) - .map_err(|err| CoreError::InvalidResponse(err.to_string()))?; + .map_err(|err| Error::InvalidResponse(err.to_string()))?; observe(&event); for outbound in config.transform_realtime_response(&event, model)?.events { client_out .send(outbound) .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; } } Message::Close(_) => break, @@ -225,7 +224,7 @@ pub async fn realtime( observe: impl FnMut(&RealtimeEvent) + Send, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -258,7 +257,7 @@ pub async fn realtime_warm( observe: impl FnMut(&RealtimeEvent) + Send, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime_pool.rs b/litellm-rust/crates/ai-gateway/src/io/realtime_pool.rs index 4a1a3cd1166..49e9c459a88 100644 --- a/litellm-rust/crates/ai-gateway/src/io/realtime_pool.rs +++ b/litellm-rust/crates/ai-gateway/src/io/realtime_pool.rs @@ -28,7 +28,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use futures_util::StreamExt; -use litellm_core::CoreResult; +use litellm_core::Error; use litellm_core::realtime::types::RealtimeEvent; use crate::io::realtime::{ @@ -438,7 +438,7 @@ impl RealtimePool { /// /// `key.api_key` is already resolved (non-blank). The first frame OpenAI sends /// unprompted is `session.created`; we buffer exactly that and read nothing more. -async fn warm_one(key: &UpstreamKey) -> CoreResult { +async fn warm_one(key: &UpstreamKey) -> Result { let upstream: UpstreamWs = dial_upstream(&key.model, &key.api_key, key.api_base.as_deref()).await?; let (tx, mut rx) = upstream.split(); diff --git a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs index 9b51019f4bc..0b01747b1a5 100644 --- a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs +++ b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs @@ -4,10 +4,10 @@ use std::time::Duration; use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{Sink, SinkExt, Stream, StreamExt}; +use litellm_core::Error; use litellm_core::providers::openai::responses::transformation::OPENAI_RESPONSES_WS_CONFIG; use litellm_core::responses::types::ResponsesWsEvent; use litellm_core::responses::websocket::ResponsesWebSocketProviderConfig; -use litellm_core::{CoreError, CoreResult}; use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; @@ -37,51 +37,49 @@ impl ResponsesWebSocketConnection { url: &str, headers: &HashMap, timeout: Option, - ) -> CoreResult { + ) -> Result { let mut request = url .into_client_request() - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; for (name, value) in headers { let header_name = name .parse::() - .map_err(|error| CoreError::InvalidRequest(error.to_string()))?; + .map_err(|error| Error::InvalidRequest(error.to_string()))?; let header_value = HeaderValue::from_str(value) - .map_err(|error| CoreError::InvalidRequest(error.to_string()))?; + .map_err(|error| Error::InvalidRequest(error.to_string()))?; request.headers_mut().insert(header_name, header_value); } let connect = connect_async(request); let result = match timeout { Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| { - CoreError::Network("Responses WebSocket connection timed out".to_string()) + Error::Network("Responses WebSocket connection timed out".to_string()) })?, None => connect.await, }; let (socket, _) = result.map_err(|error| match error { - tokio_tungstenite::tungstenite::Error::Http(response) => CoreError::Http { + tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { status: response.status().as_u16(), body: String::new(), }, - other => CoreError::Network(other.to_string()), + other => Error::Network(other.to_string()), })?; Ok(Self { socket: Arc::new(Mutex::new(Some(socket))), }) } - pub async fn send_text(&self, text: String) -> CoreResult<()> { + pub async fn send_text(&self, text: String) -> Result<(), Error> { let mut socket = self.socket.lock().await; let Some(socket) = socket.as_mut() else { - return Err(CoreError::Network( - "Responses WebSocket is closed".to_string(), - )); + return Err(Error::Network("Responses WebSocket is closed".to_string())); }; socket .send(Message::Text(text)) .await - .map_err(|error| CoreError::Network(error.to_string())) + .map_err(|error| Error::Network(error.to_string())) } - pub async fn recv_text(&self) -> CoreResult> { + pub async fn recv_text(&self) -> Result, Error> { let mut socket_guard = self.socket.lock().await; let Some(socket) = socket_guard.as_mut() else { return Ok(None); @@ -90,27 +88,27 @@ impl ResponsesWebSocketConnection { Some(Ok(Message::Text(text))) => Ok(Some(text)), Some(Ok(Message::Binary(bytes))) => String::from_utf8(bytes.to_vec()) .map(Some) - .map_err(|error| CoreError::InvalidResponse(error.to_string())), + .map_err(|error| Error::InvalidResponse(error.to_string())), Some(Ok(Message::Close(_))) | None => Ok(None), Some(Ok(_)) => Ok(None), - Some(Err(error)) => Err(CoreError::Network(error.to_string())), + Some(Err(error)) => Err(Error::Network(error.to_string())), } } - pub async fn close(&self) -> CoreResult<()> { + pub async fn close(&self) -> Result<(), Error> { let mut socket = self.socket.lock().await; if let Some(socket) = socket.as_mut() { socket .close(None) .await - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; } *socket = None; Ok(()) } } -pub(crate) fn resolve_api_key(api_key: Option<&str>) -> CoreResult { +pub(crate) fn resolve_api_key(api_key: Option<&str>) -> Result { api_key .map(str::trim) .filter(|value| !value.is_empty()) @@ -120,38 +118,38 @@ pub(crate) fn resolve_api_key(api_key: Option<&str>) -> CoreResult { .ok() .filter(|value| !value.trim().is_empty()) }) - .ok_or_else(|| CoreError::Auth(MISSING_KEY_MESSAGE.to_string())) + .ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string())) } async fn dial_upstream( model: &str, api_key: &str, api_base: Option<&str>, -) -> CoreResult { +) -> Result { let url = OPENAI_RESPONSES_WS_CONFIG.complete_websocket_url(api_base, model); let mut request = url .as_str() .into_client_request() - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; request.headers_mut().insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {api_key}")) - .map_err(|error| CoreError::Auth(error.to_string()))?, + .map_err(|error| Error::Auth(error.to_string()))?, ); let result = tokio::time::timeout( Duration::from_secs(DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS), connect_async(request), ) .await - .map_err(|_| CoreError::Network("Responses WebSocket connection timed out".to_string()))?; + .map_err(|_| Error::Network("Responses WebSocket connection timed out".to_string()))?; result .map(|(socket, _)| socket) .map_err(|error| match error { - tokio_tungstenite::tungstenite::Error::Http(response) => CoreError::Http { + tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { status: response.status().as_u16(), body: String::new(), }, - other => CoreError::Network(other.to_string()), + other => Error::Network(other.to_string()), }) } @@ -166,7 +164,7 @@ impl ResponsesWebSocketStreaming { observe: impl FnMut(&ResponsesWsEvent) + Send, client_in: In, client_out: Out, - ) -> CoreResult<()> + ) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -193,7 +191,7 @@ pub(crate) async fn splice( mut observe: impl FnMut(&ResponsesWsEvent) + Send, mut client_in: In, mut client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -210,18 +208,18 @@ where .events { let payload = serde_json::to_string(&outbound) - .map_err(|error| CoreError::InvalidResponse(error.to_string()))?; + .map_err(|error| Error::InvalidResponse(error.to_string()))?; upstream_tx.send(Message::Text(payload)) .await - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; } } message = upstream_rx.next() => { let Some(message) = message else { break }; - match message.map_err(|error| CoreError::Network(error.to_string()))? { + match message.map_err(|error| Error::Network(error.to_string()))? { Message::Text(text) => { let event = serde_json::from_str::(&text) - .map_err(|error| CoreError::InvalidResponse(error.to_string()))?; + .map_err(|error| Error::InvalidResponse(error.to_string()))?; observe(&event); for outbound in OPENAI_RESPONSES_WS_CONFIG .transform_ws_response(&event, model)? @@ -229,7 +227,7 @@ where { client_out.send(outbound) .await - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; } } Message::Close(_) => break, @@ -252,7 +250,7 @@ pub async fn async_responses_websocket( mut observe: impl FnMut(&ResponsesWsEvent) + Send, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -267,11 +265,11 @@ where .events { let payload = serde_json::to_string(&outbound) - .map_err(|error| CoreError::InvalidResponse(error.to_string()))?; + .map_err(|error| Error::InvalidResponse(error.to_string()))?; upstream_tx .send(Message::Text(payload)) .await - .map_err(|error| CoreError::Network(error.to_string()))?; + .map_err(|error| Error::Network(error.to_string()))?; } } ResponsesWebSocketStreaming::bidirectional_forward( @@ -296,7 +294,7 @@ pub async fn responses_ws( observe: impl FnMut(&ResponsesWsEvent) + Send, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, @@ -514,7 +512,7 @@ mod tests { ) .await .expect_err("status error"); - assert!(matches!(error, CoreError::Http { status: 401, .. })); + assert!(matches!(error, Error::Http { status: 401, .. })); server.await.expect("server task"); } @@ -543,7 +541,7 @@ mod tests { ) .await .expect_err("status error"); - assert!(matches!(error, CoreError::Http { status: 500, .. })); + assert!(matches!(error, Error::Http { status: 500, .. })); server.await.expect("server task"); } } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs index 9bc2818b6e7..e0ce165dc93 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs @@ -3,8 +3,7 @@ use std::time::{Duration, Instant}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use litellm_core::CoreResult; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::ocr::transformation::OcrProviderConfig; use reqwest::Url; use serde_json::{Map, Value}; @@ -56,7 +55,7 @@ fn is_azure_document_intelligence_model(model: &str) -> bool { pub(super) fn string_headers( extra_headers: Option>, -) -> CoreResult> { +) -> Result, Error> { extra_headers .unwrap_or_default() .into_iter() @@ -65,7 +64,7 @@ pub(super) fn string_headers( .as_str() .map(|value| (key.clone(), value.to_string())) .ok_or_else(|| { - CoreError::InvalidRequest(format!( + Error::InvalidRequest(format!( "OCR extra_headers.{key} must be a string, got {}", litellm_core::error::json_type_name(&value) )) @@ -80,7 +79,7 @@ pub(super) fn has_header(headers: &[(String, String)], name: &str) -> bool { .any(|(key, _)| key.eq_ignore_ascii_case(name)) } -fn document_url_field(document: &Value) -> CoreResult> { +fn document_url_field(document: &Value) -> Result, Error> { let Some(object) = document.as_object() else { return Ok(None); }; @@ -138,13 +137,13 @@ fn is_blocked_ip(ip: IpAddr) -> bool { } } -fn blocked_url_error(url: &Url) -> CoreError { - CoreError::InvalidRequest(format!( +fn blocked_url_error(url: &Url) -> Error { + Error::InvalidRequest(format!( "OCR document URL rejected by SSRF protection: {url}" )) } -async fn validate_safe_fetch_url(url: &Url) -> CoreResult<()> { +async fn validate_safe_fetch_url(url: &Url) -> Result<(), Error> { if !matches!(url.scheme(), "http" | "https") { return Err(blocked_url_error(url)); } @@ -162,7 +161,7 @@ async fn validate_safe_fetch_url(url: &Url) -> CoreResult<()> { .ok_or_else(|| blocked_url_error(url))?; let addresses = tokio::net::lookup_host((host, port)) .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; let mut saw_address = false; for address in addresses { saw_address = true; @@ -176,25 +175,25 @@ async fn validate_safe_fetch_url(url: &Url) -> CoreResult<()> { Ok(()) } -fn redirect_location(response: &reqwest::Response, url: &Url) -> CoreResult { +fn redirect_location(response: &reqwest::Response, url: &Url) -> Result { let location = response .headers() .get(reqwest::header::LOCATION) .and_then(|value| value.to_str().ok()) .ok_or_else(|| { - CoreError::InvalidResponse("OCR document redirect missing Location header".to_string()) + Error::InvalidResponse("OCR document redirect missing Location header".to_string()) })?; url.join(location) - .map_err(|err| CoreError::InvalidResponse(format!("invalid OCR document redirect: {err}"))) + .map_err(|err| Error::InvalidResponse(format!("invalid OCR document redirect: {err}"))) } -async fn safe_get_document_url(url: &str) -> CoreResult<(Url, reqwest::Response)> { +async fn safe_get_document_url(url: &str) -> Result<(Url, reqwest::Response), Error> { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; let mut current_url = Url::parse(url) - .map_err(|err| CoreError::InvalidRequest(format!("invalid OCR document URL: {err}")))?; + .map_err(|err| Error::InvalidRequest(format!("invalid OCR document URL: {err}")))?; for _ in 0..MAX_SAFE_FETCH_REDIRECTS { validate_safe_fetch_url(¤t_url).await?; @@ -202,28 +201,28 @@ async fn safe_get_document_url(url: &str) -> CoreResult<(Url, reqwest::Response) .get(current_url.clone()) .send() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; if !response.status().is_redirection() { return Ok((current_url, response)); } current_url = redirect_location(&response, ¤t_url)?; } - Err(CoreError::InvalidRequest( + Err(Error::InvalidRequest( "Too many redirects while fetching OCR document URL".to_string(), )) } -fn enforce_download_size(content_length: u64, max_bytes: u64, url: &Url) -> CoreResult<()> { +fn enforce_download_size(content_length: u64, max_bytes: u64, url: &Url) -> Result<(), Error> { if max_bytes == 0 { - return Err(CoreError::InvalidRequest(format!( + return Err(Error::InvalidRequest(format!( "OCR document URL download is disabled (MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0). url={url}" ))); } if content_length > max_bytes { let size_mb = content_length as f64 / (1024.0 * 1024.0); let max_size_mb = max_bytes as f64 / (1024.0 * 1024.0); - return Err(CoreError::InvalidRequest(format!( + return Err(Error::InvalidRequest(format!( "OCR document size ({size_mb:.2}MB) exceeds maximum allowed size ({max_size_mb:.2}MB). url={url}" ))); } @@ -233,7 +232,7 @@ fn enforce_download_size(content_length: u64, max_bytes: u64, url: &Url) -> Core async fn read_response_with_limit( mut response: reqwest::Response, url: &Url, -) -> CoreResult> { +) -> Result, Error> { let max_bytes = max_document_download_bytes(); if let Some(content_length) = response.content_length() { enforce_download_size(content_length, max_bytes, url)?; @@ -246,7 +245,7 @@ async fn read_response_with_limit( while let Some(chunk) = response .chunk() .await - .map_err(|err| CoreError::Network(err.to_string()))? + .map_err(|err| Error::Network(err.to_string()))? { bytes_downloaded += chunk.len() as u64; enforce_download_size(bytes_downloaded, max_bytes, url)?; @@ -255,7 +254,7 @@ async fn read_response_with_limit( Ok(bytes) } -pub(super) async fn convert_document_url_to_data_uri(document: Value) -> CoreResult { +pub(super) async fn convert_document_url_to_data_uri(document: Value) -> Result { let Some((field, url)) = document_url_field(&document)? else { return Ok(document); }; @@ -267,7 +266,7 @@ pub(super) async fn convert_document_url_to_data_uri(document: Value) -> CoreRes let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - return Err(CoreError::Http { + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&body), }); @@ -290,7 +289,7 @@ pub(super) async fn convert_document_url_to_data_uri(document: Value) -> CoreRes let mut transformed = document .as_object() .cloned() - .ok_or_else(|| CoreError::InvalidRequest("OCR document must be an object".to_string()))?; + .ok_or_else(|| Error::InvalidRequest("OCR document must be an object".to_string()))?; transformed.insert(field.to_string(), Value::String(data_uri)); Ok(Value::Object(transformed)) } @@ -316,11 +315,11 @@ fn retry_after_secs(response: &reqwest::Response) -> u64 { .unwrap_or(2) } -fn operation_status(response_json: &Value) -> CoreResult<&str> { +fn operation_status(response_json: &Value) -> Result<&str, Error> { let status = response_json .get("status") .and_then(Value::as_str) - .ok_or(CoreError::MissingField("status"))?; + .ok_or(Error::MissingField("status"))?; match status { "succeeded" => Ok("succeeded"), "running" | "notStarted" => Ok("running"), @@ -330,11 +329,11 @@ fn operation_status(response_json: &Value) -> CoreResult<&str> { .and_then(|error| error.get("message")) .and_then(Value::as_str) .unwrap_or("Unknown error"); - Err(CoreError::InvalidResponse(format!( + Err(Error::InvalidResponse(format!( "Azure Document Intelligence analysis failed: {message}" ))) } - other => Err(CoreError::InvalidResponse(format!( + other => Err(Error::InvalidResponse(format!( "Unknown operation status: {other}" ))), } @@ -345,9 +344,9 @@ pub(super) async fn poll_document_intelligence( original_url: &str, headers: &[(String, String)], timeout: Option, -) -> CoreResult { +) -> Result { if !same_origin(operation_url, original_url) { - return Err(CoreError::InvalidResponse( + return Err(Error::InvalidResponse( "Azure Document Intelligence: rejected cross-origin polling URL".to_string(), )); } @@ -358,7 +357,7 @@ pub(super) async fn poll_document_intelligence( )); loop { if start.elapsed() > timeout { - return Err(CoreError::Network(format!( + return Err(Error::Network(format!( "Azure Document Intelligence operation polling timed out after {} seconds", timeout.as_secs() ))); @@ -373,21 +372,21 @@ pub(super) async fn poll_document_intelligence( let response = request_builder .send() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; let retry_after = retry_after_secs(&response); let status = response.status(); let text = response .text() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; if !status.is_success() { - return Err(CoreError::Http { + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&text), }); } let response_json: Value = serde_json::from_str(&text).map_err(|err| { - CoreError::InvalidResponse(format!("invalid Azure DI poll response JSON: {err}")) + Error::InvalidResponse(format!("invalid Azure DI poll response JSON: {err}")) })?; if operation_status(&response_json)? == "succeeded" { return Ok(response_json); @@ -426,7 +425,7 @@ mod tests { assert!(matches!( error, - CoreError::InvalidRequest(message) + Error::InvalidRequest(message) if message.contains("SSRF protection") )); } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs index 1de34eb400e..815bc84363a 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs @@ -1,5 +1,4 @@ -use litellm_core::CoreResult; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::ocr::transformation::OcrResponseHandling; use serde_json::Value; @@ -7,7 +6,7 @@ use super::common_utils::{poll_document_intelligence, truncate_error_body}; use super::types::ProviderOcrRequest; use crate::client::http_client; -pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> CoreResult { +pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> Result { let mut request_builder = http_client().post(&request.url).json(&request.body); for (key, value) in &request.upstream_headers { request_builder = request_builder.header(key, value); @@ -19,7 +18,7 @@ pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> Co let response = request_builder .send() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; let status = response.status(); if request.config.response_handling() == OcrResponseHandling::AzureDocumentIntelligencePoll @@ -31,7 +30,7 @@ pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> Co .and_then(|value| value.to_str().ok()) .map(str::to_string) .ok_or_else(|| { - CoreError::InvalidResponse( + Error::InvalidResponse( "Azure Document Intelligence returned 202 but no Operation-Location header found" .to_string(), ) @@ -52,17 +51,17 @@ pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> Co let text = response .text() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; if !status.is_success() { - return Err(CoreError::Http { + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&text), }); } let response_json: Value = serde_json::from_str(&text) - .map_err(|err| CoreError::InvalidResponse(format!("invalid OCR response JSON: {err}")))?; + .map_err(|err| Error::InvalidResponse(format!("invalid OCR response JSON: {err}")))?; Ok(request .config diff --git a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs index 95df566dc53..8025b5f713a 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs @@ -1,11 +1,9 @@ -use std::future::Future; -use std::pin::Pin; - -use litellm_core::CoreResult; use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::ocr::transformation::OcrAuthStrategy; use serde_json::{Map, Value, json}; +use std::future::Future; +use std::pin::Pin; use super::common_utils::{ convert_document_url_to_data_uri, has_header, ocr_provider_config, string_headers, @@ -27,7 +25,7 @@ pub(crate) struct OcrLifecycleHooks { request_metadata: RequestMetadata, } -type OcrFuture<'a, T> = Pin> + Send + 'a>>; +type OcrFuture<'a, T> = Pin> + Send + 'a>>; type OcrLogFuture<'a> = Pin + Send + 'a>>; impl OcrLifecycleHooks { @@ -46,7 +44,7 @@ impl OcrLifecycleHooks { async fn run_pre_call_guardrails( &self, request: PreparedOcrRequest, - ) -> CoreResult { + ) -> Result { if self.guardrail_runner.is_empty() { return Ok(request); } @@ -74,9 +72,9 @@ impl OcrLifecycleHooks { async fn prepare_provider_request( &self, request: PreparedOcrRequest, - ) -> CoreResult { + ) -> Result { let config = ocr_provider_config(&request.custom_llm_provider, &request.model) - .ok_or_else(|| CoreError::InvalidProvider(request.custom_llm_provider.clone()))?; + .ok_or_else(|| Error::InvalidProvider(request.custom_llm_provider.clone()))?; let env_lookup = |key: &str| std::env::var(key).ok(); let headers = string_headers(request.extra_headers)?; let auth_strategy = config.auth_strategy(); @@ -89,7 +87,14 @@ impl OcrLifecycleHooks { &request.optional_params, &env_lookup, )?; - let filtered_params = config.map_ocr_params(&request.optional_params); + let supported_params = config.get_supported_ocr_params(); + let non_default_params = request + .optional_params + .iter() + .filter(|(param, _)| supported_params.contains(¶m.as_str())) + .map(|(param, value)| (param.clone(), value.clone())) + .collect(); + let filtered_params = config.map_ocr_params(&non_default_params); let model = request.model.clone(); let custom_llm_provider = request.custom_llm_provider.clone(); let document = if config.requires_data_uri_document() { @@ -120,7 +125,7 @@ impl OcrLifecycleHooks { custom_llm_provider: &str, url: &str, body: Value, - ) -> CoreResult { + ) -> Result { if self.guardrail_runner.is_empty() { return Ok(body); } @@ -217,7 +222,7 @@ impl CallLifecycleHooks for OcrLi fn async_log_failure_event<'a>( &'a self, context: &'a CallLifecycleContext, - error: &'a CoreError, + error: &'a Error, timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a> { Box::pin(async move { @@ -278,19 +283,19 @@ fn guardrail_context(metadata: &RequestMetadata) -> GuardrailContext { fn parse_ocr_pre_call_guardrail_request( request: GuardrailRequest, -) -> CoreResult<(Value, Map)> { +) -> Result<(Value, Map), Error> { let Value::Object(mut data) = request.data else { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "OCR pre_call guardrail must return an object".to_string(), )); }; let document = data.remove("document").ok_or_else(|| { - CoreError::InvalidRequest("OCR pre_call guardrail removed document".to_string()) + Error::InvalidRequest("OCR pre_call guardrail removed document".to_string()) })?; let optional_params = match data.remove("optional_params") { Some(Value::Object(params)) => params, Some(_) => { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "OCR pre_call guardrail optional_params must be an object".to_string(), )); } @@ -299,33 +304,31 @@ fn parse_ocr_pre_call_guardrail_request( Ok((document, optional_params)) } -fn parse_ocr_during_call_guardrail_request(request: GuardrailRequest) -> CoreResult { +fn parse_ocr_during_call_guardrail_request(request: GuardrailRequest) -> Result { let Value::Object(mut data) = request.data else { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "OCR during_call guardrail must return an object".to_string(), )); }; - data.remove("body").ok_or_else(|| { - CoreError::InvalidRequest("OCR during_call guardrail removed body".to_string()) - }) + data.remove("body") + .ok_or_else(|| Error::InvalidRequest("OCR during_call guardrail removed body".to_string())) } -fn guardrail_error_to_core_error(error: GuardrailError) -> CoreError { - CoreError::InvalidRequest(format!("{}: {}", error.kind, error.message)) +fn guardrail_error_to_core_error(error: GuardrailError) -> Error { + Error::InvalidRequest(format!("{}: {}", error.kind, error.message)) } -fn core_error_kind(error: &CoreError) -> &'static str { +fn core_error_kind(error: &Error) -> &'static str { match error { - CoreError::Auth(_) => "AuthError", - CoreError::InvalidProvider(_) => "InvalidProvider", - CoreError::InvalidRequest(_) => "InvalidRequest", - CoreError::InvalidType { .. } => "InvalidType", - CoreError::MissingField(_) => "MissingField", - CoreError::Http { .. } => "HttpError", - CoreError::InvalidResponse(_) => "InvalidResponse", - CoreError::Network(_) => "NetworkError", - CoreError::Connect(_) => "ConnectError", - CoreError::Routing(_) => "RoutingError", - CoreError::Unsupported(_) => "UnsupportedRequest", + Error::Auth(_) => "AuthError", + Error::InvalidProvider(_) => "InvalidProvider", + Error::InvalidRequest(_) => "InvalidRequest", + Error::InvalidType { .. } => "InvalidType", + Error::MissingField(_) => "MissingField", + Error::Http { .. } => "HttpError", + Error::InvalidResponse(_) => "InvalidResponse", + Error::Network(_) => "NetworkError", + Error::Routing(_) => "RoutingError", + Error::Unsupported(_) => "UnsupportedRequest", } } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs index c4c13e2300c..b59ab626fd3 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs @@ -1,4 +1,4 @@ -use litellm_core::CoreResult; +use litellm_core::Error; use litellm_core::call_lifecycle::CallLifecycle; use serde_json::Value; @@ -13,7 +13,7 @@ pub use types::OcrRequest; use handler::execute_ocr_provider_call; use prepare::{PreparedOcrCall, prepare_ocr_call}; -pub async fn ocr(request: OcrRequest<'_>) -> CoreResult { +pub async fn ocr(request: OcrRequest<'_>) -> Result { let PreparedOcrCall { request, hooks } = prepare_ocr_call(request); CallLifecycle::default() .run_request(request, &hooks, execute_ocr_provider_call) diff --git a/litellm-rust/crates/ai-gateway/src/ocr/tests.rs b/litellm-rust/crates/ai-gateway/src/ocr/tests.rs index bb2a6b06501..0dd58b5f9ae 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/tests.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/tests.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::ocr::transformation::OcrResponseHandling; use serde_json::{Map, Value, json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -18,6 +18,14 @@ use crate::integrations::custom_logger::{ }; use crate::integrations::types::RequestMetadata; +type LifecycleEvents = Arc>>; + +fn record_lifecycle_event(events: Option<&LifecycleEvents>, event: &'static str) { + if let Some(events) = events { + events.lock().unwrap().push(event); + } +} + async fn read_http_headers(socket: &mut TcpStream) -> String { let mut request = Vec::new(); let mut buffer = [0_u8; 1024]; @@ -80,9 +88,17 @@ struct RecordedLogEvent { #[derive(Default)] struct RecordingOcrLogger { events: Mutex>, + lifecycle_events: Option, } impl RecordingOcrLogger { + fn with_lifecycle_events(lifecycle_events: LifecycleEvents) -> Self { + Self { + events: Mutex::new(Vec::new()), + lifecycle_events: Some(lifecycle_events), + } + } + fn events(&self) -> Vec { self.events.lock().unwrap().clone() } @@ -96,6 +112,7 @@ impl CustomLogger for RecordingOcrLogger { _timing: CallbackTiming, ) -> LogFuture<'a> { Box::pin(async move { + record_lifecycle_event(self.lifecycle_events.as_ref(), "async_log_success_event"); self.events.lock().unwrap().push(RecordedLogEvent { hook: "async_log_success_event", model: model_call_details.model.clone(), @@ -115,6 +132,7 @@ impl CustomLogger for RecordingOcrLogger { _timing: CallbackTiming, ) -> LogFuture<'a> { Box::pin(async move { + record_lifecycle_event(self.lifecycle_events.as_ref(), "async_log_failure_event"); self.events.lock().unwrap().push(RecordedLogEvent { hook: "async_log_failure_event", model: model_call_details.model.clone(), @@ -135,22 +153,41 @@ struct RecordingOcrGuardrail { hooks: Vec, events: Mutex>, block_pre_call: bool, + block_during_call: bool, + lifecycle_events: Option, } impl RecordingOcrGuardrail { - fn new(hooks: Vec) -> Self { + fn with_lifecycle_events( + hooks: Vec, + lifecycle_events: LifecycleEvents, + ) -> Self { Self { hooks, events: Mutex::new(Vec::new()), block_pre_call: false, + block_during_call: false, + lifecycle_events: Some(lifecycle_events), } } - fn blocking_pre_call() -> Self { + fn blocking_pre_call(lifecycle_events: LifecycleEvents) -> Self { Self { hooks: vec![GuardrailEventHook::PreCall], events: Mutex::new(Vec::new()), block_pre_call: true, + block_during_call: false, + lifecycle_events: Some(lifecycle_events), + } + } + + fn blocking_during_call(lifecycle_events: LifecycleEvents) -> Self { + Self { + hooks: vec![GuardrailEventHook::PreCall, GuardrailEventHook::DuringCall], + events: Mutex::new(Vec::new()), + block_pre_call: false, + block_during_call: true, + lifecycle_events: Some(lifecycle_events), } } @@ -174,6 +211,7 @@ impl CustomGuardrail for RecordingOcrGuardrail { mut request: GuardrailRequest, ) -> GuardrailFuture<'a> { Box::pin(async move { + record_lifecycle_event(self.lifecycle_events.as_ref(), "async_pre_call_hook"); self.events.lock().unwrap().push("async_pre_call_hook"); if self.block_pre_call { return Ok(GuardrailDecision::Block(GuardrailError::blocked( @@ -191,7 +229,13 @@ impl CustomGuardrail for RecordingOcrGuardrail { mut request: GuardrailRequest, ) -> GuardrailFuture<'a> { Box::pin(async move { + record_lifecycle_event(self.lifecycle_events.as_ref(), "async_moderation_hook"); self.events.lock().unwrap().push("async_moderation_hook"); + if self.block_during_call { + return Ok(GuardrailDecision::Block(GuardrailError::blocked( + "blocked during provider preparation", + ))); + } request.data["body"]["guarded_during"] = json!(true); Ok(GuardrailDecision::Mask(request)) }) @@ -242,7 +286,7 @@ fn ocr_dispatch_supports_migrated_providers() { assert!( ocr_provider_config("vertex_ai", "deepseek-ocr-maas") .expect("vertex deepseek config resolves") - .supported_ocr_params() + .get_supported_ocr_params() .contains(&"temperature") ); assert!(ocr_provider_config("openai", "gpt-4o").is_none()); @@ -281,14 +325,17 @@ fn auth_header_detection_is_case_insensitive() { #[tokio::test] async fn ocr_lifecycle_runs_pre_during_and_success_hooks() { + let lifecycle_events: LifecycleEvents = Arc::new(Mutex::new(Vec::new())); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("test listener binds"); let addr = listener.local_addr().expect("listener has local addr"); + let provider_events = lifecycle_events.clone(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.expect("accepts one request"); let request = read_http_request(&mut socket).await; + record_lifecycle_event(Some(&provider_events), "provider_request_received"); let response_body = r#"{"pages":[{"index":0,"markdown":"ok"}],"model":"mistral-ocr-latest","usage_info":{"pages_processed":1}}"#; let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", @@ -302,11 +349,13 @@ async fn ocr_lifecycle_runs_pre_during_and_success_hooks() { request }); - let logger = Arc::new(RecordingOcrLogger::default()); - let guardrail = Arc::new(RecordingOcrGuardrail::new(vec![ - GuardrailEventHook::PreCall, - GuardrailEventHook::DuringCall, - ])); + let logger = Arc::new(RecordingOcrLogger::with_lifecycle_events( + lifecycle_events.clone(), + )); + let guardrail = Arc::new(RecordingOcrGuardrail::with_lifecycle_events( + vec![GuardrailEventHook::PreCall, GuardrailEventHook::DuringCall], + lifecycle_events.clone(), + )); let response = ocr(OcrRequest { model: "mistral-ocr-latest", document: json!({ @@ -346,22 +395,81 @@ async fn ocr_lifecycle_runs_pre_during_and_success_hooks() { error_kind: None, }] ); + assert_eq!( + lifecycle_events.lock().unwrap().as_slice(), + [ + "async_pre_call_hook", + "async_moderation_hook", + "provider_request_received", + "async_log_success_event", + ] + ); let request = server.await.expect("server task completes"); assert!(request.contains(r#""guarded_pre":true"#), "{request}"); assert!(request.contains(r#""guarded_during":true"#), "{request}"); } +#[tokio::test] +async fn ocr_lifecycle_during_call_block_skips_provider_and_runs_failure_callback() { + let lifecycle_events: LifecycleEvents = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let addr = listener.local_addr().expect("listener has local addr"); + let logger = Arc::new(RecordingOcrLogger::with_lifecycle_events( + lifecycle_events.clone(), + )); + let guardrail = Arc::new(RecordingOcrGuardrail::blocking_during_call( + lifecycle_events.clone(), + )); + + let error = ocr(OcrRequest { + model: "mistral-ocr-latest", + document: json!({ + "type": "document_url", + "document_url": "https://example.com/doc.pdf" + }), + api_key: Some("sk-test"), + api_base: Some(&format!("http://{addr}")), + custom_llm_provider: Some("mistral"), + extra_headers: None, + optional_params: Map::new(), + timeout: Some(Duration::from_millis(100)), + callbacks: vec![logger], + guardrails: vec![guardrail], + request_metadata: RequestMetadata::default(), + litellm_call_id: Some("ocr-call-during-block"), + }) + .await + .expect_err("during-call guardrail blocks request"); + + assert!(matches!(error, Error::InvalidRequest(_))); + assert_eq!( + lifecycle_events.lock().unwrap().as_slice(), + [ + "async_pre_call_hook", + "async_moderation_hook", + "async_log_failure_event", + ] + ); + let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await; + assert!(accepted.is_err(), "provider socket should not be touched"); +} + #[tokio::test] async fn ocr_lifecycle_runs_failure_hook_on_provider_error() { + let lifecycle_events: LifecycleEvents = Arc::new(Mutex::new(Vec::new())); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("test listener binds"); let addr = listener.local_addr().expect("listener has local addr"); + let provider_events = lifecycle_events.clone(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.expect("accepts one request"); let _request = read_http_request(&mut socket).await; + record_lifecycle_event(Some(&provider_events), "provider_request_received"); let response_body = "provider failed"; let response = format!( "HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", @@ -374,7 +482,9 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() { .expect("writes response"); }); - let logger = Arc::new(RecordingOcrLogger::default()); + let logger = Arc::new(RecordingOcrLogger::with_lifecycle_events( + lifecycle_events.clone(), + )); let err = ocr(OcrRequest { model: "mistral-ocr-latest", document: json!({ @@ -395,7 +505,7 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() { .await .expect_err("provider error propagates"); - assert!(matches!(err, CoreError::Http { status: 500, .. })); + assert!(matches!(err, Error::Http { status: 500, .. })); server.await.expect("server task completes"); assert_eq!( logger.events(), @@ -408,16 +518,25 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() { error_kind: Some("HttpError".to_string()), }] ); + assert_eq!( + lifecycle_events.lock().unwrap().as_slice(), + ["provider_request_received", "async_log_failure_event"] + ); } #[tokio::test] async fn ocr_lifecycle_pre_call_block_skips_provider_socket() { + let lifecycle_events: LifecycleEvents = Arc::new(Mutex::new(Vec::new())); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("test listener binds"); let addr = listener.local_addr().expect("listener has local addr"); - let logger = Arc::new(RecordingOcrLogger::default()); - let guardrail = Arc::new(RecordingOcrGuardrail::blocking_pre_call()); + let logger = Arc::new(RecordingOcrLogger::with_lifecycle_events( + lifecycle_events.clone(), + )); + let guardrail = Arc::new(RecordingOcrGuardrail::blocking_pre_call( + lifecycle_events.clone(), + )); let err = ocr(OcrRequest { model: "mistral-ocr-latest", @@ -439,7 +558,7 @@ async fn ocr_lifecycle_pre_call_block_skips_provider_socket() { .await .expect_err("guardrail blocks request"); - assert!(matches!(err, CoreError::InvalidRequest(_))); + assert!(matches!(err, Error::InvalidRequest(_))); assert_eq!(guardrail.events(), vec!["async_pre_call_hook"]); assert_eq!( logger.events(), @@ -452,6 +571,10 @@ async fn ocr_lifecycle_pre_call_block_skips_provider_socket() { error_kind: Some("InvalidRequest".to_string()), }] ); + assert_eq!( + lifecycle_events.lock().unwrap().as_slice(), + ["async_pre_call_hook", "async_log_failure_event"] + ); let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await; assert!(accepted.is_err(), "provider socket should not be touched"); } @@ -607,7 +730,7 @@ fn string_headers_rejects_non_string_values() { let err = string_headers(Some(headers)).expect_err("non-string header rejected"); assert_eq!( err, - CoreError::InvalidRequest( + Error::InvalidRequest( "OCR extra_headers.x-retry-count must be a string, got number".to_string() ) ); diff --git a/litellm-rust/crates/ai-gateway/src/python/config.rs b/litellm-rust/crates/ai-gateway/src/python/config.rs index c028d3d6b51..d5a4dd69c8d 100644 --- a/litellm-rust/crates/ai-gateway/src/python/config.rs +++ b/litellm-rust/crates/ai-gateway/src/python/config.rs @@ -6,33 +6,31 @@ //! (and recorded in [`crate::gil`]); the realtime hot path never touches Python. //! //! Compiled only under the `python-config` feature. - -use litellm_core::CoreResult; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::router::{Deployment, Router}; use pyo3::prelude::*; use crate::gil; /// Load the router's `model_list` from `config_path` via the Python reader. -pub fn load_router_from_config(config_path: &str) -> CoreResult { +pub fn load_router_from_config(config_path: &str) -> Result { gil::record_acquisition(); Python::attach(|py| { let model_list = py .import("litellm.proxy.read_model_list") .and_then(|module| module.getattr("read_model_list")) .and_then(|reader| reader.call1((config_path,))) - .map_err(|err| CoreError::Routing(format!("read_model_list failed: {err}")))?; + .map_err(|err| Error::Routing(format!("read_model_list failed: {err}")))?; let model_list_json: String = py .import("json") .and_then(|json| json.getattr("dumps")) .and_then(|dumps| dumps.call1((model_list,))) .and_then(|encoded| encoded.extract()) - .map_err(|err| CoreError::Routing(format!("serializing model_list failed: {err}")))?; + .map_err(|err| Error::Routing(format!("serializing model_list failed: {err}")))?; let deployments: Vec = serde_json::from_str(&model_list_json) - .map_err(|err| CoreError::Routing(format!("parsing model_list failed: {err}")))?; + .map_err(|err| Error::Routing(format!("parsing model_list failed: {err}")))?; Ok(Router::new(deployments)) }) diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs index 7e38d10c6ff..05c74a05924 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs @@ -4,21 +4,36 @@ mod service; use axum::Router; use axum::body::Body; -use axum::extract::{Json, State}; +use axum::extract::{Json, Request, State}; use axum::http::StatusCode; use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderMap, HeaderValue}; +use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::post; -use litellm_core::CoreError; +use litellm_core::Error; use serde_json::{Map, Value}; use crate::auth::RequireMasterKey; use crate::constants::{MESSAGES_HEADERS_NOT_FORWARDED, MESSAGES_ROUTE_PATH}; use crate::state::AppState; +const CORE_ENGINE_HEADER: &str = "x-litellm-core"; +const RUST_CORE_ENGINE: &str = "rust"; + /// This route's contribution to the app router. pub fn router() -> Router { - Router::new().route(MESSAGES_ROUTE_PATH, post(handle)) + Router::new() + .route(MESSAGES_ROUTE_PATH, post(handle)) + .route_layer(middleware::from_fn(core_engine_header)) +} + +async fn core_engine_header(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert( + CORE_ENGINE_HEADER, + HeaderValue::from_static(RUST_CORE_ENGINE), + ); + response } async fn handle( @@ -46,7 +61,7 @@ fn stream_response(upstream: reqwest::Response) -> Result Result Result>, CoreError> { +fn forwarded_headers(headers: &HeaderMap) -> Result>, Error> { let forwarded = headers .iter() .filter(|(name, _)| { @@ -74,19 +89,19 @@ fn forwarded_headers(headers: &HeaderMap) -> Result>, }) .map(|(name, value)| { let value = value.to_str().map_err(|_| { - CoreError::InvalidRequest(format!("invalid value for header {}", name.as_str())) + Error::InvalidRequest(format!("invalid value for header {}", name.as_str())) })?; Ok((name.to_string(), Value::String(value.to_string()))) }) - .collect::, CoreError>>()?; + .collect::, Error>>()?; Ok((!forwarded.is_empty()).then_some(forwarded)) } #[derive(Debug)] -struct MessagesRouteError(CoreError); +struct MessagesRouteError(Error); -impl From for MessagesRouteError { - fn from(error: CoreError) -> Self { +impl From for MessagesRouteError { + fn from(error: Error) -> Self { Self(error) } } @@ -94,28 +109,27 @@ impl From for MessagesRouteError { impl IntoResponse for MessagesRouteError { fn into_response(self) -> Response { let (status, message) = match self.0 { - CoreError::InvalidRequest(message) => (StatusCode::BAD_REQUEST, message), - CoreError::InvalidProvider(_) | CoreError::Routing(_) => ( + Error::InvalidRequest(message) => (StatusCode::BAD_REQUEST, message), + Error::InvalidProvider(_) | Error::Routing(_) => ( StatusCode::NOT_FOUND, "no messages deployment is configured for this model".to_string(), ), - CoreError::Auth(_) => ( + Error::Auth(_) => ( StatusCode::BAD_GATEWAY, "messages provider authentication failed".to_string(), ), - CoreError::Http { .. } - | CoreError::Network(_) - | CoreError::Connect(_) - | CoreError::InvalidResponse(_) - | CoreError::InvalidType { .. } - | CoreError::MissingField(_) => ( + Error::Http { .. } + | Error::Network(_) + | Error::InvalidResponse(_) + | Error::InvalidType { .. } + | Error::MissingField(_) => ( StatusCode::BAD_GATEWAY, "messages provider request failed".to_string(), ), // The gateway has no Python implementation to decline to, so a // request the core cannot serve is reported to the caller. The // reason is a fixed internal string, never provider content. - CoreError::Unsupported(reason) => ( + Error::Unsupported(reason) => ( StatusCode::BAD_REQUEST, format!("messages request is not supported: {reason}"), ), @@ -143,6 +157,7 @@ mod tests { use tower::ServiceExt; use super::super::app; + use super::{CORE_ENGINE_HEADER, RUST_CORE_ENGINE}; use crate::io::realtime_pool::RealtimePool; use crate::state::AppState; @@ -290,6 +305,7 @@ mod tests { .await .expect("route responds"); assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CORE_ENGINE_HEADER], RUST_CORE_ENGINE); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("response body reads"); diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs index 5f4c5fe8de4..4fd29db05d6 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs @@ -1,10 +1,10 @@ use std::sync::Arc; +use litellm_core::Error; use litellm_core::constants::ANTHROPIC_MESSAGES_PROVIDER; use litellm_core::messages::types::MessagesRequest; use litellm_core::messages::{messages, messages_stream}; use litellm_core::router::Router; -use litellm_core::{CoreError, CoreResult}; use serde_json::{Map, Value}; pub(crate) enum MessagesResponse { @@ -16,16 +16,16 @@ pub async fn run( router: &Arc, body: Value, extra_headers: Option>, -) -> CoreResult { +) -> Result { let model = body .get("model") .and_then(Value::as_str) .map(str::trim) .filter(|model| !model.is_empty()) - .ok_or_else(|| CoreError::InvalidRequest("messages body requires a model".to_string()))?; - let deployment = router.get_available_deployment(model).ok_or_else(|| { - CoreError::Routing(format!("no deployment available for model '{model}'")) - })?; + .ok_or_else(|| Error::InvalidRequest("messages body requires a model".to_string()))?; + let deployment = router + .get_available_deployment(model) + .ok_or_else(|| Error::Routing(format!("no deployment available for model '{model}'")))?; let provider_model = deployment.litellm_params.model.as_str(); let upstream_model = provider_model .split_once('/') @@ -37,7 +37,7 @@ pub async fn run( }; let mut body = body; body.as_object_mut() - .ok_or_else(|| CoreError::InvalidRequest("messages body must be an object".to_string()))? + .ok_or_else(|| Error::InvalidRequest("messages body must be an object".to_string()))? .insert( "model".to_string(), Value::String(upstream_model.to_string()), @@ -60,6 +60,6 @@ pub async fn run( serde_json::to_value(response) .map(MessagesResponse::Json) .map_err(|err| { - CoreError::InvalidResponse(format!("failed to serialize messages response: {err}")) + Error::InvalidResponse(format!("failed to serialize messages response: {err}")) }) } diff --git a/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs b/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs index 4ae8cfe7379..b8ee77c4269 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs @@ -11,8 +11,7 @@ use std::time::Duration; use crate::io::realtime_pool::{RealtimePool, upstream_key}; use futures_util::{Sink, Stream}; -use litellm_core::CoreResult; -use litellm_core::error::CoreError; +use litellm_core::error::Error; use litellm_core::realtime::types::RealtimeEvent; use litellm_core::router::Router; @@ -29,15 +28,15 @@ pub async fn run( observe: impl FnMut(&RealtimeEvent) + Send, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, >::Error: std::fmt::Display, { - let deployment = router.get_available_deployment(model).ok_or_else(|| { - CoreError::Routing(format!("no deployment available for model '{model}'")) - })?; + let deployment = router + .get_available_deployment(model) + .ok_or_else(|| Error::Routing(format!("no deployment available for model '{model}'")))?; let params = &deployment.litellm_params; // Strip a leading `openai/` so the OpenAI-only realtime fn gets the bare model. let provider_model = params diff --git a/litellm-rust/crates/ai-gateway/src/routes/responses/service.rs b/litellm-rust/crates/ai-gateway/src/routes/responses/service.rs index 165c95695d3..e8f840c0c8e 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/responses/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/responses/service.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use std::time::Duration; use futures_util::{Sink, Stream}; +use litellm_core::Error; use litellm_core::call_lifecycle::{CallLifecycle, CallLifecycleContext}; use litellm_core::responses::instrumentation::{ ResponsesWsCallbackPayload, ResponsesWsInstrumentation, ResponsesWsLogOutcome, ResponsesWsMetadata, }; use litellm_core::responses::types::ResponsesWsEvent; -use litellm_core::{CoreError, CoreResult}; use crate::integrations::custom_logger::{ CallbackTiming, CallbackValue, CustomLogger, CustomLoggerRunner, LoggingError, ModelCallDetails, @@ -26,22 +26,22 @@ pub async fn run( metadata: RequestMetadata, client_in: In, client_out: Out, -) -> CoreResult<()> +) -> Result<(), Error> where In: Stream + Unpin + Send, Out: Sink + Unpin + Send, Out::Error: std::fmt::Display, { - let deployment = router.get_available_deployment(model).ok_or_else(|| { - CoreError::Routing(format!("no deployment available for model '{model}'")) - })?; + let deployment = router + .get_available_deployment(model) + .ok_or_else(|| Error::Routing(format!("no deployment available for model '{model}'")))?; let params = &deployment.litellm_params; let provider_model = params .model .strip_prefix("openai/") .unwrap_or(¶ms.model); if params.model.contains('/') && !params.model.starts_with("openai/") { - return Err(CoreError::InvalidProvider( + return Err(Error::InvalidProvider( "Responses WebSocket route supports OpenAI deployments only".to_string(), )); } diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index ab8050734f2..18917e08ebc 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -6,11 +6,17 @@ license.workspace = true repository.workspace = true [dependencies] +tracing.workspace = true +base64.workspace = true +futures-util.workspace = true +bytes.workspace = true +mime.workspace = true rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +url.workspace = true sha2.workspace = true aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true } aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"], optional = true } diff --git a/litellm-rust/crates/core/src/audio_transcription/client.rs b/litellm-rust/crates/core/src/audio_transcription/client.rs new file mode 100644 index 00000000000..0e612628dc6 --- /dev/null +++ b/litellm-rust/crates/core/src/audio_transcription/client.rs @@ -0,0 +1,14 @@ +use std::sync::OnceLock; +use std::time::Duration; + +use crate::constants::AUDIO_TRANSCRIPTION_TIMEOUT_SECS; + +pub(super) fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(AUDIO_TRANSCRIPTION_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} diff --git a/litellm-rust/crates/core/src/audio_transcription/handler.rs b/litellm-rust/crates/core/src/audio_transcription/handler.rs new file mode 100644 index 00000000000..30ba0da5e68 --- /dev/null +++ b/litellm-rust/crates/core/src/audio_transcription/handler.rs @@ -0,0 +1,91 @@ +use serde_json::Value; + +use crate::error::Error; +use crate::http_utils::truncate_error_body; + +use super::client::http_client; +use super::types::ProviderAudioTranscriptionRequest; + +pub async fn execute_audio_transcription_provider_call( + request: ProviderAudioTranscriptionRequest, +) -> Result { + let body = serde_json::to_vec(&request.body) + .map_err(|error| Error::InvalidRequest(format!("invalid audio request body: {error}")))?; + let headers = signed_headers(&request, &body).await?; + let mut request_builder = http_client().post(&request.url).body(body); + for (key, value) in headers { + request_builder = request_builder.header(key, value); + } + if let Some(duration) = request.timeout { + request_builder = request_builder.timeout(duration); + } + let response = request_builder + .send() + .await + .map_err(|error| Error::Network(error.to_string()))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| Error::Network(error.to_string()))?; + if !status.is_success() { + return Err(Error::Http { + status: status.as_u16(), + body: truncate_error_body(&text), + }); + } + let response_json = serde_json::from_str(&text) + .map_err(|error| Error::InvalidResponse(format!("invalid audio response JSON: {error}")))?; + Ok(request + .config + .transform_transcription_response(&request.model, response_json)? + .into_json()) +} + +#[cfg(feature = "bedrock-auth")] +async fn signed_headers( + request: &ProviderAudioTranscriptionRequest, + body: &[u8], +) -> Result, Error> { + use std::collections::BTreeMap; + use std::time::SystemTime; + + use crate::audio_transcription::transformation::AudioTranscriptionAuth; + use crate::providers::bedrock::audio_transcription::aws_auth_config; + use crate::providers::bedrock::aws_base::{resolve_credentials, sign_bedrock_post}; + + let AudioTranscriptionAuth::AwsSigV4 { region, .. } = &request.auth else { + return Ok(request.upstream_headers.clone()); + }; + let env_lookup = |key: &str| std::env::var(key).ok(); + let credentials = resolve_credentials( + aws_auth_config(&request.optional_params, &env_lookup), + &env_lookup, + ) + .await?; + let unsigned: BTreeMap = request.upstream_headers.iter().cloned().collect(); + let signature = sign_bedrock_post( + &request.url, + body, + &unsigned, + region, + &credentials, + SystemTime::now(), + )?; + Ok(unsigned.into_iter().chain(signature).collect()) +} + +#[cfg(not(feature = "bedrock-auth"))] +async fn signed_headers( + request: &ProviderAudioTranscriptionRequest, + _body: &[u8], +) -> Result, Error> { + use crate::audio_transcription::transformation::AudioTranscriptionAuth; + + match request.auth { + AudioTranscriptionAuth::AwsSigV4 { .. } => Err(Error::Unsupported( + "AWS SigV4 requires the bedrock-auth feature", + )), + AudioTranscriptionAuth::Bearer => Ok(request.upstream_headers.clone()), + } +} diff --git a/litellm-rust/crates/core/src/audio_transcription/mod.rs b/litellm-rust/crates/core/src/audio_transcription/mod.rs index ec2fbb969a6..b71748082bf 100644 --- a/litellm-rust/crates/core/src/audio_transcription/mod.rs +++ b/litellm-rust/crates/core/src/audio_transcription/mod.rs @@ -1,2 +1,20 @@ +use crate::Error; +mod client; +mod handler; +mod prepare; pub mod transformation; pub mod types; + +use serde_json::Value; + +pub use handler::execute_audio_transcription_provider_call; +pub use prepare::prepare_audio_transcription_provider_call; +pub use types::{AudioTranscriptionRequest, ProviderAudioTranscriptionRequest}; + +pub async fn audio_transcription(request: AudioTranscriptionRequest<'_>) -> Result { + execute_audio_transcription_provider_call(prepare_audio_transcription_provider_call(request)?) + .await +} + +#[cfg(test)] +mod tests; diff --git a/litellm-rust/crates/core/src/audio_transcription/prepare.rs b/litellm-rust/crates/core/src/audio_transcription/prepare.rs new file mode 100644 index 00000000000..6288e96b380 --- /dev/null +++ b/litellm-rust/crates/core/src/audio_transcription/prepare.rs @@ -0,0 +1,72 @@ +use crate::error::Error; +use crate::http_utils::{has_header, string_headers}; +#[cfg(feature = "bedrock-auth")] +use crate::providers::bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG; +use crate::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider}; + +use super::transformation::{AudioTranscriptionAuth, AudioTranscriptionProviderConfig}; +use super::types::{AudioTranscriptionRequest, ProviderAudioTranscriptionRequest}; + +fn provider_config(provider: &str) -> Option<&'static dyn AudioTranscriptionProviderConfig> { + #[cfg(feature = "bedrock-auth")] + if provider == "bedrock" { + return Some(&BEDROCK_AUDIO_TRANSCRIPTION_CONFIG); + } + let _ = provider; + None +} + +pub fn prepare_audio_transcription_provider_call( + request: AudioTranscriptionRequest<'_>, +) -> Result { + let provider_info = get_custom_llm_provider(request.model, request.custom_llm_provider) + .or_else(|| { + request + .custom_llm_provider + .map(|provider| CustomLlmProvider { + model: request.model, + custom_llm_provider: provider, + }) + }) + .ok_or_else(|| { + Error::InvalidProvider( + "unable to resolve custom_llm_provider for audio transcription request".to_string(), + ) + })?; + let model = provider_info.model.to_string(); + let config = provider_config(provider_info.custom_llm_provider) + .ok_or_else(|| Error::InvalidProvider(provider_info.custom_llm_provider.to_string()))?; + let env_lookup = |key: &str| std::env::var(key).ok(); + let mut headers = string_headers("audio transcription", request.extra_headers)?; + let auth = config.auth_strategy(&model, &request.optional_params, &env_lookup)?; + if matches!(auth, AudioTranscriptionAuth::Bearer) + && !has_header(&headers, "authorization") + && let Some(api_key) = request.api_key + { + headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); + } + if !has_header(&headers, "content-type") { + headers.push(("Content-Type".to_string(), "application/json".to_string())); + } + let url = config.complete_url( + request.api_base, + &model, + &request.optional_params, + &env_lookup, + )?; + let filtered_params = config.map_transcription_params(&request.optional_params); + let transformed = + config.transform_transcription_request(&model, request.audio, filtered_params)?; + Ok(ProviderAudioTranscriptionRequest { + model, + custom_llm_provider: provider_info.custom_llm_provider.to_string(), + config, + url, + body: transformed.body, + upstream_headers: headers, + auth, + #[cfg(feature = "bedrock-auth")] + optional_params: request.optional_params, + timeout: request.timeout, + }) +} diff --git a/litellm-rust/crates/core/src/audio_transcription/tests.rs b/litellm-rust/crates/core/src/audio_transcription/tests.rs new file mode 100644 index 00000000000..263d63337b0 --- /dev/null +++ b/litellm-rust/crates/core/src/audio_transcription/tests.rs @@ -0,0 +1,50 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +use serde_json::{Map, json}; + +use super::audio_transcription; +use super::types::AudioTranscriptionRequest; + +#[tokio::test] +async fn bedrock_request_is_signed_and_contains_audio() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("connection"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 16_384]; + let count = stream.read(&mut buffer).expect("request"); + request.extend_from_slice(&buffer[..count]); + let request = String::from_utf8_lossy(&request); + assert!(request.contains("POST /model/mistral.voxtral-mini-3b-2507/converse")); + assert!(request.contains("authorization: AWS4-HMAC-SHA256")); + assert!(request.contains("x-amz-date:")); + assert!(request.contains("\"bytes\":\"AQI=\"")); + assert!(request.contains("Transcribe the audio. Respond with only the transcript.")); + let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 53\r\nConnection: close\r\n\r\n{\"output\":{\"message\":{\"content\":[{\"text\":\"hello\"}]}}}"; + stream.write_all(response).expect("response"); + }); + + let optional_params = Map::from_iter([ + ("aws_access_key_id".to_string(), json!("access-key")), + ("aws_secret_access_key".to_string(), json!("secret-key")), + ("aws_region_name".to_string(), json!("us-east-1")), + ]); + let api_base = format!("http://{address}"); + let response = audio_transcription(AudioTranscriptionRequest { + model: "mistral.voxtral-mini-3b-2507", + audio: json!({"data": "AQI=", "format": "wav", "filename": "audio.wav"}), + api_key: None, + api_base: Some(&api_base), + custom_llm_provider: Some("bedrock"), + extra_headers: None, + optional_params, + timeout: None, + }) + .await + .expect("transcription"); + assert_eq!(response, json!({"text": "hello"})); + server.join().expect("server"); +} diff --git a/litellm-rust/crates/core/src/audio_transcription/transformation.rs b/litellm-rust/crates/core/src/audio_transcription/transformation.rs index eab34c13843..16a28fbcac0 100644 --- a/litellm-rust/crates/core/src/audio_transcription/transformation.rs +++ b/litellm-rust/crates/core/src/audio_transcription/transformation.rs @@ -1,7 +1,6 @@ +use crate::Error; use serde_json::{Map, Value}; -use crate::CoreResult; - use super::types::{AudioTranscriptionRequestData, AudioTranscriptionResponseData}; #[derive(Clone, Debug, PartialEq, Eq)] @@ -32,13 +31,13 @@ pub trait AudioTranscriptionProviderConfig: Sync { model: &str, audio: Value, optional_params: Map, - ) -> CoreResult; + ) -> Result; fn transform_transcription_response( &self, model: &str, response_json: Value, - ) -> CoreResult; + ) -> Result; fn complete_url( &self, @@ -46,12 +45,12 @@ pub trait AudioTranscriptionProviderConfig: Sync { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn auth_strategy( &self, model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; } diff --git a/litellm-rust/crates/core/src/audio_transcription/types.rs b/litellm-rust/crates/core/src/audio_transcription/types.rs index 3a9e1ecd88c..559d7837027 100644 --- a/litellm-rust/crates/core/src/audio_transcription/types.rs +++ b/litellm-rust/crates/core/src/audio_transcription/types.rs @@ -1,5 +1,56 @@ +use std::time::Duration; + use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; + +use super::transformation::{AudioTranscriptionAuth, AudioTranscriptionProviderConfig}; + +pub struct AudioTranscriptionRequest<'a> { + pub model: &'a str, + pub audio: Value, + pub api_key: Option<&'a str>, + pub api_base: Option<&'a str>, + pub custom_llm_provider: Option<&'a str>, + pub extra_headers: Option>, + pub optional_params: Map, + pub timeout: Option, +} + +#[derive(Clone)] +pub struct ProviderAudioTranscriptionRequest { + pub(super) model: String, + pub(super) custom_llm_provider: String, + pub(super) config: &'static dyn AudioTranscriptionProviderConfig, + pub(super) url: String, + pub(super) body: Value, + pub(super) upstream_headers: Vec<(String, String)>, + pub(super) auth: AudioTranscriptionAuth, + #[cfg(feature = "bedrock-auth")] + pub(super) optional_params: Map, + pub(super) timeout: Option, +} + +impl ProviderAudioTranscriptionRequest { + pub fn model(&self) -> &str { + &self.model + } + + pub fn custom_llm_provider(&self) -> &str { + &self.custom_llm_provider + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn body(&self) -> &Value { + &self.body + } + + pub fn with_body(self, body: Value) -> Self { + Self { body, ..self } + } +} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AudioTranscriptionRequestData { diff --git a/litellm-rust/crates/core/src/call_lifecycle/mod.rs b/litellm-rust/crates/core/src/call_lifecycle/mod.rs index d9b68a1b726..637c156e192 100644 --- a/litellm-rust/crates/core/src/call_lifecycle/mod.rs +++ b/litellm-rust/crates/core/src/call_lifecycle/mod.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use crate::{CoreError, CoreResult}; +use crate::Error; pub mod types; @@ -11,14 +11,14 @@ pub use types::{ }; pub trait CallLifecycleHooks: Send + Sync { - type PreCallFuture<'a>: Future> + Send + 'a + type PreCallFuture<'a>: Future> + Send + 'a where Self: 'a, InitialReq: 'a, ProviderReq: 'a, Resp: 'a; - type DuringCallFuture<'a>: Future> + Send + 'a + type DuringCallFuture<'a>: Future> + Send + 'a where Self: 'a, InitialReq: 'a, @@ -56,7 +56,7 @@ pub trait CallLifecycleHooks: Send + Sync { fn async_log_failure_event<'a>( &'a self, context: &'a CallLifecycleContext, - error: &'a CoreError, + error: &'a Error, timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a>; } @@ -86,12 +86,12 @@ impl<'a> CallLifecycle<'a> { request: InitialReq, hooks: &Hooks, provider_call: ProviderCall, - ) -> CoreResult + ) -> Result where InitialReq: CallLifecycleRequest, Hooks: CallLifecycleHooks, ProviderCall: FnOnce(ProviderReq) -> ProviderFuture, - ProviderFuture: Future>, + ProviderFuture: Future>, { let context = request.lifecycle_context(); self.run(context, request, hooks, provider_call).await @@ -103,11 +103,11 @@ impl<'a> CallLifecycle<'a> { request: InitialReq, hooks: &Hooks, provider_call: ProviderCall, - ) -> CoreResult + ) -> Result where Hooks: CallLifecycleHooks, ProviderCall: FnOnce(ProviderReq) -> ProviderFuture, - ProviderFuture: Future>, + ProviderFuture: Future>, { let call_start = epoch_seconds(); let mut phases = Vec::new(); @@ -166,7 +166,7 @@ impl<'a> CallLifecycle<'a> { &self, context: &CallLifecycleContext, hooks: &Hooks, - error: &CoreError, + error: &Error, call_start: f64, phases: &mut Vec, ) where @@ -251,8 +251,8 @@ mod tests { } impl CallLifecycleHooks for RecordingHooks { - type PreCallFuture<'a> = BoxFuture<'a, CoreResult>; - type DuringCallFuture<'a> = BoxFuture<'a, CoreResult>; + type PreCallFuture<'a> = BoxFuture<'a, Result>; + type DuringCallFuture<'a> = BoxFuture<'a, Result>; type SuccessFuture<'a> = BoxFuture<'a, ()>; type FailureFuture<'a> = BoxFuture<'a, ()>; @@ -294,7 +294,7 @@ mod tests { fn async_log_failure_event<'a>( &'a self, _context: &'a CallLifecycleContext, - _error: &'a CoreError, + _error: &'a Error, _timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a> { Box::pin(async move { @@ -304,8 +304,8 @@ mod tests { } impl CallLifecycleHooks for RecordingHooks { - type PreCallFuture<'a> = BoxFuture<'a, CoreResult>; - type DuringCallFuture<'a> = BoxFuture<'a, CoreResult>; + type PreCallFuture<'a> = BoxFuture<'a, Result>; + type DuringCallFuture<'a> = BoxFuture<'a, Result>; type SuccessFuture<'a> = BoxFuture<'a, ()>; type FailureFuture<'a> = BoxFuture<'a, ()>; @@ -345,7 +345,7 @@ mod tests { fn async_log_failure_event<'a>( &'a self, _context: &'a CallLifecycleContext, - _error: &'a CoreError, + _error: &'a Error, _timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a> { Box::pin(async move { @@ -383,13 +383,13 @@ mod tests { "request".to_string(), &hooks, |_request| async move { - Err::(CoreError::Network("provider down".to_string())) + Err::(Error::Network("provider down".to_string())) }, ) .await .expect_err("call fails"); - assert_eq!(error, CoreError::Network("provider down".to_string())); + assert_eq!(error, Error::Network("provider down".to_string())); assert_eq!(hooks.events(), vec!["pre_call", "during_call", "failure"]); } diff --git a/litellm-rust/crates/core/src/chat_completions/common_utils.rs b/litellm-rust/crates/core/src/chat_completions/common_utils.rs index 36eaf242a5a..ca51471eb7c 100644 --- a/litellm-rust/crates/core/src/chat_completions/common_utils.rs +++ b/litellm-rust/crates/core/src/chat_completions/common_utils.rs @@ -1,8 +1,7 @@ -use serde_json::{Map, Value}; - -use crate::error::CoreResult; +use crate::Error; use crate::http_utils::string_headers as shared_string_headers; use crate::providers::anthropic::chat_completions::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG; +use serde_json::{Map, Value}; use super::transformation::ChatCompletionsProviderConfig; @@ -23,6 +22,6 @@ pub(super) fn chat_completions_provider_config( pub(super) fn string_headers( extra_headers: Option>, -) -> CoreResult> { +) -> Result, Error> { shared_string_headers(HEADER_CONTEXT, extra_headers) } diff --git a/litellm-rust/crates/core/src/chat_completions/handler.rs b/litellm-rust/crates/core/src/chat_completions/handler.rs index afc4529fd26..f282e82c4fa 100644 --- a/litellm-rust/crates/core/src/chat_completions/handler.rs +++ b/litellm-rust/crates/core/src/chat_completions/handler.rs @@ -1,7 +1,7 @@ use serde_json::Value; -use crate::error::{CoreError, CoreResult}; -use crate::http_utils::truncate_error_body; +use crate::error::{Error, as_response_error}; +use crate::http_utils::{classify_send_error, truncate_error_body}; use super::client::http_client; use super::transformation::ChatCompletionsAuth; @@ -11,9 +11,9 @@ use super::types::{ pub(super) async fn execute_chat_completions_provider_call( request: ProviderChatCompletionsRequest, -) -> CoreResult { +) -> Result { let body = serde_json::to_vec(&request.body).map_err(|err| { - CoreError::InvalidRequest(format!( + Error::InvalidRequest(format!( "failed to serialize chat completions request: {err}" )) })?; @@ -27,32 +27,23 @@ pub(super) async fn execute_chat_completions_provider_call( request_builder = request_builder.timeout(duration); } - let response = request_builder.send().await.map_err(|err| { - // Failing to establish the connection means the request never went out, - // so the host can still serve it. Everything else here, a timeout - // above all, may have reached the provider and been answered. - if err.is_connect() || err.is_builder() { - CoreError::Connect(err.to_string()) - } else { - CoreError::Network(err.to_string()) - } - })?; + let response = request_builder.send().await.map_err(classify_send_error)?; let status = response.status(); let text = response .text() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; if !status.is_success() { - return Err(CoreError::Http { + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&text), }); } let body: Value = serde_json::from_str(&text).map_err(|err| { - CoreError::InvalidResponse(format!("invalid chat completions response JSON: {err}")) + Error::InvalidResponse(format!("invalid chat completions response JSON: {err}")) })?; request .config @@ -60,27 +51,11 @@ pub(super) async fn execute_chat_completions_provider_call( .map_err(as_response_error) } -/// Re-tag an error raised while normalizing a response the provider already -/// returned. -/// -/// A config reports the same variants on either side of the call: a missing -/// field or an unsupported block can mean "this request cannot be translated" -/// during prepare and "this response cannot be normalized" here. Only the -/// second kind has already been billed, and a host that keeps a reference -/// implementation must not retry those, so collapse them to one variant that -/// can only mean the provider was already called. -pub(super) fn as_response_error(err: CoreError) -> CoreError { - match err { - already @ (CoreError::InvalidResponse(_) | CoreError::Http { .. }) => already, - other => CoreError::InvalidResponse(other.to_string()), - } -} - #[cfg(feature = "bedrock-auth")] pub(super) async fn signed_headers( request: &ProviderChatCompletionsRequest, body: &[u8], -) -> CoreResult> { +) -> Result, Error> { use std::collections::BTreeMap; use std::time::SystemTime; @@ -101,7 +76,7 @@ pub(super) async fn signed_headers( .iter() .any(|(name, _)| is_sigv4_computed_header(name)) { - return Err(CoreError::Unsupported( + return Err(Error::Unsupported( "request forwards a header AWS SigV4 computes", )); } @@ -137,9 +112,9 @@ pub(super) async fn signed_headers( pub(super) async fn signed_headers( request: &ProviderChatCompletionsRequest, _body: &[u8], -) -> CoreResult> { +) -> Result, Error> { match &request.auth { - ChatCompletionsAuth::AwsSigV4 { .. } => Err(CoreError::Unsupported( + ChatCompletionsAuth::AwsSigV4 { .. } => Err(Error::Unsupported( "AWS SigV4 requires the bedrock-auth feature", )), _ => Ok(request.upstream_headers.clone()), diff --git a/litellm-rust/crates/core/src/chat_completions/mod.rs b/litellm-rust/crates/core/src/chat_completions/mod.rs index f30ac1a24bf..283fccaebe6 100644 --- a/litellm-rust/crates/core/src/chat_completions/mod.rs +++ b/litellm-rust/crates/core/src/chat_completions/mod.rs @@ -6,6 +6,7 @@ //! credentials, and it resolves the provider, translates the conversation, //! calls the provider, and returns a typed OpenAI-shaped response. +use crate::Error; mod client; mod common_utils; pub mod conversation; @@ -15,17 +16,18 @@ pub mod response_utils; pub mod transformation; pub mod types; +use crate::streaming::OpenedStream; use serde_json::{Map, Value}; -use crate::error::CoreResult; - use handler::execute_chat_completions_provider_call; use prepare::{parse_messages, prepare_chat_completions_call, resolve_provider_config}; -use types::{ChatCompletionsRequest, ChatCompletionsResponse}; +use types::{ + ChatCompletionsRequest, ChatCompletionsResponse, ChatCompletionsStreamRequest, ChatStreamEvent, +}; pub async fn chat_completions( request: ChatCompletionsRequest<'_>, -) -> CoreResult { +) -> Result { execute_chat_completions_provider_call(prepare_chat_completions_call(request)?).await } @@ -55,5 +57,51 @@ pub fn chat_completions_decline_reason( .map(|reason| reason.0) } +pub async fn chat_completions_stream( + _request: ChatCompletionsStreamRequest, +) -> Result, Error> { + Err(crate::Error::Unsupported( + "chat completions streaming provider registration", + )) +} + #[cfg(test)] mod tests; + +#[cfg(test)] +mod stream_entrypoint_tests { + use serde_json::json; + + use super::*; + use crate::Error; + use crate::streaming::{ + ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions, + }; + + #[tokio::test] + async fn typed_stream_declines_until_a_provider_is_registered() { + let body = serde_json::from_value(json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "hello"}], + "stream": true + })) + .expect("valid chat stream request"); + let result = chat_completions_stream(ChatCompletionsStreamRequest { + body, + target: StreamTarget::new( + StreamProviderId::Anthropic, + ProviderCredentials::default(), + None, + ), + transport: StreamTransportOptions::default(), + }) + .await; + + assert!(matches!( + result, + Err(Error::Unsupported( + "chat completions streaming provider registration" + )) + )); + } +} diff --git a/litellm-rust/crates/core/src/chat_completions/prepare.rs b/litellm-rust/crates/core/src/chat_completions/prepare.rs index 1e1c8d1bafd..142b2f2aaed 100644 --- a/litellm-rust/crates/core/src/chat_completions/prepare.rs +++ b/litellm-rust/crates/core/src/chat_completions/prepare.rs @@ -1,6 +1,6 @@ use serde_json::Value; -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use crate::http_utils::has_header; use crate::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider}; @@ -11,7 +11,7 @@ use super::types::{ChatCompletionsRequest, ChatMessage, ProviderChatCompletionsR pub(super) fn resolve_provider_config<'a>( model: &'a str, custom_llm_provider: Option<&'a str>, -) -> CoreResult<(String, &'static dyn ChatCompletionsProviderConfig)> { +) -> Result<(String, &'static dyn ChatCompletionsProviderConfig), Error> { let provider_info = get_custom_llm_provider(model, custom_llm_provider) .or_else(|| { custom_llm_provider.map(|provider| CustomLlmProvider { @@ -20,35 +20,34 @@ pub(super) fn resolve_provider_config<'a>( }) }) .ok_or_else(|| { - CoreError::InvalidProvider( + Error::InvalidProvider( "unable to resolve custom_llm_provider for chat completions request".to_string(), ) })?; let config = chat_completions_provider_config(provider_info.custom_llm_provider) - .ok_or_else(|| CoreError::InvalidProvider(provider_info.custom_llm_provider.to_string()))?; + .ok_or_else(|| Error::InvalidProvider(provider_info.custom_llm_provider.to_string()))?; Ok((provider_info.model.to_string(), config)) } -pub(super) fn parse_messages(messages: Value) -> CoreResult> { - serde_json::from_value(messages).map_err(|err| { - CoreError::InvalidRequest(format!("invalid chat completions messages: {err}")) - }) +pub(super) fn parse_messages(messages: Value) -> Result, Error> { + serde_json::from_value(messages) + .map_err(|err| Error::InvalidRequest(format!("invalid chat completions messages: {err}"))) } pub(super) fn prepare_chat_completions_call( request: ChatCompletionsRequest<'_>, -) -> CoreResult { +) -> Result { let (model, config) = resolve_provider_config(request.model, request.custom_llm_provider)?; let env_lookup = |key: &str| std::env::var(key).ok(); let messages = parse_messages(request.messages)?; if messages.is_empty() { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "chat completions requires at least one message".to_string(), )); } if let Some(reason) = config.unsupported_reason(&messages, &request.optional_params) { - return Err(CoreError::Unsupported(reason.0)); + return Err(Error::Unsupported(reason.0)); } let mut headers = string_headers(request.extra_headers)?; diff --git a/litellm-rust/crates/core/src/chat_completions/tests.rs b/litellm-rust/crates/core/src/chat_completions/tests.rs index e2383723cb0..bdd413d7cff 100644 --- a/litellm-rust/crates/core/src/chat_completions/tests.rs +++ b/litellm-rust/crates/core/src/chat_completions/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Map, Value, json}; -use crate::error::CoreError; +use crate::error::Error; use super::prepare::prepare_chat_completions_call; use super::transformation::ChatCompletionsAuth; @@ -29,7 +29,7 @@ fn request<'a>( /// `ProviderChatCompletionsRequest` deliberately has no `Debug` (its headers /// carry resolved credentials), so unwrap the failure case by hand. -fn decline(request: ChatCompletionsRequest<'_>) -> CoreError { +fn decline(request: ChatCompletionsRequest<'_>) -> Error { match prepare_chat_completions_call(request) { Err(error) => error, Ok(prepared) => panic!("expected a decline, prepared a call to {}", prepared.url), @@ -196,7 +196,7 @@ fn declines_an_unsupported_request_before_resolving_credentials() { call.api_key = None; // No api_key is set and no env is consulted: the gate must run first, so the // error is the decline rather than a missing-credential error. - assert_eq!(decline(call), CoreError::Unsupported("streaming")); + assert_eq!(decline(call), Error::Unsupported("streaming")); } #[test] @@ -208,7 +208,7 @@ fn rejects_an_unknown_provider() { json!([{"role": "user", "content": "hi"}]), json!({}), )), - CoreError::InvalidProvider("openai".to_string()) + Error::InvalidProvider("openai".to_string()) ); } @@ -221,7 +221,7 @@ fn rejects_a_model_with_no_resolvable_provider() { json!([{"role": "user", "content": "hi"}]), json!({}), )), - CoreError::InvalidProvider(_) + Error::InvalidProvider(_) )); } @@ -234,7 +234,7 @@ fn rejects_an_empty_or_malformed_message_list() { json!([]), json!({}), )), - CoreError::InvalidRequest("chat completions requires at least one message".to_string()) + Error::InvalidRequest("chat completions requires at least one message".to_string()) ); assert!(matches!( decline(request( @@ -243,7 +243,7 @@ fn rejects_an_empty_or_malformed_message_list() { json!("not a list"), json!({}), )), - CoreError::InvalidRequest(_) + Error::InvalidRequest(_) )); } @@ -258,7 +258,7 @@ fn rejects_non_string_extra_headers() { call.extra_headers = Some(Map::from_iter([("x-trace".to_string(), json!(7))])); assert_eq!( decline(call), - CoreError::InvalidRequest( + Error::InvalidRequest( "chat completions extra_headers.x-trace must be a string, got number".to_string() ) ); @@ -374,7 +374,7 @@ async fn a_forwarded_header_the_signer_computes_declines_to_python() { .await .expect_err("{forwarded} should decline instead of being signed"); assert!( - matches!(error, CoreError::Unsupported(_)), + matches!(error, Error::Unsupported(_)), "{forwarded} declined as {error:?}, which the host would not fall back on" ); } @@ -727,7 +727,7 @@ mod round_trip { .expect_err("response cannot be normalized"); handle.await.expect("server task"); assert!( - matches!(err, CoreError::InvalidResponse(_)), + matches!(err, Error::InvalidResponse(_)), "expected a post-send error, got {err:?}" ); } @@ -745,7 +745,7 @@ mod round_trip { .expect_err("response cannot be normalized"); handle.await.expect("server task"); assert!( - matches!(err, CoreError::InvalidResponse(_)), + matches!(err, Error::InvalidResponse(_)), "expected a post-send error, got {err:?}" ); } @@ -763,17 +763,13 @@ mod round_trip { .expect_err("upstream rejects"); handle.await.expect("server task"); assert!( - matches!(err, CoreError::Http { status: 429, .. }), + matches!(err, Error::Http { status: 429, .. }), "expected a 429, got {err:?}" ); } #[tokio::test] - async fn a_connection_that_is_never_established_declines_instead_of_failing() { - // Nothing was sent, so nothing was billed and the host can still serve - // the request. Classing this with the post-send failures would turn a - // recoverable fallback into a user-facing error on exactly the - // deployments whose transport is configured only on the Python client. + async fn a_connection_that_is_never_established_is_still_terminal() { let port = { let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); listener.local_addr().expect("has an address").port() @@ -787,34 +783,8 @@ mod round_trip { .await .expect_err("nothing is listening"); assert!( - matches!(err, CoreError::Connect(_)), - "expected a pre-send connect failure, got {err:?}" + matches!(err, Error::Network(_)), + "expected a terminal network failure, got {err:?}" ); } - - #[test] - fn response_errors_collapse_to_one_variant_that_can_only_mean_already_sent() { - use crate::chat_completions::handler::as_response_error; - - for original in [ - CoreError::MissingField("usage"), - CoreError::Unsupported("non-text response content block"), - CoreError::InvalidRequest("whatever".to_string()), - CoreError::Auth("whatever".to_string()), - ] { - let label = format!("{original:?}"); - assert!( - matches!(as_response_error(original), CoreError::InvalidResponse(_)), - "{label} must not stay retryable once the provider has answered" - ); - } - // An upstream status is already unambiguous, so it survives intact. - assert!(matches!( - as_response_error(CoreError::Http { - status: 500, - body: "boom".to_string() - }), - CoreError::Http { status: 500, .. } - )); - } } diff --git a/litellm-rust/crates/core/src/chat_completions/transformation.rs b/litellm-rust/crates/core/src/chat_completions/transformation.rs index a30ce9dc77c..aec28c68886 100644 --- a/litellm-rust/crates/core/src/chat_completions/transformation.rs +++ b/litellm-rust/crates/core/src/chat_completions/transformation.rs @@ -1,11 +1,22 @@ +use crate::Error; use serde_json::{Map, Value}; -use crate::error::CoreResult; - use super::types::{ ChatCompletionsResponse, ChatMessage, ChatMessageContent, ProviderChatRequestData, ProviderChatResponseData, }; +use super::types::{ChatCompletionsStreamRequest, ChatStreamEvent}; +use crate::streaming::StreamProvider; + +pub trait ChatCompletionsStreamProvider: + StreamProvider +{ +} + +impl ChatCompletionsStreamProvider for T where + T: StreamProvider +{ +} /// How the upstream call is authenticated. API-key strategies are resolved in /// `prepare`; SigV4 needs the serialized body, so the handler signs it. @@ -39,7 +50,7 @@ pub trait ChatCompletionsProviderConfig: Sync { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn auth( &self, @@ -47,7 +58,7 @@ pub trait ChatCompletionsProviderConfig: Sync { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn default_headers(&self) -> &'static [(&'static str, &'static str)] { &[("content-type", "application/json")] @@ -91,13 +102,13 @@ pub trait ChatCompletionsProviderConfig: Sync { model: &str, messages: Vec, optional_params: Map, - ) -> CoreResult; + ) -> Result; fn transform_response( &self, model: &str, response: ProviderChatResponseData, - ) -> CoreResult; + ) -> Result; } pub fn unsupported_param( diff --git a/litellm-rust/crates/core/src/chat_completions/types.rs b/litellm-rust/crates/core/src/chat_completions/types.rs index 35dd543a986..ff0273604fb 100644 --- a/litellm-rust/crates/core/src/chat_completions/types.rs +++ b/litellm-rust/crates/core/src/chat_completions/types.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::transformation::{ChatCompletionsAuth, ChatCompletionsProviderConfig}; +use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions}; /// A `/chat/completions` call as it crosses into the core. /// @@ -110,3 +111,183 @@ pub struct ChatCompletionsResponse { pub choices: Vec, pub usage: ChatCompletionsUsage, } + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChatStreamRole { + Assistant, + Developer, + Function, + System, + Tool, + User, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ChatStreamMessageContent { + Text(String), + Parts(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatStreamMessage { + pub role: ChatStreamRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ChatStreamStop { + One(String), + Many(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ChatStreamStringOrObject { + Name(String), + Definition(JsonObject), +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionsStreamParameters { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionsStreamRequestBody { + pub model: String, + pub messages: Vec, + #[serde(flatten)] + pub parameters: ChatCompletionsStreamParameters, +} + +pub struct ChatCompletionsStreamRequest { + pub body: ChatCompletionsStreamRequestBody, + pub target: StreamTarget, + pub transport: StreamTransportOptions, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatStreamToolFunctionChunk { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_specific_fields: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatStreamToolCallChunk { + pub id: Option, + #[serde(rename = "type")] + pub tool_type: String, + pub function: ChatStreamToolFunctionChunk, + pub index: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatStreamUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_tokens_details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_tokens_details: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatStreamEvent { + pub text: String, + pub tool_use: Option, + pub is_finished: bool, + pub finish_reason: String, + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_specific_fields: Option, +} + +#[cfg(test)] +mod stream_contract_tests { + use super::*; + + #[test] + fn request_uses_public_chat_completion_parameter_names() { + let request: ChatCompletionsStreamRequestBody = serde_json::from_value(serde_json::json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 32, + "stream": true, + "tool_choice": "auto" + })) + .expect("public request shape"); + + assert_eq!(request.parameters.max_tokens, Some(32)); + assert_eq!(request.parameters.stream, Some(true)); + assert!(matches!( + request.parameters.tool_choice, + Some(ChatStreamStringOrObject::Name(ref value)) if value == "auto" + )); + } + + #[test] + fn event_matches_python_generic_streaming_chunk_shape() { + let event = ChatStreamEvent { + text: "hello".to_string(), + tool_use: None, + is_finished: false, + finish_reason: String::new(), + usage: None, + index: Some(0), + provider_specific_fields: None, + }; + + assert_eq!( + serde_json::to_value(event).expect("serializable event"), + serde_json::json!({ + "text": "hello", + "tool_use": null, + "is_finished": false, + "finish_reason": "", + "usage": null, + "index": 0 + }) + ); + } +} diff --git a/litellm-rust/crates/core/src/constants.rs b/litellm-rust/crates/core/src/constants.rs index e1ac0a4fc8f..a73961060eb 100644 --- a/litellm-rust/crates/core/src/constants.rs +++ b/litellm-rust/crates/core/src/constants.rs @@ -30,6 +30,8 @@ pub(crate) const CHAT_COMPLETIONS_TIMEOUT_SECS: u64 = 600; /// Connect timeout for chat completions provider calls, in seconds. pub(crate) const CHAT_COMPLETIONS_CONNECT_TIMEOUT_SECS: u64 = 10; +pub(crate) const AUDIO_TRANSCRIPTION_TIMEOUT_SECS: u64 = 600; + /// `object` field every non-streaming chat completion response carries. pub const CHAT_COMPLETION_OBJECT: &str = "chat.completion"; diff --git a/litellm-rust/crates/core/src/error.rs b/litellm-rust/crates/core/src/error.rs index 739532f8cb5..a5de4b722d2 100644 --- a/litellm-rust/crates/core/src/error.rs +++ b/litellm-rust/crates/core/src/error.rs @@ -1,9 +1,7 @@ -use thiserror::Error; +use thiserror::Error as ThisError; -pub type CoreResult = Result; - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum CoreError { +#[derive(Debug, ThisError, PartialEq, Eq)] +pub enum Error { #[error("expected {expected}, got {actual}")] InvalidType { expected: &'static str, @@ -23,13 +21,6 @@ pub enum CoreError { Http { status: u16, body: String }, #[error("upstream network error: {0}")] Network(String), - /// The provider was never reached: DNS, TCP, TLS or proxy setup failed - /// before any byte of the request went out. Nothing was billed, so a host - /// that keeps a reference implementation can serve the request itself. - /// A timeout is deliberately not this, since the provider may have received - /// and answered the request already. - #[error("could not reach the provider: {0}")] - Connect(String), #[error("routing error: {0}")] Routing(String), /// The request is outside the surface this route covers in Rust. Hosts that @@ -38,6 +29,14 @@ pub enum CoreError { Unsupported(&'static str), } +/// Re-tag an error raised after the provider has already returned a response. +pub(crate) fn as_response_error(err: Error) -> Error { + match err { + already @ (Error::InvalidResponse(_) | Error::Http { .. }) => already, + other => Error::InvalidResponse(other.to_string()), + } +} + pub fn json_type_name(value: &serde_json::Value) -> &'static str { match value { serde_json::Value::Null => "null", @@ -48,3 +47,34 @@ pub fn json_type_name(value: &serde_json::Value) -> &'static str { serde_json::Value::Object(_) => "object", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_errors_collapse_to_one_non_retryable_variant() { + for original in [ + Error::MissingField("usage"), + Error::Unsupported("non-text response content block"), + Error::InvalidRequest("whatever".to_string()), + Error::Auth("whatever".to_string()), + ] { + assert!(matches!( + as_response_error(original), + Error::InvalidResponse(_) + )); + } + } + + #[test] + fn response_errors_preserve_an_upstream_status() { + assert!(matches!( + as_response_error(Error::Http { + status: 500, + body: "boom".to_string() + }), + Error::Http { status: 500, .. } + )); + } +} diff --git a/litellm-rust/crates/core/src/http_utils.rs b/litellm-rust/crates/core/src/http_utils.rs index c541f50275b..759c7ba5652 100644 --- a/litellm-rust/crates/core/src/http_utils.rs +++ b/litellm-rust/crates/core/src/http_utils.rs @@ -3,7 +3,11 @@ use serde_json::{Map, Value}; use crate::constants::UPSTREAM_ERROR_BODY_MAX_CHARS; -use crate::error::{CoreError, CoreResult, json_type_name}; +use crate::error::{Error, json_type_name}; + +pub(crate) fn classify_send_error(error: reqwest::Error) -> Error { + Error::Network(error.to_string()) +} /// Bound an upstream error body before it crosses a host boundary, so provider /// bodies stay data-minimized. @@ -18,7 +22,7 @@ pub fn truncate_error_body(body: &str) -> String { pub fn string_headers( context: &'static str, extra_headers: Option>, -) -> CoreResult> { +) -> Result, Error> { extra_headers .unwrap_or_default() .into_iter() @@ -27,7 +31,7 @@ pub fn string_headers( .as_str() .map(|value| (key.clone(), value.to_string())) .ok_or_else(|| { - CoreError::InvalidRequest(format!( + Error::InvalidRequest(format!( "{context} extra_headers.{key} must be a string, got {}", json_type_name(&value) )) @@ -81,7 +85,7 @@ mod tests { let err = string_headers("chat completions", Some(headers)).expect_err("non-string value"); assert_eq!( err, - CoreError::InvalidRequest( + Error::InvalidRequest( "chat completions extra_headers.x-trace must be a string, got number".to_string() ) ); diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index dce4a425ea0..3a651111f8d 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -12,5 +12,6 @@ pub mod realtime; pub mod responses; pub mod router; pub mod routing_utils; +pub mod streaming; -pub use error::{CoreError, CoreResult}; +pub use error::Error; diff --git a/litellm-rust/crates/core/src/messages/common_utils.rs b/litellm-rust/crates/core/src/messages/common_utils.rs index a14dffbc1fe..8dfdb2e361a 100644 --- a/litellm-rust/crates/core/src/messages/common_utils.rs +++ b/litellm-rust/crates/core/src/messages/common_utils.rs @@ -1,9 +1,8 @@ -use serde_json::{Map, Value}; - -use crate::error::CoreResult; +use crate::Error; use crate::http_utils::string_headers as shared_string_headers; use crate::providers::anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG; use crate::providers::azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG; +use serde_json::{Map, Value}; use super::transformation::AnthropicMessagesProviderConfig; @@ -23,6 +22,6 @@ pub(super) fn messages_provider_config( pub(super) fn string_headers( extra_headers: Option>, -) -> CoreResult> { +) -> Result, Error> { shared_string_headers(HEADER_CONTEXT, extra_headers) } diff --git a/litellm-rust/crates/core/src/messages/handler.rs b/litellm-rust/crates/core/src/messages/handler.rs index 1c895f66eba..441f133b182 100644 --- a/litellm-rust/crates/core/src/messages/handler.rs +++ b/litellm-rust/crates/core/src/messages/handler.rs @@ -1,5 +1,6 @@ use crate::constants::ANTHROPIC_MESSAGES_PROVIDER; -use crate::error::{CoreError, CoreResult}; +use crate::error::{Error, as_response_error}; +use crate::http_utils::classify_send_error; use super::client::http_client; use super::common_utils::truncate_error_body; @@ -7,7 +8,7 @@ use super::types::{AnthropicMessagesResponse, ProviderMessagesRequest}; pub(super) async fn execute_messages_provider_call( request: ProviderMessagesRequest, -) -> CoreResult { +) -> Result { let mut request_builder = http_client().post(&request.url).json(&request.body); for (key, value) in &request.upstream_headers { request_builder = request_builder.header(key, value); @@ -16,35 +17,34 @@ pub(super) async fn execute_messages_provider_call( request_builder = request_builder.timeout(duration); } - let response = request_builder - .send() - .await - .map_err(|err| CoreError::Network(err.to_string()))?; + let response = request_builder.send().await.map_err(classify_send_error)?; let status = response.status(); let text = response .text() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; if !status.is_success() { - return Err(CoreError::Http { + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&text), }); } - let response = serde_json::from_str(&text).map_err(|err| { - CoreError::InvalidResponse(format!("invalid messages response JSON: {err}")) - })?; - request.config.transform_response(&request.model, response) + let response = serde_json::from_str(&text) + .map_err(|err| Error::InvalidResponse(format!("invalid messages response JSON: {err}")))?; + request + .config + .transform_response(&request.model, response) + .map_err(as_response_error) } pub(super) async fn execute_messages_provider_stream( request: ProviderMessagesRequest, -) -> CoreResult { +) -> Result { if request.provider != ANTHROPIC_MESSAGES_PROVIDER { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "streaming messages is not supported for this provider".to_string(), )); } @@ -60,17 +60,169 @@ pub(super) async fn execute_messages_provider_stream( let response = request_builder .send() .await - .map_err(|err| CoreError::Network(err.to_string()))?; + .map_err(|err| Error::Network(err.to_string()))?; let status = response.status(); if !status.is_success() { let text = response .text() .await - .map_err(|err| CoreError::Network(err.to_string()))?; - return Err(CoreError::Http { + .map_err(|err| Error::Network(err.to_string()))?; + return Err(Error::Http { status: status.as_u16(), body: truncate_error_body(&text), }); } Ok(response) } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::json; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + use super::*; + use crate::messages::transformation::AnthropicMessagesProviderConfig; + + struct RejectingResponseConfig; + + impl AnthropicMessagesProviderConfig for RejectingResponseConfig { + fn complete_url( + &self, + _api_base: Option<&str>, + _model: &str, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + unreachable!() + } + + fn resolve_api_key( + &self, + _api_key: Option<&str>, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + unreachable!() + } + + fn transform_response( + &self, + _model: &str, + _response: AnthropicMessagesResponse, + ) -> Result { + Err(Error::MissingField("normalized_content")) + } + } + + static REJECTING_RESPONSE_CONFIG: RejectingResponseConfig = RejectingResponseConfig; + + fn request(url: String, timeout: Duration) -> ProviderMessagesRequest { + ProviderMessagesRequest { + provider: "anthropic".to_string(), + model: "claude-test".to_string(), + config: &REJECTING_RESPONSE_CONFIG, + url, + body: json!({}), + upstream_headers: Vec::new(), + timeout: Some(timeout), + } + } + + async fn read_http_request(socket: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = socket.read(&mut buffer).await.expect("reads request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + String::from_utf8(request).expect("request is utf8") + } + + #[tokio::test] + async fn post_response_transform_errors_are_non_retryable() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let addr = listener.local_addr().expect("addr"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts request"); + let _ = read_http_request(&mut socket).await; + let body = r#"{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-test"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("writes response"); + }); + + let error = execute_messages_provider_call(request( + format!("http://{addr}/v1/messages"), + Duration::from_secs(5), + )) + .await + .expect_err("response transform should fail"); + + server.await.expect("server task completes"); + assert!( + matches!(error, Error::InvalidResponse(message) if message.contains("normalized_content")) + ); + } + + #[tokio::test] + async fn refused_connections_are_terminal_network_errors() { + let port = { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + listener.local_addr().expect("has an address").port() + }; + let error = execute_messages_provider_call(request( + format!("http://127.0.0.1:{port}"), + Duration::from_secs(1), + )) + .await + .expect_err("nothing is listening"); + + assert!(matches!(error, Error::Network(_))); + } + + #[tokio::test] + async fn established_request_timeouts_are_network_errors() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let addr = listener.local_addr().expect("has an address"); + let (request_received_tx, request_received_rx) = tokio::sync::oneshot::channel(); + let (release_server_tx, release_server_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts request"); + let received = read_http_request(&mut socket).await; + request_received_tx.send(received).expect("reports request"); + release_server_rx.await.expect("server is released"); + }); + + let error = tokio::time::timeout( + Duration::from_secs(2), + execute_messages_provider_call(request( + format!("http://{addr}"), + Duration::from_millis(100), + )), + ) + .await + .expect("client call completes") + .expect_err("established request times out"); + + let received = tokio::time::timeout(Duration::from_secs(2), request_received_rx) + .await + .expect("server observes request") + .expect("server reports request"); + assert!(received.starts_with("POST / "), "{received}"); + release_server_tx.send(()).expect("releases server"); + server.await.expect("server task completes"); + assert!(matches!(error, Error::Network(_))); + } +} diff --git a/litellm-rust/crates/core/src/messages/mod.rs b/litellm-rust/crates/core/src/messages/mod.rs index acb36d89daf..f9a70a991d8 100644 --- a/litellm-rust/crates/core/src/messages/mod.rs +++ b/litellm-rust/crates/core/src/messages/mod.rs @@ -7,26 +7,75 @@ //! is the streaming variant; it hands the raw upstream response back so a host //! can splice the event stream to its own caller. +use crate::Error; mod client; mod common_utils; mod handler; mod prepare; pub mod transformation; pub mod types; - -use crate::error::CoreResult; +use crate::streaming::OpenedStream; use handler::{execute_messages_provider_call, execute_messages_provider_stream}; use prepare::prepare_messages_call; -use types::{AnthropicMessagesResponse, MessagesRequest}; +use types::{ + AnthropicMessagesResponse, MessagesRequest, MessagesStreamEvent, MessagesStreamRequest, +}; -pub async fn messages(request: MessagesRequest<'_>) -> CoreResult { +pub async fn messages(request: MessagesRequest<'_>) -> Result { execute_messages_provider_call(prepare_messages_call(request)?).await } -pub async fn messages_stream(request: MessagesRequest<'_>) -> CoreResult { +pub async fn messages_stream(request: MessagesRequest<'_>) -> Result { execute_messages_provider_stream(prepare_messages_call(request)?).await } +pub async fn messages_event_stream( + _request: MessagesStreamRequest, +) -> Result, Error> { + Err(crate::Error::Unsupported( + "messages event streaming provider registration", + )) +} + #[cfg(test)] mod tests; + +#[cfg(test)] +mod stream_entrypoint_tests { + use serde_json::json; + + use super::*; + use crate::Error; + use crate::streaming::{ + ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions, + }; + + #[tokio::test] + async fn typed_event_stream_declines_until_a_provider_is_registered() { + let body = serde_json::from_value(json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 32, + "stream": true + })) + .expect("valid Messages stream request"); + let result = messages_event_stream(MessagesStreamRequest { + body, + target: StreamTarget::new( + StreamProviderId::Anthropic, + ProviderCredentials::default(), + None, + ), + transport: StreamTransportOptions::default(), + }) + .await; + + assert!(matches!( + result, + Err(Error::Unsupported( + "messages event streaming provider registration" + )) + )); + } +} diff --git a/litellm-rust/crates/core/src/messages/prepare.rs b/litellm-rust/crates/core/src/messages/prepare.rs index 94b5b1eaed7..3d10a9d824f 100644 --- a/litellm-rust/crates/core/src/messages/prepare.rs +++ b/litellm-rust/crates/core/src/messages/prepare.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use crate::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider}; use super::common_utils::{has_bearer_auth, has_header, messages_provider_config, string_headers}; @@ -7,7 +7,7 @@ use super::types::{MessagesRequest, ProviderMessagesRequest}; pub(super) fn prepare_messages_call( request: MessagesRequest<'_>, -) -> CoreResult { +) -> Result { let provider_info = get_custom_llm_provider(request.model, request.custom_llm_provider) .or_else(|| { request @@ -18,15 +18,16 @@ pub(super) fn prepare_messages_call( }) }) .ok_or_else(|| { - CoreError::InvalidProvider( + Error::InvalidProvider( "unable to resolve custom_llm_provider for messages request".to_string(), ) })?; let model = provider_info.model.to_string(); let provider = provider_info.custom_llm_provider; - let config = messages_provider_config(provider) - .ok_or_else(|| CoreError::InvalidProvider(provider.to_string()))?; + let config = messages_provider_config(provider).ok_or(Error::Unsupported( + "messages provider is not registered in the Rust bridge", + ))?; let env_lookup = |key: &str| std::env::var(key).ok(); let mut headers = string_headers(request.extra_headers)?; @@ -53,11 +54,11 @@ pub(super) fn prepare_messages_call( let url = config.complete_url(request.api_base, &model, &env_lookup)?; let typed_request = serde_json::from_value(request.body).map_err(|err| { - CoreError::InvalidRequest(format!("invalid Anthropic messages request: {err}")) + Error::InvalidRequest(format!("invalid Anthropic messages request: {err}")) })?; let transformed = config.transform_request(typed_request)?; let body = serde_json::to_value(transformed).map_err(|err| { - CoreError::InvalidRequest(format!( + Error::InvalidRequest(format!( "failed to serialize Anthropic messages request: {err}" )) })?; diff --git a/litellm-rust/crates/core/src/messages/tests.rs b/litellm-rust/crates/core/src/messages/tests.rs index 9fc1763683b..27dc2281d64 100644 --- a/litellm-rust/crates/core/src/messages/tests.rs +++ b/litellm-rust/crates/core/src/messages/tests.rs @@ -4,7 +4,7 @@ use serde_json::{Map, Value, json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; -use crate::error::CoreError; +use crate::error::Error; use super::common_utils::{ has_bearer_auth, has_header, messages_provider_config, string_headers, truncate_error_body, @@ -77,7 +77,7 @@ fn truncate_error_body_caps_long_payloads() { fn string_headers_rejects_non_string_values() { let headers = json!({"x-count": 3}).as_object().unwrap().clone(); let err = string_headers(Some(headers)).expect_err("non-string header rejected"); - assert!(matches!(err, CoreError::InvalidRequest(_))); + assert!(matches!(err, Error::InvalidRequest(_))); } #[test] @@ -341,7 +341,7 @@ async fn messages_requires_auth_when_no_key_and_no_header() { .await .expect_err("missing auth errors"); - assert!(matches!(err, CoreError::Auth(_))); + assert!(matches!(err, Error::Auth(_))); } #[tokio::test] @@ -420,7 +420,7 @@ async fn messages_maps_provider_error_status_to_http_error() { .await .expect_err("provider error propagates"); - assert!(matches!(err, CoreError::Http { status: 401, .. })); + assert!(matches!(err, Error::Http { status: 401, .. })); } #[tokio::test] @@ -437,5 +437,8 @@ async fn messages_rejects_unsupported_provider() { .await .expect_err("unsupported provider errors"); - assert!(matches!(err, CoreError::InvalidProvider(provider) if provider == "openai")); + assert!(matches!( + err, + Error::Unsupported("messages provider is not registered in the Rust bridge") + )); } diff --git a/litellm-rust/crates/core/src/messages/transformation.rs b/litellm-rust/crates/core/src/messages/transformation.rs index b478e20d24b..1c3913a6802 100644 --- a/litellm-rust/crates/core/src/messages/transformation.rs +++ b/litellm-rust/crates/core/src/messages/transformation.rs @@ -1,6 +1,19 @@ -use crate::error::CoreResult; +use crate::Error; +use crate::streaming::StreamProvider; -use super::types::{AnthropicMessagesRequest, AnthropicMessagesResponse}; +use super::types::{ + AnthropicMessagesRequest, AnthropicMessagesResponse, MessagesStreamEvent, MessagesStreamRequest, +}; + +pub trait MessagesStreamProvider: + StreamProvider +{ +} + +impl MessagesStreamProvider for T where + T: StreamProvider +{ +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MessagesAuthStrategy { @@ -23,13 +36,13 @@ pub trait AnthropicMessagesProviderConfig: Sync { api_base: Option<&str>, model: &str, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn resolve_api_key( &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn auth_strategy(&self) -> MessagesAuthStrategy { MessagesAuthStrategy::Header("x-api-key") @@ -49,7 +62,7 @@ pub trait AnthropicMessagesProviderConfig: Sync { fn transform_request( &self, request: AnthropicMessagesRequest, - ) -> CoreResult { + ) -> Result { Ok(request) } @@ -57,7 +70,7 @@ pub trait AnthropicMessagesProviderConfig: Sync { &self, _model: &str, response: AnthropicMessagesResponse, - ) -> CoreResult { + ) -> Result { Ok(response) } } diff --git a/litellm-rust/crates/core/src/messages/types.rs b/litellm-rust/crates/core/src/messages/types.rs index b9f807c29fd..94f979bb730 100644 --- a/litellm-rust/crates/core/src/messages/types.rs +++ b/litellm-rust/crates/core/src/messages/types.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::transformation::AnthropicMessagesProviderConfig; +use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions}; pub struct MessagesRequest<'a> { pub model: &'a str, @@ -47,6 +48,76 @@ pub struct ContentBlock { pub extra: Map, } +pub struct MessagesStreamRequest { + pub body: AnthropicMessagesRequest, + pub target: StreamTarget, + pub transport: StreamTransportOptions, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MessagesStreamEvent { + MessageStart { + message: AnthropicMessagesResponse, + }, + ContentBlockStart { + index: u64, + content_block: JsonObject, + }, + ContentBlockDelta { + index: u64, + delta: JsonObject, + }, + ContentBlockStop { + index: u64, + }, + MessageDelta { + delta: JsonObject, + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, + }, + MessageStop, + Ping, + Error { + error: JsonObject, + }, +} + +#[cfg(test)] +mod stream_contract_tests { + use super::*; + + #[test] + fn message_stop_serializes_as_anthropic_event() { + assert_eq!( + serde_json::to_value(MessagesStreamEvent::MessageStop).expect("serializable event"), + serde_json::json!({"type": "message_stop"}) + ); + } + + #[test] + fn content_delta_keeps_typed_event_fields() { + let event = MessagesStreamEvent::ContentBlockDelta { + index: 0, + delta: JsonObject( + serde_json::json!({"type": "text_delta", "text": "hello"}) + .as_object() + .expect("object") + .clone(), + ), + }; + + assert_eq!( + serde_json::to_value(event).expect("serializable event"), + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hello"} + }) + ); + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct CacheControl { #[serde(rename = "type", skip_serializing_if = "Option::is_none")] diff --git a/litellm-rust/crates/core/src/ocr/canonical.rs b/litellm-rust/crates/core/src/ocr/canonical.rs new file mode 100644 index 00000000000..8d77e5509a9 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/canonical.rs @@ -0,0 +1,234 @@ +use std::collections::BTreeMap; + +use bytes::Bytes; +use mime::Mime; +use serde_json::Value; +use thiserror::Error; +use url::Url; + +use super::policy::OcrCanonicalField; +use super::types::{Field, OcrDialectId}; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CanonicalOcrError { + #[error("invalid OCR parameter {field}: {reason}")] + InvalidParameter { + field: &'static str, + reason: &'static str, + }, + #[error("provider extra collides with canonical OCR field: {0}")] + ExtraCollidesWithCanonical(String), + #[error("LiteLLM control cannot enter OCR provider extras: {0}")] + ReservedExtra(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DocumentKind { + Image, + Pdf, +} + +#[derive(Clone, PartialEq)] +pub enum OcrDocument { + RemoteUrl { + kind: DocumentKind, + url: Url, + }, + Inline { + kind: DocumentKind, + media_type: Mime, + bytes: Bytes, + }, + ProviderReference { + provider: OcrDialectId, + id: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageSelection(Vec); + +impl PageSelection { + pub fn new(pages: impl IntoIterator) -> Self { + Self(pages.into_iter().collect()) + } + + pub fn pages(&self) -> &[u32] { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AnnotationFormat(Value); + +impl AnnotationFormat { + pub fn new(schema: Value) -> Result { + if !schema.is_object() { + return Err(CanonicalOcrError::InvalidParameter { + field: "annotation_format", + reason: "must be a JSON object", + }); + } + Ok(Self(schema)) + } + + pub fn as_value(&self) -> &Value { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableFormat { + Html, + Markdown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfidenceScoresGranularity { + Page, + Word, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OcrOutputOptions { + pub include_image_base64: Field, + pub image_limit: Field, + pub image_min_size: Field, + pub bbox_annotation_format: Field, + pub document_annotation_format: Field, + pub document_annotation_prompt: Field, + pub extract_header: Field, + pub extract_footer: Field, + pub table_format: Field, + pub confidence_scores_granularity: Field, + pub include_blocks: Field, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OcrRequestId(String); + +impl OcrRequestId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(CanonicalOcrError::InvalidParameter { + field: "id", + reason: "must not be blank", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExplicitProviderExtras { + dialect: OcrDialectId, + fields: BTreeMap, +} + +impl ExplicitProviderExtras { + pub fn try_new( + dialect: OcrDialectId, + fields: BTreeMap, + ) -> Result { + if let Some(field) = fields.keys().find(|field| { + OcrCanonicalField::from_wire_name(field).is_some() || is_litellm_control(field) + }) { + return Err(if OcrCanonicalField::from_wire_name(field).is_some() { + CanonicalOcrError::ExtraCollidesWithCanonical(field.clone()) + } else { + CanonicalOcrError::ReservedExtra(field.clone()) + }); + } + Ok(Self { dialect, fields }) + } + + pub fn dialect(&self) -> OcrDialectId { + self.dialect + } + + pub fn fields(&self) -> &BTreeMap { + &self.fields + } +} + +fn is_litellm_control(field: &str) -> bool { + field.starts_with("litellm_") + || matches!( + field, + "api_base" + | "api_key" + | "custom_llm_provider" + | "fallbacks" + | "metadata" + | "mock_response" + | "num_retries" + | "request_timeout" + | "retry_policy" + ) +} + +#[derive(Clone, PartialEq)] +pub struct CanonicalOcrRequest { + pub model: String, + pub document: OcrDocument, + pub pages: Field, + pub output: OcrOutputOptions, + pub request_id: Field, + pub provider_extras: ExplicitProviderExtras, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extras_reject_canonical_field_collisions() { + let error = ExplicitProviderExtras::try_new( + OcrDialectId::Mistral, + BTreeMap::from([("pages".to_string(), json!([0]))]), + ) + .expect_err("canonical fields cannot enter extras"); + assert_eq!( + error, + CanonicalOcrError::ExtraCollidesWithCanonical("pages".to_string()) + ); + } + + #[test] + fn extras_reject_litellm_controls() { + for field in ["request_timeout", "litellm_future_control"] { + let error = ExplicitProviderExtras::try_new( + OcrDialectId::Mistral, + BTreeMap::from([(field.to_string(), json!(true))]), + ) + .expect_err("LiteLLM controls cannot enter extras"); + assert_eq!(error, CanonicalOcrError::ReservedExtra(field.to_string())); + } + } + + #[test] + fn extras_remain_bound_to_one_dialect() { + let extras = ExplicitProviderExtras::try_new( + OcrDialectId::ReductoV3, + BTreeMap::from([("chunking".to_string(), json!({"size": 1}))]), + ) + .expect("provider field is accepted"); + assert_eq!(extras.dialect(), OcrDialectId::ReductoV3); + assert_eq!(extras.fields()["chunking"], json!({"size": 1})); + } + + #[test] + fn canonical_fields_preserve_absent_null_and_value() { + assert_ne!(Field::::Absent, Field::Null); + assert_ne!( + Field::Null, + Field::Value(PageSelection::new([0_u32, 2_u32])) + ); + } +} diff --git a/litellm-rust/crates/core/src/ocr/compiler.rs b/litellm-rust/crates/core/src/ocr/compiler.rs new file mode 100644 index 00000000000..331132d9ceb --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/compiler.rs @@ -0,0 +1,218 @@ +use std::collections::BTreeMap; + +use serde_json::Value; +use thiserror::Error; +use url::Url; + +use super::canonical::{CanonicalOcrRequest, DocumentKind, OcrDocument}; +use super::plan::{CompletionPlan, DocumentPlan}; +use super::policy::OcrParameterPolicy; +use super::response::NormalizedOcr; +use super::types::OcrDialectId; +pub use super::wire::{MultipartBodyPlan, MultipartPart, OcrJsonValue, OcrWireBody, OcrWireError}; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CompileError { + #[error("invalid OCR parameter {field}: {reason}")] + InvalidParameter { + field: &'static str, + reason: &'static str, + }, + #[error("OCR dialect is not compiled yet: {0:?}")] + UnsupportedDialect(OcrDialectId), +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum NormalizeError { + #[error("invalid terminal OCR response: {0}")] + InvalidPayload(&'static str), +} + +#[derive(Clone, Default, PartialEq, Eq)] +pub struct OcrCredentials { + api_key: Option, + oauth_token: Option, +} + +impl OcrCredentials { + pub fn new(api_key: Option, oauth_token: Option) -> Self { + Self { + api_key, + oauth_token, + } + } + + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + pub fn oauth_token(&self) -> Option<&str> { + self.oauth_token.as_deref() + } +} + +#[derive(Clone, PartialEq)] +pub struct ResolvedOcrTarget { + dialect: OcrDialectId, + api_base: Url, + credentials: OcrCredentials, +} + +impl ResolvedOcrTarget { + pub fn new(dialect: OcrDialectId, api_base: Url, credentials: OcrCredentials) -> Self { + Self { + dialect, + api_base, + credentials, + } + } + + pub fn dialect(&self) -> OcrDialectId { + self.dialect + } + + pub fn api_base(&self) -> &Url { + &self.api_base + } + + pub fn credentials(&self) -> &OcrCredentials { + &self.credentials + } +} + +#[derive(Clone, PartialEq)] +pub enum ProviderDocument { + RemoteUrl { + kind: DocumentKind, + url: Url, + }, + Inline { + kind: DocumentKind, + media_type: mime::Mime, + bytes: bytes::Bytes, + }, + Reference { + id: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HttpMethod { + Get, + Post, +} + +#[derive(Clone, PartialEq)] +pub struct CompiledHttpRequest { + pub method: HttpMethod, + pub url: Url, + pub headers: BTreeMap, + pub body: OcrWireBody, +} + +#[derive(Clone, PartialEq)] +pub struct ProviderPayload(Value); + +impl ProviderPayload { + pub fn new(value: Value) -> Self { + Self(value) + } + + pub fn as_value(&self) -> &Value { + &self.0 + } + + pub fn into_value(self) -> Value { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OcrDocumentPolicy { + Ready, + FetchRemoteUrlAndInline, + UploadUnlessProviderReference, +} + +pub trait OcrDialectCompiler: Send + Sync { + fn parameter_policy(&self) -> &'static OcrParameterPolicy; + + fn prepare_document( + &self, + document: &OcrDocument, + target: &ResolvedOcrTarget, + ) -> Result; + + fn compile_submit( + &self, + request: &CanonicalOcrRequest, + document: ProviderDocument, + target: &ResolvedOcrTarget, + ) -> Result; + + fn completion_plan(&self) -> CompletionPlan; + + fn normalize( + &self, + terminal_response: ProviderPayload, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use mime::Mime; + use serde_json::json; + + use super::*; + + #[test] + fn compilation_preserves_the_inline_media_allocation() { + let source = Bytes::from_static(b"pdf payload"); + let source_pointer = source.as_ptr(); + let canonical_document = OcrDocument::Inline { + kind: DocumentKind::Pdf, + media_type: "application/pdf".parse::().expect("valid MIME type"), + bytes: source.clone(), + }; + let OcrDocument::Inline { + kind, + media_type, + bytes, + } = &canonical_document + else { + panic!("inline canonical document expected"); + }; + let provider_document = ProviderDocument::Inline { + kind: *kind, + media_type: media_type.clone(), + bytes: bytes.clone(), + }; + let ProviderDocument::Inline { + media_type, bytes, .. + } = provider_document + else { + panic!("inline document expected"); + }; + let request = CompiledHttpRequest { + method: HttpMethod::Post, + url: Url::parse("https://example.com/ocr").expect("valid URL"), + headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]), + body: OcrWireBody::JsonWithMedia(OcrJsonValue::Object(BTreeMap::from([ + ( + "document".to_string(), + OcrJsonValue::InlineDataUri { media_type, bytes }, + ), + ("model".to_string(), OcrJsonValue::Value(json!("ocr-model"))), + ]))), + }; + + let OcrWireBody::JsonWithMedia(OcrJsonValue::Object(fields)) = &request.body else { + panic!("media JSON body expected"); + }; + let OcrJsonValue::InlineDataUri { bytes, .. } = &fields["document"] else { + panic!("inline media expected"); + }; + assert_eq!(bytes.as_ptr(), source_pointer); + } +} diff --git a/litellm-rust/crates/core/src/ocr/mod.rs b/litellm-rust/crates/core/src/ocr/mod.rs index ec2fbb969a6..14ab6f2294e 100644 --- a/litellm-rust/crates/core/src/ocr/mod.rs +++ b/litellm-rust/crates/core/src/ocr/mod.rs @@ -1,2 +1,9 @@ +pub mod canonical; +pub mod compiler; +pub mod plan; +pub mod policy; +pub mod profile; +pub mod response; pub mod transformation; pub mod types; +pub mod wire; diff --git a/litellm-rust/crates/core/src/ocr/plan.rs b/litellm-rust/crates/core/src/ocr/plan.rs new file mode 100644 index 00000000000..724f83e5a63 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/plan.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +use url::Url; + +use super::canonical::{DocumentKind, OcrDocument}; +use super::compiler::ProviderDocument; +use super::types::OcrDialectId; + +#[derive(Clone, PartialEq)] +pub enum DocumentPlan { + Ready(ProviderDocument), + FetchAndInline(FetchPlan), + Upload(UploadPlan), +} + +#[derive(Clone, PartialEq)] +pub struct FetchPlan { + pub kind: DocumentKind, + pub url: Url, + pub max_bytes: usize, +} + +#[derive(Clone, PartialEq)] +pub struct UploadPlan { + pub dialect: OcrDialectId, + pub document: OcrDocument, +} + +#[derive(Clone, PartialEq)] +pub enum CompletionPlan { + Immediate, + Poll(PollPlan), +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PollPlan { + pub operation_location_header: &'static str, + pub interval: Duration, + pub timeout: Duration, +} diff --git a/litellm-rust/crates/core/src/ocr/policy.rs b/litellm-rust/crates/core/src/ocr/policy.rs new file mode 100644 index 00000000000..ae0d68157b6 --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/policy.rs @@ -0,0 +1,106 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParameterDisposition { + Forward, + Rename(&'static str), + Transform, + ConsumeAsConfiguration, + Reject, +} + +macro_rules! ocr_parameter_schema { + ($(($variant:ident, $field:ident, $wire_name:literal)),+ $(,)?) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum OcrCanonicalField { + $($variant),+ + } + + impl OcrCanonicalField { + pub const ALL: [Self; ocr_parameter_schema!(@count $($variant),+)] = [ + $(Self::$variant),+ + ]; + + pub const fn wire_name(self) -> &'static str { + match self { + $(Self::$variant => $wire_name),+ + } + } + + pub fn from_wire_name(value: &str) -> Option { + match value { + $($wire_name => Some(Self::$variant)),+, + _ => None, + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct OcrParameterPolicy { + $(pub $field: ParameterDisposition),+ + } + + impl OcrParameterPolicy { + pub const fn disposition(self, field: OcrCanonicalField) -> ParameterDisposition { + match field { + $(OcrCanonicalField::$variant => self.$field),+ + } + } + } + }; + (@count $($item:ident),+) => { + <[()]>::len(&[$(ocr_parameter_schema!(@replace $item ())),+]) + }; + (@replace $_item:ident $sub:expr) => { $sub }; +} + +ocr_parameter_schema!( + (Pages, pages, "pages"), + ( + IncludeImageBase64, + include_image_base64, + "include_image_base64" + ), + (ImageLimit, image_limit, "image_limit"), + (ImageMinSize, image_min_size, "image_min_size"), + ( + BboxAnnotationFormat, + bbox_annotation_format, + "bbox_annotation_format" + ), + ( + DocumentAnnotationFormat, + document_annotation_format, + "document_annotation_format" + ), + ( + DocumentAnnotationPrompt, + document_annotation_prompt, + "document_annotation_prompt" + ), + (ExtractHeader, extract_header, "extract_header"), + (ExtractFooter, extract_footer, "extract_footer"), + (TableFormat, table_format, "table_format"), + ( + ConfidenceScoresGranularity, + confidence_scores_granularity, + "confidence_scores_granularity" + ), + (IncludeBlocks, include_blocks, "include_blocks"), + (RequestId, request_id, "id"), +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_canonical_field_round_trips_through_its_wire_name() { + assert_eq!(OcrCanonicalField::ALL.len(), 13); + for field in OcrCanonicalField::ALL { + assert_eq!( + OcrCanonicalField::from_wire_name(field.wire_name()), + Some(field) + ); + } + assert_eq!(OcrCanonicalField::from_wire_name("provider_private"), None); + } +} diff --git a/litellm-rust/crates/core/src/ocr/profile.rs b/litellm-rust/crates/core/src/ocr/profile.rs new file mode 100644 index 00000000000..af1637eaada --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/profile.rs @@ -0,0 +1,189 @@ +use super::compiler::OcrDocumentPolicy; +use super::policy::{OcrParameterPolicy, ParameterDisposition}; +use super::types::OcrDialectId; + +pub const MISTRAL_OCR_PARAMETER_POLICY: OcrParameterPolicy = OcrParameterPolicy { + pages: ParameterDisposition::Forward, + include_image_base64: ParameterDisposition::Forward, + image_limit: ParameterDisposition::Forward, + image_min_size: ParameterDisposition::Forward, + bbox_annotation_format: ParameterDisposition::Forward, + document_annotation_format: ParameterDisposition::Forward, + document_annotation_prompt: ParameterDisposition::Forward, + extract_header: ParameterDisposition::Forward, + extract_footer: ParameterDisposition::Forward, + table_format: ParameterDisposition::Forward, + confidence_scores_granularity: ParameterDisposition::Forward, + include_blocks: ParameterDisposition::Forward, + request_id: ParameterDisposition::Forward, +}; + +pub const AZURE_DOCUMENT_INTELLIGENCE_PARAMETER_POLICY: OcrParameterPolicy = OcrParameterPolicy { + pages: ParameterDisposition::Transform, + include_image_base64: ParameterDisposition::Reject, + image_limit: ParameterDisposition::Reject, + image_min_size: ParameterDisposition::Reject, + bbox_annotation_format: ParameterDisposition::Reject, + document_annotation_format: ParameterDisposition::Reject, + document_annotation_prompt: ParameterDisposition::Reject, + extract_header: ParameterDisposition::Reject, + extract_footer: ParameterDisposition::Reject, + table_format: ParameterDisposition::Reject, + confidence_scores_granularity: ParameterDisposition::Reject, + include_blocks: ParameterDisposition::Reject, + request_id: ParameterDisposition::Reject, +}; + +pub const REJECT_CANONICAL_OCR_PARAMETER_POLICY: OcrParameterPolicy = OcrParameterPolicy { + pages: ParameterDisposition::Reject, + include_image_base64: ParameterDisposition::Reject, + image_limit: ParameterDisposition::Reject, + image_min_size: ParameterDisposition::Reject, + bbox_annotation_format: ParameterDisposition::Reject, + document_annotation_format: ParameterDisposition::Reject, + document_annotation_prompt: ParameterDisposition::Reject, + extract_header: ParameterDisposition::Reject, + extract_footer: ParameterDisposition::Reject, + table_format: ParameterDisposition::Reject, + confidence_scores_granularity: ParameterDisposition::Reject, + include_blocks: ParameterDisposition::Reject, + request_id: ParameterDisposition::Reject, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OcrPollingProfile { + pub operation_location_header: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OcrDialectProfile { + pub dialect: OcrDialectId, + pub parameter_policy: &'static OcrParameterPolicy, + pub document_policy: OcrDocumentPolicy, + pub polling: Option, +} + +pub const OCR_DIALECT_PROFILES: [OcrDialectProfile; 7] = [ + OcrDialectProfile { + dialect: OcrDialectId::Mistral, + parameter_policy: &MISTRAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::Ready, + polling: None, + }, + OcrDialectProfile { + dialect: OcrDialectId::AzureFoundryMistral, + parameter_policy: &MISTRAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::FetchRemoteUrlAndInline, + polling: None, + }, + OcrDialectProfile { + dialect: OcrDialectId::AzureDocumentIntelligence, + parameter_policy: &AZURE_DOCUMENT_INTELLIGENCE_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::Ready, + polling: Some(OcrPollingProfile { + operation_location_header: "operation-location", + }), + }, + OcrDialectProfile { + dialect: OcrDialectId::VertexMistral, + parameter_policy: &MISTRAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::FetchRemoteUrlAndInline, + polling: None, + }, + OcrDialectProfile { + dialect: OcrDialectId::VertexDeepSeek, + parameter_policy: &REJECT_CANONICAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::Ready, + polling: None, + }, + OcrDialectProfile { + dialect: OcrDialectId::ReductoV3, + parameter_policy: &REJECT_CANONICAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::UploadUnlessProviderReference, + polling: None, + }, + OcrDialectProfile { + dialect: OcrDialectId::ReductoLegacy, + parameter_policy: &REJECT_CANONICAL_OCR_PARAMETER_POLICY, + document_policy: OcrDocumentPolicy::UploadUnlessProviderReference, + polling: None, + }, +]; + +pub const fn ocr_dialect_profile(dialect: OcrDialectId) -> &'static OcrDialectProfile { + match dialect { + OcrDialectId::Mistral => &OCR_DIALECT_PROFILES[0], + OcrDialectId::AzureFoundryMistral => &OCR_DIALECT_PROFILES[1], + OcrDialectId::AzureDocumentIntelligence => &OCR_DIALECT_PROFILES[2], + OcrDialectId::VertexMistral => &OCR_DIALECT_PROFILES[3], + OcrDialectId::VertexDeepSeek => &OCR_DIALECT_PROFILES[4], + OcrDialectId::ReductoV3 => &OCR_DIALECT_PROFILES[5], + OcrDialectId::ReductoLegacy => &OCR_DIALECT_PROFILES[6], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ocr::policy::{OcrCanonicalField, ParameterDisposition}; + + #[test] + fn mistral_compatible_dialects_share_parameter_rules_but_not_document_rules() { + let mistral = ocr_dialect_profile(OcrDialectId::Mistral); + let foundry = ocr_dialect_profile(OcrDialectId::AzureFoundryMistral); + let vertex = ocr_dialect_profile(OcrDialectId::VertexMistral); + + for field in OcrCanonicalField::ALL { + let expected = mistral.parameter_policy.disposition(field); + assert_eq!(foundry.parameter_policy.disposition(field), expected); + assert_eq!(vertex.parameter_policy.disposition(field), expected); + } + assert_eq!(mistral.document_policy, OcrDocumentPolicy::Ready); + assert_eq!( + foundry.document_policy, + OcrDocumentPolicy::FetchRemoteUrlAndInline + ); + assert_eq!( + vertex.document_policy, + OcrDocumentPolicy::FetchRemoteUrlAndInline + ); + } + + #[test] + fn non_mistral_profiles_preserve_provider_specific_boundaries() { + let azure = ocr_dialect_profile(OcrDialectId::AzureDocumentIntelligence); + assert_eq!( + azure.parameter_policy.disposition(OcrCanonicalField::Pages), + ParameterDisposition::Transform + ); + assert_eq!( + azure.polling, + Some(OcrPollingProfile { + operation_location_header: "operation-location" + }) + ); + + for dialect in [OcrDialectId::ReductoV3, OcrDialectId::ReductoLegacy] { + let reducto = ocr_dialect_profile(dialect); + assert_eq!( + reducto.document_policy, + OcrDocumentPolicy::UploadUnlessProviderReference + ); + assert!(OcrCanonicalField::ALL.iter().all(|field| { + reducto.parameter_policy.disposition(*field) == ParameterDisposition::Reject + })); + } + } + + #[test] + fn every_dialect_has_exactly_one_profile() { + for (index, profile) in OCR_DIALECT_PROFILES.iter().enumerate() { + assert_eq!(ocr_dialect_profile(profile.dialect), profile); + assert!( + OCR_DIALECT_PROFILES[index + 1..] + .iter() + .all(|other| other.dialect != profile.dialect) + ); + } + } +} diff --git a/litellm-rust/crates/core/src/ocr/response.rs b/litellm-rust/crates/core/src/ocr/response.rs new file mode 100644 index 00000000000..1338d99ae2d --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/response.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OcrPage { + pub index: u32, + pub markdown: String, +} + +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct OcrUsage { + pub pages_processed: Option, + pub credits: Option, + pub document_size_bytes: Option, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct NormalizedOcr { + pub pages: Vec, + pub model: String, + pub document_annotation: Option, + pub content: Option, + pub tables: Option>, + pub key_value_pairs: Option>, + pub usage: OcrUsage, +} + +#[derive(Clone, PartialEq)] +pub struct NativeOcrPayload(Value); + +impl NativeOcrPayload { + pub fn new(value: Value) -> Self { + Self(value) + } + + pub fn as_value(&self) -> &Value { + &self.0 + } + + pub fn into_value(self) -> Value { + self.0 + } +} + +#[derive(Clone, PartialEq)] +pub struct OcrOutcome { + pub normalized: NormalizedOcr, + pub native: NativeOcrPayload, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalized_serialization_excludes_the_native_payload() { + let outcome = OcrOutcome { + normalized: NormalizedOcr { + pages: vec![OcrPage { + index: 0, + markdown: "portable".to_string(), + }], + model: "ocr-model".to_string(), + document_annotation: None, + content: None, + tables: None, + key_value_pairs: None, + usage: OcrUsage::default(), + }, + native: NativeOcrPayload::new(json!({"provider_secret_field": "native"})), + }; + let public = + serde_json::to_value(&outcome.normalized).expect("normalized output serializes"); + assert_eq!(public["pages"][0]["markdown"], "portable"); + assert!(public.get("provider_secret_field").is_none()); + assert_eq!(outcome.native.as_value()["provider_secret_field"], "native"); + } +} diff --git a/litellm-rust/crates/core/src/ocr/transformation.rs b/litellm-rust/crates/core/src/ocr/transformation.rs index cb3e735e533..cb5c58c6fea 100644 --- a/litellm-rust/crates/core/src/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/transformation.rs @@ -1,7 +1,6 @@ +use crate::Error; use serde_json::{Map, Value}; -use crate::CoreResult; - use super::types::{OcrRequestData, OcrResponseData}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -26,16 +25,16 @@ pub enum OcrResponseHandling { } pub trait OcrProviderConfig: Sync { - fn supported_ocr_params(&self) -> &'static [&'static str]; + fn get_supported_ocr_params(&self) -> &'static [&'static str]; + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn map_ocr_params(&self, non_default_params: &Map) -> Map { - let mut mapped_params = Map::new(); - for (param, value) in non_default_params { - if self.supported_ocr_params().contains(¶m.as_str()) { - mapped_params.insert(param.clone(), value.clone()); - } - } - mapped_params + let supported_params = self.get_supported_ocr_params(); + non_default_params + .iter() + .filter(|(param, _)| supported_params.contains(¶m.as_str())) + .map(|(param, value)| (param.clone(), value.clone())) + .collect() } fn transform_ocr_request( @@ -43,13 +42,13 @@ pub trait OcrProviderConfig: Sync { model: &str, document: Value, optional_params: Map, - ) -> CoreResult; + ) -> Result; fn transform_ocr_response( &self, model: &str, response_json: Value, - ) -> CoreResult; + ) -> Result; fn complete_url( &self, @@ -57,13 +56,13 @@ pub trait OcrProviderConfig: Sync { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn resolve_api_key( &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult; + ) -> Result; fn auth_strategy(&self) -> OcrAuthStrategy { OcrAuthStrategy::Bearer diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index 1a72b8f1d66..e5ed78987e8 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -1,6 +1,34 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Field { + Absent, + Null, + Value(T), +} + +impl Field { + pub fn as_ref(&self) -> Field<&T> { + match self { + Self::Absent => Field::Absent, + Self::Null => Field::Null, + Self::Value(value) => Field::Value(value), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OcrDialectId { + Mistral, + AzureFoundryMistral, + AzureDocumentIntelligence, + VertexMistral, + VertexDeepSeek, + ReductoV3, + ReductoLegacy, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OcrRequestData { pub data: Value, diff --git a/litellm-rust/crates/core/src/ocr/wire.rs b/litellm-rust/crates/core/src/ocr/wire.rs new file mode 100644 index 00000000000..e920e96519b --- /dev/null +++ b/litellm-rust/crates/core/src/ocr/wire.rs @@ -0,0 +1,358 @@ +use std::collections::BTreeMap; +use std::io::{self, Write}; +use std::str::Utf8Error; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use mime::Mime; +use serde_json::Value; +use thiserror::Error; + +const BASE64_INPUT_CHUNK_SIZE: usize = 48 * 1024; + +#[derive(Debug, Error)] +pub enum OcrWireError { + #[error("failed to encode OCR JSON body: {0}")] + Json(#[from] serde_json::Error), + #[error("failed to write OCR body: {0}")] + Io(#[from] io::Error), + #[error("encoded OCR data URI is not UTF-8: {0}")] + InvalidDataUri(#[from] Utf8Error), +} + +#[derive(Clone, PartialEq)] +pub enum OcrJsonValue { + Value(Value), + Array(Vec), + Object(BTreeMap), + InlineDataUri { media_type: Mime, bytes: Bytes }, + EncodedDataUri(Bytes), +} + +impl OcrJsonValue { + fn write_to(&self, writer: &mut impl Write) -> Result<(), OcrWireError> { + match self { + Self::Value(value) => serde_json::to_writer(writer, value).map_err(Into::into), + Self::Array(values) => { + writer.write_all(b"[")?; + for (index, value) in values.iter().enumerate() { + if index != 0 { + writer.write_all(b",")?; + } + value.write_to(writer)?; + } + writer.write_all(b"]")?; + Ok(()) + } + Self::Object(fields) => { + writer.write_all(b"{")?; + for (index, (key, value)) in fields.iter().enumerate() { + if index != 0 { + writer.write_all(b",")?; + } + serde_json::to_writer(&mut *writer, key)?; + writer.write_all(b":")?; + value.write_to(writer)?; + } + writer.write_all(b"}")?; + Ok(()) + } + Self::InlineDataUri { media_type, bytes } => { + writer.write_all(b"\"data:")?; + writer.write_all(media_type.as_ref().as_bytes())?; + writer.write_all(b";base64,")?; + for chunk in bytes.chunks(BASE64_INPUT_CHUNK_SIZE) { + let encoded = STANDARD.encode(chunk); + writer.write_all(encoded.as_bytes())?; + } + writer.write_all(b"\"")?; + Ok(()) + } + Self::EncodedDataUri(data_uri) => { + let data_uri = std::str::from_utf8(data_uri)?; + serde_json::to_writer(writer, data_uri).map_err(Into::into) + } + } + } +} + +#[derive(Clone, PartialEq)] +pub enum MultipartPart { + Text { + name: String, + value: String, + }, + Json { + name: String, + value: Value, + }, + File { + name: String, + file_name: String, + media_type: Mime, + bytes: Bytes, + }, +} + +#[derive(Clone, PartialEq)] +pub struct MultipartBodyPlan { + boundary: String, + parts: Vec, +} + +impl MultipartBodyPlan { + pub fn new(boundary: impl Into, parts: Vec) -> Self { + Self { + boundary: boundary.into(), + parts, + } + } + + pub fn boundary(&self) -> &str { + &self.boundary + } + + pub fn parts(&self) -> &[MultipartPart] { + &self.parts + } + + fn write_to(&self, writer: &mut impl Write) -> Result<(), OcrWireError> { + for part in &self.parts { + write!(writer, "--{}\r\n", self.boundary)?; + match part { + MultipartPart::Text { name, value } => { + write!( + writer, + "Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" + )?; + } + MultipartPart::Json { name, value } => { + write!( + writer, + "Content-Disposition: form-data; name=\"{name}\"\r\nContent-Type: application/json\r\n\r\n" + )?; + serde_json::to_writer(&mut *writer, value)?; + writer.write_all(b"\r\n")?; + } + MultipartPart::File { + name, + file_name, + media_type, + bytes, + } => { + write!( + writer, + "Content-Disposition: form-data; name=\"{name}\"; filename=\"{file_name}\"\r\nContent-Type: {media_type}\r\n\r\n" + )?; + writer.write_all(bytes)?; + writer.write_all(b"\r\n")?; + } + } + } + write!(writer, "--{}--\r\n", self.boundary)?; + Ok(()) + } +} + +#[derive(Clone, PartialEq)] +pub enum OcrWireBody { + Json(Value), + JsonWithMedia(OcrJsonValue), + Multipart(MultipartBodyPlan), +} + +impl OcrWireBody { + pub fn content_type(&self) -> String { + match self { + Self::Json(_) | Self::JsonWithMedia(_) => "application/json".to_string(), + Self::Multipart(plan) => { + format!("multipart/form-data; boundary={}", plan.boundary()) + } + } + } + + pub fn write_to(&self, mut writer: impl Write) -> Result<(), OcrWireError> { + match self { + Self::Json(value) => serde_json::to_writer(writer, value).map_err(Into::into), + Self::JsonWithMedia(value) => value.write_to(&mut writer), + Self::Multipart(plan) => plan.write_to(&mut writer), + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + use std::sync::Arc; + + use serde_json::json; + + use super::*; + + #[test] + fn ordinary_json_body_writes_without_a_media_plan() { + let body = OcrWireBody::Json(json!({"model": "ocr-model", "pages": [0, 2]})); + let mut encoded = Vec::new(); + + body.write_to(&mut encoded).expect("body writes"); + + assert_eq!(body.content_type(), "application/json"); + assert_eq!( + serde_json::from_slice::(&encoded).expect("valid JSON"), + json!({"model": "ocr-model", "pages": [0, 2]}) + ); + } + + #[test] + fn json_with_media_streams_raw_bytes_as_a_data_uri() { + let owner: Arc<[u8]> = vec![b'x'; BASE64_INPUT_CHUNK_SIZE + 1].into(); + let bytes = Bytes::from_owner(Arc::clone(&owner)); + let source_pointer = bytes.as_ptr(); + let body = OcrWireBody::JsonWithMedia(OcrJsonValue::Object(BTreeMap::from([ + ( + "document".to_string(), + OcrJsonValue::InlineDataUri { + media_type: "application/pdf".parse().expect("valid MIME type"), + bytes, + }, + ), + ("model".to_string(), OcrJsonValue::Value(json!("ocr-model"))), + ]))); + + let OcrWireBody::JsonWithMedia(OcrJsonValue::Object(fields)) = &body else { + panic!("media JSON body must retain its typed representation"); + }; + let OcrJsonValue::InlineDataUri { bytes, .. } = &fields["document"] else { + panic!("document must remain shared binary media"); + }; + assert_eq!(bytes.as_ptr(), source_pointer); + assert_eq!(Arc::strong_count(&owner), 2); + + let mut encoded = Vec::new(); + body.write_to(&mut encoded).expect("body writes"); + let expected_data_uri = format!( + "data:application/pdf;base64,{}", + STANDARD.encode(owner.as_ref()) + ); + let expected = json!({"document": expected_data_uri, "model": "ocr-model"}); + assert_eq!( + serde_json::from_slice::(&encoded).expect("valid JSON"), + expected + ); + } + + #[test] + fn raw_media_encoding_uses_bounded_writes() { + let bytes = Bytes::from(vec![b'x'; BASE64_INPUT_CHUNK_SIZE * 3 + 1]); + let body = OcrWireBody::JsonWithMedia(OcrJsonValue::InlineDataUri { + media_type: "application/pdf".parse().expect("valid MIME type"), + bytes, + }); + let mut sink = BoundedSink { + maximum_write: BASE64_INPUT_CHUNK_SIZE * 4 / 3, + written: 0, + }; + + body.write_to(&mut sink).expect("writes remain bounded"); + + assert!(sink.written > BASE64_INPUT_CHUNK_SIZE * 4); + } + + #[test] + fn encoded_data_uri_is_retained_without_decoding_or_copying() { + let data_uri = Bytes::from_static(b"data:image/png;base64,aGVsbG8="); + let source_pointer = data_uri.as_ptr(); + let body = OcrWireBody::JsonWithMedia(OcrJsonValue::EncodedDataUri(data_uri)); + + let OcrWireBody::JsonWithMedia(OcrJsonValue::EncodedDataUri(retained)) = &body else { + panic!("encoded data URI must remain bytes"); + }; + assert_eq!(retained.as_ptr(), source_pointer); + + let mut encoded = Vec::new(); + body.write_to(&mut encoded).expect("body writes"); + assert_eq!(encoded, br#""data:image/png;base64,aGVsbG8=""#); + } + + #[test] + fn encoded_data_uri_is_json_escaped_without_changing_its_allocation() { + let data_uri = Bytes::from_static(b"data:text/plain,quoted%20\"value\""); + let source_pointer = data_uri.as_ptr(); + let body = OcrWireBody::JsonWithMedia(OcrJsonValue::EncodedDataUri(data_uri)); + + let mut encoded = Vec::new(); + body.write_to(&mut encoded).expect("body writes"); + + let OcrWireBody::JsonWithMedia(OcrJsonValue::EncodedDataUri(retained)) = &body else { + panic!("encoded data URI expected"); + }; + assert_eq!(retained.as_ptr(), source_pointer); + assert_eq!( + serde_json::from_slice::(&encoded).expect("valid JSON string"), + "data:text/plain,quoted%20\"value\"" + ); + } + + #[test] + fn multipart_file_is_replayable_and_retains_shared_bytes() { + let file = Bytes::from_static(b"large-pdf-payload"); + let source_pointer = file.as_ptr(); + let body = OcrWireBody::Multipart(MultipartBodyPlan::new( + "ocr-boundary", + vec![MultipartPart::File { + name: "file".to_string(), + file_name: "document.pdf".to_string(), + media_type: "application/pdf".parse().expect("valid MIME type"), + bytes: file, + }], + )); + + let OcrWireBody::Multipart(plan) = &body else { + panic!("multipart body expected"); + }; + let MultipartPart::File { bytes, .. } = &plan.parts()[0] else { + panic!("file part expected"); + }; + assert_eq!(bytes.as_ptr(), source_pointer); + + let mut first = Vec::new(); + let mut retry = Vec::new(); + body.write_to(&mut first).expect("first write succeeds"); + body.write_to(&mut retry).expect("retry write succeeds"); + assert_eq!(first, retry); + assert!( + first + .windows(file_name_marker().len()) + .any(|window| window == file_name_marker()) + ); + assert!( + first + .windows(bytes.len()) + .any(|window| window == bytes.as_ref()) + ); + } + + fn file_name_marker() -> &'static [u8] { + b"filename=\"document.pdf\"" + } + + struct BoundedSink { + maximum_write: usize, + written: usize, + } + + impl Write for BoundedSink { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if buffer.len() > self.maximum_write { + return Err(io::Error::other("write exceeded bound")); + } + self.written += buffer.len(); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } +} diff --git a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/tests.rs b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/tests.rs index 4534ac0182c..b22de6c47de 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/tests.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::Error; use serde_json::json; fn messages(value: Value) -> Vec { @@ -19,7 +20,7 @@ fn transform(model: &str, msgs: Value, opts: Value) -> Value { .body } -fn transform_response(body: Value) -> CoreResult { +fn transform_response(body: Value) -> Result { ANTHROPIC_CHAT_COMPLETIONS_CONFIG .transform_response("claude-sonnet-4-5", ProviderChatResponseData { body }) } @@ -390,29 +391,26 @@ fn declines_a_response_carrying_a_non_text_block() { "usage": {"input_tokens": 1, "output_tokens": 1} })) .expect_err("non-text block"); - assert_eq!( - err, - CoreError::Unsupported("non-text response content block") - ); + assert_eq!(err, Error::Unsupported("non-text response content block")); } #[test] fn errors_on_a_response_missing_required_fields() { assert_eq!( transform_response(json!("nope")).expect_err("not an object"), - CoreError::InvalidResponse("messages response is not an object".to_string()) + Error::InvalidResponse("messages response is not an object".to_string()) ); assert_eq!( transform_response(json!({"model": "m", "usage": {}})).expect_err("no content"), - CoreError::MissingField("content") + Error::MissingField("content") ); assert_eq!( transform_response(json!({"model": "m", "content": []})).expect_err("no usage"), - CoreError::MissingField("usage") + Error::MissingField("usage") ); assert_eq!( transform_response(json!({"content": [], "usage": {}})).expect_err("no model"), - CoreError::MissingField("model") + Error::MissingField("model") ); } diff --git a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs index 3658642b539..97cc48aa6f2 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs @@ -10,7 +10,7 @@ use crate::chat_completions::types::{ ProviderChatRequestData, ProviderChatResponseData, }; use crate::constants::ANTHROPIC_OAUTH_TOKEN_PREFIX; -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use crate::providers::anthropic::messages::transformation::{ complete_anthropic_url, resolve_anthropic_api_key, }; @@ -74,7 +74,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { _model: &str, _optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { Ok(complete_anthropic_url(api_base, env_lookup)) } @@ -84,7 +84,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { _model: &str, _optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { Ok(ChatCompletionsAuth::Header { name: "x-api-key", value: resolve_anthropic_api_key(api_key, env_lookup)?, @@ -137,7 +137,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { model: &str, messages: Vec, optional_params: Map, - ) -> CoreResult { + ) -> Result { Ok(ProviderChatRequestData { body: anthropic_body(model, &build_conversation(&messages), optional_params), }) @@ -147,15 +147,16 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { &self, _model: &str, response: ProviderChatResponseData, - ) -> CoreResult { - let body = response.body.as_object().ok_or_else(|| { - CoreError::InvalidResponse("messages response is not an object".into()) - })?; + ) -> Result { + let body = response + .body + .as_object() + .ok_or_else(|| Error::InvalidResponse("messages response is not an object".into()))?; let content = body .get("content") .and_then(Value::as_array) - .ok_or(CoreError::MissingField("content"))?; + .ok_or(Error::MissingField("content"))?; // The route declines tool and thinking requests, so a non-text block // means the response carries something this path never asked for. // Decline rather than silently dropping it; the host falls back. @@ -163,7 +164,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { .iter() .any(|block| block.get("type").and_then(Value::as_str) != Some("text")) { - return Err(CoreError::Unsupported("non-text response content block")); + return Err(Error::Unsupported("non-text response content block")); } let text: String = content .iter() @@ -173,7 +174,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { let usage = body .get("usage") .and_then(Value::as_object) - .ok_or(CoreError::MissingField("usage"))?; + .ok_or(Error::MissingField("usage"))?; let field = |name: &str| usage.get(name).and_then(Value::as_u64).unwrap_or(0); Ok(ChatCompletionsResponse { @@ -181,7 +182,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig { model: body .get("model") .and_then(Value::as_str) - .ok_or(CoreError::MissingField("model"))? + .ok_or(Error::MissingField("model"))? .to_string(), choices: vec![ChatCompletionsChoice { index: 0, diff --git a/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs b/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs index 829f2260d3c..8fcc0f36c7d 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy}; const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; @@ -17,12 +17,12 @@ pub fn non_empty(value: Option<&str>) -> Option<&str> { pub fn resolve_anthropic_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { non_empty(api_key) .map(str::to_string) .or_else(|| env_lookup(ANTHROPIC_API_KEY_ENV).filter(|value| !value.trim().is_empty())) .ok_or_else(|| { - CoreError::Auth( + Error::Auth( "Missing Anthropic API Key - Set `api_key` or the ANTHROPIC_API_KEY \ environment variable" .to_string(), @@ -52,7 +52,7 @@ impl AnthropicMessagesProviderConfig for AnthropicMessagesConfig { api_base: Option<&str>, _model: &str, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { Ok(complete_anthropic_url(api_base, env_lookup)) } @@ -60,7 +60,7 @@ impl AnthropicMessagesProviderConfig for AnthropicMessagesConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_anthropic_api_key(api_key, env_lookup) } @@ -121,7 +121,7 @@ mod tests { ); assert!(matches!( resolve_anthropic_api_key(None, &|_| None).expect_err("missing key"), - CoreError::Auth(_) + Error::Auth(_) )); } diff --git a/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs b/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs index 7b958c77ba3..70dad0300f1 100644 --- a/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs +++ b/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy}; use crate::messages::types::{ AnthropicMessage, AnthropicMessagesRequest, AnthropicMessagesResponse, ContentBlock, @@ -28,12 +28,12 @@ pub const AZURE_ANTHROPIC_MESSAGES_CONFIG: AzureAnthropicMessagesConfig = pub fn resolve_azure_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { non_empty(api_key) .map(str::to_string) .or_else(|| env_lookup(AZURE_API_KEY_ENV).filter(|value| !value.trim().is_empty())) .ok_or_else(|| { - CoreError::Auth( + Error::Auth( "Missing Azure API Key - Set `api_key` or the AZURE_API_KEY environment variable" .to_string(), ) @@ -43,12 +43,12 @@ pub fn resolve_azure_api_key( pub fn complete_azure_anthropic_url( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { let api_base = non_empty(api_base) .map(str::to_string) .or_else(|| env_lookup(AZURE_API_BASE_ENV).filter(|value| !value.trim().is_empty())) .ok_or_else(|| { - CoreError::Auth( + Error::Auth( "Missing Azure API Base - Set `api_base` or the AZURE_API_BASE environment variable. \ Expected format: https://.services.ai.azure.com/anthropic" .to_string(), @@ -147,7 +147,7 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig { api_base: Option<&str>, _model: &str, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { complete_azure_anthropic_url(api_base, env_lookup) } @@ -155,7 +155,7 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_azure_api_key(api_key, env_lookup) } @@ -174,7 +174,7 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig { fn transform_request( &self, request: AnthropicMessagesRequest, - ) -> CoreResult { + ) -> Result { let mut request = fold_system_role_messages(request); if let Some(system) = request.system.as_mut() { strip_scope_from_system(system); @@ -190,7 +190,7 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig { &self, model: &str, response: AnthropicMessagesResponse, - ) -> CoreResult { + ) -> Result { self.anthropic.transform_response(model, response) } } @@ -268,7 +268,7 @@ mod tests { "https://env.services.ai.azure.com/anthropic/v1/messages" ); let err = complete_azure_anthropic_url(Some(" "), &|_| None).expect_err("missing base"); - assert!(matches!(err, CoreError::Auth(_))); + assert!(matches!(err, Error::Auth(_))); } #[test] @@ -284,7 +284,7 @@ mod tests { ); assert!(matches!( resolve_azure_api_key(None, &|_| None).expect_err("missing key"), - CoreError::Auth(_) + Error::Auth(_) )); } diff --git a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs index c20a3984edc..5f22b3801d2 100644 --- a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use crate::error::{CoreError, CoreResult, json_type_name}; +use crate::error::{Error, json_type_name}; use crate::ocr::transformation::{OcrAuthStrategy, OcrProviderConfig, OcrResponseHandling}; use crate::ocr::types::{OcrRequestData, OcrResponseData}; use serde_json::{Map, Value, json}; @@ -32,17 +32,17 @@ fn resolve_value( env_name: &str, env_lookup: &dyn Fn(&str) -> Option, missing_message: &str, -) -> CoreResult { +) -> Result { non_empty(explicit) .map(str::to_string) .or_else(|| env_lookup(env_name).filter(|value| !value.trim().is_empty())) - .ok_or_else(|| CoreError::Auth(missing_message.to_string())) + .ok_or_else(|| Error::Auth(missing_message.to_string())) } pub fn resolve_azure_ai_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { resolve_value( api_key, AZURE_AI_API_KEY_ENV, @@ -54,7 +54,7 @@ pub fn resolve_azure_ai_api_key( pub fn resolve_azure_ai_api_base( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { resolve_value( api_base, AZURE_AI_API_BASE_ENV, @@ -66,7 +66,7 @@ pub fn resolve_azure_ai_api_base( pub fn complete_azure_ai_url( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { let base = resolve_azure_ai_api_base(api_base, env_lookup)?; Ok(format!( "{}/providers/mistral/azure/ocr", @@ -77,7 +77,7 @@ pub fn complete_azure_ai_url( pub fn resolve_document_intelligence_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { resolve_value( api_key, AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV, @@ -89,7 +89,7 @@ pub fn resolve_document_intelligence_api_key( pub fn resolve_document_intelligence_endpoint( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { resolve_value( api_base, AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV, @@ -127,7 +127,7 @@ fn pages_token_is_valid(token: &str) -> bool { } } -fn normalize_pages_param(pages: &Value) -> CoreResult> { +fn normalize_pages_param(pages: &Value) -> Result, Error> { match pages { Value::String(value) => { let normalized = value @@ -138,7 +138,7 @@ fn normalize_pages_param(pages: &Value) -> CoreResult> { if normalized.split(',').all(pages_token_is_valid) { Ok(Some(normalized)) } else { - Err(CoreError::InvalidRequest(format!( + Err(Error::InvalidRequest(format!( "Invalid `pages` string for Azure Document Intelligence: {value:?}. Expected format like '1-3,5,7-9'." ))) } @@ -152,7 +152,7 @@ fn normalize_pages_param(pages: &Value) -> CoreResult> { for value in values { let page = value.as_i64().expect("checked is_i64"); if page < 0 { - return Err(CoreError::InvalidRequest( + return Err(Error::InvalidRequest( "`pages` integers must be >= 0 (Mistral 0-based indices)".to_string(), )); } @@ -176,16 +176,16 @@ fn normalize_pages_param(pages: &Value) -> CoreResult> { if normalized.split(',').all(pages_token_is_valid) { return Ok(Some(normalized)); } - return Err(CoreError::InvalidRequest(format!( + return Err(Error::InvalidRequest(format!( "Invalid `pages` list for Azure Document Intelligence: {values:?}. Expected tokens like '1' or '3-5'." ))); } - Err(CoreError::InvalidRequest( + Err(Error::InvalidRequest( "`pages` must be a list[int] (0-based, Mistral-style) or a string like '1-3,5,7-9'." .to_string(), )) } - _ => Err(CoreError::InvalidRequest( + _ => Err(Error::InvalidRequest( "`pages` must be a list[int] (0-based, Mistral-style) or a string like '1-3,5,7-9'." .to_string(), )), @@ -199,13 +199,13 @@ fn feature_token_is_valid(token: &str) -> bool { first.is_ascii_alphabetic() && rest.iter().all(u8::is_ascii_alphanumeric) } -fn invalid_features_error(features: &Value) -> CoreError { - CoreError::InvalidRequest(format!( +fn invalid_features_error(features: &Value) -> Error { + Error::InvalidRequest(format!( "Invalid `features` for Azure Document Intelligence: {features:?}. Expected a list of feature names or a comma-separated string like 'keyValuePairs' or 'keyValuePairs,languages'." )) } -fn normalize_features_param(features: &Value) -> CoreResult> { +fn normalize_features_param(features: &Value) -> Result, Error> { let normalized = match features { Value::String(value) => value .split(',') @@ -237,7 +237,7 @@ pub fn complete_document_intelligence_url( model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { let endpoint = resolve_document_intelligence_endpoint(api_base, env_lookup)?; let mut url = format!( "{}/documentintelligence/documentModels/{}:analyze?api-version={}", @@ -263,20 +263,20 @@ pub fn complete_document_intelligence_url( Ok(url) } -fn document_url_from_mistral_document(document: &Value) -> CoreResult<&str> { - let object = document.as_object().ok_or_else(|| CoreError::InvalidType { +fn document_url_from_mistral_document(document: &Value) -> Result<&str, Error> { + let object = document.as_object().ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(document), })?; let doc_type = object .get("type") .and_then(Value::as_str) - .ok_or(CoreError::MissingField("document.type"))?; + .ok_or(Error::MissingField("document.type"))?; let field_name = match doc_type { "document_url" => "document_url", "image_url" => "image_url", other => { - return Err(CoreError::InvalidRequest(format!( + return Err(Error::InvalidRequest(format!( "Invalid document type: {other}. Must be 'document_url' or 'image_url'" ))); } @@ -285,7 +285,7 @@ fn document_url_from_mistral_document(document: &Value) -> CoreResult<&str> { .get(field_name) .and_then(Value::as_str) .filter(|value| !value.is_empty()) - .ok_or(CoreError::MissingField(field_name)) + .ok_or(Error::MissingField(field_name)) } fn extract_base64_from_data_uri(data_uri: &str) -> &str { @@ -328,8 +328,8 @@ fn page_dimensions(page: &Map) -> Value { } impl OcrProviderConfig for AzureAiOcrConfig { - fn supported_ocr_params(&self) -> &'static [&'static str] { - MISTRAL_OCR_CONFIG.supported_ocr_params() + fn get_supported_ocr_params(&self) -> &'static [&'static str] { + MISTRAL_OCR_CONFIG.get_supported_ocr_params() } fn transform_ocr_request( @@ -337,7 +337,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { model: &str, document: Value, optional_params: Map, - ) -> CoreResult { + ) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_request(model, document, optional_params) } @@ -345,7 +345,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { &self, model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } @@ -355,7 +355,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { _model: &str, _optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { complete_azure_ai_url(api_base, env_lookup) } @@ -363,7 +363,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_azure_ai_api_key(api_key, env_lookup) } @@ -373,7 +373,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { } impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { - fn supported_ocr_params(&self) -> &'static [&'static str] { + fn get_supported_ocr_params(&self) -> &'static [&'static str] { AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS } @@ -382,7 +382,7 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { _model: &str, document: Value, _optional_params: Map, - ) -> CoreResult { + ) -> Result { let document_url = document_url_from_mistral_document(&document)?; let mut data = Map::new(); if document_url.starts_with("data:") { @@ -406,19 +406,19 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { &self, model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { let response = response_json .as_object() - .ok_or_else(|| CoreError::InvalidType { + .ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(&response_json), })?; let status = response .get("status") .and_then(Value::as_str) - .ok_or(CoreError::MissingField("status"))?; + .ok_or(Error::MissingField("status"))?; if status != "succeeded" { - return Err(CoreError::InvalidResponse(format!( + return Err(Error::InvalidResponse(format!( "Azure Document Intelligence analysis failed with status: {status}" ))); } @@ -461,7 +461,7 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { complete_document_intelligence_url(api_base, model, optional_params, env_lookup) } @@ -469,7 +469,7 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_document_intelligence_api_key(api_key, env_lookup) } @@ -600,7 +600,7 @@ mod tests { .expect_err("invalid features must fail"); assert!( - matches!(error, CoreError::InvalidRequest(message) if message.contains("Invalid `features`")), + matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `features`")), "features={features:?}" ); } diff --git a/litellm-rust/crates/core/src/providers/bedrock/audio_transcription.rs b/litellm-rust/crates/core/src/providers/bedrock/audio_transcription.rs index 5e885734182..bb4f6afe5f9 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/audio_transcription.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/audio_transcription.rs @@ -6,7 +6,7 @@ use crate::audio_transcription::transformation::{ use crate::audio_transcription::types::{ AudioTranscriptionRequestData, AudioTranscriptionResponseData, }; -use crate::error::{CoreError, CoreResult, json_type_name}; +use crate::error::{Error, json_type_name}; pub use super::aws_base::{aws_auth_config, bedrock_model_id_and_region, resolve_bedrock_region}; use super::constants::{BEDROCK_RUNTIME_ENDPOINT_TEMPLATE, BEDROCK_SERVICE}; @@ -18,8 +18,8 @@ pub static BEDROCK_AUDIO_TRANSCRIPTION_CONFIG: BedrockAudioTranscriptionConfig = pub struct BedrockAudioTranscriptionConfig; -fn audio_fields(audio: Value) -> CoreResult<(String, String)> { - let object = audio.as_object().ok_or_else(|| CoreError::InvalidType { +fn audio_fields(audio: Value) -> Result<(String, String), Error> { + let object = audio.as_object().ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(&audio), })?; @@ -27,13 +27,13 @@ fn audio_fields(audio: Value) -> CoreResult<(String, String)> { .get("data") .and_then(Value::as_str) .filter(|value| !value.is_empty()) - .ok_or(CoreError::MissingField("audio.data"))?; + .ok_or(Error::MissingField("audio.data"))?; let format = object .get("format") .and_then(Value::as_str) .filter(|value| matches!(*value, "wav" | "mp3" | "flac" | "ogg")) .ok_or_else(|| { - CoreError::InvalidRequest("audio.format must be wav, mp3, flac, or ogg".to_string()) + Error::InvalidRequest("audio.format must be wav, mp3, flac, or ogg".to_string()) })?; Ok((data.to_string(), format.to_string())) } @@ -55,7 +55,7 @@ impl AudioTranscriptionProviderConfig for BedrockAudioTranscriptionConfig { _model: &str, audio: Value, optional_params: Map, - ) -> CoreResult { + ) -> Result { let (data, format) = audio_fields(audio)?; let mut instruction = "Transcribe the audio. Respond with only the transcript.".to_string(); if let Some(language) = optional_string(&optional_params, "language") { @@ -87,14 +87,14 @@ impl AudioTranscriptionProviderConfig for BedrockAudioTranscriptionConfig { &self, _model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { let content = response_json .get("output") .and_then(|value| value.get("message")) .and_then(|value| value.get("content")) .and_then(Value::as_array) .ok_or_else(|| { - CoreError::InvalidResponse("Bedrock response has no output content".to_string()) + Error::InvalidResponse("Bedrock response has no output content".to_string()) })?; let mut text = String::new(); for block in content { @@ -111,7 +111,7 @@ impl AudioTranscriptionProviderConfig for BedrockAudioTranscriptionConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { let (model_id, model_region) = bedrock_model_id_and_region(model); let region = resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup); let endpoint = optional_params @@ -133,7 +133,7 @@ impl AudioTranscriptionProviderConfig for BedrockAudioTranscriptionConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { let (_, model_region) = bedrock_model_id_and_region(model); Ok(AudioTranscriptionAuth::AwsSigV4 { region: resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup), diff --git a/litellm-rust/crates/core/src/providers/bedrock/aws_base.rs b/litellm-rust/crates/core/src/providers/bedrock/aws_base.rs index b11639aa09b..e5e52bfce95 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/aws_base.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/aws_base.rs @@ -4,7 +4,7 @@ use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use crate::caching::in_memory_cache::InMemoryCache; -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use aws_credential_types::Credentials; use aws_credential_types::provider::ProvideCredentials; use aws_sigv4::http_request::{ @@ -197,7 +197,7 @@ pub fn classify_auth( pub async fn resolve_credentials( config: AwsAuthConfig, env_lookup: &(dyn Fn(&str) -> Option + Sync), -) -> CoreResult { +) -> Result { let resolved = config.clone().with_environment(env_lookup); let flow = classify_auth(config, env_lookup); match flow { @@ -244,9 +244,10 @@ pub async fn resolve_credentials( let provider = aws_config::profile::ProfileFileCredentialsProvider::builder() .profile_name(name) .build(); - provider.provide_credentials().await.map_err(|error| { - CoreError::Auth(format!("AWS profile credentials failed: {error}")) - }) + provider + .provide_credentials() + .await + .map_err(|error| Error::Auth(format!("AWS profile credentials failed: {error}"))) } AwsAuthFlow::AssumeRole { role, session_name } => { if is_already_running_as_role(&role, &resolved).await? { @@ -260,7 +261,7 @@ pub async fn resolve_credentials( .build() .await; let credentials = provider.provide_credentials().await.map_err(|error| { - CoreError::Auth(format!("AWS default credentials failed: {error}")) + Error::Auth(format!("AWS default credentials failed: {error}")) })?; set_cached_credentials( key, @@ -301,7 +302,7 @@ pub async fn resolve_credentials( provider .provide_credentials() .await - .map_err(|error| CoreError::Auth(format!("AWS role credentials failed: {error}"))) + .map_err(|error| Error::Auth(format!("AWS role credentials failed: {error}"))) } AwsAuthFlow::WebIdentity { token, @@ -325,13 +326,13 @@ pub async fn resolve_credentials( .send() .await .map_err(|error| { - CoreError::Auth(format!("AWS web identity credentials failed: {error}")) + Error::Auth(format!("AWS web identity credentials failed: {error}")) })?; let credentials = response.credentials().ok_or_else(|| { - CoreError::Auth("AWS web identity response had no credentials".to_string()) + Error::Auth("AWS web identity response had no credentials".to_string()) })?; let expiration = SystemTime::try_from(*credentials.expiration()).map_err(|error| { - CoreError::Auth(format!("AWS web identity expiration was invalid: {error}")) + Error::Auth(format!("AWS web identity expiration was invalid: {error}")) })?; Ok(Credentials::new( credentials.access_key_id(), @@ -350,9 +351,10 @@ pub async fn resolve_credentials( aws_config::default_provider::credentials::DefaultCredentialsChain::builder() .build() .await; - let credentials = provider.provide_credentials().await.map_err(|error| { - CoreError::Auth(format!("AWS default credentials failed: {error}")) - })?; + let credentials = provider + .provide_credentials() + .await + .map_err(|error| Error::Auth(format!("AWS default credentials failed: {error}")))?; set_cached_credentials( key, credentials.clone(), @@ -363,7 +365,7 @@ pub async fn resolve_credentials( } } -async fn is_already_running_as_role(role: &str, config: &AwsAuthConfig) -> CoreResult { +async fn is_already_running_as_role(role: &str, config: &AwsAuthConfig) -> Result { if role_identity(role).is_none() { return Ok(false); } @@ -437,7 +439,7 @@ pub fn sign_bedrock_post( region: &str, credentials: &Credentials, signing_time: SystemTime, -) -> CoreResult> { +) -> Result, Error> { let identity: Identity = credentials.clone().into(); let params = v4::SigningParams::builder() .identity(&identity) @@ -447,14 +449,14 @@ pub fn sign_bedrock_post( .settings(SigningSettings::default()) .build() .map(SigningParams::from) - .map_err(|error| CoreError::Auth(format!("AWS signing parameters failed: {error}")))?; + .map_err(|error| Error::Auth(format!("AWS signing parameters failed: {error}")))?; let header_refs = headers .iter() .map(|(name, value)| (name.as_str(), value.as_str())); let request = SignableRequest::new("POST", url, header_refs, SignableBody::Bytes(body)) - .map_err(|error| CoreError::Auth(format!("AWS signable request failed: {error}")))?; + .map_err(|error| Error::Auth(format!("AWS signable request failed: {error}")))?; let (instructions, _) = sign(request, ¶ms) - .map_err(|error| CoreError::Auth(format!("AWS request signing failed: {error}")))? + .map_err(|error| Error::Auth(format!("AWS request signing failed: {error}")))? .into_parts(); Ok(instructions .headers() diff --git a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs index 4b75dcb8e9d..c86f061b9ca 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::Error; use serde_json::json; fn messages(value: Value) -> Vec { @@ -23,7 +24,7 @@ fn transform(msgs: Value, opts: Value) -> Value { .body } -fn transform_response(body: Value) -> CoreResult { +fn transform_response(body: Value) -> Result { BEDROCK_CHAT_COMPLETIONS_CONFIG.transform_response( "anthropic.claude-sonnet-4-5-v1:0", ProviderChatResponseData { body }, @@ -478,25 +479,22 @@ fn declines_a_response_carrying_a_tool_use_block() { "usage": {"inputTokens": 1, "outputTokens": 1} })) .expect_err("tool use block"); - assert_eq!( - err, - CoreError::Unsupported("non-text response content block") - ); + assert_eq!(err, Error::Unsupported("non-text response content block")); } #[test] fn errors_on_a_response_missing_required_fields() { assert_eq!( transform_response(json!("nope")).expect_err("not an object"), - CoreError::InvalidResponse("converse response is not an object".to_string()) + Error::InvalidResponse("converse response is not an object".to_string()) ); assert_eq!( transform_response(json!({"usage": {}})).expect_err("no output"), - CoreError::MissingField("output.message.content") + Error::MissingField("output.message.content") ); assert_eq!( transform_response(json!({"output": {"message": {"content": []}}})).expect_err("no usage"), - CoreError::MissingField("usage") + Error::MissingField("usage") ); } diff --git a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs index b107950748e..ef5f44b4a14 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs @@ -11,7 +11,7 @@ use crate::chat_completions::types::{ ChatCompletionsUsage, ChatMessage, ChatMessageContent, ProviderChatRequestData, ProviderChatResponseData, }; -use crate::error::{CoreError, CoreResult}; +use crate::error::Error; use super::super::aws_base::{bedrock_model_id_and_region, resolve_bedrock_region}; use super::super::constants::{AWS_BEARER_TOKEN_BEDROCK, BEDROCK_RUNTIME_ENDPOINT_TEMPLATE}; @@ -110,7 +110,7 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { let (model_id, model_region) = bedrock_model_id_and_region(model); let region = resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup); let endpoint = optional_params @@ -137,7 +137,7 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { // Python reads `api_key` as the Bedrock bearer token and consults the // env only when the caller passed none, so a caller-supplied empty key // falls through to SigV4 without reaching for the environment. An @@ -208,7 +208,7 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { _model: &str, messages: Vec, optional_params: Map, - ) -> CoreResult { + ) -> Result { Ok(ProviderChatRequestData { body: converse_body(&build_conversation(&messages), &optional_params), }) @@ -218,17 +218,18 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { &self, model: &str, response: ProviderChatResponseData, - ) -> CoreResult { - let body = response.body.as_object().ok_or_else(|| { - CoreError::InvalidResponse("converse response is not an object".into()) - })?; + ) -> Result { + let body = response + .body + .as_object() + .ok_or_else(|| Error::InvalidResponse("converse response is not an object".into()))?; let content = body .get("output") .and_then(|output| output.get("message")) .and_then(|message| message.get("content")) .and_then(Value::as_array) - .ok_or(CoreError::MissingField("output.message.content"))?; + .ok_or(Error::MissingField("output.message.content"))?; // The route declines tool requests, so anything other than a text block // is something this path never asked for. Decline; the host falls back. if content.iter().any(|block| { @@ -236,7 +237,7 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { .as_object() .is_none_or(|block| block.len() != 1 || !block.contains_key("text")) }) { - return Err(CoreError::Unsupported("non-text response content block")); + return Err(Error::Unsupported("non-text response content block")); } let text: String = content .iter() @@ -246,7 +247,7 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig { let usage = body .get("usage") .and_then(Value::as_object) - .ok_or(CoreError::MissingField("usage"))?; + .ok_or(Error::MissingField("usage"))?; let field = |name: &str| usage.get(name).and_then(Value::as_u64).unwrap_or(0); let computed = usage_from_parts( field("inputTokens"), diff --git a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs index dc720cc4244..ca4bc8b87af 100644 --- a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, CoreResult, json_type_name}; +use crate::error::{Error, json_type_name}; use crate::ocr::transformation::OcrProviderConfig; use crate::ocr::types::{OcrRequestData, OcrResponseData}; use serde_json::{Map, Value}; @@ -47,7 +47,7 @@ pub fn complete_url(api_base: Option<&str>) -> String { /// Resolve the Mistral API key from the explicit param or the environment. /// -/// Blank/whitespace values are treated as absent. Returns `CoreError::Auth` +/// Blank/whitespace values are treated as absent. Returns `Error::Auth` /// when no usable key is available. /// /// Note: the env fallback only reads the process environment. Secret-manager @@ -56,13 +56,13 @@ pub fn complete_url(api_base: Option<&str>) -> String { pub fn resolve_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { api_key .map(str::trim) .filter(|key| !key.is_empty()) .map(str::to_string) .or_else(|| env_lookup(MISTRAL_API_KEY_ENV).filter(|key| !key.trim().is_empty())) - .ok_or_else(|| CoreError::Auth(MISSING_KEY_MESSAGE.to_string())) + .ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string())) } pub struct MistralOcrConfig; @@ -70,18 +70,20 @@ pub struct MistralOcrConfig; pub const MISTRAL_OCR_CONFIG: MistralOcrConfig = MistralOcrConfig; impl OcrProviderConfig for MistralOcrConfig { - fn supported_ocr_params(&self) -> &'static [&'static str] { + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + fn get_supported_ocr_params(&self) -> &'static [&'static str] { SUPPORTED_OCR_PARAMS } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, document: Value, optional_params: Map, - ) -> CoreResult { + ) -> Result { if !document.is_object() { - return Err(CoreError::InvalidType { + return Err(Error::InvalidType { expected: "object", actual: json_type_name(&document), }); @@ -100,14 +102,15 @@ impl OcrProviderConfig for MistralOcrConfig { }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_response( &self, model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { let response_object = response_json .as_object() - .ok_or_else(|| CoreError::InvalidType { + .ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(&response_json), })?; @@ -140,7 +143,7 @@ impl OcrProviderConfig for MistralOcrConfig { _model: &str, _optional_params: &Map, _env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { Ok(complete_url(api_base)) } @@ -148,13 +151,13 @@ impl OcrProviderConfig for MistralOcrConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_api_key(api_key, env_lookup) } } -pub fn supported_ocr_params() -> &'static [&'static str] { - MISTRAL_OCR_CONFIG.supported_ocr_params() +pub fn get_supported_ocr_params() -> &'static [&'static str] { + MISTRAL_OCR_CONFIG.get_supported_ocr_params() } pub fn map_ocr_params(non_default_params: &Map) -> Map { @@ -165,11 +168,11 @@ pub fn transform_ocr_request( model: &str, document: Value, optional_params: Map, -) -> CoreResult { +) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_request(model, document, optional_params) } -pub fn transform_ocr_response(model: &str, response_json: Value) -> CoreResult { +pub fn transform_ocr_response(model: &str, response_json: Value) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } @@ -181,7 +184,7 @@ mod tests { #[test] fn supported_params_match_python_mistral_ocr_config() { assert_eq!( - supported_ocr_params(), + get_supported_ocr_params(), &[ "pages", "include_image_base64", @@ -250,7 +253,7 @@ mod tests { assert_eq!( err, - CoreError::InvalidType { + Error::InvalidType { expected: "object", actual: "string", } @@ -307,6 +310,6 @@ mod tests { #[test] fn resolve_api_key_errors_when_absent() { let err = resolve_api_key(None, &|_| None).expect_err("missing key should error"); - assert_eq!(err, CoreError::Auth(MISSING_KEY_MESSAGE.to_string())); + assert_eq!(err, Error::Auth(MISSING_KEY_MESSAGE.to_string())); } } diff --git a/litellm-rust/crates/core/src/providers/openai/realtime/transformation.rs b/litellm-rust/crates/core/src/providers/openai/realtime/transformation.rs index b3f6b03b28a..f1985f81b7d 100644 --- a/litellm-rust/crates/core/src/providers/openai/realtime/transformation.rs +++ b/litellm-rust/crates/core/src/providers/openai/realtime/transformation.rs @@ -1,4 +1,4 @@ -use crate::CoreResult; +use crate::Error; use crate::realtime::transformation::RealtimeProviderConfig; use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult}; @@ -72,7 +72,7 @@ impl RealtimeProviderConfig for OpenAiRealtimeConfig { &self, event: &RealtimeEvent, _model: &str, - ) -> CoreResult { + ) -> Result { Ok(RealtimeTransformResult::passthrough(event.clone())) } @@ -80,7 +80,7 @@ impl RealtimeProviderConfig for OpenAiRealtimeConfig { &self, event: &RealtimeEvent, _model: &str, - ) -> CoreResult { + ) -> Result { Ok(RealtimeTransformResult::passthrough(event.clone())) } } @@ -88,14 +88,14 @@ impl RealtimeProviderConfig for OpenAiRealtimeConfig { pub fn transform_realtime_request( event: &RealtimeEvent, model: &str, -) -> CoreResult { +) -> Result { OPENAI_REALTIME_CONFIG.transform_realtime_request(event, model) } pub fn transform_realtime_response( event: &RealtimeEvent, model: &str, -) -> CoreResult { +) -> Result { OPENAI_REALTIME_CONFIG.transform_realtime_response(event, model) } diff --git a/litellm-rust/crates/core/src/providers/openai/responses/transformation.rs b/litellm-rust/crates/core/src/providers/openai/responses/transformation.rs index e15197c468c..be86bb90311 100644 --- a/litellm-rust/crates/core/src/providers/openai/responses/transformation.rs +++ b/litellm-rust/crates/core/src/providers/openai/responses/transformation.rs @@ -1,4 +1,4 @@ -use crate::CoreResult; +use crate::Error; use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult}; use crate::responses::websocket::{ResponsesWebSocketProviderConfig, enforce_model}; @@ -15,7 +15,7 @@ impl ResponsesWebSocketProviderConfig for OpenAIResponsesWsConfig { &self, event: &ResponsesWsEvent, model: &str, - ) -> CoreResult { + ) -> Result { Ok(ResponsesWsTransformResult::passthrough(enforce_model( event, model, ))) @@ -25,7 +25,7 @@ impl ResponsesWebSocketProviderConfig for OpenAIResponsesWsConfig { &self, event: &ResponsesWsEvent, _model: &str, - ) -> CoreResult { + ) -> Result { Ok(ResponsesWsTransformResult::passthrough(event.clone())) } } diff --git a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs index 6300149c237..2fd9bc44630 100644 --- a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, CoreResult, json_type_name}; +use crate::error::{Error, json_type_name}; use crate::ocr::transformation::OcrProviderConfig; use crate::ocr::types::{OcrRequestData, OcrResponseData}; use serde_json::{Map, Value, json}; @@ -43,7 +43,7 @@ pub fn is_deepseek_model(model: &str) -> bool { pub fn resolve_vertex_api_key( api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { api_key .map(str::trim) .filter(|key| !key.is_empty()) @@ -51,7 +51,7 @@ pub fn resolve_vertex_api_key( .or_else(|| env_lookup(VERTEX_AI_API_KEY_ENV).filter(|key| !key.trim().is_empty())) .or_else(|| env_lookup(VERTEXAI_API_KEY_ENV).filter(|key| !key.trim().is_empty())) .ok_or_else(|| { - CoreError::Auth( + Error::Auth( "Missing Vertex AI access token - pass api_key or provide Authorization via extra_headers" .to_string(), ) @@ -61,12 +61,12 @@ pub fn resolve_vertex_api_key( fn vertex_project( params: &Map, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { string_param(params, &["vertex_project", "vertex_ai_project"]) .map(str::to_string) .or_else(|| env_lookup(VERTEXAI_PROJECT_ENV).filter(|value| !value.trim().is_empty())) .ok_or_else(|| { - CoreError::InvalidRequest( + Error::InvalidRequest( "Missing vertex_project - Set VERTEXAI_PROJECT environment variable or pass vertex_project parameter" .to_string(), ) @@ -99,7 +99,7 @@ pub fn complete_vertex_mistral_url( model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { let project = vertex_project(optional_params, env_lookup)?; let location = vertex_location(optional_params, env_lookup); let base = vertex_mistral_api_base(api_base, &location); @@ -112,7 +112,7 @@ pub fn complete_vertex_deepseek_url( api_base: Option<&str>, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, -) -> CoreResult { +) -> Result { let project = vertex_project(optional_params, env_lookup)?; let location = vertex_location(optional_params, env_lookup); let base = api_base @@ -125,20 +125,20 @@ pub fn complete_vertex_deepseek_url( )) } -fn document_content_item(document: &Value) -> CoreResult { - let object = document.as_object().ok_or_else(|| CoreError::InvalidType { +fn document_content_item(document: &Value) -> Result { + let object = document.as_object().ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(document), })?; let doc_type = object .get("type") .and_then(Value::as_str) - .ok_or(CoreError::MissingField("document.type"))?; + .ok_or(Error::MissingField("document.type"))?; let url_field = match doc_type { "image_url" => "image_url", "document_url" => "document_url", other => { - return Err(CoreError::InvalidRequest(format!( + return Err(Error::InvalidRequest(format!( "Unsupported document type: {other}. Expected 'image_url' or 'document_url'" ))); } @@ -147,7 +147,7 @@ fn document_content_item(document: &Value) -> CoreResult { .get(url_field) .and_then(Value::as_str) .filter(|value| !value.is_empty()) - .ok_or(CoreError::MissingField(url_field))?; + .ok_or(Error::MissingField(url_field))?; Ok(json!({ "type": "image_url", @@ -163,7 +163,7 @@ fn deepseek_model_name(model: &str) -> String { } } -fn first_choice_content(response: &Value) -> CoreResult { +fn first_choice_content(response: &Value) -> Result { response .get("choices") .and_then(Value::as_array) @@ -176,9 +176,7 @@ fn first_choice_content(response: &Value) -> CoreResult { Value::Object(_) => true, _ => false, }) - .ok_or_else(|| { - CoreError::InvalidResponse("No content in DeepSeek OCR response".to_string()) - }) + .ok_or_else(|| Error::InvalidResponse("No content in DeepSeek OCR response".to_string())) } fn ocr_data_from_content(content: Value, usage: Option, model: &str) -> Value { @@ -210,8 +208,8 @@ fn ocr_data_from_content(content: Value, usage: Option, model: &str) -> V } impl OcrProviderConfig for VertexAiOcrConfig { - fn supported_ocr_params(&self) -> &'static [&'static str] { - MISTRAL_OCR_CONFIG.supported_ocr_params() + fn get_supported_ocr_params(&self) -> &'static [&'static str] { + MISTRAL_OCR_CONFIG.get_supported_ocr_params() } fn transform_ocr_request( @@ -219,7 +217,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { model: &str, document: Value, optional_params: Map, - ) -> CoreResult { + ) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_request(model, document, optional_params) } @@ -227,7 +225,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { &self, model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } @@ -237,7 +235,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { complete_vertex_mistral_url(api_base, model, optional_params, env_lookup) } @@ -245,7 +243,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_vertex_api_key(api_key, env_lookup) } @@ -255,7 +253,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { } impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { - fn supported_ocr_params(&self) -> &'static [&'static str] { + fn get_supported_ocr_params(&self) -> &'static [&'static str] { DEEPSEEK_SUPPORTED_OCR_PARAMS } @@ -264,7 +262,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { model: &str, document: Value, optional_params: Map, - ) -> CoreResult { + ) -> Result { let mut data = Map::new(); data.insert( "model".to_string(), @@ -289,10 +287,10 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { &self, model: &str, response_json: Value, - ) -> CoreResult { + ) -> Result { let response = response_json .as_object() - .ok_or_else(|| CoreError::InvalidType { + .ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(&response_json), })?; @@ -314,7 +312,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { }); } - let object = ocr_data.as_object().ok_or_else(|| CoreError::InvalidType { + let object = ocr_data.as_object().ok_or_else(|| Error::InvalidType { expected: "object", actual: json_type_name(&ocr_data), })?; @@ -346,7 +344,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { _model: &str, optional_params: &Map, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { complete_vertex_deepseek_url(api_base, optional_params, env_lookup) } @@ -354,7 +352,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { &self, api_key: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, - ) -> CoreResult { + ) -> Result { resolve_vertex_api_key(api_key, env_lookup) } } diff --git a/litellm-rust/crates/core/src/realtime/transformation.rs b/litellm-rust/crates/core/src/realtime/transformation.rs index 69b88687000..b08084514ef 100644 --- a/litellm-rust/crates/core/src/realtime/transformation.rs +++ b/litellm-rust/crates/core/src/realtime/transformation.rs @@ -1,4 +1,4 @@ -use crate::CoreResult; +use crate::Error; use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult}; pub trait RealtimeProviderConfig { @@ -11,12 +11,12 @@ pub trait RealtimeProviderConfig { &self, event: &RealtimeEvent, model: &str, - ) -> CoreResult; + ) -> Result; /// Transform a backend → client event before it is forwarded downstream. fn transform_realtime_response( &self, event: &RealtimeEvent, model: &str, - ) -> CoreResult; + ) -> Result; } diff --git a/litellm-rust/crates/core/src/responses/instrumentation.rs b/litellm-rust/crates/core/src/responses/instrumentation.rs index ec04571da14..b1098f4d386 100644 --- a/litellm-rust/crates/core/src/responses/instrumentation.rs +++ b/litellm-rust/crates/core/src/responses/instrumentation.rs @@ -5,9 +5,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; +use crate::Error; use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType}; -use crate::{CoreError, CoreResult}; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ResponsesWsUsage { @@ -205,7 +205,7 @@ impl ResponsesWsInstrumentation { } } -type LifecycleFuture<'a, T> = Pin> + Send + 'a>>; +type LifecycleFuture<'a, T> = Pin> + Send + 'a>>; impl CallLifecycleHooks<(), (), ()> for ResponsesWsInstrumentation { type PreCallFuture<'a> = LifecycleFuture<'a, ()>; @@ -246,7 +246,7 @@ impl CallLifecycleHooks<(), (), ()> for ResponsesWsInstrumentation { fn async_log_failure_event<'a>( &'a self, _context: &'a CallLifecycleContext, - _error: &'a CoreError, + _error: &'a Error, _timing: &'a CallLifecycleTiming, ) -> Self::FailureFuture<'a> { Box::pin(async move { @@ -342,7 +342,7 @@ mod tests { ), (), &instrumentation, - |_| async { Ok::<(), CoreError>(()) }, + |_| async { Ok::<(), Error>(()) }, ) .await; diff --git a/litellm-rust/crates/core/src/responses/mod.rs b/litellm-rust/crates/core/src/responses/mod.rs index 5ec5a2caef8..7b211ba191b 100644 --- a/litellm-rust/crates/core/src/responses/mod.rs +++ b/litellm-rust/crates/core/src/responses/mod.rs @@ -1,3 +1,81 @@ pub mod instrumentation; pub mod types; pub mod websocket; + +use crate::error::Error; +use crate::streaming::OpenedStream; +use types::{ResponsesStreamEvent, ResponsesStreamRequest, ResponsesWebSocketRequest}; +use websocket::TypedResponsesWebSocketSession; + +pub async fn responses_stream( + _request: ResponsesStreamRequest, +) -> Result, Error> { + Err(Error::Unsupported( + "responses HTTP streaming provider registration", + )) +} + +pub async fn responses_websocket( + _request: ResponsesWebSocketRequest, +) -> Result, Error> { + Err(Error::Unsupported( + "responses WebSocket streaming provider registration", + )) +} + +#[cfg(test)] +mod stream_entrypoint_tests { + use serde_json::json; + + use super::*; + use crate::streaming::{ + ProviderCredentials, StreamProviderId, StreamTarget, StreamTransportOptions, + }; + + fn target() -> StreamTarget { + StreamTarget::new( + StreamProviderId::OpenAi, + ProviderCredentials::default(), + None, + ) + } + + #[tokio::test] + async fn typed_http_stream_declines_until_a_provider_is_registered() { + let body = serde_json::from_value(json!({ + "model": "gpt-5", + "input": "hello", + "stream": true + })) + .expect("valid Responses stream request"); + let result = responses_stream(ResponsesStreamRequest { + body, + target: target(), + transport: StreamTransportOptions::default(), + }) + .await; + + assert!(matches!( + result, + Err(Error::Unsupported( + "responses HTTP streaming provider registration" + )) + )); + } + + #[tokio::test] + async fn typed_websocket_declines_until_a_provider_is_registered() { + let result = responses_websocket(ResponsesWebSocketRequest { + target: target(), + transport: StreamTransportOptions::default(), + }) + .await; + + assert!(matches!( + result, + Err(Error::Unsupported( + "responses WebSocket streaming provider registration" + )) + )); + } +} diff --git a/litellm-rust/crates/core/src/responses/types.rs b/litellm-rust/crates/core/src/responses/types.rs index 4942309992e..c05408c2aa7 100644 --- a/litellm-rust/crates/core/src/responses/types.rs +++ b/litellm-rust/crates/core/src/responses/types.rs @@ -1,13 +1,46 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value}; +use crate::streaming::{JsonObject, StreamTarget, StreamTransportOptions}; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ResponsesWsEventType { ResponseCreate, ResponseCreated, + ResponseInProgress, + ResponseReasoningSummaryPartAdded, + ResponseReasoningSummaryTextDelta, + ResponseReasoningSummaryTextDone, + ResponseReasoningSummaryPartDone, + ResponseOutputItemAdded, + ResponseOutputTextDelta, + ResponseOutputTextAnnotationAdded, + ResponseOutputTextDone, + ResponseRefusalDelta, + ResponseRefusalDone, + ResponseFunctionCallArgumentsDelta, + ResponseFunctionCallArgumentsDone, + ResponseFileSearchCallInProgress, + ResponseFileSearchCallSearching, + ResponseFileSearchCallCompleted, + ResponseWebSearchCallInProgress, + ResponseWebSearchCallSearching, + ResponseWebSearchCallCompleted, + ResponseMcpListToolsInProgress, + ResponseMcpListToolsCompleted, + ResponseMcpListToolsFailed, + ResponseMcpCallInProgress, + ResponseMcpCallArgumentsDelta, + ResponseMcpCallArgumentsDone, + ResponseMcpCallCompleted, + ResponseMcpCallFailed, + ResponseContentPartAdded, + ResponseContentPartDone, + ResponseOutputItemDone, ResponseCompleted, ResponseFailed, ResponseIncomplete, + ImageGenerationPartialImage, Error, Other(String), } @@ -17,9 +50,40 @@ impl ResponsesWsEventType { match self { Self::ResponseCreate => "response.create", Self::ResponseCreated => "response.created", + Self::ResponseInProgress => "response.in_progress", + Self::ResponseReasoningSummaryPartAdded => "response.reasoning_summary_part.added", + Self::ResponseReasoningSummaryTextDelta => "response.reasoning_summary_text.delta", + Self::ResponseReasoningSummaryTextDone => "response.reasoning_summary_text.done", + Self::ResponseReasoningSummaryPartDone => "response.reasoning_summary_part.done", + Self::ResponseOutputItemAdded => "response.output_item.added", + Self::ResponseOutputTextDelta => "response.output_text.delta", + Self::ResponseOutputTextAnnotationAdded => "response.output_text.annotation.added", + Self::ResponseOutputTextDone => "response.output_text.done", + Self::ResponseRefusalDelta => "response.refusal.delta", + Self::ResponseRefusalDone => "response.refusal.done", + Self::ResponseFunctionCallArgumentsDelta => "response.function_call_arguments.delta", + Self::ResponseFunctionCallArgumentsDone => "response.function_call_arguments.done", + Self::ResponseFileSearchCallInProgress => "response.file_search_call.in_progress", + Self::ResponseFileSearchCallSearching => "response.file_search_call.searching", + Self::ResponseFileSearchCallCompleted => "response.file_search_call.completed", + Self::ResponseWebSearchCallInProgress => "response.web_search_call.in_progress", + Self::ResponseWebSearchCallSearching => "response.web_search_call.searching", + Self::ResponseWebSearchCallCompleted => "response.web_search_call.completed", + Self::ResponseMcpListToolsInProgress => "response.mcp_list_tools.in_progress", + Self::ResponseMcpListToolsCompleted => "response.mcp_list_tools.completed", + Self::ResponseMcpListToolsFailed => "response.mcp_list_tools.failed", + Self::ResponseMcpCallInProgress => "response.mcp_call.in_progress", + Self::ResponseMcpCallArgumentsDelta => "response.mcp_call_arguments.delta", + Self::ResponseMcpCallArgumentsDone => "response.mcp_call_arguments.done", + Self::ResponseMcpCallCompleted => "response.mcp_call.completed", + Self::ResponseMcpCallFailed => "response.mcp_call.failed", + Self::ResponseContentPartAdded => "response.content_part.added", + Self::ResponseContentPartDone => "response.content_part.done", + Self::ResponseOutputItemDone => "response.output_item.done", Self::ResponseCompleted => "response.completed", Self::ResponseFailed => "response.failed", Self::ResponseIncomplete => "response.incomplete", + Self::ImageGenerationPartialImage => "image_generation.partial_image", Self::Error => "error", Self::Other(value) => value, } @@ -44,9 +108,40 @@ impl<'de> Deserialize<'de> for ResponsesWsEventType { Ok(match value.as_str() { "response.create" => Self::ResponseCreate, "response.created" => Self::ResponseCreated, + "response.in_progress" => Self::ResponseInProgress, + "response.reasoning_summary_part.added" => Self::ResponseReasoningSummaryPartAdded, + "response.reasoning_summary_text.delta" => Self::ResponseReasoningSummaryTextDelta, + "response.reasoning_summary_text.done" => Self::ResponseReasoningSummaryTextDone, + "response.reasoning_summary_part.done" => Self::ResponseReasoningSummaryPartDone, + "response.output_item.added" => Self::ResponseOutputItemAdded, + "response.output_text.delta" => Self::ResponseOutputTextDelta, + "response.output_text.annotation.added" => Self::ResponseOutputTextAnnotationAdded, + "response.output_text.done" => Self::ResponseOutputTextDone, + "response.refusal.delta" => Self::ResponseRefusalDelta, + "response.refusal.done" => Self::ResponseRefusalDone, + "response.function_call_arguments.delta" => Self::ResponseFunctionCallArgumentsDelta, + "response.function_call_arguments.done" => Self::ResponseFunctionCallArgumentsDone, + "response.file_search_call.in_progress" => Self::ResponseFileSearchCallInProgress, + "response.file_search_call.searching" => Self::ResponseFileSearchCallSearching, + "response.file_search_call.completed" => Self::ResponseFileSearchCallCompleted, + "response.web_search_call.in_progress" => Self::ResponseWebSearchCallInProgress, + "response.web_search_call.searching" => Self::ResponseWebSearchCallSearching, + "response.web_search_call.completed" => Self::ResponseWebSearchCallCompleted, + "response.mcp_list_tools.in_progress" => Self::ResponseMcpListToolsInProgress, + "response.mcp_list_tools.completed" => Self::ResponseMcpListToolsCompleted, + "response.mcp_list_tools.failed" => Self::ResponseMcpListToolsFailed, + "response.mcp_call.in_progress" => Self::ResponseMcpCallInProgress, + "response.mcp_call_arguments.delta" => Self::ResponseMcpCallArgumentsDelta, + "response.mcp_call_arguments.done" => Self::ResponseMcpCallArgumentsDone, + "response.mcp_call.completed" => Self::ResponseMcpCallCompleted, + "response.mcp_call.failed" => Self::ResponseMcpCallFailed, + "response.content_part.added" => Self::ResponseContentPartAdded, + "response.content_part.done" => Self::ResponseContentPartDone, + "response.output_item.done" => Self::ResponseOutputItemDone, "response.completed" => Self::ResponseCompleted, "response.failed" => Self::ResponseFailed, "response.incomplete" => Self::ResponseIncomplete, + "image_generation.partial_image" => Self::ImageGenerationPartialImage, "error" => Self::Error, _ => Self::Other(value), }) @@ -84,6 +179,60 @@ pub struct ResponsesWsTransformResult { pub events: Vec, } +pub type ResponsesStreamEvent = ResponsesWsEvent; +pub type ResponseCommand = ResponsesWsEvent; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponsesInput { + Text(String), + Items(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponsesToolChoice { + Name(String), + Definition(JsonObject), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ResponsesStreamRequestBody { + pub model: String, + pub input: ResponsesInput, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include: Option>, + #[serde(flatten)] + pub extra: Map, +} + +pub struct ResponsesStreamRequest { + pub body: ResponsesStreamRequestBody, + pub target: StreamTarget, + pub transport: StreamTransportOptions, +} + +pub struct ResponsesWebSocketRequest { + pub target: StreamTarget, + pub transport: StreamTransportOptions, +} + impl ResponsesWsTransformResult { pub fn passthrough(event: ResponsesWsEvent) -> Self { Self { @@ -127,11 +276,14 @@ mod tests { let known: ResponsesWsEventType = serde_json::from_str("\"response.completed\"").expect("valid event type"); assert_eq!(known, ResponsesWsEventType::ResponseCompleted); - let unknown: ResponsesWsEventType = + let output_delta: ResponsesWsEventType = serde_json::from_str("\"response.output_text.delta\"").expect("valid event type"); + assert_eq!(output_delta, ResponsesWsEventType::ResponseOutputTextDelta); + let unknown: ResponsesWsEventType = + serde_json::from_str("\"response.future_event\"").expect("valid event type"); assert_eq!( unknown, - ResponsesWsEventType::Other("response.output_text.delta".to_string()) + ResponsesWsEventType::Other("response.future_event".to_string()) ); } @@ -163,4 +315,42 @@ mod tests { assert_eq!(flat.model(), Some("gpt-5")); assert_eq!(nested.model(), Some("gpt-5-mini")); } + + #[test] + fn stream_request_deserializes_the_public_responses_shape() { + let request: ResponsesStreamRequestBody = serde_json::from_value(serde_json::json!({ + "model": "gpt-5", + "input": "hello", + "stream": true, + "max_output_tokens": 32 + })) + .expect("public request shape"); + + assert_eq!(request.model, "gpt-5"); + assert_eq!(request.stream, Some(true)); + assert!(matches!(request.input, ResponsesInput::Text(ref text) if text == "hello")); + } + + #[test] + fn unknown_stream_events_round_trip_for_forward_compatibility() { + let event: ResponsesStreamEvent = serde_json::from_value(serde_json::json!({ + "type": "response.future_event", + "sequence_number": 7, + "future_field": "value" + })) + .expect("unknown event"); + + assert_eq!( + event.event_type, + ResponsesWsEventType::Other("response.future_event".to_string()) + ); + assert_eq!( + serde_json::to_value(event).expect("serializable event"), + serde_json::json!({ + "type": "response.future_event", + "sequence_number": 7, + "future_field": "value" + }) + ); + } } diff --git a/litellm-rust/crates/core/src/responses/websocket.rs b/litellm-rust/crates/core/src/responses/websocket.rs index 92dc19627a0..2432e46e017 100644 --- a/litellm-rust/crates/core/src/responses/websocket.rs +++ b/litellm-rust/crates/core/src/responses/websocket.rs @@ -1,6 +1,18 @@ -use crate::CoreResult; +use crate::Error; use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH}; -use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult}; +use crate::responses::types::{ + ResponseCommand, ResponsesStreamEvent, ResponsesWsEvent, ResponsesWsEventType, + ResponsesWsTransformResult, +}; +use futures_util::future::BoxFuture; + +pub trait TypedResponsesWebSocketSession: Send + Sync { + fn send(&self, command: ResponseCommand) -> BoxFuture<'_, Result<(), Error>>; + + fn recv(&self) -> BoxFuture<'_, Result, Error>>; + + fn close(&self) -> BoxFuture<'_, Result<(), Error>>; +} pub trait ResponsesWebSocketProviderConfig: Sync { fn supports_native_websocket(&self) -> bool { @@ -19,13 +31,13 @@ pub trait ResponsesWebSocketProviderConfig: Sync { &self, event: &ResponsesWsEvent, model: &str, - ) -> CoreResult; + ) -> Result; fn transform_ws_response( &self, event: &ResponsesWsEvent, model: &str, - ) -> CoreResult; + ) -> Result; } pub fn complete_websocket_url( diff --git a/litellm-rust/crates/core/src/streaming.rs b/litellm-rust/crates/core/src/streaming.rs new file mode 100644 index 00000000000..811ff201200 --- /dev/null +++ b/litellm-rust/crates/core/src/streaming.rs @@ -0,0 +1,636 @@ +use std::collections::VecDeque; +use std::marker::PhantomData; +use std::pin::Pin; +use std::time::Duration; + +use crate::error::Error; +use futures_util::future::BoxFuture; +use futures_util::{Stream, StreamExt, stream}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub type EventStream = Pin> + Send + 'static>>; +pub type ProviderChunkStream = + Pin> + Send + 'static>>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamTransport { + Http, + WebSocket, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamProviderId { + Anthropic, + AzureAi, + BedrockConverse, + OpenAi, +} + +impl TryFrom<&str> for StreamProviderId { + type Error = Error; + + fn try_from(value: &str) -> Result { + match value { + "anthropic" => Ok(Self::Anthropic), + "azure_ai" => Ok(Self::AzureAi), + "bedrock" | "bedrock_converse" => Ok(Self::BedrockConverse), + "openai" => Ok(Self::OpenAi), + _ => Err(Error::InvalidProvider(value.to_string())), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct JsonObject(pub Map); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Header { + pub name: String, + pub value: String, +} + +#[derive(Clone, Default, PartialEq)] +pub struct ProviderCredentials { + api_key: Option, + aws_access_key_id: Option, + aws_secret_access_key: Option, + aws_session_token: Option, +} + +impl ProviderCredentials { + pub fn new( + api_key: Option, + aws_access_key_id: Option, + aws_secret_access_key: Option, + aws_session_token: Option, + ) -> Self { + Self { + api_key, + aws_access_key_id, + aws_secret_access_key, + aws_session_token, + } + } + + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + + pub fn aws_access_key_id(&self) -> Option<&str> { + self.aws_access_key_id.as_deref() + } + + pub fn aws_secret_access_key(&self) -> Option<&str> { + self.aws_secret_access_key.as_deref() + } + + pub fn aws_session_token(&self) -> Option<&str> { + self.aws_session_token.as_deref() + } +} + +/// ```compile_fail +/// fn assert_serialize() {} +/// assert_serialize::(); +/// assert_serialize::(); +/// assert_serialize::(); +/// ``` +/// +/// ```compile_fail +/// use litellm_core::streaming::{JsonObject, ProviderCredentials, StreamProviderId, StreamTarget}; +/// let mut target = StreamTarget::new( +/// StreamProviderId::OpenAi, +/// ProviderCredentials::default(), +/// None, +/// ); +/// target.metadata = JsonObject::default(); +/// ``` +#[derive(Clone, PartialEq)] +pub struct StreamTarget { + provider: StreamProviderId, + credentials: ProviderCredentials, + api_base: Option, +} + +impl StreamTarget { + pub fn new( + provider: StreamProviderId, + credentials: ProviderCredentials, + api_base: Option, + ) -> Self { + Self { + provider, + credentials, + api_base, + } + } + + pub fn provider(&self) -> StreamProviderId { + self.provider + } + + pub fn credentials(&self) -> &ProviderCredentials { + &self.credentials + } + + pub fn api_base(&self) -> Option<&str> { + self.api_base.as_deref() + } +} + +#[derive(Clone, Default, PartialEq)] +pub struct StreamTransportOptions { + forwarded_headers: Vec
, + timeout: Option, +} + +impl StreamTransportOptions { + pub fn new(forwarded_headers: Vec
, timeout: Option) -> Self { + Self { + forwarded_headers, + timeout, + } + } + + pub fn forwarded_headers(&self) -> &[Header] { + &self.forwarded_headers + } + + pub fn timeout(&self) -> Option { + self.timeout + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StreamMetadata { + pub status_code: u16, + pub provider: StreamProviderId, + pub transport: StreamTransport, + pub response_headers: Vec
, +} + +pub struct OpenedStream { + pub metadata: StreamMetadata, + pub events: EventStream, +} + +pub struct OpenedWireStream { + pub metadata: StreamMetadata, + pub chunks: ProviderChunkStream, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderStreamChunk(Vec); + +impl ProviderStreamChunk { + pub fn new(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +pub trait StreamDecoder: Send + 'static { + type WireEvent: Send + 'static; + + fn push(&mut self, chunk: ProviderStreamChunk) -> Result, Error>; + + fn finish(&mut self) -> Result, Error> { + Ok(Vec::new()) + } +} + +pub trait StreamProvider: Send + Sync + 'static { + type PreparedRequest: Send + 'static; + type WireEvent: Send + 'static; + type Decoder: StreamDecoder; + + fn transform_request(&self, request: R) -> Result; + + fn call( + &'static self, + request: Self::PreparedRequest, + ) -> BoxFuture<'static, Result>; + + fn decoder(&self) -> Self::Decoder; + + fn normalize(&self, event: Self::WireEvent) -> Result, Error>; +} + +struct PipelineState +where + D: StreamDecoder, +{ + provider: &'static P, + decoder: D, + chunks: ProviderChunkStream, + pending: VecDeque>, + finished: bool, + request: PhantomData, +} + +pub async fn open_provider_stream( + provider: &'static P, + request: R, +) -> Result, Error> +where + P: StreamProvider, + R: Send + 'static, + E: Send + 'static, +{ + let prepared = provider.transform_request(request)?; + let opened = provider.call(prepared).await?; + let state = PipelineState { + provider, + decoder: provider.decoder(), + chunks: opened.chunks, + pending: VecDeque::new(), + finished: false, + request: PhantomData, + }; + let events = stream::unfold(state, |mut state| async move { + loop { + if let Some(event) = state.pending.pop_front() { + return Some((event, state)); + } + if state.finished { + return None; + } + match state.chunks.next().await { + Some(Ok(chunk)) => match state.decoder.push(chunk) { + Ok(events) => queue_normalized(&mut state, events), + Err(error) => { + state.finished = true; + return Some((Err(error), state)); + } + }, + Some(Err(error)) => { + state.finished = true; + return Some((Err(error), state)); + } + None => { + state.finished = true; + match state.decoder.finish() { + Ok(events) => queue_normalized(&mut state, events), + Err(error) => return Some((Err(error), state)), + } + } + } + } + }); + Ok(OpenedStream { + metadata: opened.metadata, + events: Box::pin(events), + }) +} + +fn queue_normalized(state: &mut PipelineState, events: Vec) +where + P: StreamProvider, + D: StreamDecoder>::WireEvent>, +{ + for event in events { + match state.provider.normalize(event) { + Ok(normalized) => state.pending.extend(normalized.into_iter().map(Ok)), + Err(error) => { + state.pending.push_back(Err(error)); + state.finished = true; + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use std::pin::Pin; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + + use futures_util::future::FutureExt; + + use super::*; + + struct FakeRequest; + struct PreparedRequest; + + struct FakeProvider { + calls: Arc>>, + } + + struct FakeDecoder { + calls: Arc>>, + pending: String, + } + + #[derive(Clone, Copy)] + enum FailurePoint { + Transform, + Call, + Chunk, + Push, + Finish, + Normalize, + } + + struct FailureProvider(FailurePoint); + + struct FailureDecoder(FailurePoint); + + impl StreamDecoder for FailureDecoder { + type WireEvent = String; + + fn push(&mut self, chunk: ProviderStreamChunk) -> Result, Error> { + if matches!(self.0, FailurePoint::Push) { + return Err(Error::InvalidResponse("decoder push failed".to_string())); + } + Ok(vec![ + String::from_utf8(chunk.0).expect("test chunk should be UTF-8"), + ]) + } + + fn finish(&mut self) -> Result, Error> { + if matches!(self.0, FailurePoint::Finish) { + return Err(Error::InvalidResponse("decoder finish failed".to_string())); + } + Ok(Vec::new()) + } + } + + impl StreamProvider for FailureProvider { + type PreparedRequest = PreparedRequest; + type WireEvent = String; + type Decoder = FailureDecoder; + + fn transform_request(&self, _request: FakeRequest) -> Result { + if matches!(self.0, FailurePoint::Transform) { + return Err(Error::InvalidRequest("transform failed".to_string())); + } + Ok(PreparedRequest) + } + + fn call( + &'static self, + _request: Self::PreparedRequest, + ) -> BoxFuture<'static, Result> { + async move { + if matches!(self.0, FailurePoint::Call) { + return Err(Error::Network("open failed".to_string())); + } + let chunks: ProviderChunkStream = match self.0 { + FailurePoint::Chunk => Box::pin(stream::iter([ + Err(Error::Network("source failed".to_string())), + Ok(ProviderStreamChunk::new("ignored")), + ])), + FailurePoint::Finish => Box::pin(stream::empty()), + _ => Box::pin(stream::iter([Ok(ProviderStreamChunk::new("event"))])), + }; + Ok(OpenedWireStream { + metadata: StreamMetadata { + status_code: 200, + provider: StreamProviderId::OpenAi, + transport: StreamTransport::Http, + response_headers: Vec::new(), + }, + chunks, + }) + } + .boxed() + } + + fn decoder(&self) -> Self::Decoder { + FailureDecoder(self.0) + } + + fn normalize(&self, event: Self::WireEvent) -> Result, Error> { + if matches!(self.0, FailurePoint::Normalize) { + return Err(Error::InvalidResponse("normalize failed".to_string())); + } + Ok(vec![event]) + } + } + + struct PendingUntilDropped(Arc); + + impl Stream for PendingUntilDropped { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + } + + impl Drop for PendingUntilDropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + struct PendingProvider(Arc); + + impl StreamProvider for PendingProvider { + type PreparedRequest = PreparedRequest; + type WireEvent = String; + type Decoder = FailureDecoder; + + fn transform_request(&self, _request: FakeRequest) -> Result { + Ok(PreparedRequest) + } + + fn call( + &'static self, + _request: Self::PreparedRequest, + ) -> BoxFuture<'static, Result> { + async move { + Ok(OpenedWireStream { + metadata: StreamMetadata { + status_code: 200, + provider: StreamProviderId::OpenAi, + transport: StreamTransport::Http, + response_headers: Vec::new(), + }, + chunks: Box::pin(PendingUntilDropped(self.0.clone())), + }) + } + .boxed() + } + + fn decoder(&self) -> Self::Decoder { + FailureDecoder(FailurePoint::Push) + } + + fn normalize(&self, event: Self::WireEvent) -> Result, Error> { + Ok(vec![event]) + } + } + + impl StreamDecoder for FakeDecoder { + type WireEvent = String; + + fn push(&mut self, chunk: ProviderStreamChunk) -> Result, Error> { + self.calls.lock().expect("call log").push("decode"); + self.pending + .push_str(std::str::from_utf8(chunk.as_bytes()).expect("test utf-8")); + let mut parts = self + .pending + .split('|') + .map(str::to_string) + .collect::>(); + self.pending = parts.pop().expect("split always returns one item"); + Ok(parts) + } + + fn finish(&mut self) -> Result, Error> { + if self.pending.is_empty() { + return Ok(Vec::new()); + } + Ok(vec![std::mem::take(&mut self.pending)]) + } + } + + impl StreamProvider for FakeProvider { + type PreparedRequest = PreparedRequest; + type WireEvent = String; + type Decoder = FakeDecoder; + + fn transform_request(&self, _request: FakeRequest) -> Result { + self.calls.lock().expect("call log").push("transform"); + Ok(PreparedRequest) + } + + fn call( + &'static self, + _request: Self::PreparedRequest, + ) -> BoxFuture<'static, Result> { + self.calls.lock().expect("call log").push("call"); + async move { + Ok(OpenedWireStream { + metadata: StreamMetadata { + status_code: 200, + provider: StreamProviderId::Anthropic, + transport: StreamTransport::Http, + response_headers: vec![Header { + name: "x-test".to_string(), + value: "ready".to_string(), + }], + }, + chunks: Box::pin(stream::iter([ + Ok(ProviderStreamChunk::new(b"one|tw".to_vec())), + Ok(ProviderStreamChunk::new(b"o|three".to_vec())), + ])), + }) + } + .boxed() + } + + fn decoder(&self) -> Self::Decoder { + FakeDecoder { + calls: self.calls.clone(), + pending: String::new(), + } + } + + fn normalize(&self, event: Self::WireEvent) -> Result, Error> { + self.calls.lock().expect("call log").push("normalize"); + Ok(vec![event.to_uppercase()]) + } + } + + #[tokio::test] + async fn fake_provider_proves_pipeline_order_and_fragmentation() { + let calls = Arc::new(Mutex::new(Vec::new())); + let provider = Box::leak(Box::new(FakeProvider { + calls: calls.clone(), + })); + let mut opened = open_provider_stream(provider, FakeRequest) + .await + .expect("stream opens"); + let events = opened + .events + .by_ref() + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("events normalize"); + + assert_eq!(events, ["ONE", "TWO", "THREE"]); + assert_eq!(opened.metadata.response_headers[0].name, "x-test"); + assert_eq!( + *calls.lock().expect("call log"), + [ + "transform", + "call", + "decode", + "normalize", + "decode", + "normalize", + "normalize", + ] + ); + } + + #[tokio::test] + async fn request_and_open_failures_stop_before_a_stream_is_returned() { + for (point, expected) in [ + (FailurePoint::Transform, "invalid request: transform failed"), + (FailurePoint::Call, "upstream network error: open failed"), + ] { + let provider = Box::leak(Box::new(FailureProvider(point))); + let error = match open_provider_stream(provider, FakeRequest).await { + Ok(_) => panic!("failure should prevent the stream from opening"), + Err(error) => error, + }; + assert_eq!(error.to_string(), expected); + } + } + + #[tokio::test] + async fn pipeline_failures_are_emitted_once_and_then_terminate() { + for (point, expected) in [ + (FailurePoint::Chunk, "upstream network error: source failed"), + (FailurePoint::Push, "invalid response: decoder push failed"), + ( + FailurePoint::Finish, + "invalid response: decoder finish failed", + ), + ( + FailurePoint::Normalize, + "invalid response: normalize failed", + ), + ] { + let provider = Box::leak(Box::new(FailureProvider(point))); + let mut opened = open_provider_stream(provider, FakeRequest) + .await + .expect("stream should open before its terminal failure"); + let error = opened + .events + .next() + .await + .expect("stream should emit its error") + .expect_err("first event should be the configured failure"); + assert_eq!(error.to_string(), expected); + assert!(opened.events.next().await.is_none()); + } + } + + #[tokio::test] + async fn dropping_the_event_stream_drops_the_provider_chunk_stream() { + let dropped = Arc::new(AtomicBool::new(false)); + let provider = Box::leak(Box::new(PendingProvider(dropped.clone()))); + let opened = open_provider_stream(provider, FakeRequest) + .await + .expect("stream should open"); + + drop(opened.events); + + assert!(dropped.load(Ordering::SeqCst)); + } +} diff --git a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs b/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs index 656ba033b62..8a8a5ea263a 100644 --- a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs +++ b/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs @@ -1,7 +1,8 @@ -//! Enforcement: the litellm-rust workspace has exactly three crates. +//! Enforcement: the litellm-rust workspace has exactly four crates. //! -//! `core` (pure translation), `ai-gateway` (routes + all network I/O), and -//! `python-bridge` (the PyO3 cdylib). Adding or removing a crate must be a +//! `core` (the Rust SDK), `ai-gateway` (the HTTP/WebSocket host), +//! `python-interop` (domain-neutral PyO3 primitives), and `python-bridge` (the +//! PyO3 cdylib). Adding or removing a crate must be a //! deliberate act: this test fails until the allowlist here is updated, forcing //! whoever changes the crate set to justify the new crate per the rule that a //! crate is a layer needing independent compilation / its own deps / a separate @@ -16,10 +17,15 @@ use std::path::{Path, PathBuf}; /// The one true crate set. Update BOTH this and `litellm-rust/AGENTS.md` when the /// workspace legitimately gains or loses a crate. -const EXPECTED_MEMBERS: &[&str] = &["crates/core", "crates/ai-gateway", "crates/python-bridge"]; +const EXPECTED_MEMBERS: &[&str] = &[ + "crates/core", + "crates/ai-gateway", + "crates/python-interop", + "crates/python-bridge", +]; /// The crate subdirectory names that must exist under `crates/`. -const EXPECTED_CRATE_DIRS: &[&str] = &["core", "ai-gateway", "python-bridge"]; +const EXPECTED_CRATE_DIRS: &[&str] = &["core", "ai-gateway", "python-interop", "python-bridge"]; const MISMATCH: &str = "litellm-rust crate set changed — update this allowlist AND litellm-rust/AGENTS.md, and justify the crate per the rule (crate = layer needing independent compilation / its own deps / a separate artifact)."; diff --git a/litellm-rust/crates/python-bridge/AGENTS.md b/litellm-rust/crates/python-bridge/AGENTS.md index ad3cddfa5fd..42282ca4da4 100644 --- a/litellm-rust/crates/python-bridge/AGENTS.md +++ b/litellm-rust/crates/python-bridge/AGENTS.md @@ -1,3 +1,3 @@ -litellm-python-bridge is the PyO3 cdylib that exposes Rust to the litellm Python SDK — a thin adapter (Python objects → Rust calls → Python results) over the litellm-core route entrypoints (e.g. `litellm_core::messages::messages`). +litellm-python-bridge is the PyO3 cdylib that exposes LiteLLM Rust APIs to the Python SDK. Keep API registration, domain dependency wiring, request assembly, and Python exception mapping here. Put domain-neutral Python/Serde conversion and GIL primitives in litellm-python-interop. Keep it thin: no business logic, no transforms, no I/O orchestration — just marshal in/out and call the core entrypoint. diff --git a/litellm-rust/crates/python-bridge/CLAUDE.md b/litellm-rust/crates/python-bridge/CLAUDE.md index 3ce8b8c639a..d25ae5a8130 100644 --- a/litellm-rust/crates/python-bridge/CLAUDE.md +++ b/litellm-rust/crates/python-bridge/CLAUDE.md @@ -5,8 +5,9 @@ Rules for `litellm-rust/crates/python-bridge`. ## Responsibility `python-bridge` is the PyO3 boundary between Python LiteLLM and Rust transforms. -Keep this crate thin. It adapts Python objects to Rust payloads and returns -Python-compatible dictionaries. +Keep this crate thin. It exposes LiteLLM Rust APIs, assembles domain requests, +maps domain errors to Python exceptions, and delegates generic conversion and +GIL handling to `litellm-python-interop`. ## Bridge Shape diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index d461a483ae0..e933237e7d9 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -13,19 +13,24 @@ crate-type = ["cdylib"] default = ["abi3"] abi3 = ["pyo3/abi3-py310"] extension-module = ["pyo3/extension-module"] +panic-test = [] [dependencies] +tracing.workspace = true +tracing-subscriber.workspace = true +futures-util.workspace = true litellm-core = { workspace = true, features = ["bedrock-auth"] } litellm-ai-gateway = { workspace = true, default-features = false } +litellm-python-interop.workspace = true pyo3.workspace = true pyo3-async-runtimes.workspace = true -pythonize.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true [dev-dependencies] criterion = "0.8.2" +tokio-tungstenite.workspace = true [[bench]] name = "serialization" diff --git a/litellm-rust/crates/python-bridge/benches/serialization.rs b/litellm-rust/crates/python-bridge/benches/serialization.rs index 8a90cf667d0..0b9436d0cb7 100644 --- a/litellm-rust/crates/python-bridge/benches/serialization.rs +++ b/litellm-rust/crates/python-bridge/benches/serialization.rs @@ -2,6 +2,7 @@ use std::hint::black_box; use std::time::Duration; use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use litellm_python_interop::{from_py, to_py}; use pyo3::prelude::*; use pyo3::types::PyDict; use serde_json::{Value, json}; @@ -25,7 +26,7 @@ fn former_json_roundtrip_from_py(py: Python<'_>, value: &Bound<'_, PyAny>) -> Va } fn pythonize_from_py(value: &Bound<'_, PyAny>) -> Value { - pythonize::depythonize(value).expect("payload should depythonize") + from_py(value).expect("payload should depythonize") } fn former_json_roundtrip_to_py(py: Python<'_>, value: &Value) -> Py { @@ -37,12 +38,10 @@ fn former_json_roundtrip_to_py(py: Python<'_>, value: &Value) -> Py { } fn pythonize_to_py(py: Python<'_>, value: &Value) -> Py { - pythonize::pythonize(py, value) - .expect("response should pythonize") - .unbind() + to_py(py, value).expect("response should pythonize") } -fn serialization(c: &mut Criterion) { +fn bridge_serialization(c: &mut Criterion) { Python::initialize(); Python::attach(|py| { for &(label, payload_bytes) in PAYLOAD_SIZES { @@ -98,6 +97,6 @@ criterion_group! { .sample_size(20) .warm_up_time(Duration::from_secs(1)) .measurement_time(Duration::from_secs(4)); - targets = serialization + targets = bridge_serialization } criterion_main!(benches); diff --git a/litellm-rust/crates/python-bridge/src/diagnostics.rs b/litellm-rust/crates/python-bridge/src/diagnostics.rs new file mode 100644 index 00000000000..cc153a89b8f --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/diagnostics.rs @@ -0,0 +1,23 @@ +use litellm_python_interop::release_count; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +#[pyfunction] +fn gil_stats(py: Python<'_>) -> PyResult> { + let stats = PyDict::new(py); + stats.set_item("releases", release_count())?; + Ok(stats.into_any().unbind()) +} + +#[cfg(feature = "panic-test")] +#[pyfunction] +fn _panic_for_test() { + panic!("intentional PyO3 panic smoke test"); +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(gil_stats, module)?)?; + #[cfg(feature = "panic-test")] + module.add_function(wrap_pyfunction!(_panic_for_test, module)?)?; + Ok(()) +} diff --git a/litellm-rust/crates/python-bridge/src/errors.rs b/litellm-rust/crates/python-bridge/src/errors.rs new file mode 100644 index 00000000000..9eaffcab712 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/errors.rs @@ -0,0 +1,146 @@ +use litellm_core::error::Error; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +pyo3::create_exception!( + _native, + RustBridgeDeclined, + pyo3::exceptions::PyException, + "The route declined before calling the provider, so the host may retry on its own path." +); + +pub(crate) fn declined(error: impl std::fmt::Display) -> PyErr { + RustBridgeDeclined::new_err(error.to_string()) +} + +pyo3::create_exception!( + _native, + RustUpstreamError, + pyo3::exceptions::PyException, + "The provider call was already issued and failed. Args are (status, message); status is 0 when there was no HTTP response." +); + +pub(crate) fn core_error_to_pyerr(err: Error) -> PyErr { + match err { + Error::Auth(message) => PyValueError::new_err(message), + Error::InvalidProvider(_) + | Error::InvalidRequest(_) + | Error::InvalidType { .. } + | Error::MissingField(_) => PyValueError::new_err(err.to_string()), + other => PyRuntimeError::new_err(other.to_string()), + } +} + +pub(crate) fn ocr_error_to_pyerr(err: Error) -> PyErr { + match err { + Error::MissingField("document_url" | "image_url") => { + PyValueError::new_err("Document URL is required") + } + Error::Http { status, body } => RustUpstreamError::new_err((status, body)), + other => core_error_to_pyerr(other), + } +} + +/// Map a core error for a route whose host keeps a Python implementation. +/// +/// Only an explicit capability decline permits the host to try Python. Every +/// other error may have happened after provider dispatch and must be terminal. +pub(crate) fn fallback_route_error_to_pyerr(err: Error) -> PyErr { + match err { + Error::Unsupported(_) => RustBridgeDeclined::new_err(err.to_string()), + other => executed_route_error_to_pyerr(other), + } +} + +pub(crate) fn executed_route_error_to_pyerr(err: Error) -> PyErr { + match err { + Error::Http { status, body } => { + RustUpstreamError::new_err((status, format!("{status}: {body}"))) + } + other => RustUpstreamError::new_err((0u16, other.to_string())), + } +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + let py = module.py(); + module.add("RustBridgeDeclined", py.get_type::())?; + module.add("RustUpstreamError", py.get_type::()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ocr_errors_preserve_python_validation_and_provider_details() { + Python::initialize(); + Python::attach(|py| { + for field in ["document_url", "image_url"] { + let mapped = ocr_error_to_pyerr(Error::MissingField(field)); + assert!(mapped.is_instance_of::(py)); + assert_eq!(mapped.value(py).to_string(), "Document URL is required"); + } + let mapped = ocr_error_to_pyerr(Error::Http { + status: 429, + body: r#"{"message":"rate limited"}"#.to_string(), + }); + assert!(mapped.is_instance_of::(py)); + let args: (u16, String) = mapped + .value(py) + .getattr("args") + .and_then(|args| args.extract()) + .expect("OCR failures retain status and unprefixed provider message"); + assert_eq!(args, (429, r#"{"message":"rate limited"}"#.to_string())); + }); + } + + #[test] + fn fallback_routes_distinguish_declines_from_upstream_failures() { + Python::initialize(); + Python::attach(|py| { + let declines = [Error::Unsupported("unsupported")]; + for error in declines { + let mapped = fallback_route_error_to_pyerr(error); + assert!(mapped.is_instance_of::(py)); + } + + let upstream_failures = [ + ( + Error::Http { + status: 429, + body: "rate limited".to_string(), + }, + (429, "429: rate limited"), + ), + ( + Error::Network("request timed out".to_string()), + (0, "upstream network error: request timed out"), + ), + ( + Error::InvalidResponse("bad JSON".to_string()), + (0, "invalid response: bad JSON"), + ), + (Error::Auth("missing key".to_string()), (0, "missing key")), + ( + Error::InvalidRequest("invalid".to_string()), + (0, "invalid request: invalid"), + ), + ]; + for (error, expected) in upstream_failures { + let mapped = fallback_route_error_to_pyerr(error); + assert!(mapped.is_instance_of::(py)); + let args: (u16, String) = mapped + .value(py) + .getattr("args") + .and_then(|args| args.extract()) + .expect("upstream error should carry status and message"); + assert_eq!(args, (expected.0, expected.1.to_string())); + } + + let midstream = executed_route_error_to_pyerr(Error::Unsupported( + "a stream cannot fall back after opening", + )); + assert!(midstream.is_instance_of::(py)); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/function_trace.rs b/litellm-rust/crates/python-bridge/src/function_trace.rs new file mode 100644 index 00000000000..7061e374416 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/function_trace.rs @@ -0,0 +1,129 @@ +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tracing::span::{Attributes, Id}; +use tracing::{Dispatch, Level, Subscriber}; +use tracing_subscriber::filter::{LevelFilter, filter_fn}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::{Layer, Registry}; + +const TARGET: &str = "litellm::function_trace"; + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct FunctionTraceEvent { + pub(crate) function: &'static str, + pub(crate) depth: usize, +} + +#[derive(Clone, Default)] +pub(crate) struct FunctionTrace { + events: Arc>>, +} + +impl FunctionTrace { + pub(crate) fn dispatcher(&self) -> Dispatch { + let filter = filter_fn(|metadata| { + metadata.is_span() && metadata.target() == TARGET && *metadata.level() == Level::TRACE + }) + .with_max_level_hint(LevelFilter::TRACE); + Dispatch::new( + Registry::default().with( + FunctionTraceLayer { + trace: self.clone(), + } + .with_filter(filter), + ), + ) + } + + pub(crate) fn events(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +struct FunctionTraceLayer { + trace: FunctionTrace, +} + +impl Layer for FunctionTraceLayer +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { + let depth = context + .span(id) + .map(|span| span.scope().skip(1).count()) + .unwrap_or_default(); + self.trace + .events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(FunctionTraceEvent { + function: attributes.metadata().name(), + depth, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_matching_spans_in_creation_order() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let _ignored = tracing::trace_span!(target: "other", "ignored"); + let _first = tracing::trace_span!(target: TARGET, "same_name"); + let _wrong_level = tracing::debug_span!(target: TARGET, "wrong_level"); + let _second = tracing::trace_span!(target: TARGET, "same_name"); + }); + + assert_eq!( + trace.events(), + vec![ + FunctionTraceEvent { + function: "same_name", + depth: 0, + }, + FunctionTraceEvent { + function: "same_name", + depth: 0, + }, + ] + ); + } + + #[test] + fn records_matching_span_nesting_depth() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let outer = tracing::trace_span!(target: TARGET, "outer"); + let _outer_guard = outer.enter(); + let _inner = tracing::trace_span!(target: TARGET, "inner"); + }); + + assert_eq!( + trace.events(), + vec![ + FunctionTraceEvent { + function: "outer", + depth: 0, + }, + FunctionTraceEvent { + function: "inner", + depth: 1, + }, + ] + ); + } +} diff --git a/litellm-rust/crates/python-bridge/src/gil.rs b/litellm-rust/crates/python-bridge/src/gil.rs deleted file mode 100644 index e887c8ec1e3..00000000000 --- a/litellm-rust/crates/python-bridge/src/gil.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! GIL accounting. -//! -//! A single chokepoint for releasing the GIL around blocking work. Every -//! blocking call in the bridge goes through [`release_gil`] instead of calling -//! `Python::detach` directly, so the release count stays accurate and we -//! have one place to extend later (timing histograms, per-call labels, etc.). - -use std::sync::atomic::{AtomicU64, Ordering}; - -use pyo3::prelude::*; - -/// Number of times the bridge has released the GIL since process start. -static GIL_RELEASES: AtomicU64 = AtomicU64::new(0); - -/// Release the GIL around `f`, recording the release. -/// -/// `f` must not touch any Python state — that is what makes releasing the GIL -/// safe. Returning the value back to Python re-acquires the GIL at the call -/// site, after `f` has finished. -pub fn release_gil(py: Python<'_>, f: F) -> T -where - F: FnOnce() -> T + Send, - T: Send, -{ - GIL_RELEASES.fetch_add(1, Ordering::Relaxed); - py.detach(f) -} - -/// Total GIL releases performed by the bridge so far. -pub fn release_count() -> u64 { - GIL_RELEASES.load(Ordering::Relaxed) -} diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 1b646eb6fbe..6be7ea2e900 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,156 +1,16 @@ -use std::collections::HashMap; -use std::time::Duration; - -use litellm_ai_gateway::io::audio_transcription::{ - AudioTranscriptionRequest, audio_transcription as run_audio_transcription, -}; -use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr}; -use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; -use litellm_core::chat_completions::types::{ChatCompletionsRequest, ChatCompletionsResponse}; -use litellm_core::chat_completions::{ - chat_completions as run_chat_completions, chat_completions_decline_reason, -}; -use litellm_core::error::CoreError; -use litellm_core::messages::messages as run_messages; -use litellm_core::messages::types::{AnthropicMessagesResponse, MessagesRequest}; -use pyo3::exceptions::{PyRuntimeError, PyValueError}; -use pyo3::prelude::*; -use pyo3::types::{PyAny, PyDict}; -use serde_json::{Map, Value}; - -mod gil; +mod diagnostics; +mod errors; +mod function_trace; mod marshal; +mod routes; -use marshal::{from_py, to_py}; +use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection as RustResponsesWebSocketConnection; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use serde_json::Value; -pyo3::create_exception!( - _native, - RustBridgeDeclined, - pyo3::exceptions::PyException, - "The route declined before calling the provider, so the host may retry on its own path." -); - -pyo3::create_exception!( - _native, - RustUpstreamError, - pyo3::exceptions::PyException, - "The provider call was already issued and failed. Args are (status, message); status is 0 when there was no HTTP response." -); - -type MarshaledOcrInputs = ( - Value, - Option>, - Map, - Option, -); - -fn messages_response_to_py( - py: Python<'_>, - response: AnthropicMessagesResponse, -) -> PyResult> { - to_py(py, &response) -} - -fn chat_completions_response_to_py( - py: Python<'_>, - response: ChatCompletionsResponse, -) -> PyResult> { - to_py(py, &response) -} - -fn core_error_to_pyerr(err: CoreError) -> PyErr { - match err { - CoreError::Auth(message) => PyValueError::new_err(message), - CoreError::InvalidProvider(_) - | CoreError::InvalidRequest(_) - | CoreError::InvalidType { .. } - | CoreError::MissingField(_) => PyValueError::new_err(err.to_string()), - other => PyRuntimeError::new_err(other.to_string()), - } -} - -fn ocr_error_to_pyerr(err: CoreError) -> PyErr { - match err { - CoreError::MissingField("document_url" | "image_url") => { - PyValueError::new_err("Document URL is required") - } - CoreError::Http { status, body } => RustUpstreamError::new_err((status, body)), - other => core_error_to_pyerr(other), - } -} - -/// Map a core error for a route whose host keeps a Python implementation. -/// -/// The distinction the host needs is whether the provider was already called. -/// Everything raised before the request goes out is safe for the host to retry -/// on its own path; anything after it is not, because the provider has already -/// done the work and billed for it. -fn chat_completions_error_to_pyerr(err: CoreError) -> PyErr { - match err { - CoreError::Unsupported(_) - | CoreError::Auth(_) - | CoreError::InvalidProvider(_) - | CoreError::InvalidRequest(_) - | CoreError::InvalidType { .. } - | CoreError::MissingField(_) - | CoreError::Routing(_) - // Nothing reached the provider, so serving it on Python cannot double - // bill and is the only way the caller gets an answer at all. - | CoreError::Connect(_) => RustBridgeDeclined::new_err(err.to_string()), - CoreError::Http { status, body } => { - RustUpstreamError::new_err((status, format!("{status}: {body}"))) - } - CoreError::Network(message) | CoreError::InvalidResponse(message) => { - RustUpstreamError::new_err((0u16, message)) - } - } -} - -fn optional_object_to_map( - py: Python<'_>, - name: &'static str, - value: Option>, -) -> PyResult> { - match value { - Some(value) => match from_py(value.bind(py))? { - Value::Object(map) => Ok(map), - _ => Err(PyValueError::new_err(format!("{name} must be a dict"))), - }, - None => Ok(Map::new()), - } -} - -fn optional_timeout(timeout_seconds: Option) -> Option { - timeout_seconds.and_then(|secs| { - if secs.is_finite() && secs > 0.0 { - Some(Duration::from_secs_f64(secs)) - } else { - None - } - }) -} - -fn marshal_headers( - py: Python<'_>, - headers: Option>, -) -> PyResult> { - let value = match headers { - Some(headers) => from_py(headers.bind(py))?, - None => Value::Object(Map::new()), - }; - let Value::Object(headers) = value else { - return Err(PyValueError::new_err("headers must be a dict")); - }; - headers - .into_iter() - .map(|(name, value)| { - value - .as_str() - .map(|value| (name, value.to_string())) - .ok_or_else(|| PyValueError::new_err("header values must be strings")) - }) - .collect() -} +use crate::errors::core_error_to_pyerr; +use crate::marshal::{marshal_headers, optional_timeout}; #[pyclass] struct ResponsesWebSocketConnection { @@ -165,16 +25,16 @@ impl ResponsesWebSocketConnection { _cls: &Bound<'py, pyo3::types::PyType>, py: Python<'py>, url: String, - headers: Option>, + #[pyo3(from_py_with = litellm_python_interop::from_py)] headers: Option, timeout_seconds: Option, ) -> PyResult> { - let headers = marshal_headers(py, headers)?; + let headers = marshal_headers(headers)?; let timeout = optional_timeout(timeout_seconds); pyo3_async_runtimes::tokio::future_into_py(py, async move { let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout) .await .map_err(core_error_to_pyerr)?; - Python::attach(|py| Py::new(py, ResponsesWebSocketConnection { inner })) + Ok(ResponsesWebSocketConnection { inner }) }) } @@ -200,445 +60,136 @@ impl ResponsesWebSocketConnection { } } -fn marshal_inputs( - py: Python<'_>, - document: Py, - extra_headers: Option>, - optional_params: Option>, - timeout_seconds: Option, -) -> PyResult { - let document = from_py(document.bind(py))?; - let extra_headers = match extra_headers { - Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), - None => None, - }; - let optional_params = optional_object_to_map(py, "optional_params", optional_params)?; - let timeout = optional_timeout(timeout_seconds); +#[pymodule(gil_used = false)] +mod _native { + use pyo3::prelude::*; - Ok((document, extra_headers, optional_params, timeout)) -} - -#[pyfunction] -#[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn ocr( - py: Python<'_>, - model: String, - document: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - optional_params: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (document, extra_headers, optional_params, timeout) = marshal_inputs( - py, - document, - extra_headers, - optional_params, - timeout_seconds, - )?; - - let result = gil::release_gil(py, || { - pyo3_async_runtimes::tokio::get_runtime().block_on(run_ocr(OcrRequest { - model: &model, - document, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - optional_params, - timeout, - callbacks: Vec::new(), - guardrails: Vec::new(), - request_metadata: Default::default(), - litellm_call_id: None, - })) - }); - - match result { - Ok(value) => to_py(py, &value), - Err(err) => Err(ocr_error_to_pyerr(err)), + #[pymodule_init] + fn init(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::errors::register(module)?; + super::routes::register(module)?; + module.add_class::()?; + super::diagnostics::register(module) } } -#[pyfunction] -#[pyo3(signature = (model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn aocr( - py: Python<'_>, - model: String, - document: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - optional_params: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (document, extra_headers, optional_params, timeout) = marshal_inputs( - py, - document, - extra_headers, - optional_params, - timeout_seconds, - )?; +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::time::Duration; - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let value = run_ocr(OcrRequest { - model: &model, - document, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - optional_params, - timeout, - callbacks: Vec::new(), - guardrails: Vec::new(), - request_metadata: Default::default(), - litellm_call_id: None, - }) - .await - .map_err(ocr_error_to_pyerr)?; + use futures_util::{SinkExt, StreamExt}; + use pyo3::types::PyDict; + use tokio::net::TcpListener; + use tokio_tungstenite::{accept_async, tungstenite::Message}; - Python::attach(|py| to_py(py, &value)) - }) -} + use super::*; -#[pyfunction] -#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn transcription( - py: Python<'_>, - model: String, - audio: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - optional_params: Option>, - timeout_seconds: Option, -) -> PyResult> { - let audio = from_py(audio.bind(py))?; - let extra_headers = match extra_headers { - Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), - None => None, - }; - let optional_params = optional_object_to_map(py, "optional_params", optional_params)?; - let timeout = optional_timeout(timeout_seconds); - let result = gil::release_gil(py, || { - pyo3_async_runtimes::tokio::get_runtime().block_on(run_audio_transcription( - AudioTranscriptionRequest { - model: &model, - audio, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - optional_params, - timeout, - callbacks: Vec::new(), - guardrails: Vec::new(), - request_metadata: Default::default(), - litellm_call_id: None, - }, - )) - }); - match result { - Ok(value) => to_py(py, &value), - Err(err) => Err(core_error_to_pyerr(err)), + #[test] + fn module_registration_preserves_the_public_surface() { + Python::initialize(); + Python::attach(|py| { + let module = pyo3::wrap_pymodule!(_native)(py).into_bound(py); + + let expected = [ + "RustBridgeDeclined", + "RustUpstreamError", + "ocr", + "aocr", + "transcription", + "atranscription", + "messages", + "amessages", + "chat_completions_decline", + "chat_completions", + "achat_completions", + "ChatCompletionsEventStream", + "MessagesEventStream", + "ResponsesEventStream", + "ResponsesWebSocketSession", + "chat_completions_stream", + "achat_completions_stream", + "messages_stream", + "amessages_stream", + "responses_stream", + "aresponses_stream", + "ResponsesWebSocketConnection", + "gil_stats", + ]; + + let public_names: Vec = module + .dict() + .keys() + .extract::>() + .expect("module names should be strings") + .into_iter() + .filter(|name| !name.starts_with("__")) + .collect(); + assert_eq!(public_names, expected); + }); + } + + #[test] + fn responses_websocket_connection_round_trips_through_python() { + Python::initialize(); + let runtime = pyo3_async_runtimes::tokio::get_runtime(); + let listener = runtime + .block_on(TcpListener::bind("127.0.0.1:0")) + .expect("listener should bind"); + let address = listener + .local_addr() + .expect("listener should have an address"); + let server = runtime.spawn(async move { + let (stream, _) = listener.accept().await.expect("server should accept"); + let mut socket = accept_async(stream) + .await + .expect("handshake should succeed"); + + let message = socket + .next() + .await + .expect("client should send a frame") + .expect("client frame should be valid"); + assert_eq!(message, Message::Text("from-python".into())); + socket + .send(Message::Text("from-server".into())) + .await + .expect("server should reply"); + assert!(matches!(socket.next().await, Some(Ok(Message::Close(_))))); + }); + + Python::attach(|py| { + let module = pyo3::wrap_pymodule!(_native)(py).into_bound(py); + let locals = PyDict::new(py); + locals + .set_item("native", &module) + .expect("module should enter Python locals"); + locals + .set_item("url", format!("ws://{address}")) + .expect("URL should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + connection = await native.ResponsesWebSocketConnection.connect(url) + assert type(connection) is native.ResponsesWebSocketConnection + await connection.send_text("from-python") + assert await connection.recv_text() == "from-server" + await connection.close() + assert await connection.recv_text() is None + +asyncio.run(asyncio.wait_for(exercise(), timeout=5)) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("Python WebSocket methods should round trip"); + }); + + runtime + .block_on(async { tokio::time::timeout(Duration::from_secs(5), server).await }) + .expect("server should finish") + .expect("server task should not panic"); } } - -#[pyfunction] -#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn atranscription( - py: Python<'_>, - model: String, - audio: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - optional_params: Option>, - timeout_seconds: Option, -) -> PyResult> { - let audio = from_py(audio.bind(py))?; - let extra_headers = match extra_headers { - Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), - None => None, - }; - let optional_params = optional_object_to_map(py, "optional_params", optional_params)?; - let timeout = optional_timeout(timeout_seconds); - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let value = run_audio_transcription(AudioTranscriptionRequest { - model: &model, - audio, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - optional_params, - timeout, - callbacks: Vec::new(), - guardrails: Vec::new(), - request_metadata: Default::default(), - litellm_call_id: None, - }) - .await - .map_err(core_error_to_pyerr)?; - Python::attach(|py| to_py(py, &value)) - }) -} - -type MarshaledMessagesInputs = (Value, Option>, Option); - -fn marshal_messages_inputs( - py: Python<'_>, - body: Py, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult { - let body: Value = from_py(body.bind(py))?; - if !body.is_object() { - return Err(PyValueError::new_err("body must be a dict")); - } - let extra_headers = match extra_headers { - Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), - None => None, - }; - Ok((body, extra_headers, optional_timeout(timeout_seconds))) -} - -#[pyfunction] -#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn messages( - py: Python<'_>, - model: String, - body: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (body, extra_headers, timeout) = - marshal_messages_inputs(py, body, extra_headers, timeout_seconds)?; - - let result = gil::release_gil(py, || { - pyo3_async_runtimes::tokio::get_runtime().block_on(run_messages(MessagesRequest { - model: &model, - body, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - })) - }); - - match result { - Ok(response) => messages_response_to_py(py, response), - Err(err) => Err(core_error_to_pyerr(err)), - } -} - -#[pyfunction] -#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn amessages( - py: Python<'_>, - model: String, - body: Py, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (body, extra_headers, timeout) = - marshal_messages_inputs(py, body, extra_headers, timeout_seconds)?; - - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let response = run_messages(MessagesRequest { - model: &model, - body, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - }) - .await - .map_err(core_error_to_pyerr)?; - - Python::attach(|py| messages_response_to_py(py, response)) - }) -} - -type MarshaledChatCompletionsInputs = ( - Value, - Map, - Option>, - Option, -); - -fn marshal_chat_completions_inputs( - py: Python<'_>, - messages: Py, - optional_params: Option>, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult { - let messages: Value = from_py(messages.bind(py))?; - if !messages.is_array() { - return Err(PyValueError::new_err("messages must be a list")); - } - let optional_params = optional_object_to_map(py, "optional_params", optional_params)?; - let extra_headers = match extra_headers { - Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), - None => None, - }; - Ok(( - messages, - optional_params, - extra_headers, - optional_timeout(timeout_seconds), - )) -} - -/// The decline reason for this request, or `None` when the Rust path accepts -/// it. Resolves no credentials and performs no I/O, so a host can ask before -/// committing to either path. -#[pyfunction] -#[pyo3(signature = (model, messages, optional_params=None, custom_llm_provider=None))] -fn chat_completions_decline( - py: Python<'_>, - model: String, - messages: Py, - optional_params: Option>, - custom_llm_provider: Option, -) -> PyResult> { - let messages = from_py(messages.bind(py))?; - let optional_params = optional_object_to_map(py, "optional_params", optional_params)?; - Ok(chat_completions_decline_reason( - &model, - custom_llm_provider.as_deref(), - messages, - &optional_params, - ) - .map(str::to_string)) -} - -#[pyfunction] -#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn chat_completions( - py: Python<'_>, - model: String, - messages: Py, - optional_params: Option>, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (messages, optional_params, extra_headers, timeout) = marshal_chat_completions_inputs( - py, - messages, - optional_params, - extra_headers, - timeout_seconds, - )?; - - let result = gil::release_gil(py, || { - pyo3_async_runtimes::tokio::get_runtime().block_on(run_chat_completions( - ChatCompletionsRequest { - model: &model, - messages, - optional_params, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - }, - )) - }); - - match result { - Ok(response) => chat_completions_response_to_py(py, response), - Err(err) => Err(chat_completions_error_to_pyerr(err)), - } -} - -#[pyfunction] -#[pyo3(signature = (model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] -#[allow(clippy::too_many_arguments)] -fn achat_completions( - py: Python<'_>, - model: String, - messages: Py, - optional_params: Option>, - api_key: Option, - api_base: Option, - custom_llm_provider: Option, - extra_headers: Option>, - timeout_seconds: Option, -) -> PyResult> { - let (messages, optional_params, extra_headers, timeout) = marshal_chat_completions_inputs( - py, - messages, - optional_params, - extra_headers, - timeout_seconds, - )?; - - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let response = run_chat_completions(ChatCompletionsRequest { - model: &model, - messages, - optional_params, - api_key: api_key.as_deref(), - api_base: api_base.as_deref(), - custom_llm_provider: custom_llm_provider.as_deref(), - extra_headers, - timeout, - }) - .await - .map_err(chat_completions_error_to_pyerr)?; - - Python::attach(|py| chat_completions_response_to_py(py, response)) - }) -} - -#[pyfunction] -fn gil_stats(py: Python<'_>) -> PyResult> { - let stats = PyDict::new(py); - stats.set_item("releases", gil::release_count())?; - Ok(stats.into_any().unbind()) -} - -#[pymodule] -fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { - let py = module.py(); - module.add_function(wrap_pyfunction!(ocr, module)?)?; - module.add_function(wrap_pyfunction!(aocr, module)?)?; - module.add_function(wrap_pyfunction!(transcription, module)?)?; - module.add_function(wrap_pyfunction!(atranscription, module)?)?; - module.add_function(wrap_pyfunction!(messages, module)?)?; - module.add_function(wrap_pyfunction!(amessages, module)?)?; - module.add("RustBridgeDeclined", py.get_type::())?; - module.add("RustUpstreamError", py.get_type::())?; - module.add_function(wrap_pyfunction!(chat_completions_decline, module)?)?; - module.add_function(wrap_pyfunction!(chat_completions, module)?)?; - module.add_function(wrap_pyfunction!(achat_completions, module)?)?; - module.add_class::()?; - module.add_function(wrap_pyfunction!(gil_stats, module)?)?; - Ok(()) -} diff --git a/litellm-rust/crates/python-bridge/src/marshal.rs b/litellm-rust/crates/python-bridge/src/marshal.rs index c3d0638427c..a14e4b55d82 100644 --- a/litellm-rust/crates/python-bridge/src/marshal.rs +++ b/litellm-rust/crates/python-bridge/src/marshal.rs @@ -1,20 +1,104 @@ +use std::collections::HashMap; +use std::time::Duration; + use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; -pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult -where - T: DeserializeOwned, -{ - pythonize::depythonize(value).map_err(|error| PyValueError::new_err(error.to_string())) +pub(crate) struct RouteOptions { + pub(crate) model: String, + pub(crate) api_key: Option, + pub(crate) api_base: Option, + pub(crate) custom_llm_provider: Option, + pub(crate) extra_headers: Option>, + pub(crate) timeout: Option, } -pub fn to_py(py: Python<'_>, value: &T) -> PyResult> -where - T: Serialize + ?Sized, -{ - pythonize::pythonize(py, value) - .map(Bound::unbind) - .map_err(|error| PyValueError::new_err(error.to_string())) +pub(crate) struct RouteOptionsInputs { + pub(crate) model: String, + pub(crate) api_key: Option, + pub(crate) api_base: Option, + pub(crate) custom_llm_provider: Option, + pub(crate) extra_headers: Option, + pub(crate) timeout_seconds: Option, +} + +impl RouteOptions { + pub(crate) fn from_python(inputs: RouteOptionsInputs) -> PyResult { + Ok(Self { + model: inputs.model, + api_key: inputs.api_key, + api_base: inputs.api_base, + custom_llm_provider: inputs.custom_llm_provider, + extra_headers: optional_object("extra_headers", inputs.extra_headers)?, + timeout: optional_timeout(inputs.timeout_seconds), + }) + } +} + +pub(crate) fn required_value( + name: &'static str, + value: Value, + expected: fn(&Value) -> bool, + expected_name: &'static str, +) -> PyResult { + if expected(&value) { + return Ok(value); + } + Err(PyValueError::new_err(format!( + "{name} must be a {expected_name}" + ))) +} + +pub(crate) fn object_or_empty( + name: &'static str, + value: Option, +) -> PyResult> { + match value { + Some(value) => object(name, value), + None => Ok(Map::new()), + } +} + +fn optional_object( + name: &'static str, + value: Option, +) -> PyResult>> { + value.map(|value| object(name, value)).transpose() +} + +fn object(name: &'static str, value: Value) -> PyResult> { + match value { + Value::Object(map) => Ok(map), + _ => Err(PyValueError::new_err(format!("{name} must be a dict"))), + } +} + +pub(crate) fn optional_timeout(timeout_seconds: Option) -> Option { + timeout_seconds.and_then(|secs| { + if secs.is_finite() && secs > 0.0 { + Some(Duration::from_secs_f64(secs)) + } else { + None + } + }) +} + +pub(crate) fn marshal_headers(headers: Option) -> PyResult> { + let value = match headers { + Some(headers) => headers, + None => Value::Object(Map::new()), + }; + let Value::Object(headers) = value else { + return Err(PyValueError::new_err("headers must be a dict")); + }; + headers + .into_iter() + .map(|(name, value)| { + value + .as_str() + .map(|value| (name, value.to_string())) + .ok_or_else(|| PyValueError::new_err("header values must be strings")) + }) + .collect() } diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs new file mode 100644 index 00000000000..10b86132be7 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs @@ -0,0 +1,71 @@ +use litellm_core::Error; +use std::future::Future; + +use litellm_core::audio_transcription::{ + AudioTranscriptionRequest, audio_transcription as run_audio_transcription, +}; +use pyo3::prelude::*; +use serde_json::Value; + +use crate::errors::core_error_to_pyerr; +use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; + +fn prepare_transcription( + inputs: AudioTranscriptionInputs, +) -> PyResult> + Send + 'static> { + let audio = inputs.audio; + let options = RouteOptions::from_python(RouteOptionsInputs { + model: inputs.model, + api_key: inputs.api_key, + api_base: inputs.api_base, + custom_llm_provider: inputs.custom_llm_provider, + extra_headers: inputs.extra_headers, + timeout_seconds: inputs.timeout_seconds, + })?; + let optional_params = object_or_empty("optional_params", inputs.optional_params)?; + + Ok(async move { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_audio_transcription(AudioTranscriptionRequest { + model: &model, + audio, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + optional_params, + timeout, + }) + .await + }) +} + +bridge_route! { + sync = transcription, + asynchronous = atranscription, + inputs = AudioTranscriptionInputs, + required = { + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + audio: Value, + }, + optional = { + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + optional_params: Option, + timeout_seconds: Option, + }, + prepare = prepare_transcription, + errors = core_error_to_pyerr, +} diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs new file mode 100644 index 00000000000..a0257a4ade2 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs @@ -0,0 +1,91 @@ +use litellm_core::Error; +use std::future::Future; + +use litellm_core::chat_completions::types::{ChatCompletionsRequest, ChatCompletionsResponse}; +use litellm_core::chat_completions::{ + chat_completions as run_chat_completions, chat_completions_decline_reason, +}; +use pyo3::prelude::*; +use serde_json::Value; + +use crate::errors::fallback_route_error_to_pyerr; +use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty, required_value}; + +fn prepare_chat_completions( + inputs: ChatCompletionsInputs, +) -> PyResult> + Send + 'static> { + let messages = required_value("messages", inputs.messages, Value::is_array, "list")?; + let optional_params = object_or_empty("optional_params", inputs.optional_params)?; + let options = RouteOptions::from_python(RouteOptionsInputs { + model: inputs.model, + api_key: inputs.api_key, + api_base: inputs.api_base, + custom_llm_provider: inputs.custom_llm_provider, + extra_headers: inputs.extra_headers, + timeout_seconds: inputs.timeout_seconds, + })?; + + Ok(async move { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_chat_completions(ChatCompletionsRequest { + model: &model, + messages, + optional_params, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + }) + .await + }) +} + +#[pyfunction] +#[pyo3(signature = (model, messages, optional_params=None, custom_llm_provider=None))] +fn chat_completions_decline( + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] messages: Value, + #[pyo3(from_py_with = litellm_python_interop::from_py)] optional_params: Option, + custom_llm_provider: Option, +) -> PyResult> { + let optional_params = object_or_empty("optional_params", optional_params)?; + Ok(chat_completions_decline_reason( + &model, + custom_llm_provider.as_deref(), + messages, + &optional_params, + ) + .map(str::to_string)) +} + +bridge_route! { + sync = chat_completions, + asynchronous = achat_completions, + inputs = ChatCompletionsInputs, + required = { + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + messages: Value, + }, + optional = { + #[pyo3(from_py_with = litellm_python_interop::from_py)] + optional_params: Option, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + extra_headers: Option, + timeout_seconds: Option, + }, + prepare = prepare_chat_completions, + errors = fallback_route_error_to_pyerr, + extra = [chat_completions_decline], +} diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs new file mode 100644 index 00000000000..9dc356d2d40 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/definition.rs @@ -0,0 +1,448 @@ +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use pyo3::types::PyCFunction; + +macro_rules! bridge_route { + ( + sync = $sync_name:ident, + asynchronous = $async_name:ident, + inputs = $inputs:ident, + required = { $($(#[$required_attr:meta])* $required_name:ident: $required_type:ty),+ $(,)? }, + optional = { $($(#[$optional_attr:meta])* $optional_name:ident: $optional_type:ty),* $(,)? }, + prepare = $prepare:path, + errors = $map_error:path + $(, extra = [$($extra:ident),* $(,)?])? + $(,)? + ) => { + struct $inputs { + $($required_name: $required_type,)* + $($optional_name: $optional_type),* + } + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $sync_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type),* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::routes::runtime::run_sync(py, future, $map_error) + } + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $async_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type),* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::routes::runtime::run_async(py, future, $map_error) + } + + pub(super) fn register( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + $($($crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($extra, module)?)?;)*)? + $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($sync_name, module)?)?; + $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?; + Ok(()) + } + }; +} + +pub(super) fn add_function( + module: &Bound<'_, PyModule>, + function: Bound<'_, PyCFunction>, +) -> PyResult<()> { + let name: String = function.getattr("__name__")?.extract()?; + if module.hasattr(&name)? { + return Err(PyRuntimeError::new_err(format!( + "duplicate native route: {name}" + ))); + } + module.add_function(function) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::sync::atomic::{AtomicBool, Ordering}; + + use litellm_core::error::Error; + use pyo3::exceptions::PyLookupError; + use pyo3::types::{PyDict, PyList}; + + use super::*; + + mod synthetic { + use std::future::{Future, pending}; + + use super::*; + + static FUTURE_DROPPED: AtomicBool = AtomicBool::new(false); + + struct DropGuard; + + impl Drop for DropGuard { + fn drop(&mut self) { + FUTURE_DROPPED.store(true, Ordering::SeqCst); + } + } + + #[pyfunction] + fn future_dropped() -> bool { + FUTURE_DROPPED.load(Ordering::SeqCst) + } + + bridge_route! { + sync = echo, + asynchronous = aecho, + inputs = EchoInputs, + required = { value: String }, + optional = {}, + prepare = prepare_echo, + errors = map_error, + extra = [future_dropped], + } + + fn prepare_echo( + inputs: EchoInputs, + ) -> PyResult> + Send + 'static> { + FUTURE_DROPPED.store(false, Ordering::SeqCst); + let drop_guard = (inputs.value == "pending").then_some(DropGuard); + Ok(async move { + let _drop_guard = drop_guard; + tokio::task::yield_now().await; + match inputs.value.as_str() { + "error" => Err(Error::InvalidRequest("synthetic error".to_string())), + "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), + "panic" => panic!("synthetic panic"), + "pending" => { + pending::<()>().await; + unreachable!() + } + _ => Ok(inputs.value), + } + }) + } + + fn map_error(error: Error) -> PyErr { + if matches!(&error, Error::InvalidRequest(message) if message == "panic in mapper") { + panic!("synthetic mapper panic") + } + PyLookupError::new_err(error.to_string()) + } + } + + #[test] + fn sync_and_async_route_signatures_match_the_python_contract() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + crate::routes::register(&module).expect("routes should register"); + let routes = [ + ( + "ocr", + "aocr", + "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=None)", + ), + ( + "transcription", + "atranscription", + "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", + ), + ( + "messages", + "amessages", + "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ( + "chat_completions", + "achat_completions", + "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", + ), + ]; + + for (sync_name, async_name, expected) in routes { + let sync_signature: String = module + .getattr(sync_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("sync signature should be available"); + let async_signature: String = module + .getattr(async_name) + .and_then(|function| function.getattr("__text_signature__")) + .and_then(|signature| signature.extract()) + .expect("async signature should be available"); + + assert_eq!(sync_signature, expected); + assert_eq!(async_signature, expected); + } + }); + } + + #[test] + fn sync_and_async_routes_apply_the_same_input_validation() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + crate::routes::register(&module).expect("routes should register"); + + let invalid_messages = PyDict::new(py); + let sync_chat_error = module + .getattr("chat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("sync chat should reject a non-list messages value"); + let async_chat_error = module + .getattr("achat_completions") + .and_then(|function| function.call1(("model", &invalid_messages))) + .expect_err("async chat should reject a non-list messages value"); + + assert_eq!( + sync_chat_error.to_string(), + "ValueError: messages must be a list" + ); + assert_eq!(async_chat_error.to_string(), sync_chat_error.to_string()); + + let invalid_body = PyList::empty(py); + let sync_messages_error = module + .getattr("messages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("sync Messages should reject a non-dict body"); + let async_messages_error = module + .getattr("amessages") + .and_then(|function| function.call1(("model", &invalid_body))) + .expect_err("async Messages should reject a non-dict body"); + + assert_eq!( + sync_messages_error.to_string(), + "ValueError: body must be a dict" + ); + assert_eq!( + async_messages_error.to_string(), + sync_messages_error.to_string() + ); + + let invalid_headers = PyList::empty(py); + let kwargs = PyDict::new(py); + kwargs + .set_item("extra_headers", &invalid_headers) + .expect("kwargs should accept extra_headers"); + let document = PyDict::new(py); + + for (sync_name, async_name) in [("ocr", "aocr"), ("transcription", "atranscription")] { + let sync_error = module + .getattr(sync_name) + .and_then(|function| function.call(("model", &document), Some(&kwargs))) + .expect_err("sync route should reject non-dict extra_headers"); + let async_error = module + .getattr(async_name) + .and_then(|function| function.call(("model", &document), Some(&kwargs))) + .expect_err("async route should reject non-dict extra_headers"); + + assert_eq!( + sync_error.to_string(), + "ValueError: extra_headers must be a dict" + ); + assert_eq!(async_error.to_string(), sync_error.to_string()); + } + }); + } + + #[test] + fn route_input_validation_preserves_left_to_right_order() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + crate::routes::register(&module).expect("routes should register"); + let invalid = PyList::empty(py); + + let chat_kwargs = PyDict::new(py); + chat_kwargs + .set_item("optional_params", &invalid) + .expect("kwargs should accept optional_params"); + chat_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_messages = PyDict::new(py); + let error = module + .getattr("chat_completions") + .and_then(|function| { + function.call(("model", &invalid_messages), Some(&chat_kwargs)) + }) + .expect_err("messages should be validated first"); + assert_eq!(error.to_string(), "ValueError: messages must be a list"); + + let valid_messages = PyList::empty(py); + let error = module + .getattr("chat_completions") + .and_then(|function| function.call(("model", &valid_messages), Some(&chat_kwargs))) + .expect_err("optional_params should be validated before headers"); + assert_eq!( + error.to_string(), + "ValueError: optional_params must be a dict" + ); + + let headers_kwargs = PyDict::new(py); + headers_kwargs + .set_item("extra_headers", &invalid) + .expect("kwargs should accept extra_headers"); + let invalid_body = PyList::empty(py); + let error = module + .getattr("messages") + .and_then(|function| function.call(("model", &invalid_body), Some(&headers_kwargs))) + .expect_err("body should be validated before headers"); + assert_eq!(error.to_string(), "ValueError: body must be a dict"); + + let invalid_payload = + PyModule::new(py, "invalid_payload").expect("invalid payload should be created"); + for name in ["ocr", "transcription"] { + let error = module + .getattr(name) + .and_then(|function| { + function.call(("model", &invalid_payload), Some(&headers_kwargs)) + }) + .expect_err("payload should be validated before headers"); + assert!(!error.to_string().contains("extra_headers")); + } + }); + } + + #[test] + fn generated_routes_execute_sync_and_async_contracts() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "synthetic").expect("module should be created"); + synthetic::register(&module).expect("routes should register"); + + let sync_value: String = module + .getattr("echo") + .and_then(|function| function.call1(("sync",))) + .and_then(|value| value.extract()) + .expect("sync route should return its value"); + assert_eq!(sync_value, "sync"); + + let sync_error = module + .getattr("echo") + .and_then(|function| function.call1(("error",))) + .expect_err("sync route should map its error"); + assert!(sync_error.is_instance_of::(py)); + assert_eq!( + sync_error.to_string(), + "LookupError: invalid request: synthetic error" + ); + + let locals = PyDict::new(py); + locals + .set_item("routes", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + assert await routes.aecho("async") == "async" + + try: + await routes.aecho("error") + except LookupError as error: + assert str(error) == "invalid request: synthetic error" + else: + raise AssertionError("mapped error was not raised") + + try: + await routes.aecho("panic") + except BaseException as error: + assert type(error).__name__ == "PanicException" + assert str(error) == "synthetic panic" + else: + raise AssertionError("panic was not raised") + + try: + await routes.aecho("map_panic") + except BaseException as error: + assert type(error).__name__ == "PanicException" + assert str(error) == "synthetic mapper panic" + else: + raise AssertionError("mapper panic was not raised") + + task = asyncio.ensure_future(routes.aecho("pending")) + await asyncio.sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + else: + raise AssertionError("cancelled route completed") + + for _ in range(100): + if routes.future_dropped(): + break + await asyncio.sleep(0.001) + assert routes.future_dropped() + +asyncio.run(exercise()) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("async route contract should hold"); + }); + } + + #[test] + fn messages_routes_only_decline_unsupported_requests() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "routes").expect("module should be created"); + crate::routes::register(&module).expect("routes should register"); + let body = PyDict::new(py); + let kwargs = PyDict::new(py); + kwargs + .set_item("custom_llm_provider", "openai") + .expect("kwargs should accept provider"); + + let error = module + .getattr("messages") + .and_then(|function| function.call(("model", &body), Some(&kwargs))) + .expect_err("unsupported provider should decline"); + assert!(error.is_instance_of::(py)); + + kwargs + .set_item("custom_llm_provider", "anthropic") + .expect("kwargs should accept provider"); + let error = module + .getattr("messages") + .and_then(|function| function.call(("model", &body), Some(&kwargs))) + .expect_err("missing credentials should fail"); + assert!(error.is_instance_of::(py)); + }); + } + + #[test] + fn route_registration_rejects_duplicate_python_names() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "synthetic").expect("module should be created"); + synthetic::register(&module).expect("first registration should succeed"); + let error = synthetic::register(&module) + .expect_err("duplicate registration should be rejected"); + + assert_eq!( + error.to_string(), + "RuntimeError: duplicate native route: future_dropped" + ); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages.rs b/litellm-rust/crates/python-bridge/src/routes/messages.rs new file mode 100644 index 00000000000..da028486e2f --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/messages.rs @@ -0,0 +1,65 @@ +use litellm_core::Error; +use litellm_core::messages::messages as run_messages; +use litellm_core::messages::types::{AnthropicMessagesResponse, MessagesRequest}; +use pyo3::prelude::*; +use serde_json::Value; +use std::future::Future; + +use crate::errors::fallback_route_error_to_pyerr; +use crate::marshal::{RouteOptions, RouteOptionsInputs, required_value}; + +fn prepare_messages( + inputs: MessagesInputs, +) -> PyResult> + Send + 'static> { + let body = required_value("body", inputs.body, Value::is_object, "dict")?; + let options = RouteOptions::from_python(RouteOptionsInputs { + model: inputs.model, + api_key: inputs.api_key, + api_base: inputs.api_base, + custom_llm_provider: inputs.custom_llm_provider, + extra_headers: inputs.extra_headers, + timeout_seconds: inputs.timeout_seconds, + })?; + + Ok(async move { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + run_messages(MessagesRequest { + model: &model, + body, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + }) + .await + }) +} + +bridge_route! { + sync = messages, + asynchronous = amessages, + inputs = MessagesInputs, + required = { + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + body: Value, + }, + optional = { + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + extra_headers: Option, + timeout_seconds: Option, + }, + prepare = prepare_messages, + errors = fallback_route_error_to_pyerr, +} diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs new file mode 100644 index 00000000000..1e64ae23248 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -0,0 +1,20 @@ +use pyo3::prelude::*; + +#[macro_use] +mod definition; +mod runtime; + +mod audio_transcription; +mod chat_completions; +mod messages; +mod ocr; +mod receiver; +mod streaming; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + ocr::register(module)?; + audio_transcription::register(module)?; + messages::register(module)?; + chat_completions::register(module)?; + streaming::register(module) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr.rs b/litellm-rust/crates/python-bridge/src/routes/ocr.rs new file mode 100644 index 00000000000..ef2c2e486d4 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/ocr.rs @@ -0,0 +1,83 @@ +use litellm_core::Error; +use std::future::Future; + +use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr}; +use pyo3::prelude::*; +use serde_json::Value; +use tracing::instrument::WithSubscriber; + +use crate::errors::ocr_error_to_pyerr; +use crate::function_trace::FunctionTrace; +use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty}; + +fn prepare_ocr( + inputs: OcrInputs, +) -> PyResult> + Send + 'static> { + let document = inputs.document; + let options = RouteOptions::from_python(RouteOptionsInputs { + model: inputs.model, + api_key: inputs.api_key, + api_base: inputs.api_base, + custom_llm_provider: inputs.custom_llm_provider, + extra_headers: inputs.extra_headers, + timeout_seconds: inputs.timeout_seconds, + })?; + let optional_params = object_or_empty("optional_params", inputs.optional_params)?; + + let trace = inputs.trace.unwrap_or(false).then(FunctionTrace::default); + Ok(async move { + let RouteOptions { + model, + api_key, + api_base, + custom_llm_provider, + extra_headers, + timeout, + } = options; + let future = run_ocr(OcrRequest { + model: &model, + document, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + optional_params, + timeout, + callbacks: Vec::new(), + guardrails: Vec::new(), + request_metadata: Default::default(), + litellm_call_id: None, + }); + match trace { + Some(trace) => { + let response = future.with_subscriber(trace.dispatcher()).await?; + Ok(serde_json::json!({ "response": response, "trace": trace.events() })) + } + None => future.await, + } + }) +} + +bridge_route! { + sync = ocr, + asynchronous = aocr, + inputs = OcrInputs, + required = { + model: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + document: Value, + }, + optional = { + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + extra_headers: Option, + #[pyo3(from_py_with = litellm_python_interop::from_py)] + optional_params: Option, + timeout_seconds: Option, + trace: Option, + }, + prepare = prepare_ocr, + errors = ocr_error_to_pyerr, +} diff --git a/litellm-rust/crates/python-bridge/src/routes/receiver.rs b/litellm-rust/crates/python-bridge/src/routes/receiver.rs new file mode 100644 index 00000000000..a97a256cfa2 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/receiver.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures_util::{Stream, StreamExt, pin_mut}; +use litellm_core::Error; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinHandle; + +const BRIDGE_CHANNEL_CAPACITY: usize = 1; + +struct ReceiverState { + receiver: Mutex>>, + reading: AtomicBool, + closed: AtomicBool, + producer: std::sync::Mutex>>, +} + +impl Drop for ReceiverState { + fn drop(&mut self) { + if let Ok(producer) = self.producer.get_mut() + && let Some(producer) = producer.take() + { + producer.abort(); + } + } +} + +#[derive(Clone)] +pub(super) struct BridgeReceiver { + state: Arc>, +} + +struct ReadGuard<'a>(&'a AtomicBool); + +impl Drop for ReadGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl BridgeReceiver { + pub(super) fn from_stream(stream: S) -> Self + where + S: Stream> + Send + 'static, + { + let (sender, receiver) = mpsc::channel(BRIDGE_CHANNEL_CAPACITY); + let producer = pyo3_async_runtimes::tokio::get_runtime().spawn(async move { + pin_mut!(stream); + while let Some(item) = stream.next().await { + let terminal = item.is_err(); + if sender.send(item).await.is_err() || terminal { + return; + } + } + }); + Self { + state: Arc::new(ReceiverState { + receiver: Mutex::new(receiver), + reading: AtomicBool::new(false), + closed: AtomicBool::new(false), + producer: std::sync::Mutex::new(Some(producer)), + }), + } + } + + pub(super) async fn next(&self) -> Result, Error> { + if self.state.closed.load(Ordering::Acquire) { + return Ok(None); + } + if self.state.reading.swap(true, Ordering::AcqRel) { + return Err(Error::InvalidRequest( + "native stream does not support concurrent reads".to_string(), + )); + } + let _guard = ReadGuard(&self.state.reading); + match self.state.receiver.lock().await.recv().await { + Some(Ok(item)) => Ok(Some(item)), + Some(Err(error)) => Err(error), + None => Ok(None), + } + } + + pub(super) fn close(&self) { + if self.state.closed.swap(true, Ordering::AcqRel) { + return; + } + if let Ok(mut producer) = self.state.producer.lock() + && let Some(producer) = producer.take() + { + producer.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use futures_util::stream; + + use super::*; + + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn receiver_preserves_items_and_terminal_error() { + let receiver = BridgeReceiver::from_stream(stream::iter([ + Ok(vec![1_u8]), + Err(Error::Network("broken".to_string())), + Ok(vec![2_u8]), + ])); + + assert_eq!(receiver.next().await.expect("first item"), Some(vec![1])); + assert!(matches!( + receiver.next().await, + Err(Error::Network(message)) if message == "broken" + )); + assert_eq!(receiver.next().await.expect("closed after error"), None); + } + + #[tokio::test] + async fn close_unblocks_a_pending_read() { + let receiver = BridgeReceiver::>::from_stream(stream::pending()); + let pending = { + let receiver = receiver.clone(); + tokio::spawn(async move { receiver.next().await }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + receiver.close(); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), pending) + .await + .expect("read should unblock") + .expect("task should finish") + .expect("close is clean"), + None + ); + } + + #[tokio::test] + async fn capacity_one_stops_the_producer_from_draining_the_source() { + let polls = Arc::new(AtomicUsize::new(0)); + let source_polls = polls.clone(); + let source = stream::poll_fn(move |_| { + let item = source_polls.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(Some(Ok(item))) + }); + let receiver = BridgeReceiver::from_stream(source); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(polls.load(Ordering::SeqCst), 2); + assert_eq!(receiver.next().await.expect("first item"), Some(0)); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(polls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn concurrent_reads_are_rejected() { + let receiver = BridgeReceiver::>::from_stream(stream::pending()); + let pending = { + let receiver = receiver.clone(); + tokio::spawn(async move { receiver.next().await }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + + assert!(matches!( + receiver.next().await, + Err(Error::InvalidRequest(message)) + if message == "native stream does not support concurrent reads" + )); + receiver.close(); + pending + .await + .expect("pending read task") + .expect("clean close"); + } + + #[tokio::test] + async fn dropping_the_last_receiver_cancels_the_producer() { + let dropped = Arc::new(AtomicBool::new(false)); + let producer_dropped = dropped.clone(); + let source = stream::once(async move { + let _flag = DropFlag(producer_dropped); + std::future::pending::, Error>>().await + }); + let receiver = BridgeReceiver::from_stream(source); + tokio::time::sleep(Duration::from_millis(10)).await; + + drop(receiver); + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("producer future should be dropped"); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/runtime.rs b/litellm-rust/crates/python-bridge/src/routes/runtime.rs new file mode 100644 index 00000000000..75cce13bd36 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/runtime.rs @@ -0,0 +1,523 @@ +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::time::Duration; + +use futures_util::FutureExt; +use litellm_core::error::Error; +use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use serde::Serialize; +use tokio::runtime::{Handle, Runtime}; +use tokio::time::{self, MissedTickBehavior}; + +pub(super) fn run_sync( + py: Python<'_>, + future: F, + map_error: fn(Error) -> PyErr, +) -> PyResult> +where + T: Serialize + Send + 'static, + F: Future> + Send + 'static, +{ + run_sync_on( + py, + pyo3_async_runtimes::tokio::get_runtime(), + future, + map_error, + ) +} + +fn run_sync_on( + py: Python<'_>, + runtime: &Runtime, + future: F, + map_error: fn(Error) -> PyErr, +) -> PyResult> +where + T: Serialize + Send + 'static, + F: Future> + Send + 'static, +{ + if Handle::try_current().is_ok() { + return Err(PyRuntimeError::new_err( + "synchronous native routes cannot run from a Tokio context; use the async route", + )); + } + + let result = release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))?; + let result = map_core_result(result, map_error)?; + Pythonized(result).into_pyobject(py).map(Bound::unbind) +} + +pub(super) fn run_async( + py: Python<'_>, + future: F, + map_error: fn(Error) -> PyErr, +) -> PyResult> +where + T: Serialize + Send + 'static, + F: Future> + Send + 'static, +{ + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = catch_route_panic(future).await?; + let result = map_core_result(result, map_error)?; + Ok(Pythonized(result)) + }) +} + +struct AttachedConversion { + value: T, + convert: C, +} + +impl<'py, T, C> IntoPyObject<'py> for AttachedConversion +where + C: FnOnce(Python<'py>, T) -> PyResult>, +{ + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> PyResult { + std::panic::catch_unwind(AssertUnwindSafe(|| (self.convert)(py, self.value))) + .map_err(panic_to_pyerr)? + .map(|value| value.into_bound(py)) + } +} + +pub(super) fn run_sync_with( + py: Python<'_>, + future: F, + map_error: fn(Error) -> PyErr, + convert: C, +) -> PyResult> +where + T: Send + 'static, + F: Future> + Send + 'static, + C: FnOnce(Python<'_>, T) -> PyResult>, +{ + if Handle::try_current().is_ok() { + return Err(PyRuntimeError::new_err( + "synchronous native routes cannot run from a Tokio context; use the async route", + )); + } + + let result = release_gil(py, move || { + pyo3_async_runtimes::tokio::get_runtime().block_on(wait_for_sync_result(future)) + })?; + let result = map_core_result(result, map_error)?; + std::panic::catch_unwind(AssertUnwindSafe(|| convert(py, result))).map_err(panic_to_pyerr)? +} + +pub(super) fn run_async_with( + py: Python<'_>, + future: F, + map_error: fn(Error) -> PyErr, + convert: C, +) -> PyResult> +where + T: Send + 'static, + F: Future> + Send + 'static, + C: for<'py> FnOnce(Python<'py>, T) -> PyResult> + Send + 'static, +{ + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result = catch_route_panic(future).await?; + let result = map_core_result(result, map_error)?; + Ok(AttachedConversion { + value: result, + convert, + }) + }) +} + +fn map_core_result(result: Result, map_error: fn(Error) -> PyErr) -> PyResult { + match result { + Ok(value) => Ok(value), + Err(error) => Err( + std::panic::catch_unwind(AssertUnwindSafe(|| map_error(error))) + .map_err(panic_to_pyerr)?, + ), + } +} + +async fn catch_route_panic(future: F) -> PyResult> +where + F: Future>, +{ + AssertUnwindSafe(future) + .catch_unwind() + .await + .map_err(panic_to_pyerr) +} + +async fn wait_for_sync_result(future: F) -> PyResult> +where + F: Future>, +{ + let future = catch_route_panic(future); + tokio::pin!(future); + + let signal_interval = Duration::from_millis(50); + let mut signal_checks = + time::interval_at(time::Instant::now() + signal_interval, signal_interval); + signal_checks.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + tokio::select! { + result = &mut future => return result, + _ = signal_checks.tick() => Python::attach(|py| py.check_signals())?, + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::future::poll_fn; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; + use std::task::Poll; + use std::thread; + use std::time::Instant; + + use pyo3::panic::PanicException; + use pyo3::types::{PyDict, PyModule}; + use serde::Serializer; + use tokio::runtime::Builder; + + use super::*; + + fn runtime_error(error: Error) -> PyErr { + PyRuntimeError::new_err(error.to_string()) + } + + fn panicking_error_mapper(_error: Error) -> PyErr { + panic!("error mapper panicked") + } + + struct PanickingOutput; + + static ASYNC_PROBE_COMPLETED: AtomicUsize = AtomicUsize::new(0); + + impl Serialize for PanickingOutput { + fn serialize(&self, _serializer: S) -> Result + where + S: Serializer, + { + panic!("serializer panicked") + } + } + + #[pyfunction] + fn async_serialization_panic(py: Python<'_>) -> PyResult> { + run_async(py, async { Ok(PanickingOutput) }, runtime_error) + } + + #[pyfunction] + fn async_runtime_probe(py: Python<'_>) -> PyResult> { + run_async( + py, + async { + ASYNC_PROBE_COMPLETED.fetch_add(1, Ordering::SeqCst); + Ok(true) + }, + runtime_error, + ) + } + + #[pyfunction] + fn async_custom_mapping(py: Python<'_>) -> PyResult> { + run_async_with(py, async { Ok("mapped") }, runtime_error, |py, value| { + Ok(value.into_pyobject(py)?.unbind().into_any()) + }) + } + + #[pyfunction] + fn runtime_worker_count() -> usize { + pyo3_async_runtimes::tokio::get_runtime() + .metrics() + .num_workers() + } + + #[pyfunction] + fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool { + let completion_deadline = Instant::now() + Duration::from_secs(2); + while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions { + if Instant::now() >= completion_deadline { + return false; + } + thread::sleep(Duration::from_millis(1)); + } + + let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1); + pyo3_async_runtimes::tokio::get_runtime().spawn(async move { + let _ = heartbeat_tx.send(()); + }); + heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok() + } + + fn extract_bool(py: Python<'_>, result: PyResult>) -> bool { + result + .expect("route should complete") + .bind(py) + .extract() + .expect("result should convert") + } + + #[test] + fn sync_runner_polls_future_on_the_caller_thread() { + Python::initialize(); + Python::attach(|py| { + let caller_thread = std::thread::current().id(); + let result = run_sync( + py, + async move { Ok(std::thread::current().id() == caller_thread) }, + runtime_error, + ); + + assert!(extract_bool(py, result)); + }); + } + + #[test] + fn sync_runner_releases_gil_while_waiting() { + Python::initialize(); + Python::attach(|py| { + let result = run_sync( + py, + async { + let gil_acquired = tokio::time::timeout( + Duration::from_secs(2), + tokio::task::spawn_blocking(|| Python::attach(|_| true)), + ) + .await; + Ok(matches!(gil_acquired, Ok(Ok(true)))) + }, + runtime_error, + ); + + assert!(extract_bool(py, result)); + }); + } + + #[test] + fn sync_runner_rejects_calls_from_a_tokio_context() { + Python::initialize(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + let error = runtime.block_on(async { + Python::attach(|py| { + run_sync::(py, async { Ok(true) }, runtime_error) + .expect_err("sync route should reject a nested Tokio runtime") + }) + }); + + assert_eq!( + error.to_string(), + "RuntimeError: synchronous native routes cannot run from a Tokio context; use the async route" + ); + } + + #[test] + fn sync_runner_can_drive_a_current_thread_runtime() { + Python::initialize(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + Python::attach(|py| { + let result = run_sync_on( + py, + &runtime, + async { + tokio::task::yield_now().await; + Ok(true) + }, + runtime_error, + ); + assert!(extract_bool(py, result)); + }); + } + + #[test] + fn sync_runner_maps_a_panicked_future() { + Python::initialize(); + Python::attach(|py| { + let error = run_sync::( + py, + poll_fn(|_| -> Poll> { panic!("route future panicked") }), + runtime_error, + ) + .expect_err("panicked route should become a Python exception"); + + assert!(error.is_instance_of::(py)); + assert_eq!(error.to_string(), "PanicException: route future panicked"); + }); + } + + #[test] + fn sync_runner_maps_a_panicked_error_mapper() { + Python::initialize(); + Python::attach(|py| { + let error = run_sync::( + py, + async { Err(Error::InvalidRequest("invalid".to_string())) }, + panicking_error_mapper, + ) + .expect_err("panicked mapper should become a Python exception"); + + assert!(error.is_instance_of::(py)); + assert_eq!(error.to_string(), "PanicException: error mapper panicked"); + }); + } + + #[test] + fn sync_runner_surfaces_serializer_panics() { + Python::initialize(); + Python::attach(|py| { + let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error) + .expect_err("serializer panic should become a Python exception"); + + assert!(error.is_instance_of::(py)); + assert_eq!(error.to_string(), "PanicException: serializer panicked"); + }); + } + + #[test] + fn sync_runner_supports_concurrent_callers_on_the_shared_runtime() { + Python::initialize(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let callers: Vec<_> = (0..2) + .map(|_| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + Python::attach(|py| { + extract_bool( + py, + run_sync( + py, + async move { + Ok(tokio::time::timeout(Duration::from_secs(2), barrier.wait()) + .await + .is_ok()) + }, + runtime_error, + ), + ) + }) + }) + }) + .collect(); + let results: Vec<_> = callers + .into_iter() + .map(|caller| caller.join().expect("caller should not panic")) + .collect(); + + assert_eq!(results, vec![true, true]); + } + + #[test] + fn async_runner_surfaces_serializer_panics() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "runtime").expect("module should be created"); + module + .add_function( + wrap_pyfunction!(async_serialization_panic, &module) + .expect("function should wrap"), + ) + .expect("function should register"); + let locals = PyDict::new(py); + locals + .set_item("runtime", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + try: + await runtime.async_serialization_panic() + except BaseException as error: + assert type(error).__name__ == "PanicException" + assert str(error) == "serializer panicked" + else: + raise AssertionError("serializer panic was not raised") + +asyncio.run(exercise()) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("serializer panic should reach the Python awaiter"); + }); + } + + #[test] + fn async_result_delivery_does_not_stall_tokio_workers() { + Python::initialize(); + ASYNC_PROBE_COMPLETED.store(0, Ordering::SeqCst); + Python::attach(|py| { + let module = PyModule::new(py, "runtime").expect("module should be created"); + for function in [ + wrap_pyfunction!(async_runtime_probe, &module).expect("function should wrap"), + wrap_pyfunction!(runtime_worker_count, &module).expect("function should wrap"), + wrap_pyfunction!(runtime_is_responsive, &module).expect("function should wrap"), + ] { + module + .add_function(function) + .expect("function should register"); + } + let locals = PyDict::new(py); + locals + .set_item("runtime", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + worker_count = runtime.runtime_worker_count() + awaitables = [runtime.async_runtime_probe() for _ in range(worker_count)] + assert runtime.runtime_is_responsive(worker_count) + assert await asyncio.gather(*awaitables) == [True] * worker_count + +asyncio.run(exercise()) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("result delivery should leave Tokio workers responsive"); + }); + } + + #[test] + fn async_custom_mapping_runs_during_attached_result_delivery() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "runtime").expect("module should be created"); + module + .add_function(wrap_pyfunction!(async_custom_mapping, &module).expect("function")) + .expect("function should register"); + let locals = PyDict::new(py); + locals + .set_item("runtime", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +import asyncio + +async def exercise(): + assert await runtime.async_custom_mapping() == "mapped" + +asyncio.run(exercise()) +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("custom mapping should reach the Python awaiter"); + }); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/streaming.rs b/litellm-rust/crates/python-bridge/src/routes/streaming.rs new file mode 100644 index 00000000000..7b4edc50d4d --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/streaming.rs @@ -0,0 +1,422 @@ +use std::sync::Arc; + +use litellm_core::chat_completions::chat_completions_stream as run_chat_completions_stream; +use litellm_core::chat_completions::types::{ + ChatCompletionsStreamRequest, ChatCompletionsStreamRequestBody, ChatStreamEvent, +}; +use litellm_core::messages::messages_event_stream as run_messages_stream; +use litellm_core::messages::types::{ + AnthropicMessagesRequest, MessagesStreamEvent, MessagesStreamRequest, +}; +use litellm_core::responses::responses_stream as run_responses_stream; +use litellm_core::responses::responses_websocket as run_responses_websocket; +use litellm_core::responses::types::{ + ResponseCommand, ResponsesStreamEvent, ResponsesStreamRequest, ResponsesStreamRequestBody, + ResponsesWebSocketRequest, +}; +use litellm_core::responses::websocket::TypedResponsesWebSocketSession; +use litellm_core::streaming::{ + JsonObject, OpenedStream, ProviderCredentials, StreamMetadata, StreamProviderId, StreamTarget, + StreamTransportOptions, +}; +use litellm_python_interop::{from_py, to_py}; +use pyo3::exceptions::{PyStopAsyncIteration, PyStopIteration}; +use pyo3::prelude::*; +use pyo3::types::{PyModule, PyType}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::marshal::{marshal_headers, optional_timeout}; +use crate::routes::receiver::BridgeReceiver; +use crate::routes::runtime::{run_async, run_async_with, run_sync_with}; + +struct TypedEventReceiver { + metadata: StreamMetadata, + receiver: BridgeReceiver, +} + +impl TypedEventReceiver +where + E: Send + 'static, +{ + fn from_opened(opened: OpenedStream) -> Self { + Self { + metadata: opened.metadata, + receiver: BridgeReceiver::from_stream(opened.events), + } + } +} + +fn next_event( + py: Python<'_>, + receiver: BridgeReceiver, + stop_iteration: bool, +) -> PyResult> +where + E: Serialize + Send + 'static, +{ + run_sync_with( + py, + async move { receiver.next().await }, + crate::errors::executed_route_error_to_pyerr, + move |py, event| match event { + Some(event) => to_py(py, &event), + None if stop_iteration => Err(PyStopIteration::new_err(())), + None => Ok(py.None()), + }, + ) +} + +fn anext_event( + py: Python<'_>, + receiver: BridgeReceiver, + stop_iteration: bool, +) -> PyResult> +where + E: Serialize + Send + 'static, +{ + run_async_with( + py, + async move { receiver.next().await }, + crate::errors::executed_route_error_to_pyerr, + move |py, event| match event { + Some(event) => to_py(py, &event), + None if stop_iteration => Err(PyStopAsyncIteration::new_err(())), + None => Ok(py.None()), + }, + ) +} + +macro_rules! event_stream_class { + ($class:ident, $event:ty) => { + #[pyclass] + struct $class { + inner: TypedEventReceiver<$event>, + } + + impl From> for $class { + fn from(opened: OpenedStream<$event>) -> Self { + Self { + inner: TypedEventReceiver::from_opened(opened), + } + } + } + + #[pymethods] + impl $class { + #[getter] + fn metadata(&self, py: Python<'_>) -> PyResult> { + to_py(py, &self.inner.metadata) + } + + fn next_event(&self, py: Python<'_>) -> PyResult> { + next_event(py, self.inner.receiver.clone(), false) + } + + fn anext_event<'py>(&self, py: Python<'py>) -> PyResult> { + anext_event(py, self.inner.receiver.clone(), false) + } + + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&self, py: Python<'_>) -> PyResult> { + next_event(py, self.inner.receiver.clone(), true) + } + + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + anext_event(py, self.inner.receiver.clone(), true) + } + + fn close(&self) { + self.inner.receiver.close(); + } + + fn aclose<'py>(&self, py: Python<'py>) -> PyResult> { + let receiver = self.inner.receiver.clone(); + run_async( + py, + async move { + receiver.close(); + Ok(()) + }, + crate::errors::executed_route_error_to_pyerr, + ) + } + } + }; +} + +event_stream_class!(ChatCompletionsEventStream, ChatStreamEvent); +event_stream_class!(MessagesEventStream, MessagesStreamEvent); +event_stream_class!(ResponsesEventStream, ResponsesStreamEvent); + +#[derive(Default, Deserialize)] +struct PythonProviderCredentials { + api_key: Option, + aws_access_key_id: Option, + aws_secret_access_key: Option, + aws_session_token: Option, +} + +struct PythonStreamTarget { + provider: String, + credentials: Option>, + api_base: Option, +} + +struct PythonStreamTransport { + extra_headers: Option>, + timeout_seconds: Option, +} + +fn parse_call( + py: Python<'_>, + request: Py, + target: PythonStreamTarget, + transport: PythonStreamTransport, +) -> PyResult<(B, StreamTarget, StreamTransportOptions)> +where + B: DeserializeOwned, +{ + (|| -> PyResult<(B, StreamTarget, StreamTransportOptions)> { + let body = from_py(request.bind(py))?; + let credentials = target + .credentials + .map(|value| from_py::(value.bind(py))) + .transpose()? + .unwrap_or_default(); + let provider = StreamProviderId::try_from(target.provider.as_str()) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + let target = StreamTarget::new( + provider, + ProviderCredentials::new( + credentials.api_key, + credentials.aws_access_key_id, + credentials.aws_secret_access_key, + credentials.aws_session_token, + ), + target.api_base, + ); + let extra_headers = transport + .extra_headers + .map(|value| from_py(value.bind(py))) + .transpose()?; + let forwarded_headers = marshal_headers(extra_headers)? + .into_iter() + .map(|(name, value)| litellm_core::streaming::Header { name, value }) + .collect(); + let transport = StreamTransportOptions::new( + forwarded_headers, + optional_timeout(transport.timeout_seconds), + ); + Ok((body, target, transport)) + })() + .map_err(crate::errors::declined) +} + +macro_rules! stream_openers { + ( + sync = $sync_name:ident, + asynchronous = $async_name:ident, + body = $body:ty, + request = $request:ident, + open = $open:path, + stream = $stream:ident + ) => { + #[pyfunction] + #[pyo3(signature = (request, provider, credentials=None, api_base=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn $sync_name( + py: Python<'_>, + request: Py, + provider: String, + credentials: Option>, + api_base: Option, + extra_headers: Option>, + timeout_seconds: Option, + ) -> PyResult> { + let (body, target, transport) = parse_call::<$body>( + py, + request, + PythonStreamTarget { + provider, + credentials, + api_base, + }, + PythonStreamTransport { + extra_headers, + timeout_seconds, + }, + )?; + run_sync_with( + py, + async move { $open($request { body, target, transport }).await }, + crate::errors::fallback_route_error_to_pyerr, + |py, opened| Ok(Py::new(py, $stream::from(opened))?.into_any()), + ) + } + + #[pyfunction] + #[pyo3(signature = (request, provider, credentials=None, api_base=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn $async_name( + py: Python<'_>, + request: Py, + provider: String, + credentials: Option>, + api_base: Option, + extra_headers: Option>, + timeout_seconds: Option, + ) -> PyResult> { + let (body, target, transport) = parse_call::<$body>( + py, + request, + PythonStreamTarget { + provider, + credentials, + api_base, + }, + PythonStreamTransport { + extra_headers, + timeout_seconds, + }, + )?; + run_async_with( + py, + async move { $open($request { body, target, transport }).await }, + crate::errors::fallback_route_error_to_pyerr, + |py, opened| Ok(Py::new(py, $stream::from(opened))?.into_any()), + ) + } + }; +} + +stream_openers! { + sync = chat_completions_stream, + asynchronous = achat_completions_stream, + body = ChatCompletionsStreamRequestBody, + request = ChatCompletionsStreamRequest, + open = run_chat_completions_stream, + stream = ChatCompletionsEventStream +} + +stream_openers! { + sync = messages_stream, + asynchronous = amessages_stream, + body = AnthropicMessagesRequest, + request = MessagesStreamRequest, + open = run_messages_stream, + stream = MessagesEventStream +} + +stream_openers! { + sync = responses_stream, + asynchronous = aresponses_stream, + body = ResponsesStreamRequestBody, + request = ResponsesStreamRequest, + open = run_responses_stream, + stream = ResponsesEventStream +} + +#[pyclass] +struct ResponsesWebSocketSession { + session: Arc, +} + +#[pymethods] +impl ResponsesWebSocketSession { + #[classmethod] + #[pyo3(signature = (provider, credentials=None, api_base=None, extra_headers=None, timeout_seconds=None))] + #[allow(clippy::too_many_arguments)] + fn connect<'py>( + _cls: &Bound<'py, PyType>, + py: Python<'py>, + provider: String, + credentials: Option>, + api_base: Option, + extra_headers: Option>, + timeout_seconds: Option, + ) -> PyResult> { + let empty_request = pyo3::types::PyDict::new(py).unbind().into_any(); + let (_, target, transport) = parse_call::( + py, + empty_request, + PythonStreamTarget { + provider, + credentials, + api_base, + }, + PythonStreamTransport { + extra_headers, + timeout_seconds, + }, + )?; + run_async_with( + py, + async move { + run_responses_websocket(ResponsesWebSocketRequest { target, transport }).await + }, + crate::errors::fallback_route_error_to_pyerr, + |py, session| { + Ok(Py::new( + py, + ResponsesWebSocketSession { + session: Arc::from(session), + }, + )? + .into_any()) + }, + ) + } + + fn send_event<'py>(&self, py: Python<'py>, command: Py) -> PyResult> { + let command: ResponseCommand = from_py(command.bind(py))?; + let session = self.session.clone(); + run_async( + py, + async move { session.send(command).await }, + crate::errors::executed_route_error_to_pyerr, + ) + } + + fn recv_event<'py>(&self, py: Python<'py>) -> PyResult> { + let session = self.session.clone(); + run_async_with( + py, + async move { session.recv().await }, + crate::errors::executed_route_error_to_pyerr, + |py, event| match event { + Some(event) => to_py(py, &event), + None => Ok(py.None()), + }, + ) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let session = self.session.clone(); + run_async( + py, + async move { session.close().await }, + crate::errors::executed_route_error_to_pyerr, + ) + } +} + +pub(super) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(chat_completions_stream, module)?)?; + module.add_function(wrap_pyfunction!(achat_completions_stream, module)?)?; + module.add_function(wrap_pyfunction!(messages_stream, module)?)?; + module.add_function(wrap_pyfunction!(amessages_stream, module)?)?; + module.add_function(wrap_pyfunction!(responses_stream, module)?)?; + module.add_function(wrap_pyfunction!(aresponses_stream, module)?) +} diff --git a/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs b/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs index 6a6ede22e85..d397d20b9fd 100644 --- a/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs +++ b/litellm-rust/crates/python-bridge/tests/marshal_boundary.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; -const DISALLOWED_OUTSIDE_MARSHAL: &[&str] = &[ +const DISALLOWED_OUTSIDE_INTEROP: &[&str] = &[ "py.import(\"json\")", "pythonize::", "serde_json::to_string", @@ -33,18 +33,15 @@ fn rust_sources(directory: &Path) -> Vec { } #[test] -fn serialization_is_centralized_in_marshal_module() { +fn serialization_uses_the_interop_boundary() { let root = source_root(); for path in rust_sources(&root) { - if path == root.join("marshal.rs") { - continue; - } let source = fs::read_to_string(&path).expect("bridge source should be readable"); - for disallowed in DISALLOWED_OUTSIDE_MARSHAL { + for disallowed in DISALLOWED_OUTSIDE_INTEROP { assert!( !source.contains(disallowed), - "{} bypasses the typed marshal module with `{disallowed}`", + "{} bypasses litellm-python-interop with `{disallowed}`", path.display() ); } diff --git a/litellm-rust/crates/python-interop/AGENTS.md b/litellm-rust/crates/python-interop/AGENTS.md new file mode 100644 index 00000000000..d1d61e5dfa0 --- /dev/null +++ b/litellm-rust/crates/python-interop/AGENTS.md @@ -0,0 +1 @@ +litellm-python-interop is the domain-neutral PyO3 foundation. Keep generic Python/Serde conversion and interpreter primitives here. Do not add LiteLLM domain crates, route types, API registration, or cdylib build features. diff --git a/litellm-rust/crates/python-interop/Cargo.toml b/litellm-rust/crates/python-interop/Cargo.toml new file mode 100644 index 00000000000..9da6af6e2e2 --- /dev/null +++ b/litellm-rust/crates/python-interop/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "litellm-python-interop" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pyo3.workspace = true +pythonize.workspace = true +serde.workspace = true + +[dev-dependencies] +rstest.workspace = true +serde_json.workspace = true diff --git a/litellm-rust/crates/python-interop/src/gil.rs b/litellm-rust/crates/python-interop/src/gil.rs new file mode 100644 index 00000000000..04b966a6002 --- /dev/null +++ b/litellm-rust/crates/python-interop/src/gil.rs @@ -0,0 +1,21 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use pyo3::prelude::*; + +static GIL_RELEASES: AtomicU64 = AtomicU64::new(0); + +/// Runs work detached from the interpreter and records the release. +/// +/// `f` must not access Python state while the interpreter is detached. +pub fn release_gil(py: Python<'_>, f: F) -> T +where + F: FnOnce() -> T + Send, + T: Send, +{ + GIL_RELEASES.fetch_add(1, Ordering::Relaxed); + py.detach(f) +} + +pub fn release_count() -> u64 { + GIL_RELEASES.load(Ordering::Relaxed) +} diff --git a/litellm-rust/crates/python-interop/src/lib.rs b/litellm-rust/crates/python-interop/src/lib.rs new file mode 100644 index 00000000000..2e562bdae70 --- /dev/null +++ b/litellm-rust/crates/python-interop/src/lib.rs @@ -0,0 +1,5 @@ +mod gil; +mod marshal; + +pub use gil::{release_count, release_gil}; +pub use marshal::{Pythonized, from_py, panic_to_pyerr, to_py}; diff --git a/litellm-rust/crates/python-interop/src/marshal.rs b/litellm-rust/crates/python-interop/src/marshal.rs new file mode 100644 index 00000000000..a16d1e0ae13 --- /dev/null +++ b/litellm-rust/crates/python-interop/src/marshal.rs @@ -0,0 +1,92 @@ +use std::any::Any; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use pyo3::exceptions::PyValueError; +use pyo3::panic::PanicException; +use pyo3::prelude::*; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult +where + T: DeserializeOwned, +{ + pythonize::depythonize(value).map_err(|error| PyValueError::new_err(error.to_string())) +} + +pub fn to_py(py: Python<'_>, value: &T) -> PyResult> +where + T: Serialize + ?Sized, +{ + pythonize::pythonize(py, value) + .map(Bound::unbind) + .map_err(|error| PyValueError::new_err(error.to_string())) +} + +pub struct Pythonized(pub T); + +impl<'py, T> IntoPyObject<'py> for Pythonized +where + T: Serialize, +{ + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> PyResult { + catch_unwind(AssertUnwindSafe(|| pythonize::pythonize(py, &self.0))) + .map_err(panic_to_pyerr)? + .map_err(|error| PyValueError::new_err(error.to_string())) + } +} + +pub fn panic_to_pyerr(payload: Box) -> PyErr { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("panic from Rust code"); + PanicException::new_err(message.to_string()) +} + +#[cfg(test)] +mod tests { + use serde::Serializer; + + use super::*; + + struct PanickingSerializer; + + impl Serialize for PanickingSerializer { + fn serialize(&self, _serializer: S) -> Result + where + S: Serializer, + { + panic!("serializer panicked") + } + } + + #[test] + fn pythonized_converts_on_the_attached_thread() { + Python::initialize(); + Python::attach(|py| { + let value: Vec = Pythonized(vec![1, 2, 3]) + .into_pyobject(py) + .and_then(|value| value.extract()) + .expect("value should convert"); + assert_eq!(value, vec![1, 2, 3]); + }); + } + + #[test] + fn pythonized_maps_serializer_panics_to_a_base_exception() { + Python::initialize(); + Python::attach(|py| { + let error = Pythonized(PanickingSerializer) + .into_pyobject(py) + .expect_err("serializer panic should become a Python exception"); + assert!(error.is_instance_of::(py)); + assert_eq!(error.to_string(), "PanicException: serializer panicked"); + }); + } +} diff --git a/litellm-rust/crates/python-interop/tests/interop.rs b/litellm-rust/crates/python-interop/tests/interop.rs new file mode 100644 index 00000000000..9c456dcb938 --- /dev/null +++ b/litellm-rust/crates/python-interop/tests/interop.rs @@ -0,0 +1,44 @@ +use pyo3::Python; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; + +use litellm_python_interop::{from_py, release_count, release_gil, to_py}; + +struct InitializedPython; + +impl InitializedPython { + fn attach(&self, f: F) -> R + where + F: for<'py> FnOnce(Python<'py>) -> R, + { + Python::attach(f) + } +} + +#[fixture] +#[once] +fn initialized_python() -> InitializedPython { + Python::initialize(); + InitializedPython +} + +#[rstest] +fn serde_values_round_trip_through_python(#[from(initialized_python)] python: &InitializedPython) { + python.attach(|py| { + let expected = json!({"model": "test", "items": [1, true, null]}); + let python_value = to_py(py, &expected).expect("value should convert to Python"); + let actual: Value = + from_py(python_value.bind(py)).expect("Python value should convert to serde"); + + assert_eq!(actual, expected); + }); +} + +#[rstest] +fn release_gil_runs_work_and_records_it(#[from(initialized_python)] python: &InitializedPython) { + let before = release_count(); + let result = python.attach(|py| release_gil(py, || 42)); + + assert_eq!(result, 42); + assert_eq!(release_count(), before + 1); +} diff --git a/litellm/__init__.py b/litellm/__init__.py index 4eeececdb7e..f35fb26abf6 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1416,7 +1416,7 @@ from .skills.main import ( ) from .containers.main import * from .ocr.main import * -from .rust_bridge.ocr import use_litellm_rust +from .rust_bridge import use_litellm_rust from .rag.main import * from .sandbox.main import * from .search.main import * diff --git a/litellm/constants.py b/litellm/constants.py index a5751a416a1..1bd977dd9a9 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -13,6 +13,12 @@ DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512)) DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5)) DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10)) DEFAULT_S3_BATCH_SIZE: Final = int(os.getenv("DEFAULT_S3_BATCH_SIZE", 512)) +# https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html +MAX_S3_OBJECT_KEY_BYTES: Final = 1024 +S3_BOUNDED_OBJECT_KEY_HEAD_BYTES: Final = 64 +S3_PREFIX_DIGEST_CHARS: Final = 16 +# s3 allows 2048 bytes of combined metadata headers, which Content-Disposition counts against +MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES: Final = 1024 DEFAULT_SQS_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_SQS_FLUSH_INTERVAL_SECONDS", 10)) DEFAULT_NUM_WORKERS_LITELLM_PROXY: Final = int(os.getenv("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1)) DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE = int(os.getenv("DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE", 1)) @@ -130,6 +136,7 @@ MCP_CLIENT_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_CLIENT_TIMEOUT", "60.0" MCP_TOOL_LISTING_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIMEOUT", "30.0")) MCP_METADATA_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_METADATA_TIMEOUT", "10.0")) MCP_HEALTH_CHECK_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_HEALTH_CHECK_TIMEOUT", "10.0")) +MCP_TOOL_LISTING_MAX_PAGES: Final = 1000 # Allowlist of commands permitted for MCP stdio transport. # Prevents arbitrary command execution via /mcp-rest/test/* endpoints or server creation. @@ -1251,6 +1258,7 @@ BEDROCK_CONVERSE_MODELS: Final = [ "openai.gpt-oss-120b-1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-fable-5-1", "anthropic.claude-fable-5", "anthropic.claude-sonnet-5", "anthropic.claude-opus-5", @@ -1582,6 +1590,7 @@ KEY_ROTATION_JOB_NAME: Final = "litellm_key_rotation_job" EXPIRED_UI_SESSION_KEY_CLEANUP_JOB_NAME: Final = "litellm_expired_ui_session_key_cleanup_job" WEEKLY_SPEND_REPORT_JOB_ID: Final = "weekly_spend_report_job" MONTHLY_SPEND_REPORT_JOB_ID: Final = "monthly_spend_report_job" +USER_SPEND_ALERTS_JOB_ID: Final = "user_spend_alerts_job" PROMETHEUS_FALLBACK_STATS_JOB_ID: Final = "prometheus_fallback_stats_job" SLACK_DAILY_REPORT_LOCK_ID: Final = "slack_daily_report" SLACK_MODEL_DEPRECATION_LOCK_ID: Final = "slack_model_deprecation_warning" diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index f0a1bff8fdc..ea81e323da4 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -7,6 +7,7 @@ import base64 import os from collections.abc import Awaitable, Callable, Generator from datetime import timedelta +from functools import partial from importlib import metadata from typing import Any, Final, TypeVar @@ -47,7 +48,8 @@ from mcp.types import Tool as MCPTool from pydantic import AnyUrl from litellm._logging import verbose_logger -from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_NPM_CACHE_DIR +from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_NPM_CACHE_DIR, MCP_TOOL_LISTING_TIMEOUT +from litellm.experimental_mcp_client.tools import list_tools_with_pagination from litellm.llms.custom_httpx.http_handler import get_ssl_configuration from litellm.types.llms.custom_http import VerifyTypes from litellm.types.mcp import ( @@ -603,17 +605,19 @@ class MCPClient: """ verbose_logger.debug("MCP client listing tools from %s", self.server_url or "stdio") - async def _list_tools_operation(session: ClientSession): - return await session.list_tools() - try: - result: Final = await self.run_with_session(_list_tools_operation, quiet_on_error=raise_on_error) - tool_count: Final = len(result.tools) - tool_names: Final = [tool.name for tool in result.tools] + # A per-server timeout above the global default extends the whole-walk deadline + listing_deadline: Final = max(self.timeout, MCP_TOOL_LISTING_TIMEOUT) + tools: Final = await self.run_with_session( + partial(list_tools_with_pagination, listing_deadline=listing_deadline), + quiet_on_error=raise_on_error, + ) + tool_count: Final = len(tools) + tool_names: Final = tuple(tool.name for tool in tools) verbose_logger.info( "MCP client listed %s tools from %s: %s", tool_count, self.server_url or "stdio", tool_names ) - return result.tools + return tools except asyncio.CancelledError: verbose_logger.warning("MCP client list_tools was cancelled") raise diff --git a/litellm/experimental_mcp_client/tools.py b/litellm/experimental_mcp_client/tools.py index 30d50e2a74b..51d2139ef3b 100644 --- a/litellm/experimental_mcp_client/tools.py +++ b/litellm/experimental_mcp_client/tools.py @@ -1,14 +1,22 @@ import json from typing import Final, Literal +import anyio from mcp import ClientSession from mcp.types import CallToolRequestParams as MCPCallToolRequestParams from mcp.types import CallToolResult as MCPCallToolResult +from mcp.types import PaginatedRequestParams from mcp.types import Tool as MCPTool from openai.types.chat import ChatCompletionToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.shared_params.function_definition import FunctionDefinition +from litellm._logging import verbose_logger +from litellm.constants import ( + MCP_CLIENT_TIMEOUT, + MCP_TOOL_LISTING_MAX_PAGES, + MCP_TOOL_LISTING_TIMEOUT, +) from litellm.types.llms.anthropic import AnthropicMessagesTool from litellm.types.utils import ChatCompletionMessageToolCall @@ -90,6 +98,64 @@ def transform_mcp_tool_to_anthropic_tool(mcp_tool: MCPTool) -> AnthropicMessages ) +async def list_tools_with_pagination( + session: ClientSession, listing_deadline: float | None = None +) -> list[MCPTool]: # mutable-ok: list return contract + """Collect tools from every tools/list page by following nextCursor. + + Stops and returns the tools collected so far when the upstream repeats a + cursor, the page cap is reached, or the whole-walk deadline expires, so a + buggy or slow upstream yields a partial catalog instead of an error. + listing_deadline overrides the default whole-walk deadline; callers with a + per-server timeout above the global default pass it through here. + """ + tools: Final[list[MCPTool]] = [] # mutable-ok: accumulates each page's tools + seen_cursors: Final[set[str]] = set() # mutable-ok: guards against cursor loops + cursor: str | None = None # rebind-ok: advances to each page's nextCursor + # The per-request session read timeout restarts on every page, so a multi-page + # walk needs its own overall deadline. max() keeps the pre-pagination guarantee + # that a single page slower than the listing timeout but within the client + # timeout still succeeds. + effective_deadline: Final = ( + listing_deadline if listing_deadline is not None else max(MCP_CLIENT_TIMEOUT, MCP_TOOL_LISTING_TIMEOUT) + ) + + with anyio.move_on_after(effective_deadline): + for _ in range(MCP_TOOL_LISTING_MAX_PAGES): + result = ( + await session.list_tools() + if cursor is None + else await session.list_tools(params=PaginatedRequestParams(cursor=cursor)) + ) + tools.extend(result.tools) + + next_cursor = getattr(result, "nextCursor", None) + if not isinstance(next_cursor, str) or not next_cursor: + return tools + if next_cursor in seen_cursors: + verbose_logger.warning( + "MCP server repeated a tools/list cursor while listing tools; returning %s tools collected so far", + len(tools), + ) + return tools + seen_cursors.add(next_cursor) + cursor = next_cursor + + verbose_logger.warning( + "MCP server tools/list pagination exceeded the maximum of %s pages; returning %s tools collected so far", + MCP_TOOL_LISTING_MAX_PAGES, + len(tools), + ) + return tools + + verbose_logger.warning( + "MCP server tools/list pagination exceeded the %s second listing deadline; returning %s tools collected so far", + effective_deadline, + len(tools), + ) + return tools + + async def load_mcp_tools( session: ClientSession, format: Literal["mcp", "openai"] = "mcp" ) -> list[MCPTool] | list[ChatCompletionToolParam]: @@ -103,10 +169,12 @@ async def load_mcp_tools( If format is set to "openai", the tools are converted to OpenAI API compatible tools. """ - tools: Final = await session.list_tools() + tools: Final = await list_tools_with_pagination(session) if format == "openai": - return [transform_mcp_tool_to_openai_tool(mcp_tool=tool) for tool in tools.tools] - return tools.tools + return [ # mutable-ok: public API returns a list + transform_mcp_tool_to_openai_tool(mcp_tool=tool) for tool in tools + ] + return tools ######################################################## diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index c137164ecdb..748ef938cea 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -68,6 +68,7 @@ from .utils import process_slack_alerting_variables if TYPE_CHECKING: from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager + from litellm.proxy.utils import PrismaClient from litellm.router import Router as _Router Router = _Router @@ -1944,6 +1945,69 @@ Model Info: except Exception as e: verbose_proxy_logger.exception("Error sending weekly spend report %s", e) + async def send_user_spend_alerts(self, prisma_client: "PrismaClient | None" = None) -> None: + """Check per-user daily/monthly spend thresholds and spend anomalies, alerting once per user per period.""" + if self.alerting is None or "slack" not in self.alerting: + return + + thresholds_enabled: Final = AlertType.user_spend_thresholds in self.alert_types + anomalies_enabled: Final = AlertType.user_spend_anomalies in self.alert_types + if not thresholds_enabled and not anomalies_enabled: + return + + if prisma_client is None: + from litellm.proxy.proxy_server import prisma_client as global_prisma_client + + prisma_client = global_prisma_client # rebind-ok: fall back to the proxy's global client + if prisma_client is None: + return + + from litellm.integrations.SlackAlerting.user_spend_alerts import ( + evaluate_user_spend, + fetch_user_spend_rows, + ) + + try: + today: Final = datetime.datetime.now(datetime.timezone.utc).date() + rows: Final = await fetch_user_spend_rows( + prisma_client=prisma_client, + today=today, + baseline_days=self.alerting_args.spend_anomaly_baseline_days, + ) + all_events: Final = tuple( + event + for row in rows + for event in evaluate_user_spend( + row=row, + args=self.alerting_args, + today=today, + thresholds_enabled=thresholds_enabled, + anomalies_enabled=anomalies_enabled, + ) + ) + cached_flags: Final = await asyncio.gather( + *(self.internal_usage_cache.async_get_cache(key=event.cache_key) for event in all_events) + ) + new_events: Final = tuple(event for event, cached in zip(all_events, cached_flags) if not cached) + for alert_type in (AlertType.user_spend_thresholds, AlertType.user_spend_anomalies): + typed_events = tuple(event for event in new_events if event.alert_type == alert_type) + if not typed_events: + continue + await self.send_alert( + message="\n\n".join(event.message for event in typed_events), + level="High", + alert_type=alert_type, + alerting_metadata={}, # mutable-ok: send_alert takes a dict payload + ) + for event in typed_events: + await self.internal_usage_cache.async_set_cache( + key=event.cache_key, + value="SENT", + ttl=event.cache_ttl, + ) + except Exception as e: # noqa: BLE001 # background job must not crash the scheduler + verbose_proxy_logger.exception("Error sending user spend alerts: %s", e) + async def send_fallback_stats_from_prometheus(self): """ Helper to send fallback statistics from prometheus server -> to slack diff --git a/litellm/integrations/SlackAlerting/user_spend_alerts.py b/litellm/integrations/SlackAlerting/user_spend_alerts.py new file mode 100644 index 00000000000..38794735c1b --- /dev/null +++ b/litellm/integrations/SlackAlerting/user_spend_alerts.py @@ -0,0 +1,139 @@ +"""Per-user daily/monthly spend threshold alerts and spend anomaly detection.""" + +import datetime +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal + +from pydantic import TypeAdapter + +from litellm.constants import HOURS_IN_A_DAY +from litellm.types.integrations.slack_alerting import AlertType, SlackAlertingArgs + +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient + +DAY_SECONDS: Final = HOURS_IN_A_DAY * 60 * 60 +MONTHLY_ALERT_TTL_SECONDS: Final = 32 * DAY_SECONDS + +USER_SPEND_QUERY: Final = """ +SELECT + user_id, + COALESCE(SUM(spend) FILTER (WHERE date = $1), 0)::float AS daily_spend, + COALESCE(SUM(spend) FILTER (WHERE date >= $2), 0)::float AS monthly_spend, + COALESCE(SUM(spend) FILTER (WHERE date >= $3 AND date < $1), 0)::float AS baseline_spend +FROM "LiteLLM_DailyUserSpend" +WHERE date >= LEAST($2, $3) AND user_id IS NOT NULL +GROUP BY user_id +HAVING COALESCE(SUM(spend) FILTER (WHERE date >= $2), 0) > 0 +""" + + +@dataclass(frozen=True, slots=True) +class UserSpendRow: + user_id: str + daily_spend: float + monthly_spend: float + baseline_spend: float + + +@dataclass(frozen=True, slots=True) +class UserSpendAlertEvent: + kind: Literal["daily_threshold", "monthly_threshold", "anomaly"] + alert_type: AlertType + message: str + cache_key: str + cache_ttl: int + + +USER_SPEND_ROWS_ADAPTER: Final = TypeAdapter(tuple[UserSpendRow, ...]) + + +async def fetch_user_spend_rows( + prisma_client: "PrismaClient", + today: datetime.date, + baseline_days: int, +) -> tuple[UserSpendRow, ...]: + today_str: Final = today.strftime("%Y-%m-%d") + month_start_str: Final = today.replace(day=1).strftime("%Y-%m-%d") + baseline_start_str: Final = (today - datetime.timedelta(days=max(baseline_days, 1))).strftime("%Y-%m-%d") + raw: Final = await prisma_client.db.query_raw(USER_SPEND_QUERY, today_str, month_start_str, baseline_start_str) + return USER_SPEND_ROWS_ADAPTER.validate_python(raw) + + +def _daily_threshold_event(row: UserSpendRow, args: SlackAlertingArgs, today_str: str) -> UserSpendAlertEvent | None: + threshold: Final = args.daily_spend_per_user_threshold + if threshold is None or row.daily_spend < threshold: + return None + return UserSpendAlertEvent( + kind="daily_threshold", + alert_type=AlertType.user_spend_thresholds, + message=( + f"User Daily Spend Threshold Crossed:\n" + f"User: `{row.user_id}`\n" + f"Spend Today: `${row.daily_spend:.2f}`\n" + f"Daily Threshold: `${threshold:.2f}`" + ), + cache_key=f"user_spend_alert_daily_{row.user_id}_{today_str}", + cache_ttl=DAY_SECONDS, + ) + + +def _monthly_threshold_event(row: UserSpendRow, args: SlackAlertingArgs, month_str: str) -> UserSpendAlertEvent | None: + threshold: Final = args.monthly_spend_per_user_threshold + if threshold is None or row.monthly_spend < threshold: + return None + return UserSpendAlertEvent( + kind="monthly_threshold", + alert_type=AlertType.user_spend_thresholds, + message=( + f"User Monthly Spend Threshold Crossed:\n" + f"User: `{row.user_id}`\n" + f"Spend This Month: `${row.monthly_spend:.2f}`\n" + f"Monthly Threshold: `${threshold:.2f}`" + ), + cache_key=f"user_spend_alert_monthly_{row.user_id}_{month_str}", + cache_ttl=MONTHLY_ALERT_TTL_SECONDS, + ) + + +def _anomaly_event(row: UserSpendRow, args: SlackAlertingArgs, today_str: str) -> UserSpendAlertEvent | None: + if row.daily_spend < args.spend_anomaly_min_spend: + return None + baseline_daily_avg: Final = row.baseline_spend / args.spend_anomaly_baseline_days + if row.baseline_spend > 0 and row.daily_spend <= args.spend_anomaly_multiplier * baseline_daily_avg: + return None + return UserSpendAlertEvent( + kind="anomaly", + alert_type=AlertType.user_spend_anomalies, + message=( + f"User Spend Anomaly Detected:\n" + f"User: `{row.user_id}`\n" + f"Spend Today: `${row.daily_spend:.2f}`\n" + f"Daily Average (last {args.spend_anomaly_baseline_days} days): `${baseline_daily_avg:.2f}`\n" + f"Trigger: spend above `{args.spend_anomaly_multiplier}x` the daily average " + f"(minimum `${args.spend_anomaly_min_spend:.2f}`)" + ), + cache_key=f"user_spend_alert_anomaly_{row.user_id}_{today_str}", + cache_ttl=DAY_SECONDS, + ) + + +def evaluate_user_spend( + row: UserSpendRow, + args: SlackAlertingArgs, + today: datetime.date, + thresholds_enabled: bool, + anomalies_enabled: bool, +) -> tuple[UserSpendAlertEvent, ...]: + today_str: Final = today.strftime("%Y-%m-%d") + month_str: Final = today.strftime("%Y-%m") + threshold_events: Final = ( + ( + _daily_threshold_event(row=row, args=args, today_str=today_str), + _monthly_threshold_event(row=row, args=args, month_str=month_str), + ) + if thresholds_enabled + else () + ) + anomaly_events: Final = (_anomaly_event(row=row, args=args, today_str=today_str),) if anomalies_enabled else () + return tuple(event for event in (*threshold_events, *anomaly_events) if event is not None) diff --git a/litellm/integrations/datadog/datadog_llm_obs.py b/litellm/integrations/datadog/datadog_llm_obs.py index e5789965c6e..5e116b7301a 100644 --- a/litellm/integrations/datadog/datadog_llm_obs.py +++ b/litellm/integrations/datadog/datadog_llm_obs.py @@ -11,6 +11,7 @@ import json import os from collections.abc import Mapping, Sequence from datetime import datetime +from types import MappingProxyType from typing import Any, Final, Literal import httpx @@ -30,12 +31,16 @@ from litellm.integrations.datadog.datadog_mock_client import ( ) from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.prompt_templates.common_utils import ( + convert_content_list_to_str, handle_any_messages_to_chat_completion_str_messages_conversion, ) +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.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) +from litellm.proxy.spend_tracking.savings import extract_cache_creation_tokens, extract_cache_read_tokens from litellm.types.integrations.datadog_llm_obs import * from litellm.types.utils import ( CallTypes, @@ -44,6 +49,189 @@ from litellm.types.utils import ( StandardLoggingPayloadErrorInformation, ) +_EMPTY_MAPPING: Final[Mapping[str, Any]] = MappingProxyType({}) +_EMPTY_MESSAGE: Final[Message] = {"role": "", "content": ""} +_MAX_PARSED_TOOL_ARGUMENT_CHARS: Final = 256 * 1024 + + +def _mapping_field(source: Mapping[str, Any], key: str) -> Mapping[str, Any]: + """The value at `key` when it is a mapping, else an empty one.""" + value: Final = source.get(key) + return value if isinstance(value, dict) else _EMPTY_MAPPING + + +def _content_blocks(message: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: + content: Final = message.get("content") + if not isinstance(content, list): + return () + return tuple(block for block in content if isinstance(block, dict)) + + +def _to_dd_arguments(raw_arguments: object) -> dict[str, Any] | str: + """ + Arguments as the object LLM Obs types them as, or the raw string when they are not one. + + Strings past the size bound ship unparsed: decoding multiplies memory on hostile compact + JSON, and the raw string is what the intake receives either way. + """ + if not isinstance(raw_arguments, str): + return raw_arguments if isinstance(raw_arguments, dict) else str(raw_arguments) + if len(raw_arguments) > _MAX_PARSED_TOOL_ARGUMENT_CHARS: + return raw_arguments + parsed: Final = safe_json_loads(raw_arguments) + return parsed if isinstance(parsed, dict) else raw_arguments + + +def _to_dd_tool_calls(message: Mapping[str, Any]) -> tuple[ToolCall, ...]: + """ + The tool calls a message carries, in LLM Obs' ToolCall schema, from either dialect. + + OpenAI puts them in `tool_calls` with the callee nested under `function` and `arguments` + serialized; Anthropic puts them in `content` as `tool_use` blocks with `input` already an + object. LLM Obs reads `name` / `arguments` / `tool_id` either way. + """ + raw_tool_calls: Final = message.get("tool_calls") + openai_calls: Final = tuple( + ToolCall( + name=function.get("name", ""), + arguments=_to_dd_arguments(function.get("arguments", "")), + tool_id=tool_call.get("id", ""), + type=tool_call.get("type", "function"), + ) + for tool_call in (raw_tool_calls if isinstance(raw_tool_calls, list) else ()) + if isinstance(tool_call, dict) + for function in [_mapping_field(tool_call, "function")] + ) + anthropic_calls: Final = tuple( + ToolCall( + name=block.get("name", ""), + arguments=_to_dd_arguments(block.get("input") or {}), + tool_id=block.get("id", ""), + type="tool_use", + ) + for block in _content_blocks(message) + if block.get("type") == "tool_use" + ) + return openai_calls + anthropic_calls + + +def _to_dd_tool_results(message: Mapping[str, Any], tool_call_names: Mapping[str, str]) -> tuple[ToolResult, ...]: + """ + The tool results a message carries, linked back to the call each answers. + + OpenAI models a result as a whole `role: "tool"` message keyed by `tool_call_id`; + Anthropic nests `tool_result` blocks inside a user message, keyed by `tool_use_id`. + """ + + def to_result(tool_id: str, result: object) -> ToolResult: + return ToolResult( + name=tool_call_names.get(tool_id, ""), + result=result if isinstance(result, str) else safe_dumps(result), + tool_id=tool_id, + type="function", + ) + + if message.get("role") == "tool": + return (to_result(str(message.get("tool_call_id", "")), message.get("content") or ""),) + return tuple( + to_result(str(block.get("tool_use_id", "")), block.get("content") or "") + for block in _content_blocks(message) + if block.get("type") == "tool_result" + ) + + +def _tool_call_names_by_id(messages: Sequence[object]) -> Mapping[str, str]: + """Ids to tool names for result linking; reads names structurally and parses nothing.""" + openai_pairs: Final = tuple( + (tool_call.get("id"), function.get("name", "")) + for message in messages + if isinstance(message, dict) and isinstance(message.get("tool_calls"), list) + for tool_call in message["tool_calls"] + if isinstance(tool_call, dict) + for function in [_mapping_field(tool_call, "function")] + ) + anthropic_pairs: Final = tuple( + (block.get("id"), block.get("name", "")) + for message in messages + if isinstance(message, dict) + for block in _content_blocks(message) + if block.get("type") == "tool_use" + ) + return MappingProxyType({str(tool_id): str(name) for tool_id, name in openai_pairs + anthropic_pairs if tool_id}) + + +def _to_dd_message(message: object, tool_call_names: Mapping[str, str]) -> Message: + """ + Map one chat message onto LLM Obs' Message schema, adding fields and never destroying content. + + Content collapses to its text only when it has text; a content list with none (tool blocks, + images) rides along unchanged so nothing the caller logged is lost. Tool calls and results + move into the fields the LLM Obs Tools panel reads, from both the OpenAI and Anthropic shapes. + """ + if not isinstance(message, dict): + converted: Final = handle_any_messages_to_chat_completion_str_messages_conversion(message) + return converted[0] if converted else _EMPTY_MESSAGE + + text: Final = convert_content_list_to_str(message) # pyright: ignore[reportArgumentType] # caller-supplied dict + original_content: Final = message.get("content") + content: Final = ( + text if text or not isinstance(original_content, list) or not original_content else original_content + ) + reasoning: Final = message.get("reasoning_content") + tool_calls: Final = _to_dd_tool_calls(message) + tool_results: Final = _to_dd_tool_results(message, tool_call_names) + dd_message: Final[Message] = { + "role": message.get("role", ""), + "content": content, + **({"reasoning_content": reasoning} if reasoning is not None else {}), + **({"tool_calls": tool_calls} if tool_calls else {}), + **({"tool_results": tool_results} if tool_results else {}), + } + return dd_message + + +def _to_dd_messages(messages: object) -> tuple[Message, ...]: + """Map a whole conversation, resolving each tool result against the calls that precede it.""" + if messages is None: + return () + if not isinstance(messages, list): + return tuple(handle_any_messages_to_chat_completion_str_messages_conversion(messages)) + tool_call_names: Final = _tool_call_names_by_id(messages) + return tuple(_to_dd_message(message, tool_call_names) for message in messages) + + +def _to_dd_tool_definition(entry: Mapping[str, Any]) -> ToolDefinition | None: + function: Final = entry.get("function") + declared: Final[Mapping[str, Any]] = function if isinstance(function, dict) else entry + name: Final = declared.get("name") + if not name: + return None + schema: Final = declared.get("parameters") or declared.get("input_schema") + description: Final = declared.get("description", "") + if not isinstance(schema, dict): + return ToolDefinition(name=name, description=description) + return ToolDefinition(name=name, description=description, schema=schema) + + +def _to_dd_tool_definitions(model_parameters: object) -> tuple[ToolDefinition, ...]: + """ + Map the request's declared tools onto LLM Obs' ToolDefinition schema. + + Handles the wrapped chat-completions shape and the bare shape the Anthropic and + Responses surfaces use, since both reach this logger through `model_parameters`. + """ + if not isinstance(model_parameters, dict): + return () + raw_tools: Final = model_parameters.get("tools") or model_parameters.get("functions") + if not isinstance(raw_tools, list): + return () + return tuple( + definition + for entry in raw_tools + if isinstance(entry, dict) + if (definition := _to_dd_tool_definition(entry)) is not None + ) + class DataDogLLMObsLogger(CustomBatchLogger): def __init__(self, **kwargs): @@ -222,12 +410,9 @@ class DataDogLLMObsLogger(CustomBatchLogger): if standard_logging_payload is None: raise Exception("DataDogLLMObs: standard_logging_object is not set") - messages = standard_logging_payload["messages"] - messages = self._ensure_string_content(messages=messages) - metadata: Final = kwargs.get("litellm_params", {}).get("metadata", {}) - input_meta: Final = InputMeta(messages=handle_any_messages_to_chat_completion_str_messages_conversion(messages)) + input_meta: Final = InputMeta(messages=_to_dd_messages(standard_logging_payload["messages"])) output_meta: Final = OutputMeta( messages=self._get_response_messages( standard_logging_payload=standard_logging_payload, @@ -241,22 +426,20 @@ class DataDogLLMObsLogger(CustomBatchLogger): if isinstance(metadata, dict): metadata_parent_id = metadata.get("parent_id") - meta: Final = Meta( - kind=self._get_datadog_span_kind(standard_logging_payload.get("call_type"), metadata_parent_id), - input=input_meta, - output=output_meta, - metadata=self._get_dd_llm_obs_payload_metadata(standard_logging_payload), - error=error_info, - ) + tool_definitions: Final = _to_dd_tool_definitions(standard_logging_payload.get("model_parameters")) + span_kind: Final = self._get_datadog_span_kind(standard_logging_payload.get("call_type"), metadata_parent_id) + payload_metadata: Final = self._get_dd_llm_obs_payload_metadata(standard_logging_payload) - # Calculate metrics (you may need to adjust these based on available data) - metrics: Final = LLMMetrics( - input_tokens=float(standard_logging_payload.get("prompt_tokens", 0)), - output_tokens=float(standard_logging_payload.get("completion_tokens", 0)), - total_tokens=float(standard_logging_payload.get("total_tokens", 0)), - total_cost=float(standard_logging_payload.get("response_cost", 0)), - time_to_first_token=self._get_time_to_first_token_seconds(standard_logging_payload), - ) + meta: Final[Meta] = { + "kind": span_kind, + "input": input_meta, + "output": output_meta, + "metadata": payload_metadata, + "error": error_info, + **({"tool_definitions": tool_definitions} if tool_definitions else {}), + } + + metrics: Final = self._assemble_metrics(standard_logging_payload) payload: Final[LLMObsPayload] = LLMObsPayload( parent_id=metadata_parent_id if metadata_parent_id else "undefined", @@ -314,6 +497,45 @@ class DataDogLLMObsLogger(CustomBatchLogger): ) return error_info + def _assemble_metrics(self, standard_logging_payload: StandardLoggingPayload) -> LLMMetrics: + """ + Build the span metrics, including the prompt-cache counts LLM Obs charts cache savings from. + + Cache counts resolve through the same owners the savings dashboard uses, so every provider + spelling is covered, and `non_cached_input_tokens` subtracts BOTH cache categories because + litellm's normalized prompt count includes both (the invariant the cost calculator's custom + pricing helper documents). A zero residual on a fully cached request is real data and is + emitted; a zero read or write count is absence and is not. + """ + prompt_tokens: Final = float(standard_logging_payload.get("prompt_tokens", 0)) + completion_tokens: Final = float(standard_logging_payload.get("completion_tokens", 0)) + total_tokens: Final = float(standard_logging_payload.get("total_tokens", 0)) + total_cost: Final = float(standard_logging_payload.get("response_cost", 0)) + time_to_first_token: Final = self._get_time_to_first_token_seconds(standard_logging_payload) + + raw_usage: Final = (standard_logging_payload.get("metadata") or {}).get("usage_object") + usage_object: Final = raw_usage if isinstance(raw_usage, dict) else None + cache_read: Final = float(extract_cache_read_tokens(usage_object)) + cache_write: Final = float(extract_cache_creation_tokens(usage_object)) + + metrics: Final[LLMMetrics] = { + "input_tokens": prompt_tokens, + "output_tokens": completion_tokens, + "total_tokens": total_tokens, + "total_cost": total_cost, + "time_to_first_token": time_to_first_token, + **( + { + **({"cache_read_input_tokens": cache_read} if cache_read else {}), + **({"cache_write_input_tokens": cache_write} if cache_write else {}), + "non_cached_input_tokens": max(prompt_tokens - cache_read - cache_write, 0.0), + } + if cache_read or cache_write + else {} + ), + } + return metrics + def _get_time_to_first_token_seconds(self, standard_logging_payload: StandardLoggingPayload) -> float: """ Get the time to first token in seconds @@ -335,7 +557,7 @@ class DataDogLLMObsLogger(CustomBatchLogger): def _get_response_messages( self, standard_logging_payload: StandardLoggingPayload, call_type: str | None - ) -> list[object]: + ) -> tuple[Message, ...]: """ Get the messages from the response object @@ -344,7 +566,7 @@ class DataDogLLMObsLogger(CustomBatchLogger): response_obj = standard_logging_payload.get("response") if response_obj is None: - return [] + return () # edge case: handle response_obj is a string representation of a dict if isinstance(response_obj, str): @@ -357,7 +579,7 @@ class DataDogLLMObsLogger(CustomBatchLogger): # fallback to json parsing response_obj = json.loads(str(response_obj)) except json.JSONDecodeError: - return [] + return () if call_type in [ CallTypes.completion.value, @@ -375,12 +597,12 @@ class DataDogLLMObsLogger(CustomBatchLogger): if isinstance(response_obj, dict) and "choices" in response_obj: choices: Final = response_obj["choices"] if choices and len(choices) > 0 and "message" in choices[0]: - return [choices[0]["message"]] - return [] + return _to_dd_messages([choices[0]["message"]]) + return () except (KeyError, IndexError, TypeError): # In case of any error accessing the response structure, return empty list - return [] - return [] + return () + return () def _get_datadog_span_kind( self, call_type: str | None, parent_id: str | None = None @@ -485,17 +707,6 @@ class DataDogLLMObsLogger(CustomBatchLogger): # Default fallback for unknown or passthrough operations return "llm" - def _ensure_string_content(self, messages: str | Sequence[object] | Mapping[object, object] | None) -> list[object]: - if messages is None: - return [] - if isinstance(messages, str): - return [messages] - elif isinstance(messages, list): - return [message for message in messages] - elif isinstance(messages, dict): - return [str(messages.get("content", ""))] - return [] - def _get_dd_llm_obs_payload_metadata(self, standard_logging_payload: StandardLoggingPayload) -> dict[str, object]: """ Fields to track in DD LLM Observability metadata from litellm standard logging payload @@ -524,10 +735,6 @@ class DataDogLLMObsLogger(CustomBatchLogger): spend_metrics: Final = self._get_spend_metrics(standard_logging_payload) _metadata.update({"spend_metrics": dict(spend_metrics)}) - ## extract tool calls and add to metadata - tool_call_metadata: Final = self._extract_tool_call_metadata(standard_logging_payload) - _metadata.update(tool_call_metadata) - _standard_logging_metadata: Final[dict] = dict(standard_logging_payload.get("metadata", {})) or {} _metadata.update(_standard_logging_metadata) return _metadata @@ -647,107 +854,3 @@ class DataDogLLMObsLogger(CustomBatchLogger): verbose_logger.debug("Original value: %s", user_api_key_budget_reset_at) return spend_metrics - - def _process_input_messages_preserving_tool_calls(self, messages: Sequence[object]) -> list[dict[str, object]]: - """ - Process input messages while preserving tool_calls and tool message types. - - This bypasses the lossy string conversion when tool calls are present, - allowing complex nested tool_calls objects to be preserved for Datadog. - """ - processed: Final = [] - for msg in messages: - if isinstance(msg, dict): - # Preserve messages with tool_calls or tool role as-is - if "tool_calls" in msg or msg.get("role") == "tool": - processed.append(msg) - else: - # For regular messages, still apply string conversion - converted = handle_any_messages_to_chat_completion_str_messages_conversion([msg]) - processed.extend(converted) - else: - # For non-dict messages, apply string conversion - converted = handle_any_messages_to_chat_completion_str_messages_conversion([msg]) - processed.extend(converted) - return processed - - @staticmethod - def _tool_calls_kv_pair(tool_calls: list[dict[str, Any]]) -> dict[str, object]: - """ - Extract tool call information into key-value pairs for Datadog metadata. - - Similar to OpenTelemetry's implementation but adapted for Datadog's format. - """ - kv_pairs: Final[dict[str, object]] = {} - for idx, tool_call in enumerate(tool_calls): - try: - # Extract tool call ID - tool_id = tool_call.get("id") - if tool_id: - kv_pairs[f"tool_calls.{idx}.id"] = tool_id - - # Extract tool call type - tool_type = tool_call.get("type") - if tool_type: - kv_pairs[f"tool_calls.{idx}.type"] = tool_type - - # Extract function information - function = tool_call.get("function") - if function: - function_name = function.get("name") - if function_name: - kv_pairs[f"tool_calls.{idx}.function.name"] = function_name - - function_arguments = function.get("arguments") - if function_arguments: - # Store arguments as JSON string for Datadog - if isinstance(function_arguments, str): - kv_pairs[f"tool_calls.{idx}.function.arguments"] = function_arguments - else: - import json - - kv_pairs[f"tool_calls.{idx}.function.arguments"] = json.dumps(function_arguments) - except (KeyError, TypeError, ValueError) as e: - verbose_logger.debug("DataDogLLMObs: Error processing tool call %s: %s", idx, e) - continue - - return kv_pairs - - def _extract_tool_call_metadata(self, standard_logging_payload: StandardLoggingPayload) -> dict[str, object]: - """ - Extract tool call information from both input messages and response for Datadog metadata. - """ - tool_call_metadata: Final[dict[str, object]] = {} - - try: - # Extract tool calls from input messages - messages: Final = standard_logging_payload.get("messages", []) - if messages and isinstance(messages, list): - for message in messages: - if isinstance(message, dict) and "tool_calls" in message: - tool_calls = message.get("tool_calls") - if tool_calls: - input_tool_calls_kv = self._tool_calls_kv_pair(tool_calls) - # Prefix with "input_" to distinguish from response tool calls - for key, value in input_tool_calls_kv.items(): - tool_call_metadata[f"input_{key}"] = value - - # Extract tool calls from response - response_obj: Final = standard_logging_payload.get("response") - if response_obj and isinstance(response_obj, dict): - choices: Final = response_obj.get("choices", []) - for choice in choices: - if isinstance(choice, dict): - message = choice.get("message") - if message and isinstance(message, dict): - tool_calls = message.get("tool_calls") - if tool_calls: - response_tool_calls_kv = self._tool_calls_kv_pair(tool_calls) - # Prefix with "output_" to distinguish from input tool calls - for key, value in response_tool_calls_kv.items(): - tool_call_metadata[f"output_{key}"] = value - - except Exception as e: - verbose_logger.debug("DataDogLLMObs: Error extracting tool call metadata: %s", e) - - return tool_call_metadata diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index d35405538f6..e8ed269f6cb 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -6,6 +6,7 @@ import json from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum +from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar, Final, cast from urllib.parse import urlsplit @@ -62,6 +63,31 @@ if TYPE_CHECKING: # --- typed sub-structures ---------------------------------------------------- # +def _cache_token_value(*values: object) -> int | None: + explicit_zero = False + invalid_before_zero = False + for raw_value in values: + if raw_value is None: + continue + if isinstance(raw_value, bool): + parsed = None + else: + try: + parsed = as_int(raw_value) + except (OverflowError, ValueError): + parsed = None + if parsed is None: + if not explicit_zero: + invalid_before_zero = True + elif parsed > 0: + return parsed + elif parsed == 0: + explicit_zero = True + elif not explicit_zero: + invalid_before_zero = True + return 0 if explicit_zero and not invalid_before_zero else None + + @dataclass(frozen=True) class LLMRequestParams: temperature: float | None = None @@ -104,12 +130,25 @@ class LLMUsage: metadata: Final[Mapping[str, object]] = payload.get("metadata") or {} raw_usage: Final = metadata.get("usage_object") usage_object: Final[Mapping[str, object]] = raw_usage if isinstance(raw_usage, Mapping) else {} + raw_details: Final = usage_object.get("prompt_tokens_details") + prompt_details: Final[Mapping[str, object]] = ( + raw_details if isinstance(raw_details, Mapping) else MappingProxyType({}) + ) return cls( input_tokens=as_int(payload.get("prompt_tokens")), output_tokens=as_int(payload.get("completion_tokens")), total_tokens=as_int(payload.get("total_tokens")), - cache_creation_input_tokens=as_int(usage_object.get("cache_creation_input_tokens")), - cache_read_input_tokens=as_int(usage_object.get("cache_read_input_tokens")), + cache_creation_input_tokens=_cache_token_value( + usage_object.get("cache_creation_input_tokens"), + prompt_details.get("cache_write_tokens"), + prompt_details.get("cache_creation_tokens"), + prompt_details.get("cache_creation_input_tokens"), + ), + cache_read_input_tokens=_cache_token_value( + usage_object.get("cache_read_input_tokens"), + prompt_details.get("cached_tokens"), + usage_object.get("prompt_cache_hit_tokens"), + ), ) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 467ec72dc4a..975a9bd8639 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -8,6 +8,7 @@ import math import os import sys from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import replace from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast @@ -58,7 +59,10 @@ from litellm.types.utils import ( if TYPE_CHECKING: from apscheduler.schedulers.asyncio import AsyncIOScheduler + from prometheus_client import Gauge from prometheus_client.metrics import MetricWrapperBase + + from litellm.router import Router else: AsyncIOScheduler = Any @@ -67,6 +71,8 @@ _TableRowT: Final = TypeVar("_TableRowT", bound=BaseModel) _DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT: Final = 5.0 +UNRECOGNIZED_REQUESTED_MODEL_LABEL: Final = "other" + _NON_ENUM_METRIC_LABELS: Final[frozenset[str]] = frozenset( ( "guardrail_name", @@ -154,6 +160,44 @@ def _get_budget_metrics_per_request_timeout() -> float: return parsed +def _get_proxy_llm_router() -> Router | None: + try: + from litellm.proxy.proxy_server import llm_router + except Exception: + return None + return llm_router + + +def _bounded_requested_model_label(requested_model: str | None, router_originated: bool = False) -> str | None: + """ + Bound ``requested_model`` label cardinality: names the router recognizes + (model names, deployment ids, aliases, routing groups, team public model + names) or matches via a global or team wildcard/pattern route keep their + own label value; any other client-supplied string collapses into the + single ``other`` bucket. With no proxy router to vouch for the string, + client-supplied values collapse to ``other`` while ``router_originated`` + values (emitted by an SDK ``Router``'s own deployment failure and + fallback events, where the proxy router never exists) pass through. + """ + if not requested_model: + return requested_model + llm_router: Final = _get_proxy_llm_router() + if llm_router is None: + return requested_model if router_originated else UNRECOGNIZED_REQUESTED_MODEL_LABEL + if llm_router.is_recognized_model(requested_model): + return requested_model + if requested_model in llm_router.team_public_model_names: + return requested_model + if llm_router.pattern_router.route(requested_model) is not None: + return requested_model + if any( + team_pattern_router.route(requested_model) is not None + for team_pattern_router in llm_router.team_pattern_routers.values() + ): + return requested_model + return UNRECOGNIZED_REQUESTED_MODEL_LABEL + + class PrometheusLogger(CustomLogger): # Class variables or attributes @@ -434,6 +478,30 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric("litellm_remaining_api_key_tokens_for_model"), ) + self.litellm_api_key_rate_limit_allowed_metric = self._gauge_factory( + "litellm_api_key_rate_limit_allowed_metric", + "Configured rate limit for the API Key in the current window (rpm_limit / tpm_limit), by rate_limit_type", + labelnames=self.get_labels_for_metric("litellm_api_key_rate_limit_allowed_metric"), + ) + + self.litellm_api_key_rate_limit_used_metric = self._gauge_factory( + "litellm_api_key_rate_limit_used_metric", + "Requests or tokens the API Key has consumed in the current rate limit window, by rate_limit_type", + labelnames=self.get_labels_for_metric("litellm_api_key_rate_limit_used_metric"), + ) + + self.litellm_team_rate_limit_allowed_metric = self._gauge_factory( + "litellm_team_rate_limit_allowed_metric", + "Configured rate limit for the Team in the current window (team rpm_limit / tpm_limit), by rate_limit_type", + labelnames=self.get_labels_for_metric("litellm_team_rate_limit_allowed_metric"), + ) + + self.litellm_team_rate_limit_used_metric = self._gauge_factory( + "litellm_team_rate_limit_used_metric", + "Requests or tokens the Team has consumed in the current rate limit window, by rate_limit_type", + labelnames=self.get_labels_for_metric("litellm_team_rate_limit_used_metric"), + ) + ######################################## # LLM API Deployment Metrics / analytics ######################################## @@ -1433,6 +1501,11 @@ class PrometheusLogger(CustomLogger): model_id=enum_values.model_id, ) + self._set_key_and_team_rate_limit_metrics( + standard_logging_payload=standard_logging_payload, # pyright: ignore[reportArgumentType] # isinstance(dict) above narrows the TypedDict to dict[Unknown, Unknown] + enum_values=enum_values, + ) + # set latency metrics self._set_latency_metrics( kwargs=kwargs, @@ -1960,17 +2033,102 @@ class PrometheusLogger(CustomLogger): """ if standard_logging_payload is None: return None + return PrometheusLogger._get_int_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, + header_name=f"x-ratelimit-model_per_key-remaining-{rate_limit_type}", + ) + + @staticmethod + def _get_int_from_v3_rate_limit_headers( + standard_logging_payload: StandardLoggingPayload, + header_name: str, + ) -> int | None: hidden_params: Final = standard_logging_payload.get("hidden_params") if hidden_params is None: return None - additional_headers: Final = hidden_params.get("additional_headers") + additional_headers: Final[Mapping[str, object] | None] = hidden_params.get("additional_headers") if additional_headers is None: return None - value: Final = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}") + value: Final = additional_headers.get(header_name) if isinstance(value, bool) or not isinstance(value, int): return None return value + def _set_key_and_team_rate_limit_metrics( + self, + standard_logging_payload: StandardLoggingPayload, + enum_values: UserAPIKeyLabelValues, + ) -> None: + """ + Export the key-level and team-level RPM / TPM limit and current window + usage from the ``x-ratelimit-{api_key,team}-{limit,remaining}-*`` + headers the v3 rate limiter mirrors into the logging payload. The + limiter already read these counters (from Redis when configured) on + the request path, so no extra store lookup happens here. Descriptors + without a configured limit emit no header, so their series is removed + rather than left at the value from before the limit was dropped. + """ + descriptor_gauges: Final[ + tuple[tuple[Literal["api_key", "team"], DEFINED_PROMETHEUS_METRICS, Gauge, Gauge], ...] + ] = ( + ( + "api_key", + "litellm_api_key_rate_limit_allowed_metric", + self.litellm_api_key_rate_limit_allowed_metric, + self.litellm_api_key_rate_limit_used_metric, + ), + ( + "team", + "litellm_team_rate_limit_allowed_metric", + self.litellm_team_rate_limit_allowed_metric, + self.litellm_team_rate_limit_used_metric, + ), + ) + for descriptor_key, metric_name, allowed_gauge, used_gauge in descriptor_gauges: + for rate_limit_type in ("requests", "tokens"): + self._set_rate_limit_allowed_and_used_gauges( + standard_logging_payload=standard_logging_payload, + enum_values=enum_values, + descriptor_key=descriptor_key, + metric_name=metric_name, + allowed_gauge=allowed_gauge, + used_gauge=used_gauge, + rate_limit_type=rate_limit_type, + ) + + def _set_rate_limit_allowed_and_used_gauges( + self, + standard_logging_payload: StandardLoggingPayload, + enum_values: UserAPIKeyLabelValues, + descriptor_key: Literal["api_key", "team"], + metric_name: DEFINED_PROMETHEUS_METRICS, + allowed_gauge: Gauge, + used_gauge: Gauge, + rate_limit_type: Literal["requests", "tokens"], + ) -> None: + limit: Final = self._get_int_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, + header_name=f"x-ratelimit-{descriptor_key}-limit-{rate_limit_type}", + ) + remaining: Final = self._get_int_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, + header_name=f"x-ratelimit-{descriptor_key}-remaining-{rate_limit_type}", + ) + labelled_values: Final = replace(enum_values, rate_limit_type=rate_limit_type) + labelnames: Final = self.get_labels_for_metric(metric_name) + labels: Final = prometheus_label_factory( + supported_enum_labels=labelnames, + enum_values=labelled_values, + label_context=PrometheusLabelFactoryContext(labelled_values), + ) + if limit is None or remaining is None: + label_values: Final = tuple(labels.get(label) for label in labelnames) + self._bounded_prometheus_series_tracker.remove_series(allowed_gauge, label_values) + self._bounded_prometheus_series_tracker.remove_series(used_gauge, label_values) + return + allowed_gauge.labels(**labels).set(limit) + used_gauge.labels(**labels).set(limit - remaining) + def _set_virtual_key_rate_limit_metrics( self, user_api_key: str | None, @@ -2407,7 +2565,7 @@ class PrometheusLogger(CustomLogger): team_alias=user_api_key_dict.team_alias, org_id=user_api_key_dict.org_id, org_alias=user_api_key_dict.organization_alias, - requested_model=request_data.get("model", ""), + requested_model=_bounded_requested_model_label(request_data.get("model", "")), status_code=str(status_code), exception_status=str(status_code), exception_class=self._get_exception_class_name(original_exception), @@ -2627,7 +2785,9 @@ class PrometheusLogger(CustomLogger): label_model_id = "" label_api_base = "" label_api_provider = "" - label_requested_model = litellm_model_name or model_group or "" + label_requested_model = ( + _bounded_requested_model_label(litellm_model_name or model_group, router_originated=True) or "" + ) enum_values: Final = UserAPIKeyLabelValues( litellm_model_name=label_litellm_model_name, @@ -3186,7 +3346,7 @@ class PrometheusLogger(CustomLogger): _tags: Final = cast(list[str], kwargs.get("tags") or []) enum_values: Final = UserAPIKeyLabelValues( - requested_model=original_model_group, + requested_model=_bounded_requested_model_label(original_model_group, router_originated=True), fallback_model=_new_model, hashed_api_key=standard_metadata["user_api_key_hash"], api_key_alias=standard_metadata["user_api_key_alias"], @@ -3227,7 +3387,7 @@ class PrometheusLogger(CustomLogger): ) enum_values: Final = UserAPIKeyLabelValues( - requested_model=original_model_group, + requested_model=_bounded_requested_model_label(original_model_group, router_originated=True), fallback_model=_new_model, hashed_api_key=standard_metadata["user_api_key_hash"], api_key_alias=standard_metadata["user_api_key_alias"], diff --git a/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py b/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py index c54790b8ae7..c1ccf09d5d6 100644 --- a/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py +++ b/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py @@ -60,6 +60,10 @@ class BoundedPrometheusSeriesTracker: break del series[tracked_label_values] + def remove_series(self, metric: object, label_values: tuple[str | None, ...]) -> bool: + """Drop one child series, True when it is gone (removed or never existed).""" + return self._remove_metric_child(metric, label_values) + def _should_run_ttl_cleanup( self, metric_name: str, diff --git a/litellm/integrations/s3.py b/litellm/integrations/s3.py index ddeb410c54a..8ce461eea5b 100644 --- a/litellm/integrations/s3.py +++ b/litellm/integrations/s3.py @@ -1,11 +1,18 @@ #### What this does #### # On success + failure, log events to Supabase +import hashlib from datetime import datetime from typing import Final, cast import litellm from litellm._logging import print_verbose, verbose_logger +from litellm.constants import ( + MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES, + MAX_S3_OBJECT_KEY_BYTES, + S3_BOUNDED_OBJECT_KEY_HEAD_BYTES, + S3_PREFIX_DIGEST_CHARS, +) from litellm.types.utils import StandardLoggingPayload @@ -133,9 +140,7 @@ class S3Logger: s3_file_name, ) - s3_object_download_filename: Final = ( - "time-" + start_time.strftime("%Y-%m-%dT%H-%M-%S-%f") + "_" + payload["id"] + ".json" - ) + s3_object_download_filename: Final = get_s3_object_download_filename(start_time, payload["id"]) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps @@ -198,6 +203,47 @@ def resolve_sse_params( return algorithm, valid_key_id +S3_MIN_BOUNDED_FILE_NAME_BYTES: Final = 64 + + +def _truncate_to_utf8_bytes(value: str, max_bytes: int) -> str: + """Trim `value` so its UTF-8 encoding fits `max_bytes`, never splitting a character.""" + if max_bytes <= 0: + return "" + encoded: Final = value.encode("utf-8") + if len(encoded) <= max_bytes: + return value + return encoded[:max_bytes].decode("utf-8", errors="ignore") + + +def get_s3_object_download_filename(start_time: datetime, response_id: str) -> str: + """Content-Disposition filename for the uploaded object, bounded to the metadata header cap.""" + sanitized_response_id: Final = response_id.replace("/", "_").replace('"', "_") + file_name: Final = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{response_id}" + sanitized_file_name: Final = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{sanitized_response_id}" + budget: Final = MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES - len(b".json") + if len(sanitized_file_name.encode("utf-8")) <= budget: + return sanitized_file_name + ".json" + return _bounded_s3_file_name(file_name, sanitized_file_name, budget) + ".json" + + +def _bounded_s3_file_name(s3_file_name: str, sanitized_s3_file_name: str, max_bytes: int) -> str: + """As much of the file name as `max_bytes` allows, then the sha256 of the whole name.""" + digest: Final = hashlib.sha256(s3_file_name.encode("utf-8")).hexdigest() + head_budget: Final = min(S3_BOUNDED_OBJECT_KEY_HEAD_BYTES, max_bytes - len(digest) - 1) + head: Final = _truncate_to_utf8_bytes(sanitized_s3_file_name, head_budget) + return f"{head}_{digest}" if head else digest + + +def _bounded_s3_prefix(configured_prefix: str, max_bytes: int) -> str: + """As much of the configured prefix as fits, then a digest segment naming the full prefix.""" + digest_segment: Final = hashlib.sha256(configured_prefix.encode("utf-8")).hexdigest()[:S3_PREFIX_DIGEST_CHARS] + "/" + if max_bytes < len(digest_segment): + return "" + head: Final = _truncate_to_utf8_bytes(configured_prefix, max_bytes - len(digest_segment) - 1).rstrip("/") + return f"{head}/{digest_segment}" if head else digest_segment + + def get_s3_object_key( s3_path: str, prefix: str, @@ -205,12 +251,23 @@ def get_s3_object_key( s3_file_name: str, ) -> str: sanitized_s3_file_name: Final = s3_file_name.replace("/", "_") - s3_object_key = ( - (s3_path.rstrip("/") + "/" if s3_path else "") - + prefix - + start_time.strftime("%Y-%m-%d") - + "/" - + sanitized_s3_file_name - ) # we need the s3 key to include the time, so we log cache hits too - s3_object_key += ".json" - return s3_object_key + configured_prefix: Final = (s3_path.rstrip("/") + "/" if s3_path else "") + prefix + date_segment: Final = start_time.strftime("%Y-%m-%d") + "/" + # we need the s3 key to include the time, so we log cache hits too + s3_object_key: Final = configured_prefix + date_segment + sanitized_s3_file_name + ".json" + if len(s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES: + return s3_object_key + + # shorten the response id first and only trim the configured prefix if that is what does not + # fit, so prefix scoped IAM policies and lifecycle rules keep matching + budget: Final = MAX_S3_OBJECT_KEY_BYTES - len(date_segment.encode("utf-8")) - len(b".json") + prefix_bytes: Final = len(configured_prefix.encode("utf-8")) + if prefix_bytes + S3_MIN_BOUNDED_FILE_NAME_BYTES <= budget: + bounded_file_name: Final = _bounded_s3_file_name(s3_file_name, sanitized_s3_file_name, budget - prefix_bytes) + return configured_prefix + date_segment + bounded_file_name + ".json" + + shortest_file_name: Final = _bounded_s3_file_name( + s3_file_name, sanitized_s3_file_name, S3_MIN_BOUNDED_FILE_NAME_BYTES + ) + bounded_prefix: Final = _bounded_s3_prefix(configured_prefix, budget - len(shortest_file_name.encode("utf-8"))) + return bounded_prefix + date_segment + shortest_file_name + ".json" diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 9f6ae72fb3a..712ce41d09e 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -16,7 +16,11 @@ from urllib.parse import quote import litellm from litellm._logging import print_verbose, verbose_logger from litellm.constants import DEFAULT_S3_BATCH_SIZE, DEFAULT_S3_FLUSH_INTERVAL_SECONDS -from litellm.integrations.s3 import get_s3_object_key, resolve_sse_params +from litellm.integrations.s3 import ( + get_s3_object_download_filename, + get_s3_object_key, + resolve_sse_params, +) from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker @@ -259,11 +263,11 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): now: Final = datetime.now(timezone.utc) audit_log_id: Final = audit_log.get("id", "unknown") - s3_path = cast(str | None, self.s3_path) or "" - s3_path = s3_path.rstrip("/") + "/" if s3_path else "" - - s3_object_key: Final = ( - f"{s3_path}audit_logs/{now.strftime('%Y-%m-%d')}/{now.strftime('%H-%M-%S')}_{audit_log_id}.json" + s3_object_key: Final = get_s3_object_key( + cast(str | None, self.s3_path) or "", + "audit_logs/", + now, + f"{now.strftime('%H-%M-%S')}_{audit_log_id}", ) element: Final = s3BatchLoggingElement( @@ -463,9 +467,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): ) verbose_logger.debug("s3_object_key=%s", s3_object_key) - s3_object_download_filename: Final = ( - f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{standard_logging_payload['id']}.json" - ) + s3_object_download_filename: Final = get_s3_object_download_filename(start_time, standard_logging_payload["id"]) return s3BatchLoggingElement( payload=dict(standard_logging_payload), diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 207e024ce0b..ce51fb19970 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -127,7 +127,7 @@ def handle_anthropic_text_model_custom_llm_provider( return model, custom_llm_provider -def declared_authenticating_provider(model: str, custom_llm_provider: str | None = None) -> str | None: +def declared_authenticating_provider(model: str | None, custom_llm_provider: str | None = None) -> str | None: """The authenticating provider this pair already names, or None. get_llm_provider runs the OAuth device flow for github_copilot and chatgpt, because their @@ -135,7 +135,7 @@ def declared_authenticating_provider(model: str, custom_llm_provider: str | None and for a declared pair the resolver's answer is the declaration itself, so metadata callers adopt the declaration instead of resolving. """ - declared: Final = custom_llm_provider or model.split("/", 1)[0] + declared: Final = custom_llm_provider or (model.split("/", 1)[0] if model and "/" in model else None) return declared if declared in PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO else None diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index 2043a9e2f89..9cba5db8ab7 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -12,6 +12,7 @@ import asyncio import json import os import random +import time from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timezone @@ -154,18 +155,6 @@ class GetModelCostMap: return True - @staticmethod - def fetch_remote_model_cost_map(url: str, timeout: int = 5) -> dict: - """ - Fetch the model cost map from a remote URL. - - Returns the parsed JSON dict. Raises on network/parse errors - (caller is expected to handle). - """ - response: Final = httpx.get(url, timeout=timeout) - response.raise_for_status() - return response.json() - RETRYABLE_FETCH_STATUS_CODES: Final = frozenset({429, 500, 502, 503, 504}) MODEL_COST_MAP_FETCH_MAX_ATTEMPTS: Final = 3 @@ -212,6 +201,13 @@ class _AsyncGetClient(Protocol): def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ... +class _SyncGetClient(Protocol): + def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: ... + + +_FetchAttemptOutcome = ModelCostMapReloaded | ModelCostMapReloadUnavailable | _FetchAttemptRetryable + + def _default_reload_client() -> _AsyncGetClient: from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider @@ -219,13 +215,30 @@ def _default_reload_client() -> _AsyncGetClient: return get_async_httpx_client(llm_provider=httpxSpecialProvider.ModelCostMap) -async def _attempt_fetch( - client: _AsyncGetClient, url: str, timeout: int -) -> ModelCostMapReloaded | ModelCostMapReloadUnavailable | _FetchAttemptRetryable: +def _classify_fetch_error(error: httpx.HTTPError | httpx.InvalidURL, url: str) -> _FetchAttemptOutcome: + reason: Final = f"{type(error).__name__} fetching {url}: {error}" + if isinstance(error, (httpx.InvalidURL, httpx.UnsupportedProtocol)): + return ModelCostMapReloadUnavailable(reason=reason) + return _FetchAttemptRetryable(reason=reason, retry_after_seconds=None) + + +async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: try: response: Final = await client.get(url, timeout=timeout) - except httpx.HTTPError as e: - return _FetchAttemptRetryable(reason=f"{type(e).__name__} fetching {url}: {e}", retry_after_seconds=None) + except (httpx.HTTPError, httpx.InvalidURL) as e: + return _classify_fetch_error(e, url) + return _classify_fetch_response(response, url) + + +def _attempt_fetch_sync(client: _SyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome: + try: + response: Final = client.get(url, timeout=timeout) + except (httpx.HTTPError, httpx.InvalidURL) as e: + return _classify_fetch_error(e, url) + return _classify_fetch_response(response, url) + + +def _classify_fetch_response(response: httpx.Response, url: str) -> _FetchAttemptOutcome: if response.status_code in RETRYABLE_FETCH_STATUS_CODES: return _FetchAttemptRetryable( reason=f"HTTP {response.status_code} from {url}", @@ -242,6 +255,22 @@ async def _attempt_fetch( return ModelCostMapReloaded(model_cost_map=parsed) +def _next_retry_wait( + outcome: _FetchAttemptRetryable, attempt: int, max_attempts: int, rng: random.Random +) -> float | ModelCostMapReloadUnavailable: + if attempt == max_attempts: + return ModelCostMapReloadUnavailable(reason=f"{outcome.reason} (after {max_attempts} attempts)") + wait_seconds: Final = _retry_wait_seconds(outcome=outcome, attempt=attempt, rng=rng) + verbose_logger.warning( + "LiteLLM: model cost map fetch attempt %d/%d failed (%s); retrying in %.1fs", + attempt, + max_attempts, + outcome.reason, + wait_seconds, + ) + return wait_seconds + + async def _fetch_remote_model_cost_map_with_retry( url: str, timeout: int, @@ -254,20 +283,32 @@ async def _fetch_remote_model_cost_map_with_retry( outcome = await _attempt_fetch(client=client, url=url, timeout=timeout) if not isinstance(outcome, _FetchAttemptRetryable): return outcome - if attempt == max_attempts: - return ModelCostMapReloadUnavailable(reason=f"{outcome.reason} (after {max_attempts} attempts)") - wait_seconds = _retry_wait_seconds(outcome=outcome, attempt=attempt, rng=rng) - verbose_logger.warning( - "LiteLLM: model cost map fetch attempt %d/%d failed (%s); retrying in %.1fs", - attempt, - max_attempts, - outcome.reason, - wait_seconds, - ) + wait_seconds = _next_retry_wait(outcome=outcome, attempt=attempt, max_attempts=max_attempts, rng=rng) + if isinstance(wait_seconds, ModelCostMapReloadUnavailable): + return wait_seconds await sleep(wait_seconds) return ModelCostMapReloadUnavailable(reason="model cost map fetch failed") +def _fetch_remote_model_cost_map_with_retry_sync( + url: str, + timeout: int, + max_attempts: int, + sleep: Callable[[float], None], + rng: random.Random, + client: _SyncGetClient, +) -> ModelCostMapReloadResult: + for attempt in range(1, max_attempts + 1): + outcome = _attempt_fetch_sync(client=client, url=url, timeout=timeout) + if not isinstance(outcome, _FetchAttemptRetryable): + return outcome + wait_seconds = _next_retry_wait(outcome=outcome, attempt=attempt, max_attempts=max_attempts, rng=rng) + if isinstance(wait_seconds, ModelCostMapReloadUnavailable): + return wait_seconds + sleep(wait_seconds) + return ModelCostMapReloadUnavailable(reason="model cost map fetch failed") + + async def refetch_model_cost_map( url: str, timeout: int = 5, @@ -423,13 +464,21 @@ def _finalize_model_cost_map(model_cost: dict) -> dict: return _expand_model_aliases(model_cost) -def get_model_cost_map(url: str) -> dict: +def get_model_cost_map( + url: str, + timeout: int = 5, + max_attempts: int = MODEL_COST_MAP_FETCH_MAX_ATTEMPTS, + sleep: Callable[[float], None] = time.sleep, + rng: random.Random | None = None, + client: "_SyncGetClient | None" = None, +) -> dict: """ Public entry point — returns the model cost map dict. 1. If ``LITELLM_LOCAL_MODEL_COST_MAP`` is set, uses the local backup only. - 2. Otherwise fetches from ``url``, validates integrity, and falls back - to the local backup on any failure. + 2. Otherwise fetches from ``url``, retrying transient HTTP errors + (429/5xx/transport) with Retry-After-aware backoff, validates + integrity, and falls back to the local backup on any failure. Only the backup model count is cached (a single int) for validation. The full backup dict is only parsed when it must be *returned* as a @@ -448,17 +497,24 @@ def get_model_cost_map(url: str) -> dict: _cost_map_source_info.url = url _cost_map_source_info.is_env_forced = False - try: - content: Final = GetModelCostMap.fetch_remote_model_cost_map(url) - except Exception as e: + result: Final = _fetch_remote_model_cost_map_with_retry_sync( + url=url, + timeout=timeout, + max_attempts=max_attempts, + sleep=sleep, + rng=rng if rng is not None else random.Random(), + client=client if client is not None else httpx, + ) + if isinstance(result, ModelCostMapReloadUnavailable): verbose_logger.warning( "LiteLLM: Failed to fetch remote model cost map from %s: %s. Falling back to local backup.", url, - str(e), + result.reason, ) _cost_map_source_info.source = "local" - _cost_map_source_info.fallback_reason = f"Remote fetch failed: {e}" + _cost_map_source_info.fallback_reason = f"Remote fetch failed: {result.reason}" return _finalize_model_cost_map(GetModelCostMap.load_local_model_cost_map()) + content: Final = result.model_cost_map # Validate using cached count (cheap int comparison, no file I/O) if not GetModelCostMap.validate_model_cost_map( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 350e5403e4c..9a6fb11f978 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -111,6 +111,7 @@ from litellm.types.mcp import MCPPostCallResponseObject from litellm.types.prompts.init_prompts import PromptSpec from litellm.types.rerank import RerankResponse from litellm.types.utils import ( + DEPLOYMENT_SCOPED_PRICING_FIELDS, CachingDetails, CallTypes, CostBreakdown, @@ -255,6 +256,7 @@ _STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggi # Cache custom pricing keys as frozenset for O(1) lookups instead of looping through 49 keys _CUSTOM_PRICING_KEYS: Final[frozenset[str]] = frozenset(CustomPricingLiteLLMParams.model_fields.keys()) +_MODEL_INFO_CUSTOM_PRICING_KEYS: Final[frozenset[str]] = _CUSTOM_PRICING_KEYS | DEPLOYMENT_SCOPED_PRICING_FIELDS sentry_sdk_instance = None capture_exception = None @@ -2957,13 +2959,25 @@ class Logging(LiteLLMLoggingBaseClass): "Model=%s not found in completion cost map. Setting 'response_cost' to None", self.model ) self.model_call_details["response_cost"] = None + except Exception: # noqa: BLE001 # cost calculation must never block later callbacks (slot release) + verbose_logger.exception( + "Error calculating streaming response cost for model=%s. Setting 'response_cost' to None", + self.model, + ) + self.model_call_details["response_cost"] = None self._merge_hidden_params_from_response_into_metadata(complete_streaming_response) ## STANDARDIZED LOGGING PAYLOAD - self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload( - complete_streaming_response, start_time, end_time - ) + try: + self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload( + complete_streaming_response, start_time, end_time + ) + except Exception: # noqa: BLE001 # payload build must never block later callbacks (slot release) + verbose_logger.exception( + "LiteLLM.LoggingError: [Non-Blocking] Exception building the standard logging payload " + "for a streaming response; callbacks still run without it" + ) # print standard logging payload if (standard_logging_payload := self.model_call_details.get("standard_logging_object")) is not None: @@ -3003,32 +3017,39 @@ class Logging(LiteLLMLoggingBaseClass): ## LOGGING HOOK ## for callback in callbacks: - if isinstance(callback, CustomGuardrail): - from litellm.types.guardrails import GuardrailEventHooks + try: + if isinstance(callback, CustomGuardrail): + from litellm.types.guardrails import GuardrailEventHooks - if ( - callback.should_run_guardrail( - data=self.model_call_details, - event_type=GuardrailEventHooks.logging_only, + if ( + callback.should_run_guardrail( + data=self.model_call_details, + event_type=GuardrailEventHooks.logging_only, + ) + is not True + ): + continue + + self.model_call_details, result = await callback.async_logging_hook( + kwargs=self.model_call_details, + result=result, + call_type=self.call_type, ) - is not True - ): - continue - - self.model_call_details, result = await callback.async_logging_hook( - kwargs=self.model_call_details, - result=result, - call_type=self.call_type, - ) - elif isinstance(callback, CustomLogger): - result = redact_message_input_output_from_custom_logger( - result=result, litellm_logging_obj=self, custom_logger=callback - ) - self.model_call_details, result = await callback.async_logging_hook( - kwargs=self.model_call_details, - result=result, - call_type=self.call_type, + elif isinstance(callback, CustomLogger): + result = redact_message_input_output_from_custom_logger( + result=result, litellm_logging_obj=self, custom_logger=callback + ) + self.model_call_details, result = await callback.async_logging_hook( + kwargs=self.model_call_details, + result=result, + call_type=self.call_type, + ) + except Exception: # noqa: BLE001 # one failing hook must not skip later callbacks (slot release) + verbose_logger.error( + "LiteLLM.LoggingError: [Non-Blocking] Exception occurred in async_logging_hook %s", + traceback.format_exc(), ) + self._handle_callback_failure(callback=callback) self.has_run_logging(event_type="async_success") @@ -5033,7 +5054,9 @@ def use_custom_pricing_for_model(litellm_params: dict | None) -> bool: """ Check if the model uses custom pricing - Returns True if any of `SPECIAL_MODEL_INFO_PARAMS` are present in `litellm_params` or `model_info` + Returns True if any custom pricing field is present in `litellm_params`, or if + any custom pricing or deployment-scoped pricing field (such as + ``off_peak_pricing``) is present in the metadata ``model_info`` """ if litellm_params is None: return False @@ -5051,7 +5074,7 @@ def use_custom_pricing_for_model(litellm_params: dict | None) -> bool: model_info: dict = metadata.get("model_info", {}) or {} if model_info: - matching_keys = _CUSTOM_PRICING_KEYS & model_info.keys() + matching_keys = _MODEL_INFO_CUSTOM_PRICING_KEYS & model_info.keys() for key in matching_keys: if model_info.get(key) is not None: return True diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 19e3f624268..b34c416cd40 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -2,10 +2,12 @@ ## Helper utilities for cost_per_token() import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass +from datetime import datetime, timezone, tzinfo from types import MappingProxyType from typing import Any, Final, Literal, TypedDict, cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import litellm from litellm._logging import verbose_logger @@ -290,10 +292,187 @@ def _get_tiered_base_costs(model_info: ModelInfo, usage: Usage) -> tuple[float, ) +def _is_within_off_peak_window(off_peak_hours_utc: str | Sequence[str], current_time: datetime | None = None) -> bool: + """Return True if current_time (UTC, defaulting to now) falls inside any off-peak window. + + off_peak_hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of such strings for providers + with multiple daily windows (e.g. ["16:30-00:30", "04:00-06:00"]). A window may wrap past + midnight, and a window whose start equals its end covers the whole day. The start is + inclusive and the end is exclusive; malformed windows are ignored. + + An aware current_time is converted to UTC. A naive one is taken to already be UTC rather + than being localised, so callers must pass datetime.now(timezone.utc), never datetime.now(), + or every window shifts by the host's offset. + """ + reference: Final = current_time if current_time is not None else datetime.now(timezone.utc) + now: Final = (reference.astimezone(timezone.utc) if reference.tzinfo is not None else reference).time() + windows: Final = (off_peak_hours_utc,) if isinstance(off_peak_hours_utc, str) else off_peak_hours_utc + for window in windows: + try: + start_str, end_str = window.split("-") + start = datetime.strptime(start_str.strip(), "%H:%M").replace(tzinfo=timezone.utc).time() + end = datetime.strptime(end_str.strip(), "%H:%M").replace(tzinfo=timezone.utc).time() + except (ValueError, AttributeError): + continue + if start < end: + if start <= now < end: + return True + elif now >= start or now < end: + return True + return False + + +_WEEKDAY_NUMBERS: Final = MappingProxyType( + { + "mon": 1, + "monday": 1, + "tue": 2, + "tues": 2, + "tuesday": 2, + "wed": 3, + "wednesday": 3, + "thu": 4, + "thur": 4, + "thurs": 4, + "thursday": 4, + "fri": 5, + "friday": 5, + "sat": 6, + "saturday": 6, + "sun": 7, + "sunday": 7, + } +) + + +def _normalize_weekday(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if 1 <= value <= 7 else None + if isinstance(value, str): + return _WEEKDAY_NUMBERS.get(value.strip().lower()) + return None + + +def _weekday_calendar(weekday_timezone: object) -> tzinfo: + if isinstance(weekday_timezone, str) and weekday_timezone.strip(): + try: + return ZoneInfo(weekday_timezone.strip()) + except (ValueError, ZoneInfoNotFoundError): + return timezone.utc + return timezone.utc + + +def _matches_weekdays(reference_utc: datetime, weekdays: object, weekday_timezone: object) -> bool: + """Return True when reference_utc falls on one of the rule's weekdays, read on the calendar + named by weekday_timezone (default UTC). An absent weekdays means every day. The calendar + matters even when UTC and vendor-local weekdays agree at every currently priced hour: a + window past 16:00 UTC is where an Asia/Shanghai weekday diverges from the UTC one. + """ + if weekdays is None: + return True + if isinstance(weekdays, str) or not isinstance(weekdays, Sequence): + return False + allowed: Final = frozenset(day for day in map(_normalize_weekday, weekdays) if day is not None) + return reference_utc.astimezone(_weekday_calendar(weekday_timezone)).isoweekday() in allowed + + +def _as_window_strings(value: object) -> tuple[str, ...]: + if isinstance(value, str): + return (value,) + if isinstance(value, Sequence): + return tuple(entry for entry in value if isinstance(entry, str)) + return () + + +def _is_off_peak(off_peak: Mapping[str, object], current_time: datetime | None = None) -> bool: + """Return True when current_time (UTC, defaulting to now) is off-peak under the block's + rules: the flat hours_utc windows, which apply every day, or any entry in windows, whose + hours apply only on its weekdays. + """ + reference: Final = current_time if current_time is not None else datetime.now(timezone.utc) + reference_utc: Final = ( + reference.astimezone(timezone.utc) if reference.tzinfo is not None else reference.replace(tzinfo=timezone.utc) + ) + flat_windows: Final = _as_window_strings(off_peak.get("hours_utc")) + if flat_windows and _is_within_off_peak_window(flat_windows, reference_utc): + return True + windows: Final = off_peak.get("windows") + if isinstance(windows, str) or not isinstance(windows, Sequence): + return False + weekday_timezone: Final = off_peak.get("weekday_timezone") + for rule in windows: + if not isinstance(rule, Mapping): + continue + rule_windows = _as_window_strings(rule.get("hours_utc")) + if not rule_windows: + continue + if not _matches_weekdays(reference_utc, rule.get("weekdays"), weekday_timezone): + continue + if _is_within_off_peak_window(rule_windows, reference_utc): + return True + return False + + +def _coerce_off_peak_rate(value: object, default: float) -> float: + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return default + return default + + +def _apply_off_peak_pricing( + model_info: ModelInfo, + current_time: datetime | None, + prompt_base_cost: float, + completion_base_cost: float, + cache_read_cost: float, +) -> tuple[float, float, float]: + """Swap in off-peak per-token rates when the current UTC time is inside one of the model's + off_peak_pricing rules, the every-day hours_utc windows or a day-of-week-qualified entry in + windows. An off-peak rate replaces the rate that would otherwise apply rather than + discounting it, so a model that also has tiered or above-threshold pricing bills the flat + off-peak rate for the whole request while the window is open. Any rate left unset in + off_peak_pricing falls back to the standard rate. + """ + off_peak: Final = model_info.get("off_peak_pricing") + if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time): + return prompt_base_cost, completion_base_cost, cache_read_cost + return ( + _coerce_off_peak_rate(off_peak.get("input_cost_per_token"), prompt_base_cost), + _coerce_off_peak_rate(off_peak.get("output_cost_per_token"), completion_base_cost), + _coerce_off_peak_rate(off_peak.get("cache_read_input_token_cost"), cache_read_cost), + ) + + +def _apply_off_peak_to_base_costs( + model_info: ModelInfo, + current_time: datetime | None, + base_costs: tuple[float, float, float, float, float], +) -> tuple[float, float, float, float, float]: + """Apply off-peak rates to an already-resolved set of base costs, whichever pricing path + produced them. Cache-creation rates are passed through untouched, since off_peak_pricing + has no field for them. + """ + prompt, completion, cache_creation, cache_creation_above_1hr, cache_read = base_costs + off_peak_prompt, off_peak_completion, off_peak_cache_read = _apply_off_peak_pricing( + model_info, current_time, prompt, completion, cache_read + ) + return (off_peak_prompt, off_peak_completion, cache_creation, cache_creation_above_1hr, off_peak_cache_read) + + def _get_token_base_cost( model_info: ModelInfo, usage: Usage, service_tier: str | None = None, + current_time: datetime | None = None, *, threshold_is_inclusive: bool = False, ) -> tuple[float, float, float, float, float]: @@ -311,7 +490,7 @@ def _get_token_base_cost( """ tiered_base_costs: Final = _get_tiered_base_costs(model_info=model_info, usage=usage) if tiered_base_costs is not None: - return tiered_base_costs + return _apply_off_peak_to_base_costs(model_info, current_time, tiered_base_costs) # Get service tier aware cost keys input_cost_key: Final = _get_service_tier_cost_key("input_cost_per_token", service_tier) @@ -345,12 +524,16 @@ def _get_token_base_cost( k for k in model_info if k.startswith("input_cost_per_token_above_") and not k.endswith(_SERVICE_TIER_SUFFIXES) ] if not threshold_keys: - return ( - prompt_base_cost, - completion_base_cost, - cache_creation_cost, - cache_creation_cost_above_1hr, - cache_read_cost, + return _apply_off_peak_to_base_costs( + model_info, + current_time, + ( + prompt_base_cost, + completion_base_cost, + cache_creation_cost, + cache_creation_cost_above_1hr, + cache_read_cost, + ), ) # Only sort the threshold keys (typically 1-2 keys instead of 66+) @@ -451,12 +634,16 @@ def _get_token_base_cost( except Exception: continue - return ( - prompt_base_cost, - completion_base_cost, - cache_creation_cost, - cache_creation_cost_above_1hr, - cache_read_cost, + return _apply_off_peak_to_base_costs( + model_info, + current_time, + ( + prompt_base_cost, + completion_base_cost, + cache_creation_cost, + cache_creation_cost_above_1hr, + cache_read_cost, + ), ) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 2fca19dc8d1..ff46440ff5c 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1281,6 +1281,19 @@ def flatten_top_level_schema_combinators(schema: Mapping[str, object]) -> Mappin return _flatten_schema_against_root(schema, schema, frozenset(), 0, {}) # mutable-ok: fresh per-call $ref memo +def tool_with_flattened_parameters(tool: Mapping[str, object]) -> Mapping[str, object]: + function: Final = tool.get("function") + if not isinstance(function, dict): + return tool + parameters: Final = function.get("parameters") + if not isinstance(parameters, dict): + return tool + flattened: Final = flatten_top_level_schema_combinators(parameters) + if flattened is parameters: + return tool + return {**tool, "function": {**function, "parameters": flattened}} # mutable-ok: request tools are JSON dicts + + def _get_image_mime_type_from_url(url: str) -> str | None: """ Get mime type for common image URLs diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 795fb36961e..ba59e3fa997 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1694,6 +1694,18 @@ def convert_function_to_anthropic_tool_invoke( raise e +def _find_server_tool_result( + tool_id: str, + web_search_results: Sequence[object] | None, + tool_results: Sequence[object] | None, +) -> dict[str, object] | None: + candidates: Final = (*(web_search_results or ()), *(tool_results or ())) + return next( + (result for result in candidates if isinstance(result, dict) and result.get("tool_use_id") == tool_id), + None, + ) + + def convert_to_anthropic_tool_invoke( tool_calls: list[ChatCompletionAssistantToolCall], web_search_results: list[Any] | None = None, @@ -1758,32 +1770,22 @@ def convert_to_anthropic_tool_invoke( context="Anthropic tool invoke", ) - # Check if this is a server-side tool (web_search, tool_search, etc.) - # Server tool IDs start with "srvtoolu_" - if tool_id.startswith("srvtoolu_"): - # Create server_tool_use block instead of tool_use - _anthropic_server_tool_use: dict[str, object] = { - "type": "server_tool_use", - "id": tool_id, - "name": tool_name, - "input": tool_input, - } - anthropic_tool_invoke.append(_anthropic_server_tool_use) - - # Add corresponding tool result if available. - # Check both web_search_results (web_search_tool_result / web_fetch_tool_result) - # and tool_results (bash_code_execution_tool_result, etc.) - _all_tool_results: list[Any] = [] - if web_search_results: - _all_tool_results.extend(web_search_results) - if tool_results: - _all_tool_results.extend(tool_results) - for result in _all_tool_results: - if result.get("tool_use_id") == tool_id: - anthropic_tool_invoke.append(result) - break + server_tool_result = ( + _find_server_tool_result(tool_id, web_search_results, tool_results) + if tool_id.startswith("srvtoolu_") + else None + ) + if server_tool_result is not None: + anthropic_tool_invoke.append( + { + "type": "server_tool_use", + "id": tool_id, + "name": tool_name, + "input": tool_input, + } + ) + anthropic_tool_invoke.append(server_tool_result) else: - # Regular tool_use sanitized_tool_id = _sanitize_anthropic_tool_use_id(tool_id) _anthropic_tool_use_param = AnthropicMessagesToolUseParam( type="tool_use", @@ -4955,10 +4957,13 @@ def make_valid_bedrock_tool_name(input_tool_name: str) -> str: def add_cache_point_tool_block(tool: dict, model: str | None = None) -> BedrockToolBlock | None: - from litellm.llms.bedrock.common_utils import is_claude_4_5_on_bedrock + from litellm.llms.bedrock.common_utils import ( + bedrock_model_accepts_cache_points, + is_claude_4_5_on_bedrock, + ) cache_control: Final = tool.get("cache_control", None) - if cache_control is not None: + if cache_control is not None and bedrock_model_accepts_cache_points(model): cache_point: Final = cache_control.get("type", "ephemeral") if cache_point == "ephemeral": cache_point_block: Final[CachePointBlock] = {"type": "default"} diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index c82be07a5c5..b5e18bd8e7e 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -4,7 +4,7 @@ Calling + translation logic for anthropic's `/v1/messages` endpoint import copy import json -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, Union, cast import httpx @@ -380,7 +380,10 @@ class AnthropicChatCompletion(BaseLLM): request_data: Final = config.transform_request( model=model, messages=messages, - optional_params={**optional_params, "is_vertex_request": is_vertex_request}, + optional_params={ # mutable-ok: provider transforms require a plain mutable request dict + **optional_params, + "is_vertex_request": is_vertex_request, + }, litellm_params=litellm_params, headers=headers, ) @@ -390,6 +393,46 @@ class AnthropicChatCompletion(BaseLLM): provider=custom_llm_provider, ) + def sync_python_path( + request_headers: Mapping[str, object], request_data: Mapping[str, object] + ) -> ModelResponse: + request_client: Final = ( + _get_httpx_client(params={"timeout": timeout}) # mutable-ok: HTTP client factory requires a dict + if client is None or not isinstance(client, HTTPHandler) + else client + ) + try: + response: Final = request_client.post( + api_base, + headers=dict(request_headers), # mutable-ok: HTTP client requires mutable headers + data=json.dumps(request_data), + timeout=timeout, + logging_obj=logging_obj, + ) + except Exception as error: # noqa: BLE001 # provider exceptions are normalized below + status_code: Final = getattr(error, "status_code", 500) + error_headers = getattr(error, "headers", None) + error_text = getattr(error, "text", str(error)) + error_response: Final = getattr(error, "response", None) + if error_headers is None and error_response: + error_headers = getattr(error_response, "headers", None) + if error_response and hasattr(error_response, "text"): + error_text = getattr(error_response, "text", error_text) + raise AnthropicError(message=error_text, status_code=status_code, headers=error_headers) + return config.transform_response( + model=model, + raw_response=response, + model_response=model_response, + logging_obj=logging_obj, + api_key=api_key, + request_data=dict(request_data), # mutable-ok: response transform mutates request metadata + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + json_mode=json_mode, + ) + # The Rust core owns the whole call for the subset it accepts, so ask # before transforming: whichever path runs emits pre_call exactly once. # `get_config` merges the class-level defaults (Anthropic's required @@ -466,7 +509,12 @@ class AnthropicChatCompletion(BaseLLM): on_response=log_rust_post_call, python_fallback=python_fallback, ) - rust_response: Final = rust_chat_completions_bridge.chat_completions( + + def sync_python_fallback() -> ModelResponse: + fallback_headers, fallback_data = build_request() + return sync_python_path(fallback_headers, fallback_data) + + return rust_chat_completions_bridge.chat_completions_or_fallback( model=model, messages=messages, optional_params=rust_optional_params, @@ -477,26 +525,21 @@ class AnthropicChatCompletion(BaseLLM): extra_headers=headers, timeout=timeout, on_response=log_rust_post_call, + python_fallback=sync_python_fallback, ) - if rust_response is not None: - return rust_response headers, data = build_request() ## LOGGING - # Reaching here with `serves_via_rust` set means the Rust attempt - # declined at call time, before the provider was called, and already - # logged this request. That is the same attempt continuing. - if not serves_via_rust: - logging_obj.pre_call( - input=messages, - api_key=api_key, - additional_args={ - "complete_input_dict": data, - "api_base": api_base, - "headers": headers, - }, - ) + logging_obj.pre_call( + input=messages, + api_key=api_key, + additional_args={ # mutable-ok: logging callback contract requires a mutable dict + "complete_input_dict": data, + "api_base": api_base, + "headers": headers, + }, + ) print_verbose(f"_is_function_call: {_is_function_call}") if acompletion is True: if ( @@ -579,48 +622,7 @@ class AnthropicChatCompletion(BaseLLM): _response_headers=process_anthropic_headers(headers), ) - else: - if client is None or not isinstance(client, HTTPHandler): - client = _get_httpx_client(params={"timeout": timeout}) - else: - client = client - - try: - response: Final = client.post( - api_base, - headers=headers, - data=json.dumps(data), - timeout=timeout, - logging_obj=logging_obj, - ) - except Exception as e: - status_code: Final = getattr(e, "status_code", 500) - error_headers = getattr(e, "headers", None) - error_text = getattr(e, "text", str(e)) - error_response: Final[object] = getattr(e, "response", None) - if error_headers is None and error_response: - error_headers = getattr(error_response, "headers", None) - if error_response and hasattr(error_response, "text"): - error_text = getattr(error_response, "text", error_text) - raise AnthropicError( - message=error_text, - status_code=status_code, - headers=error_headers, - ) - - return config.transform_response( - model=model, - raw_response=response, - model_response=model_response, - logging_obj=logging_obj, - api_key=api_key, - request_data=data, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - encoding=encoding, - json_mode=json_mode, - ) + return sync_python_path(headers, data) def embedding(self): # logic for parsing in - calling - parsing out model embedding calls diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 3e6d96b9780..aa805ccea71 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1485,7 +1485,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) if _tool_choice is not None: - optional_params["tool_choice"] = _tool_choice + optional_params["tool_choice"] = AnthropicConfig._apply_forced_tool_choice( + model=model, tool_choice=_tool_choice, drop_params=drop_params + ) elif param == "stream" and value is True: optional_params["stream"] = value elif param == "stop" and (isinstance(value, str) or isinstance(value, list)): @@ -1514,7 +1516,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): _tool = self.map_response_format_to_anthropic_tool(value, optional_params, is_thinking_enabled) if _tool is None: continue - if not is_thinking_enabled: + if not is_thinking_enabled and not AnthropicModelInfo.forced_tool_use_unsupported(model): _tool_choice = { "name": RESPONSE_FORMAT_TOOL_NAME, "type": "tool", diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 9871001bf66..c60ebd844ba 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -28,10 +28,15 @@ from litellm.types.llms.anthropic import ( ANTHROPIC_OAUTH_TOKEN_PREFIX, AllAnthropicToolsValues, AnthropicMcpServerTool, + AnthropicMessagesToolChoice, ) from litellm.types.llms.openai import AllMessageValues from litellm.types.proxy.model_listing import ModelInfoResponse +DROP_FORCED_TOOL_CHOICE_WARNING: Final = ( + "Downgrading forced tool_choice to 'auto' for model=%s (drop_params=True): this model rejects tool_choice type " + "'any'/'tool' with a 400 because thinking is always on and a forced call would skip it." +) DROP_DISABLED_THINKING_WARNING: Final = ( "Dropping `thinking={'type': 'disabled'}` for model=%s: thinking is always on for this model and cannot be " "disabled (the alternative is a provider 400). The model will still think adaptively, its response can contain " @@ -320,6 +325,45 @@ class AnthropicModelInfo(BaseLLMModelInfo): status_code=400, ) + @staticmethod + def forced_tool_use_unsupported(model: str) -> bool: + return AnthropicModelInfo._get_model_capability(model, "supports_forced_tool_use") is False + + @staticmethod + def forced_tool_use_downgraded(model: str, drop_params: bool) -> bool: + """True when the model map flags the model with + ``supports_forced_tool_use: false`` (Fable 5.1 / Mythos 5.1 400 on + ``any``/``tool``) and ``drop_params`` asks for the ``auto`` downgrade; + raises a clean client-side 400 for such models without ``drop_params``.""" + if not AnthropicModelInfo.forced_tool_use_unsupported(model): + return False + if not (litellm.drop_params or drop_params): + raise litellm.utils.UnsupportedParamsError( + message=( + f"{model} does not support forced tool use (tool_choice='required' or a named tool). " + "Use tool_choice='auto' and tell the model in the prompt when to call the tool, or set " + "`litellm.drop_params = True` to downgrade to 'auto' automatically." + ), + status_code=400, + ) + litellm.verbose_logger.warning(DROP_FORCED_TOOL_CHOICE_WARNING, model) + return True + + @staticmethod + def _apply_forced_tool_choice( + model: str, + tool_choice: AnthropicMessagesToolChoice, + drop_params: bool, + ) -> AnthropicMessagesToolChoice: + if tool_choice["type"] not in ("any", "tool"): + return tool_choice + if not AnthropicModelInfo.forced_tool_use_downgraded(model, drop_params): + return tool_choice + disable_parallel: Final = tool_choice.get("disable_parallel_tool_use") + if disable_parallel is None: + return AnthropicMessagesToolChoice(type="auto") + return AnthropicMessagesToolChoice(type="auto", disable_parallel_tool_use=disable_parallel) + @staticmethod def _strip_version_suffix(model: str) -> str: at: Final = model.rfind("@") diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py index 45c7825344b..4424ef4a081 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py @@ -6,7 +6,7 @@ from typing import Any, Final, Protocol, runtime_checkable import httpx from pydantic import TypeAdapter -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict from litellm.constants import ( ANTHROPIC_MESSAGES_MAX_DETACHED_STREAM_DRAINS, @@ -19,6 +19,7 @@ from litellm.llms.anthropic.common_utils import ANTHROPIC_ERROR_STATUS_CODE_MAP from litellm.proxy.pass_through_endpoints.success_handler import ( PassThroughEndpointLogging, ) +from litellm.rust_bridge.runtime import CoreEngine, execution_additional_headers from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType from litellm.types.utils import GenericStreamingChunk, ModelResponseStream @@ -86,22 +87,41 @@ def _decoded_sse_data_line(line: bytes) -> object | None: return None -def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None: +def _anthropic_event_payload(chunk: object, event_type: str) -> Mapping[str, object] | None: if isinstance(chunk, dict): - return chunk if chunk.get("type") == "error" else None + return chunk if chunk.get("type") == event_type else None if isinstance(chunk, (bytes, bytearray)): decoded_lines: Final = (_decoded_sse_data_line(line) for line in chunk.splitlines()) return next( ( candidate for candidate in decoded_lines - if isinstance(candidate, dict) and candidate.get("type") == "error" + if isinstance(candidate, dict) and candidate.get("type") == event_type ), None, ) return None +def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None: + return _anthropic_event_payload(chunk, "error") + + +def parse_anthropic_refusal_stop_details(chunk: object) -> Mapping[str, object] | None: + """ + Return the ``stop_details`` object of an Anthropic SSE ``message_delta`` + chunk whose delta carries ``stop_reason: "refusal"`` (a safeguard refusal: + https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback), + or None for any other chunk, a plain refusal without ``stop_details`` included. + """ + payload: Final = _anthropic_event_payload(chunk, "message_delta") + delta: Final = payload.get("delta") if payload is not None else None + if not isinstance(delta, dict) or delta.get("stop_reason") != "refusal": + return None + stop_details: Final = delta.get("stop_details") + return stop_details if isinstance(stop_details, dict) else None + + def _anthropic_error_body(chunk: object) -> Mapping[str, object] | None: """Return the ``error`` object of an Anthropic SSE ``event: error`` chunk, or None.""" payload: Final = _anthropic_error_event_payload(chunk) @@ -306,7 +326,8 @@ def _anthropic_content_block_events(index: int, block: Mapping[str, object]) -> class AnthropicMessagesStreamHiddenParams(TypedDict): - additional_headers: dict[str, str] + additional_headers: ReadOnly[Mapping[str, str]] + core_engine: ReadOnly[str] @runtime_checkable @@ -325,8 +346,10 @@ _RESPONSE_HEADERS_ADAPTER: Final[TypeAdapter[dict[str, str]]] = TypeAdapter(dict def anthropic_messages_stream_hidden_params( response_headers: httpx.Headers, ) -> AnthropicMessagesStreamHiddenParams: + additional_headers: Final = _RESPONSE_HEADERS_ADAPTER.validate_python(process_response_headers(response_headers)) return AnthropicMessagesStreamHiddenParams( - additional_headers=_RESPONSE_HEADERS_ADAPTER.validate_python(process_response_headers(response_headers)) + additional_headers=execution_additional_headers(additional_headers, CoreEngine.PYTHON), + core_engine=CoreEngine.PYTHON.value, ) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py index 02d82887dde..9deff950724 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py @@ -1,11 +1,40 @@ +from collections.abc import Mapping from functools import lru_cache -from typing import Any, Final, cast, get_type_hints +from typing import TYPE_CHECKING, Any, Final, cast, get_type_hints from litellm.types.llms.anthropic import AnthropicMessagesRequestOptionalParams from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) +if TYPE_CHECKING: + from litellm.exceptions import ContentPolicyViolationError + + +def get_safeguard_refusal_stop_details(response: object) -> Mapping[str, Any] | None: + """ + Return the ``stop_details`` of an Anthropic Messages response refused by a + safeguard (``stop_reason: "refusal"`` carrying ``stop_details``: + https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback), + or None for any other response, a plain refusal without ``stop_details`` included. + """ + if not isinstance(response, dict) or response.get("stop_reason") != "refusal": + return None + stop_details: Final = response.get("stop_details") + return stop_details if isinstance(stop_details, dict) else None + + +def safeguard_refusal_error(model: str, stop_details: Mapping[str, object]) -> "ContentPolicyViolationError": + """The exception a safeguard-refused Anthropic response converts into so the + content-policy fallback chain can re-dispatch it.""" + from litellm.exceptions import ContentPolicyViolationError + + return ContentPolicyViolationError( + message=f"Anthropic safeguard refusal (category: {stop_details.get('category')}).", + model=model, + llm_provider="anthropic", + ) + @lru_cache(maxsize=1) def _anthropic_messages_optional_param_keys() -> frozenset[str]: @@ -100,14 +129,12 @@ def mock_response( model=model, ) return AnthropicMessagesResponse( - **{ - "content": [{"text": mock_response, "type": "text"}], - "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", - "model": "claude-sonnet-4-20250514", - "role": "assistant", - "stop_reason": "end_turn", - "stop_sequence": None, - "type": "message", - "usage": {"input_tokens": 2095, "output_tokens": 503}, - } + content=[{"text": mock_response, "type": "text"}], + id="msg_013Zva2CMHLNnXjNJJKqJ2EF", + model="claude-sonnet-4-20250514", + role="assistant", + stop_reason="end_turn", + stop_sequence=None, + type="message", + usage={"input_tokens": 2095, "output_tokens": 503}, ) diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index 2df4ab731ab..0ac0662205a 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final from httpx._models import Headers, Response @@ -6,6 +8,7 @@ import litellm from litellm.litellm_core_utils.prompt_templates.common_utils import ( drop_tool_reference_parts_from_tool_messages, hoist_images_from_tool_messages, + tool_with_flattened_parameters, ) from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_azure_openai_messages, @@ -32,6 +35,19 @@ else: LoggingClass = Any +_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({}) + + +def flattened_tools_update(optional_params: Mapping[str, object]) -> Mapping[str, object]: + tools: Final = optional_params.get("tools") + if not isinstance(tools, list): + return _NO_TOOLS_UPDATE + flattened: Final = [ # mutable-ok: request tools are a JSON list + tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools + ] + return MappingProxyType({"tools": flattened}) + + class AzureOpenAIConfig(BaseConfig): """ Reference: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions @@ -261,6 +277,7 @@ class AzureOpenAIConfig(BaseConfig): "model": model, "messages": azure_messages, **optional_params, + **flattened_tools_update(optional_params), } def transform_response( diff --git a/litellm/llms/azure/chat/o_series_transformation.py b/litellm/llms/azure/chat/o_series_transformation.py index 6cbd91bab5d..246bf69cb5f 100644 --- a/litellm/llms/azure/chat/o_series_transformation.py +++ b/litellm/llms/azure/chat/o_series_transformation.py @@ -20,6 +20,7 @@ from litellm.types.llms.openai import AllMessageValues from litellm.utils import get_model_info, supports_reasoning from ...openai.chat.o_series_transformation import OpenAIOSeriesConfig +from .gpt_transformation import flattened_tools_update class AzureOpenAIO1Config(OpenAIOSeriesConfig): @@ -108,4 +109,8 @@ class AzureOpenAIO1Config(OpenAIOSeriesConfig): headers: dict, ) -> dict: model = model.replace("o_series/", "") # handle o_series/my-random-deployment-name - return super().transform_request(model, messages, optional_params, litellm_params, headers) + flattened_params: Final = { # mutable-ok: transform_request's contract takes a plain JSON params dict + **optional_params, + **flattened_tools_update(optional_params), + } + return super().transform_request(model, messages, flattened_params, litellm_params, headers) diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index b07a6b986d7..220fcedb0f8 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -155,8 +155,8 @@ class BaseTranslation(ABC): self, exc: "ModifyResponseException", stream_started: bool = False, - responses_so_far: list[Any] | None = None, - ) -> list[bytes] | None: + responses_so_far: Sequence[Any] | None = None, + ) -> Sequence[bytes] | None: """ Build the streaming chunks that deliver a guardrail block message and cleanly terminate the stream in this provider's wire format. diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index f09ee210e6c..9b6f9c47105 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -124,6 +124,61 @@ def blocked_responses_api_usage(original_response: object) -> ResponseAPIUsage: ) +def stream_item_field(item: object, field: str) -> object | None: + if isinstance(item, dict): + return item.get(field) + return getattr(item, field, None) + + +def blocked_chat_stream_usage(original_response: object) -> tuple[int, int]: + """ + ``(prompt_tokens, completion_tokens)`` for a synthetic guardrail-blocked + chat completions stream. + + A mid-stream block carries the chunks received so far as a list; real usage + rides on the final chunk when the upstream sent one + (``stream_options.include_usage``). Non-list originals defer to + ``blocked_response_usage``. + """ + if not isinstance(original_response, list): + usage: Final = blocked_response_usage(original_response) + return usage.get("input_tokens", 0), usage.get("output_tokens", 0) + usage_obj: Final = next( + ( + chunk_usage + for item in reversed(original_response) + if (chunk_usage := stream_item_field(item, "usage")) is not None + ), + None, + ) + return ( + _usage_tokens(usage_obj, "prompt_tokens", "input_tokens"), + _usage_tokens(usage_obj, "completion_tokens", "output_tokens"), + ) + + +def blocked_responses_stream_usage(original_response: object) -> ResponseAPIUsage: + """ + ``ResponseAPIUsage`` for a synthetic guardrail-blocked /v1/responses stream. + + A mid-stream block carries the events received so far as a list; real usage + rides on the ``response.completed`` event's response when the upstream sent + one. Non-list originals defer to ``blocked_responses_api_usage``. + """ + if not isinstance(original_response, list): + return blocked_responses_api_usage(original_response) + completed: Final = next( + ( + response + for item in reversed(original_response) + if stream_item_field(item, "type") == "response.completed" + and (response := stream_item_field(item, "response")) is not None + ), + None, + ) + return blocked_responses_api_usage(completed) + + def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool: per: Final = getattr(guardrail_to_apply, "skip_system_message_in_guardrail", None) if per is not None: diff --git a/litellm/llms/bedrock/audio_transcription/__init__.py b/litellm/llms/bedrock/audio_transcription/__init__.py index b1f8c957ff4..e1504a18f71 100644 --- a/litellm/llms/bedrock/audio_transcription/__init__.py +++ b/litellm/llms/bedrock/audio_transcription/__init__.py @@ -5,6 +5,7 @@ import httpx from litellm.litellm_core_utils.audio_utils.utils import process_audio_file from litellm.rust_bridge import transcription as rust_transcription_bridge +from litellm.rust_bridge.runtime import CoreEngine, execution_hidden_params from litellm.types.utils import FileTypes, TranscriptionResponse @@ -55,7 +56,9 @@ class BedrockAudioTranscriptionRustDispatch: ) if rust_response is None: raise RuntimeError("Rust audio transcription bridge is unavailable") - return TranscriptionResponse(**rust_response) + response: Final = TranscriptionResponse(**rust_response) + response["_hidden_params"] = execution_hidden_params(None, CoreEngine.RUST) + return response async def async_audio_transcriptions( self, @@ -81,4 +84,6 @@ class BedrockAudioTranscriptionRustDispatch: ) if rust_response is None: raise RuntimeError("Rust audio transcription bridge is unavailable") - return TranscriptionResponse(**rust_response) + response: Final = TranscriptionResponse(**rust_response) + response["_hidden_params"] = execution_hidden_params(None, CoreEngine.RUST) + return response diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 852cfaa24f2..1e634ced29b 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -1442,7 +1442,7 @@ class BaseAWSLLM: @tracer.wrap() def get_request_headers( self, - credentials: Credentials, + credentials: Credentials | None, aws_region_name: str, extra_headers: dict | None, endpoint_url: str, @@ -1469,9 +1469,13 @@ class BaseAWSLLM: try: from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest + from botocore.exceptions import NoCredentialsError except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") + if credentials is None: + raise NoCredentialsError() + # Filter headers for AWS signature calculation # AWS SigV4 only includes specific headers in signature calculation aws_signature_headers: Final = self._filter_headers_for_aws_signature(headers) diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index ca5f1298360..7d6a4d3e6a1 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -1,4 +1,6 @@ import json +from collections.abc import Mapping +from types import MappingProxyType from typing import Any, Final import httpx @@ -24,6 +26,22 @@ from ..common_utils import BedrockError, _get_all_bedrock_regions from .invoke_handler import AWSEventStreamDecoder, MockResponseIterator, make_call +def _sigv4_principal(credentials: Credentials | None) -> Mapping[str, str]: + if credentials is None: + return MappingProxyType({}) + return MappingProxyType( + { + key: value + for key, value in ( + ("aws_access_key_id", credentials.access_key), + ("aws_secret_access_key", credentials.secret_key), + ("aws_session_token", credentials.token), + ) + if value is not None + } + ) + + def make_sync_call( client: HTTPHandler | None, api_base: str, @@ -95,7 +113,7 @@ class BedrockConverseLLM(BaseAWSLLM): stream, optional_params: dict, litellm_params: dict, - credentials: Credentials, + credentials: Credentials | None, logger_fn=None, headers={}, client: AsyncHTTPHandler | None = None, @@ -167,7 +185,7 @@ class BedrockConverseLLM(BaseAWSLLM): stream, optional_params: dict, litellm_params: dict, - credentials: Credentials, + credentials: Credentials | None, logger_fn=None, headers: dict = {}, client: AsyncHTTPHandler | None = None, @@ -331,7 +349,7 @@ class BedrockConverseLLM(BaseAWSLLM): litellm_params["aws_region_name"] = aws_region_name # [DO NOT DELETE] important for async calls - credentials: Final[Credentials] = self.get_credentials( + credentials: Final[Credentials | None] = self.get_credentials( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, aws_session_token=aws_session_token, @@ -365,22 +383,101 @@ class BedrockConverseLLM(BaseAWSLLM): # Filter beta headers in HTTP headers before making the request headers = update_headers_with_filtered_beta(headers=headers, provider="bedrock_converse") + def sync_python_path(*, skip_pre_call_logging: bool) -> ModelResponse | CustomStreamWrapper: + request_data: Final = litellm.AmazonConverseConfig()._transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=extra_headers, + ) + serialized_data: Final = json.dumps(request_data) + prepped: Final = self.get_request_headers( + credentials=credentials, + aws_region_name=aws_region_name, + extra_headers=extra_headers, + endpoint_url=proxy_endpoint_url, + data=serialized_data, + headers=headers, + api_key=api_key, + ) + if not skip_pre_call_logging: + logging_obj.pre_call( + input=messages, + api_key="", + additional_args={ # mutable-ok: logging callback contract requires a mutable dict + "complete_input_dict": serialized_data, + "api_base": proxy_endpoint_url, + "headers": prepped.headers, + }, + ) + request_timeout: Final = ( + httpx.Timeout(timeout) if isinstance(timeout, float) or isinstance(timeout, int) else timeout + ) + request_client: Final = ( + _get_httpx_client( # mutable-ok: HTTP client factory requires a mutable options dict + {"timeout": request_timeout} if request_timeout is not None else {} + ) + if client is None or isinstance(client, AsyncHTTPHandler) + else client + ) + if stream is not None and stream is True: + completion_stream, response_headers = make_sync_call( + client=request_client if isinstance(request_client, HTTPHandler) else None, + api_base=proxy_endpoint_url, + headers=prepped.headers, + data=serialized_data, + model=model, + messages=messages, + logging_obj=logging_obj, + json_mode=json_mode, + fake_stream=fake_stream, + stream_chunk_size=stream_chunk_size, + ) + return CustomStreamWrapper( + completion_stream=completion_stream, + model=model, + custom_llm_provider="bedrock", + logging_obj=logging_obj, + _response_headers=response_headers, + ) + try: + response: Final = request_client.post( + url=proxy_endpoint_url, + headers=prepped.headers, + data=serialized_data, + logging_obj=logging_obj, + ) + response.raise_for_status() + except httpx.HTTPStatusError as error: + raise BedrockError(status_code=error.response.status_code, message=error.response.text) + except httpx.TimeoutException: + raise BedrockError(status_code=408, message="Timeout error occurred.") + transformed: Final = litellm.AmazonConverseConfig()._transform_response( + model=model, + response=response, + model_response=model_response, + stream=stream if isinstance(stream, bool) else False, + logging_obj=logging_obj, + api_key="", + data=serialized_data, + messages=messages, + optional_params=optional_params, + encoding=encoding, + ) + transformed.set_provider_response_headers(response.headers) + return transformed + # The Rust core owns the whole call for the subset it accepts. Ask # before transforming so whichever path runs emits pre_call once, and # hand down the credentials, region and endpoint this handler already - # resolved so both paths sign as the same principal. + # resolved so both paths sign as the same principal. Bearer-token auth + # resolves no SigV4 principal at all, and each path reads that token + # itself. rust_optional_params: Final = { # mutable-ok: json.dumps in the bridge rejects a mappingproxy **optional_params, - **{ # mutable-ok: merged into its mutable parent above - key: value - for key, value in ( - ("aws_access_key_id", credentials.access_key), - ("aws_secret_access_key", credentials.secret_key), - ("aws_session_token", credentials.token), - ("aws_region_name", aws_region_name), - ) - if value is not None - }, + **_sigv4_principal(credentials), + "aws_region_name": aws_region_name, } serves_via_rust: Final = rust_chat_completions_accepts( model=model, @@ -437,7 +534,7 @@ class BedrockConverseLLM(BaseAWSLLM): skip_pre_call_logging=True, ), ) - rust_response: Final = rust_chat_completions_bridge.chat_completions( + return rust_chat_completions_bridge.chat_completions_or_fallback( model=model, messages=messages, optional_params=rust_optional_params, @@ -448,9 +545,8 @@ class BedrockConverseLLM(BaseAWSLLM): extra_headers=headers, timeout=timeout, on_response=log_rust_post_call, + python_fallback=lambda: sync_python_path(skip_pre_call_logging=True), ) - if rust_response is not None: - return rust_response ### ROUTING (ASYNC, STREAMING, SYNC) if acompletion: @@ -496,103 +592,4 @@ class BedrockConverseLLM(BaseAWSLLM): api_key=api_key, ) - ## TRANSFORMATION ## - - _data: Final = litellm.AmazonConverseConfig()._transform_request( - model=model, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - headers=extra_headers, - ) - data: Final = json.dumps(_data) - - prepped: Final = self.get_request_headers( - credentials=credentials, - aws_region_name=aws_region_name, - extra_headers=extra_headers, - endpoint_url=proxy_endpoint_url, - data=data, - headers=headers, - api_key=api_key, - ) - - ## LOGGING - # Reaching here with `serves_via_rust` set means the synchronous Rust - # attempt declined at call time, before the provider was called, and - # already logged this request. That is the same attempt continuing. - # The asynchronous branch above returns before this point, and hands - # its own fallback `skip_pre_call_logging=True` for the same reason. - if not serves_via_rust: - logging_obj.pre_call( - input=messages, - api_key="", - additional_args={ - "complete_input_dict": data, - "api_base": proxy_endpoint_url, - "headers": prepped.headers, - }, - ) - if client is None or isinstance(client, AsyncHTTPHandler): - _params: Final = {} - if timeout is not None: - if isinstance(timeout, float) or isinstance(timeout, int): - timeout = httpx.Timeout(timeout) - _params["timeout"] = timeout - client = _get_httpx_client(_params) - else: - client = client - - if stream is not None and stream is True: - completion_stream, response_headers = make_sync_call( - client=(client if client is not None and isinstance(client, HTTPHandler) else None), - api_base=proxy_endpoint_url, - headers=prepped.headers, - data=data, - model=model, - messages=messages, - logging_obj=logging_obj, - json_mode=json_mode, - fake_stream=fake_stream, - stream_chunk_size=stream_chunk_size, - ) - streaming_response: Final = CustomStreamWrapper( - completion_stream=completion_stream, - model=model, - custom_llm_provider="bedrock", - logging_obj=logging_obj, - _response_headers=response_headers, - ) - - return streaming_response - - ### COMPLETION - - try: - response: Final = client.post( - url=proxy_endpoint_url, - headers=prepped.headers, - data=data, - logging_obj=logging_obj, - ) - response.raise_for_status() - except httpx.HTTPStatusError as err: - error_code: Final = err.response.status_code - raise BedrockError(status_code=error_code, message=err.response.text) - except httpx.TimeoutException: - raise BedrockError(status_code=408, message="Timeout error occurred.") - - sync_transformed_response: Final = litellm.AmazonConverseConfig()._transform_response( - model=model, - response=response, - model_response=model_response, - stream=stream if isinstance(stream, bool) else False, - logging_obj=logging_obj, - api_key="", - data=data, - messages=messages, - optional_params=optional_params, - encoding=encoding, - ) - sync_transformed_response.set_provider_response_headers(response.headers) - return sync_transformed_response + return sync_python_path(skip_pre_call_logging=False) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 395d99a4caa..5363c3c0366 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -87,6 +87,7 @@ from ..common_utils import ( BedrockError, BedrockModelInfo, bedrock_converse_supports_parallel_tool_use_config, + bedrock_model_accepts_cache_points, get_anthropic_beta_from_headers, get_bedrock_tool_name, is_bedrock_application_inference_profile_arn, @@ -588,6 +589,10 @@ class AmazonConverseConfig(BaseConfig): supported_params.append("context_management") return supported_params + @staticmethod + def _auto_tool_choice() -> ToolChoiceValuesBlock: + return ToolChoiceValuesBlock(auto={}) + def map_tool_choice_values( self, model: str, tool_choice: str | dict, drop_params: bool ) -> ToolChoiceValuesBlock | None: @@ -600,10 +605,14 @@ class AmazonConverseConfig(BaseConfig): status_code=400, ) elif tool_choice == "required": + if AnthropicModelInfo.forced_tool_use_downgraded(model, drop_params): + return self._auto_tool_choice() return ToolChoiceValuesBlock(any={}) elif tool_choice == "auto": - return ToolChoiceValuesBlock(auto={}) + return self._auto_tool_choice() elif isinstance(tool_choice, dict): + if AnthropicModelInfo.forced_tool_use_downgraded(model, drop_params): + return self._auto_tool_choice() # only supported for anthropic + mistral models - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolChoice.html specific_tool: Final = SpecificToolChoiceBlock( name=make_valid_bedrock_tool_name(tool_choice.get("function", {}).get("name", "")) @@ -1065,6 +1074,7 @@ class AmazonConverseConfig(BaseConfig): if ( litellm.utils.supports_tool_choice(model=model, custom_llm_provider=self.custom_llm_provider) and not is_thinking_enabled + and not AnthropicModelInfo.forced_tool_use_unsupported(model) ): optional_params["tool_choice"] = ToolChoiceValuesBlock( tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME) @@ -1140,7 +1150,7 @@ class AmazonConverseConfig(BaseConfig): model: str | None = None, ) -> SystemContentBlock | ContentBlock | None: cache_control: Final = message_block.get("cache_control", None) - if cache_control is None: + if cache_control is None or not bedrock_model_accepts_cache_points(model): return None cache_point: Final = self._build_cache_point_block(cache_control, model) @@ -1604,7 +1614,7 @@ class AmazonConverseConfig(BaseConfig): # Append cachePoint to tools if cache_control_injection_points has tool_config cache_injection_points: Final = additional_request_params.pop("cache_control_injection_points", None) - if cache_injection_points and len(bedrock_tools) > 0: + if cache_injection_points and len(bedrock_tools) > 0 and bedrock_model_accepts_cache_points(model): for point in cache_injection_points: if point.get("location") == "tool_config": cache_point = self._build_cache_point_block(point.get("control"), model) 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 8e709349400..2a4c38e71ea 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Final import httpx from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers +from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_anthropic_image_obj, ) @@ -11,6 +12,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( convert_url_to_base64, ) from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import ( AmazonInvokeConfig, ) @@ -74,10 +76,14 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): drop_params: bool, ) -> dict: # Force tool-based structured outputs for Bedrock Invoke - # (similar to VertexAI fix in #19201) - # Bedrock Invoke doesn't support output_format parameter + # (similar to VertexAI fix in #19201) unless the model map advertises + # native structured output + from litellm.utils import supports_native_structured_output + original_model: Final = model - if "response_format" in non_default_params: + if "response_format" in non_default_params and not supports_native_structured_output( + model=model, custom_llm_provider="bedrock" + ): # Use a model name that forces tool-based approach model = "claude-3-sonnet-20240229" @@ -101,6 +107,16 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): # Restore original model name model = original_model + # The stub model hides the original model from the parent's forced-tool-use backstop + response_format_tool_choice: Final = optional_params.get("tool_choice") + if ( + "response_format" in non_default_params + and isinstance(response_format_tool_choice, dict) + and response_format_tool_choice.get("name") == RESPONSE_FORMAT_TOOL_NAME + and AnthropicModelInfo.forced_tool_use_unsupported(original_model) + ): + optional_params.pop("tool_choice") + return optional_params @staticmethod diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 30a77d57f24..66ee5f10679 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -816,6 +816,30 @@ def bedrock_converse_supports_parallel_tool_use_config(model: str) -> bool: ) +def bedrock_model_accepts_cache_points(model: str | None) -> bool: + """ + Whether Converse ``cachePoint`` blocks may be sent to this model. + + Bedrock rejects requests carrying cachePoint blocks for models without prompt + caching support ("You invoked an unsupported model or your request did not allow + prompt caching"), so a model whose cost-map entry does not declare + ``supports_prompt_caching`` must not receive them. A model absent from the map + (an application inference profile ARN, a model newer than the map) keeps emitting + so existing caching setups never silently degrade. ``litellm.utils.supports_prompt_caching`` + is not reusable here: it returns False for unmapped models, the opposite polarity. + """ + if model is None: + return True + entries: Final = tuple( + entry + for candidate in (model, get_bedrock_base_model(model)) + if (entry := litellm.model_cost.get(candidate)) is not None + ) + if not entries: + return True + return any(entry.get("supports_prompt_caching") is True for entry in entries) + + def is_claude_4_5_on_bedrock(model: str) -> bool: """ Check if the model supports Bedrock prompt caching with an extended '1h' TTL diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index b6586481fd3..73adf9c7455 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -3,6 +3,7 @@ import concurrent.futures import contextlib import os import ssl +import sys import typing import urllib.request from collections.abc import Callable, Generator @@ -75,10 +76,22 @@ except ImportError: pass +def _current_task_is_cancelling() -> bool: + task: Final = asyncio.current_task() + if task is None or sys.version_info < (3, 11): + return True + return task.cancelling() > 0 + + @contextlib.contextmanager def map_aiohttp_exceptions() -> Generator[None, None, None]: try: yield + except asyncio.CancelledError as exc: + # a closing connector cancels its shielded DNS task; that surfaces here without the request task being cancelled + if _current_task_is_cancelling(): + raise + raise httpx.ConnectError("aiohttp transport cancelled the request internally") from exc except Exception as exc: mapped_exc: type[Exception] | None = None diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 834f7d564a2..075ea86cb80 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1,6 +1,5 @@ import asyncio import json -import os import ssl from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequence from contextlib import asynccontextmanager @@ -89,6 +88,7 @@ from litellm.responses.streaming_iterator import ( ResponsesWebSocketStreaming, SyncResponsesAPIStreamingIterator, ) +from litellm.rust_bridge.runtime import CoreEngine, execution_additional_headers, execution_hidden_params from litellm.types.containers.main import ( ContainerFileListResponse, ContainerListResponse, @@ -159,7 +159,27 @@ def _rust_responses_websocket_enabled( custom_llm_provider: str | None, litellm_params: GenericLiteLLMParams, ) -> bool: - return custom_llm_provider == "openai" and litellm_params.get("rust") is True + from litellm.rust_bridge.configuration import rust_enabled + + raw_request_override: Final = litellm_params.get("rust") + request_override: Final = raw_request_override if isinstance(raw_request_override, bool) else None + if custom_llm_provider != "openai" or not rust_enabled(request_override=request_override): + return False + from litellm.rust_bridge.streaming import supports_streaming + + return supports_streaming("responses", custom_llm_provider, "websocket") + + +def _anthropic_messages_with_core_engine( + response: AnthropicMessagesResponse, + source: CoreEngine, +) -> AnthropicMessagesResponse: + existing_hidden_params: Final = response.get("_hidden_params") + response["_hidden_params"] = execution_hidden_params( + existing_hidden_params if isinstance(existing_hidden_params, dict) else None, + source, + ) + return response from .http_handler import get_shared_realtime_ssl_context @@ -2357,16 +2377,25 @@ class BaseLLMHTTPHandler: kwargs=kwargs_for_agentic, ) + selected_response: Final = final_response if final_response is not None else initial_response + if not isinstance(selected_response, dict): + return self._maybe_wrap_in_fake_stream( + selected_response, + logging_obj, + "anthropic_messages", + ) + typed_response: Final = cast(AnthropicMessagesResponse, selected_response) + existing_hidden_params: Final = typed_response.get("_hidden_params") + existing_source: Final = ( + existing_hidden_params.get("core_engine") if isinstance(existing_hidden_params, dict) else None + ) + source: Final = CoreEngine.RUST if existing_source == CoreEngine.RUST.value else CoreEngine.PYTHON return self._maybe_wrap_in_fake_stream( - final_response if final_response is not None else initial_response, + _anthropic_messages_with_core_engine(typed_response, source), logging_obj, "anthropic_messages", ) - @staticmethod - def _rust_env_enabled() -> bool: - return os.getenv("LITELLM_RUST", "").strip().lower() in {"1", "true", "yes", "on"} - @staticmethod async def _maybe_rust_anthropic_messages( *, @@ -2382,7 +2411,11 @@ class BaseLLMHTTPHandler: ) -> AnthropicMessagesResponse | None: if custom_llm_provider not in ("azure_ai", "anthropic"): return None - if litellm_params.get("rust") is not True and not BaseLLMHTTPHandler._rust_env_enabled(): + from litellm.rust_bridge.configuration import rust_enabled + + raw_request_override: Final = litellm_params.get("rust") + request_override: Final = raw_request_override if isinstance(raw_request_override, bool) else None + if not rust_enabled(request_override=request_override): return None if has_agentic_hook: return None @@ -2390,28 +2423,20 @@ class BaseLLMHTTPHandler: from litellm.rust_bridge import messages as rust_messages_bridge upstream_body: Final = {key: value for key, value in request_body.items() if key != "stream"} - try: - rust_response: Final = await rust_messages_bridge.amessages( - model=model, - body=upstream_body, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=headers, - timeout=timeout, - ) - except Exception as rust_error: # noqa: BLE001 # rollout-safety fallback: any Rust bridge failure must fall back to the Python path - verbose_logger.debug( - "Rust Anthropic messages bridge raised %s; falling back to Python path", - type(rust_error).__name__, - ) - return None + rust_response: Final = await rust_messages_bridge.amessages( + model=model, + body=upstream_body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=headers, + timeout=timeout, + ) if rust_response is None: return None response_obj: Final = cast(AnthropicMessagesResponse, dict(rust_response)) - response_obj["_hidden_params"] = {"additional_headers": {"x-litellm-rust": "true"}} - return response_obj + return _anthropic_messages_with_core_engine(response_obj, CoreEngine.RUST) @staticmethod def _rust_anthropic_messages_fake_stream( @@ -2426,7 +2451,10 @@ class BaseLLMHTTPHandler: ) completion_stream = cast(AsyncIterator[bytes], FakeAnthropicMessagesStreamIterator(response=rust_response)) - hidden_params: Final = AnthropicMessagesStreamHiddenParams(additional_headers={"x-litellm-rust": "true"}) + hidden_params: Final = AnthropicMessagesStreamHiddenParams( + additional_headers=execution_additional_headers(None, CoreEngine.RUST), + core_engine=CoreEngine.RUST.value, + ) return AnthropicMessagesStreamingResponse( completion_stream=completion_stream, hidden_params=hidden_params, @@ -6487,11 +6515,14 @@ class BaseLLMHTTPHandler: if _rust_responses_websocket_enabled(custom_llm_provider, litellm_params): from litellm.rust_bridge import responses_websocket as rust_responses_websocket - rust_backend: Final = await rust_responses_websocket.connect( - url=ws_url, + rust_execution: Final = await rust_responses_websocket.connect( + provider="openai", + api_key=api_key, + api_base=api_base, headers={str(key): str(value) for key, value in headers.items()}, timeout=timeout, ) + rust_backend: Final = rust_execution.value if rust_backend is not None: yield rust_backend return diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 3a7f78fd5ba..9afc6331d96 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -4,7 +4,8 @@ Support for gpt model family import json import os -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast, overload from urllib.parse import urlparse @@ -21,6 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( drop_tool_reference_parts_from_tool_messages, get_tool_call_names, hoist_images_from_tool_messages, + tool_with_flattened_parameters, ) from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_convert_url_to_base64, @@ -65,6 +67,9 @@ else: LiteLLMLoggingObj = Any +_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({}) + + class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): """ Reference: https://platform.openai.com/docs/api-reference/chat/create @@ -397,6 +402,21 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): ) return messages, tools + def _targets_openai_hosted_endpoint( + self, + custom_llm_provider: str | None, + api_base: str | None, + ) -> bool: + if custom_llm_provider != "openai": + return False + resolved_api_base = api_base or litellm.api_base or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") + if not resolved_api_base: + return True + hostname: Final = urlparse(resolved_api_base).hostname + if hostname is None: + return True + return hostname == "openai.com" or hostname.endswith(".openai.com") + def _should_preserve_cache_control_for_endpoint( self, custom_llm_provider: str | None, @@ -408,15 +428,34 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): api_base. Those can understand cache_control, so it must survive there. Real OpenAI cannot, so it is still stripped for an openai.com host. """ - if custom_llm_provider != "openai": - return False - resolved_api_base = api_base or litellm.api_base or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") - if not resolved_api_base: - return False - hostname: Final = urlparse(resolved_api_base).hostname - if hostname is None: - return False - return hostname != "openai.com" and not hostname.endswith(".openai.com") + return custom_llm_provider == "openai" and not self._targets_openai_hosted_endpoint( + custom_llm_provider, api_base + ) + + def _flattened_tools_update_for_openai( + self, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + ) -> Mapping[str, object]: + """ + OpenAI's chat completions validator rejects tool `parameters` carrying + 'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level for every + model family, unlike the Responses API, where GPT-5+ accepts them. + """ + tools: Final = optional_params.get("tools") + if not isinstance(tools, list): + return _NO_TOOLS_UPDATE + provider: Final = litellm_params.get("custom_llm_provider") + raw_api_base: Final = litellm_params.get("api_base") + if not self._targets_openai_hosted_endpoint( + provider if isinstance(provider, str) else None, + raw_api_base if isinstance(raw_api_base, str) else None, + ): + return _NO_TOOLS_UPDATE + flattened: Final = [ # mutable-ok: request tools are a JSON list + tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools + ] + return MappingProxyType({"tools": flattened}) def transform_request( self, @@ -443,11 +482,14 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): optional_params["tools"] = tools optional_params.pop("max_retries", None) + if not optional_params.get("tools") and not optional_params.get("functions"): + optional_params.pop("tool_choice", None) return { "model": model, "messages": messages, **optional_params, + **self._flattened_tools_update_for_openai(optional_params, litellm_params), } async def async_transform_request( @@ -473,10 +515,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): if tools is not None and len(tools) > 0: optional_params["tools"] = tools if self.__class__._is_base_class: + if not optional_params.get("tools") and not optional_params.get("functions"): + optional_params.pop("tool_choice", None) return { "model": model, "messages": transformed_messages, **optional_params, + **self._flattened_tools_update_for_openai(optional_params, litellm_params), } else: ## allow for any object specific behaviour to be handled diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index ed628f55350..96a5ed663fc 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -14,9 +14,14 @@ Pattern Overview: This pattern can be replicated for other message formats (e.g., Anthropic). """ +import json +import time +import uuid from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final, Union, cast +from typing_extensions import NotRequired, ReadOnly, TypedDict + import litellm from litellm._logging import verbose_proxy_logger from litellm.llms.base_llm.guardrail_translation.base_translation import ( @@ -24,6 +29,7 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import ( StreamTransformSink, ) from litellm.llms.base_llm.guardrail_translation.utils import ( + blocked_chat_stream_usage, effective_scan_only_tool_results_for_guardrail, effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, @@ -32,6 +38,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( openai_tool_name, role_out_of_guardrail_scope, scoped_structured_message_indices, + stream_item_field, ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -49,7 +56,10 @@ from litellm.types.utils import ( if TYPE_CHECKING: from fastapi import HTTPException - from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, + ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth @@ -1005,3 +1015,129 @@ class OpenAIChatCompletionsHandler(BaseTranslation): else: # Subsequent chunks - clear the text content_item["text"] = "" + + def _check_streaming_has_ended(self, responses_so_far: Sequence[object]) -> bool: + """ + True once any relayed chunk carries a non-null ``finish_reason``. + + The unified guardrail's ``end_of_stream_only`` streaming path probes + this via ``hasattr`` to withhold the terminal chunks until + end-of-stream moderation runs, so a block can replace the finish + instead of trailing after a ``finish_reason`` the client already saw. + """ + return any( + stream_item_field(choice, "finish_reason") is not None + for item in responses_so_far + for choice in _stream_chunk_choices(item) + ) + + def build_block_sse_chunks( + self, + exc: "ModifyResponseException", + stream_started: bool = False, + responses_so_far: Sequence[object] | None = None, + ) -> Sequence[bytes]: + """ + Build OpenAI chat-completions SSE chunks that deliver the guardrail + block message and terminate the stream cleanly, mirroring the + non-streaming block response: ``finish_reason`` ``content_filter`` plus + the real usage the upstream call consumed. + + - ``stream_started`` False (buffered / pre-stream): nothing has been + sent, so open a standalone completion with a ``role`` delta. + - ``stream_started`` True (sampling / mid-stream): chunks already + reached the client, so continue the in-progress completion (reuse its + id/created/model, content-only delta). + + The proxy's data generator appends ``data: [DONE]`` itself. + """ + chunk_id, created, model = _blocked_stream_identity(exc, responses_so_far or ()) + prompt_tokens, completion_tokens = blocked_chat_stream_usage(exc.original_response) + continuation_delta: Final[_BlockedChunkDelta] = {"content": exc.message} + standalone_delta: Final[_BlockedChunkDelta] = {"role": "assistant", "content": exc.message} + message_chunk: Final[_BlockedChunk] = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": ( + { + "index": 0, + "delta": continuation_delta if stream_started else standalone_delta, + "finish_reason": None, + }, + ), + } + final_chunk: Final[_BlockedChunk] = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": ({"index": 0, "delta": {}, "finish_reason": "content_filter"},), + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + return _chat_sse_chunk(message_chunk), _chat_sse_chunk(final_chunk) + + +class _BlockedChunkDelta(TypedDict, total=False): + role: ReadOnly[str] + content: ReadOnly[str] + + +class _BlockedChunkChoice(TypedDict): + index: ReadOnly[int] + delta: ReadOnly[_BlockedChunkDelta] + finish_reason: ReadOnly[str | None] + + +class _BlockedChunkUsage(TypedDict): + prompt_tokens: ReadOnly[int] + completion_tokens: ReadOnly[int] + total_tokens: ReadOnly[int] + + +class _BlockedChunk(TypedDict): + id: ReadOnly[str] + object: ReadOnly[str] + created: ReadOnly[int] + model: ReadOnly[str] + choices: ReadOnly[tuple[_BlockedChunkChoice, ...]] + usage: NotRequired[ReadOnly[_BlockedChunkUsage]] + + +def _chat_sse_chunk(payload: _BlockedChunk) -> bytes: + return f"data: {json.dumps(payload)}\n\n".encode() + + +def _stream_chunk_choices(item: object) -> Sequence[object]: + choices: Final = stream_item_field(item, "choices") + if isinstance(choices, Sequence) and not isinstance(choices, (str, bytes)): + return choices + return () + + +def _blocked_stream_identity( + exc: "ModifyResponseException", responses_so_far: Sequence[object] +) -> tuple[str, int, str]: + identified: Final = next( + ( + (chunk_id, item) + for item in responses_so_far + if isinstance(chunk_id := stream_item_field(item, "id"), str) and chunk_id + ), + None, + ) + if identified is None: + return f"chatcmpl-{uuid.uuid4()}", int(time.time()), exc.model + chunk_id, source = identified + created: Final = stream_item_field(source, "created") + model: Final = stream_item_field(source, "model") + return ( + chunk_id, + created if isinstance(created, int) else int(time.time()), + model if isinstance(model, str) and model else exc.model, + ) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 24dbd7c08d0..1530c154e93 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -28,12 +28,16 @@ Output: response.output is List[GenericResponseOutputItem] where each has: - text: str """ -from collections.abc import Sequence +import time +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Union, cast from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall from openai.types.responses.tool_param import FunctionToolParam -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger @@ -41,17 +45,33 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i OpenAiResponsesToChatCompletionStreamIterator, ) from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.utils import ( + blocked_responses_stream_usage, + stream_item_field, +) from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) from litellm.types.llms.openai import ( AllMessageValues, + BaseLiteLLMOpenAIResponseObject, ChatCompletionToolCallChunk, ChatCompletionToolParam, + ContentPartAddedEvent, + ContentPartDoneEvent, + ContentPartDonePartOutputText, ErrorEvent, ErrorEventError, OpenAIMcpServerTool, + OutputItemAddedEvent, + OutputItemDoneEvent, + OutputTextDeltaEvent, + OutputTextDoneEvent, + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, ResponsesAPIStreamEvents, + ResponsesAPIStreamingResponse, ) from litellm.types.responses.main import ( GenericResponseOutputItem, @@ -63,11 +83,13 @@ from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: from fastapi import HTTPException - from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, + ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth from litellm.types.llms.openai import ResponseInputParam - from litellm.types.utils import ResponsesAPIResponse class ResponseOutputEnvelope(TypedDict, total=False): @@ -82,6 +104,10 @@ class ResponsesStreamChunk(TypedDict, total=False): type: ReadOnly[str] text: ReadOnly[str] + delta: ReadOnly[str] + item_id: ReadOnly[str] + output_index: ReadOnly[int] + content_index: ReadOnly[int] def _next_stream_sequence_number(responses_so_far: Sequence[Any] | None) -> int: @@ -658,8 +684,32 @@ class OpenAIResponsesHandler(BaseTranslation): def get_streaming_string_so_far(self, responses_so_far: Sequence[ResponsesStreamChunk]) -> str: """ Get the string so far from the responses so far. + + ``response.output_text.done`` events carry the whole part in ``text``, while + ``response.output_text.delta`` events carry fragments in ``delta``. A stream + that dies before its done event (``response.failed`` / ``response.incomplete``) + has text only in deltas, so per content part the done text wins when present + and the joined deltas fill in otherwise, never both. """ - return "".join([response.get("text", "") for response in responses_so_far]) + keyed_events: Final = tuple( + ( + (event.get("item_id"), event.get("output_index"), event.get("content_index")), + event.get("text"), + event.get("delta"), + ) + for event in responses_so_far + if isinstance(event.get("text"), str) or isinstance(event.get("delta"), str) + ) + + def part_text(part_key: tuple[object, object, object]) -> str: + done_texts: Final = tuple( + text for key, text, _ in keyed_events if key == part_key and isinstance(text, str) + ) + if done_texts: + return done_texts[-1] + return "".join(delta for key, _, delta in keyed_events if key == part_key and isinstance(delta, str)) + + return "".join(part_text(key) for key in dict.fromkeys(key for key, _, _ in keyed_events)) def _has_text_content(self, response: "ResponsesAPIResponse") -> bool: """ @@ -837,3 +887,331 @@ class OpenAIResponsesHandler(BaseTranslation): content[content_idx]["text"] = guardrail_response elif hasattr(content[content_idx], "text"): content[content_idx].text = guardrail_response + + def build_block_sse_chunks( + self, + exc: "ModifyResponseException", + stream_started: bool = False, + responses_so_far: Sequence[object] | None = None, + ) -> Sequence[bytes]: + """ + Build Responses API SSE events that deliver the guardrail block message + and terminate the stream cleanly, mirroring the non-streaming block + response: a completed response whose only output is the violation text, + with the real usage the upstream call consumed. + + - ``stream_started`` False (buffered / pre-stream): nothing has been + sent, so emit the full synthetic sequence (``response.created`` + through ``response.completed``). + - ``stream_started`` True (sampling / mid-stream): events already + reached the client, so continue the in-progress response: close the + output item still open on the wire, deliver the block message as a + new output item under the same response id, and close with a + ``response.completed`` carrying only the replacement item. + + The proxy's data generator appends ``data: [DONE]`` itself. + """ + events: Final = ( + self._block_continuation_events(exc, responses_so_far or ()) + if stream_started + else self._standalone_block_events(exc) + ) + return tuple( + f"data: {event.model_dump_json(exclude_none=True, exclude_unset=True, serialize_as_any=True)}\n\n".encode() + for event in events + ) + + @staticmethod + def _standalone_block_events(exc: "ModifyResponseException") -> Sequence[ResponsesAPIStreamingResponse]: + from litellm.responses.streaming_iterator import build_synthetic_response_events + + return build_synthetic_response_events( + transformed=_blocked_response(exc, response_id=f"resp_{uuid.uuid4()}", model=exc.model), + logging_obj=None, + chunk_size=max(len(exc.message), 1), + ) + + @staticmethod + def _block_continuation_events( + exc: "ModifyResponseException", responses_so_far: Sequence[object] + ) -> Sequence[ResponsesAPIStreamingResponse]: + response_id, model, output_index = _continuation_identity(exc, responses_so_far) + item: Final = _blocked_output_item(exc) + item_id: Final = item.id + part: Final[_BlockedContentPart] = {"type": "output_text", "text": exc.message, "annotations": ()} + done_part: Final[_BlockedDoneContentPart] = { + "type": "output_text", + "text": exc.message, + "annotations": (), + "logprobs": None, + } + return ( + *_open_item_closing_events(responses_so_far), + OutputItemAddedEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + output_index=output_index, + item=item, + ), + ContentPartAddedEvent( + type=ResponsesAPIStreamEvents.CONTENT_PART_ADDED, + item_id=item_id, + output_index=output_index, + content_index=0, + part=BaseLiteLLMOpenAIResponseObject.model_validate(part), + ), + OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id=item_id, + output_index=output_index, + content_index=0, + delta=exc.message, + ), + OutputTextDoneEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE, + item_id=item_id, + output_index=output_index, + content_index=0, + text=exc.message, + ), + ContentPartDoneEvent( + type=ResponsesAPIStreamEvents.CONTENT_PART_DONE, + item_id=item_id, + output_index=output_index, + content_index=0, + part=ContentPartDonePartOutputText.model_validate(done_part), + ), + OutputItemDoneEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + output_index=output_index, + item=item, + ), + ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=_blocked_response(exc, response_id=response_id, model=model, output_item=item), + ), + ) + + +class _BlockedContentPart(TypedDict): + type: ReadOnly[str] + text: ReadOnly[str] + annotations: ReadOnly[tuple[object, ...]] + + +class _BlockedDoneContentPart(TypedDict): + type: ReadOnly[str] + text: ReadOnly[str] + annotations: ReadOnly[tuple[object, ...]] + logprobs: ReadOnly[None] + + +class _BlockedItemPayload(TypedDict): + type: ReadOnly[str] + id: ReadOnly[str] + status: ReadOnly[str] + role: ReadOnly[str] + content: ReadOnly[tuple[_BlockedContentPart, ...]] + + +class _BlockedResponsePayload(TypedDict): + id: ReadOnly[str] + object: ReadOnly[str] + created_at: ReadOnly[int] + model: ReadOnly[str] + output: ReadOnly[tuple[GenericResponseOutputItem, ...]] + status: ReadOnly[str] + usage: ReadOnly[ResponseAPIUsage] + + +def _blocked_output_item(exc: "ModifyResponseException") -> GenericResponseOutputItem: + payload: Final[_BlockedItemPayload] = { + "type": "message", + "id": f"msg_{uuid.uuid4()}", + "status": "completed", + "role": "assistant", + "content": ({"type": "output_text", "text": exc.message, "annotations": ()},), + } + return GenericResponseOutputItem.model_validate(payload) + + +def _blocked_response( + exc: "ModifyResponseException", + response_id: str, + model: str, + output_item: GenericResponseOutputItem | None = None, +) -> ResponsesAPIResponse: + payload: Final[_BlockedResponsePayload] = { + "id": response_id, + "object": "response", + "created_at": int(time.time()), + "model": model, + "output": (output_item if output_item is not None else _blocked_output_item(exc),), + "status": "completed", + "usage": blocked_responses_stream_usage(exc.original_response), + } + return ResponsesAPIResponse.model_validate(payload) + + +def _continuation_identity(exc: "ModifyResponseException", responses_so_far: Sequence[object]) -> tuple[str, str, int]: + responses: Final = tuple( + response for item in responses_so_far if (response := stream_item_field(item, "response")) is not None + ) + response_id: Final = next( + (rid for response in responses if isinstance(rid := stream_item_field(response, "id"), str) and rid), + f"resp_{uuid.uuid4()}", + ) + model: Final = next( + (m for response in responses if isinstance(m := stream_item_field(response, "model"), str) and m), + exc.model, + ) + indices: Final = tuple( + index for item in responses_so_far if isinstance(index := stream_item_field(item, "output_index"), int) + ) + return response_id, model, max(indices) + 1 if indices else 0 + + +@dataclass(frozen=True, slots=True) +class _OpenItemState: + item_id: str + item_type: str + role: str + output_index: int + content_index: int + text: str + part_open: bool + payload: object + + +def _open_item_state(responses_so_far: Sequence[object]) -> _OpenItemState | None: + typed: Final = tuple((stream_item_field(event, "type"), event) for event in responses_so_far) + added: Final = tuple( + (added_index, stream_item_field(event, "item")) + for event_type, event in typed + if event_type == "response.output_item.added" + and isinstance(added_index := stream_item_field(event, "output_index"), int) + ) + done_indices: Final = frozenset( + done_index + for event_type, event in typed + if event_type == "response.output_item.done" + and isinstance(done_index := stream_item_field(event, "output_index"), int) + ) + open_added: Final = tuple((index, payload) for index, payload in added if index not in done_indices) + if not open_added: + return None + output_index, item_payload = open_added[-1] + if item_payload is None: + return None + item_id: Final = stream_item_field(item_payload, "id") + if not isinstance(item_id, str) or not item_id: + return None + raw_type: Final = stream_item_field(item_payload, "type") + raw_role: Final = stream_item_field(item_payload, "role") + part_added: Final = tuple( + part_index + for event_type, event in typed + if event_type == "response.content_part.added" + and stream_item_field(event, "item_id") == item_id + and isinstance(part_index := stream_item_field(event, "content_index"), int) + ) + part_done: Final = frozenset( + part_done_index + for event_type, event in typed + if event_type == "response.content_part.done" + and stream_item_field(event, "item_id") == item_id + and isinstance(part_done_index := stream_item_field(event, "content_index"), int) + ) + open_parts: Final = tuple(index for index in part_added if index not in part_done) + text: Final = "".join( + delta + for event_type, event in typed + if event_type == "response.output_text.delta" + and stream_item_field(event, "item_id") == item_id + and isinstance(delta := stream_item_field(event, "delta"), str) + ) + return _OpenItemState( + item_id=item_id, + item_type=raw_type if isinstance(raw_type, str) and raw_type else "message", + role=raw_role if isinstance(raw_role, str) and raw_role else "assistant", + output_index=output_index, + content_index=open_parts[-1] if open_parts else 0, + text=text, + part_open=bool(open_parts), + payload=item_payload, + ) + + +_item_fields_adapter: Final = TypeAdapter(Mapping[str, object]) +_no_item_fields: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _incomplete_item_fields(payload: object) -> Mapping[str, object]: + raw: Final = payload.model_dump() if isinstance(payload, BaseModel) else payload + if not isinstance(raw, dict): + return _no_item_fields + return _item_fields_adapter.validate_python(raw) + + +def _open_item_closing_events(responses_so_far: Sequence[object]) -> Sequence[ResponsesAPIStreamingResponse]: + """Close the output item still in progress on the relayed stream before the + block item is appended: strict Responses clients reject a + ``response.completed`` that arrives while an earlier ``output_item.added`` + was never closed. A message item closes ``completed`` with exactly the text + the client has received so far; any other item type (a function call the + guardrail rejected, for instance) closes ``incomplete`` so the synthetic + done event can never authorize acting on it.""" + open_item: Final = _open_item_state(responses_so_far) + if open_item is None: + return () + if open_item.item_type != "message": + return ( + OutputItemDoneEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + output_index=open_item.output_index, + item=BaseLiteLLMOpenAIResponseObject.model_validate( + MappingProxyType({**_incomplete_item_fields(open_item.payload), "status": "incomplete"}) + ), + ), + ) + partial_part: Final[_BlockedContentPart] = { + "type": "output_text", + "text": open_item.text, + "annotations": (), + } + closed_payload: Final[_BlockedItemPayload] = { + "type": open_item.item_type, + "id": open_item.item_id, + "status": "completed", + "role": open_item.role, + "content": (partial_part,), + } + item_done: Final = OutputItemDoneEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + output_index=open_item.output_index, + item=GenericResponseOutputItem.model_validate(closed_payload), + ) + if not open_item.part_open: + return (item_done,) + partial_done_part: Final[_BlockedDoneContentPart] = { + "type": "output_text", + "text": open_item.text, + "annotations": (), + "logprobs": None, + } + return ( + OutputTextDoneEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE, + item_id=open_item.item_id, + output_index=open_item.output_index, + content_index=open_item.content_index, + text=open_item.text, + ), + ContentPartDoneEvent( + type=ResponsesAPIStreamEvents.CONTENT_PART_DONE, + item_id=open_item.item_id, + output_index=open_item.output_index, + content_index=open_item.content_index, + part=ContentPartDonePartOutputText.model_validate(partial_done_part), + ), + item_done, + ) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 09028b6dc5f..01313e95878 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -15,6 +15,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo ) from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.responses.litellm_completion_transformation.custom_tools import TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import * from litellm.types.responses.main import * @@ -36,6 +37,7 @@ else: _NO_TOOL_UPDATE: Final[Mapping[str, object]] = MappingProxyType({}) _MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("gpt-4", "gpt-3.5", "chatgpt-4o", "o1", "o3", "o4") _PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI}) +_PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI}) class _DeleteResponseBody(TypedDict): @@ -210,8 +212,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools + replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) final_request_params: Final = dict( - ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params) + ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) ) return final_request_params @@ -248,6 +251,23 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return input, tools + def _drop_foreign_tool_call_item_ids(self, input: str | ResponseInputParam) -> str | ResponseInputParam: + if self.custom_llm_provider not in _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS or not isinstance(input, list): + return input + sanitized_items: Final = [self._without_foreign_tool_call_item_id(item) for item in input] + return cast("ResponseInputParam", sanitized_items) # cast-ok: items keep their shape, minus a rejected id + + @staticmethod + def _without_foreign_tool_call_item_id(item: object) -> object: + if not isinstance(item, dict): + return item + item_type: Final = item.get("type") + item_id: Final = item.get("id") + genuine_prefix: Final = TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE.get(item_type) if isinstance(item_type, str) else None + if genuine_prefix is None or not isinstance(item_id, str) or item_id.startswith(genuine_prefix): + return item + return {key: value for key, value in item.items() if key != "id"} # mutable-ok: outgoing JSON request item + def _flatten_tool_schema_combinators_for_openai( self, model: str, @@ -773,7 +793,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - data: Final = dict(ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params)) + replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) + data: Final = dict( + ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) + ) return url, data diff --git a/litellm/llms/parallel_ai/search/cost_calculator.py b/litellm/llms/parallel_ai/search/cost_calculator.py new file mode 100644 index 00000000000..809cd280cc8 --- /dev/null +++ b/litellm/llms/parallel_ai/search/cost_calculator.py @@ -0,0 +1,90 @@ +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import Final + +from pydantic import TypeAdapter, ValidationError + +from litellm.utils import get_model_info + +PARALLEL_AI_DEFAULT_RESULTS: Final = 10 +PARALLEL_AI_ADDITIONAL_RESULT_COST: Final = 0.001 +PARALLEL_AI_USAGE_PARAM: Final = "_parallel_ai_usage" +PARALLEL_AI_STANDARD_SEARCH_MODEL: Final = "parallel_ai/search" +PARALLEL_AI_FAST_SEARCH_MODEL: Final = "parallel_ai/search-fast" +PARALLEL_AI_TURBO_SEARCH_MODEL: Final = "parallel_ai/search-turbo" +PARALLEL_AI_PRICING_MODEL_BY_MODE: Final[Mapping[str, str]] = MappingProxyType( + { + "fast": PARALLEL_AI_FAST_SEARCH_MODEL, + "turbo": PARALLEL_AI_TURBO_SEARCH_MODEL, + } +) +ADVANCED_SETTINGS_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) + + +def _non_negative_int(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def _usage_count(usage: Sequence[Mapping[str, object]], sku: str) -> int | None: + counts: Final = tuple( + count + for item in usage + if item.get("name") == sku + if (count := _non_negative_int(item.get("count"))) is not None + ) + return sum(counts) if counts else None + + +def _effective_mode(optional_params: Mapping[str, object]) -> str: + mode: Final = optional_params.get("mode") + if isinstance(mode, str): + return mode + + processor: Final = optional_params.get("processor") + if processor == "pro": + return "advanced" + return "basic" + + +def _effective_max_results(optional_params: Mapping[str, object]) -> int: + try: + advanced_settings: Final = ADVANCED_SETTINGS_ADAPTER.validate_python(optional_params.get("advanced_settings")) + advanced_max_results: Final = _non_negative_int(advanced_settings.get("max_results")) + if advanced_max_results is not None: + return advanced_max_results + except ValidationError: + pass + + max_results: Final = _non_negative_int(optional_params.get("max_results")) + return max_results if max_results is not None else PARALLEL_AI_DEFAULT_RESULTS + + +def _request_cost(mode: str) -> float: + pricing_model: Final = PARALLEL_AI_PRICING_MODEL_BY_MODE.get(mode, PARALLEL_AI_STANDARD_SEARCH_MODEL) + model_info: Final = get_model_info(model=pricing_model, custom_llm_provider="parallel_ai") + return float(model_info.get("input_cost_per_query") or 0.0) + + +def _additional_results( + optional_params: Mapping[str, object], + usage: Sequence[Mapping[str, object]] | None, +) -> int: + usage_count: Final = _usage_count(usage, "sku_search_additional_results") if usage is not None else None + if usage_count is not None: + return usage_count + if usage is not None: + return 0 + return max(_effective_max_results(optional_params) - PARALLEL_AI_DEFAULT_RESULTS, 0) + + +def parallel_ai_search_cost( + optional_params: Mapping[str, object], + usage: Sequence[Mapping[str, object]] | None, +) -> float: + request_cost: Final = _request_cost(_effective_mode(optional_params)) + request_count_from_usage: Final = _usage_count(usage, "sku_search") if usage is not None else None + request_count: Final = request_count_from_usage if request_count_from_usage is not None else 1 + additional_results: Final = _additional_results(optional_params, usage) + return request_count * request_cost + additional_results * PARALLEL_AI_ADDITIONAL_RESULT_COST diff --git a/litellm/llms/parallel_ai/search/transformation.py b/litellm/llms/parallel_ai/search/transformation.py index ea21d1153fe..bde7b7b86db 100644 --- a/litellm/llms/parallel_ai/search/transformation.py +++ b/litellm/llms/parallel_ai/search/transformation.py @@ -4,9 +4,13 @@ Calls Parallel AI's /v1/search endpoint to search the web. Parallel AI API Reference: https://docs.parallel.ai/api-reference/search/search """ +from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final, TypedDict import httpx +from pydantic import BaseModel, ConfigDict +from typing_extensions import ReadOnly from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.search.transformation import ( @@ -14,9 +18,29 @@ from litellm.llms.base_llm.search.transformation import ( SearchResponse, SearchResult, ) +from litellm.llms.parallel_ai.search.cost_calculator import PARALLEL_AI_USAGE_PARAM from litellm.secret_managers.main import get_secret_str +class _ParallelAIV1SearchResult(BaseModel): + model_config = ConfigDict(extra="ignore") + + url: str | None = None + title: str | None = None + publish_date: str | None = None + excerpts: Sequence[str] | None = None + + +class _ParallelAIV1SearchResponse(BaseModel): + model_config = ConfigDict(extra="ignore") + + search_id: str | None = None + session_id: str | None = None + results: Sequence[_ParallelAIV1SearchResult] = () + usage: Sequence[Mapping[str, object]] | None = None + warnings: Sequence[Mapping[str, object]] | None = None + + class _ParallelAISourcePolicy(TypedDict, total=False): include_domains: list[str] exclude_domains: list[str] @@ -27,10 +51,16 @@ class _ParallelAIExcerptSettings(TypedDict, total=False): max_chars_per_result: int +class _ParallelAIFetchPolicy(TypedDict, total=False): + max_age_seconds: ReadOnly[int] + timeout_seconds: ReadOnly[float] + disable_cache_fallback: ReadOnly[bool] + + class _ParallelAIAdvancedSettings(TypedDict, total=False): source_policy: _ParallelAISourcePolicy excerpt_settings: _ParallelAIExcerptSettings - fetch_policy: dict + fetch_policy: _ParallelAIFetchPolicy location: str max_results: int @@ -43,14 +73,14 @@ class ParallelAISearchRequest(TypedDict, total=False): search_queries: list[str] # Required - at least one keyword search query objective: str # Optional - natural-language description of search goal - mode: str # Optional - 'turbo', 'basic', or 'advanced' (default 'advanced') + mode: str # Optional - 'turbo', 'fast', 'basic', or 'advanced' (default 'advanced') max_chars_total: int # Optional - upper bound on total excerpt characters session_id: str # Optional - tracks calls across search/extract requests client_model: str # Optional - model consuming the results advanced_settings: _ParallelAIAdvancedSettings -LEGACY_PROCESSOR_TO_MODE: Final = {"base": "basic", "pro": "advanced"} +LEGACY_PROCESSOR_TO_MODE: Final = MappingProxyType({"base": "basic", "pro": "advanced"}) class ParallelAISearchConfig(BaseSearchConfig): @@ -67,16 +97,16 @@ class ParallelAISearchConfig(BaseSearchConfig): api_base: str | None = None, **kwargs, ) -> dict: - api_key = self.resolve_server_api_key( + resolved_api_key: Final = self.resolve_server_api_key( caller_api_key=api_key, caller_api_base=api_base, key_env_vars=("PARALLEL_AI_API_KEY", "PARALLEL_API_KEY"), base_env_var="PARALLEL_AI_API_BASE", default_api_base=self.PARALLEL_AI_API_BASE, ) - if not api_key: + if not resolved_api_key: raise ValueError("PARALLEL_API_KEY is not set. Set `PARALLEL_API_KEY` environment variable.") - headers["x-api-key"] = api_key + headers["x-api-key"] = resolved_api_key headers["Content-Type"] = "application/json" return headers @@ -87,13 +117,12 @@ class ParallelAISearchConfig(BaseSearchConfig): data: dict | list[dict] | None = None, **kwargs, ) -> str: - api_base = api_base or get_secret_str("PARALLEL_AI_API_BASE") or self.PARALLEL_AI_API_BASE + resolved_api_base: Final = api_base or get_secret_str("PARALLEL_AI_API_BASE") or self.PARALLEL_AI_API_BASE - api_base = api_base.rstrip("/") - if not api_base.endswith("/v1/search"): - api_base = f"{api_base.removesuffix('/v1')}/v1/search" - - return api_base + trimmed: Final = resolved_api_base.rstrip("/") + if trimmed.endswith("/v1/search"): + return trimmed + return f"{trimmed.removesuffix('/v1')}/v1/search" def transform_search_request( self, @@ -109,14 +138,17 @@ class ParallelAISearchConfig(BaseSearchConfig): - If string: maps to `search_queries` (single item) and `objective` - If list: maps to `search_queries` (keyword queries) optional_params: Optional parameters for the request - - mode: Search mode ('turbo', 'basic', 'advanced'); defaults to 'basic' + - mode: Search mode ('turbo', 'fast', 'basic', 'advanced'); defaults to 'basic' - processor: Legacy v1beta param; 'base' maps to mode 'basic', 'pro' to 'advanced' - max_results: Maximum number of search results -> `advanced_settings.max_results` - - search_domain_filter: Domains to include -> `advanced_settings.source_policy.include_domains` + - search_domain_filter / include_domains: Domains to include -> `advanced_settings.source_policy.include_domains` - exclude_domains: Domains to exclude -> `advanced_settings.source_policy.exclude_domains` - - country: ISO 3166-1 alpha-2 code -> `advanced_settings.location` + - after_date: RFC 3339 date (YYYY-MM-DD) -> `advanced_settings.source_policy.after_date` + - country / location: ISO 3166-1 alpha-2 code -> `advanced_settings.location` - max_chars_per_result: -> `advanced_settings.excerpt_settings.max_chars_per_result` - - Any other params are passed through to the request body as-is + - fetch_policy: Cache vs live-fetch policy -> `advanced_settings.fetch_policy` + - Any other params (objective, max_chars_total, session_id, client_model, ...) + are passed through to the request body as-is Returns: Dict with request data following the v1 search request spec @@ -137,7 +169,7 @@ class ParallelAISearchConfig(BaseSearchConfig): mode = LEGACY_PROCESSOR_TO_MODE.get(processor, processor) # the v1 API defaults to 'advanced' when mode is omitted; default to 'basic' # instead to keep v1beta's default tier (processor 'base') and litellm's - # $0.004/query cost map entry for `parallel_ai/search` accurate + # cost map entry for `parallel_ai/search` accurate request_data["mode"] = mode or "basic" advanced_settings: Final[_ParallelAIAdvancedSettings] = {} @@ -148,17 +180,29 @@ class ParallelAISearchConfig(BaseSearchConfig): if "country" in params: advanced_settings["location"] = params.pop("country") + if "location" in params: + advanced_settings["location"] = params.pop("location") + if "max_chars_per_result" in params: advanced_settings["excerpt_settings"] = {"max_chars_per_result": params.pop("max_chars_per_result")} + if "fetch_policy" in params: + advanced_settings["fetch_policy"] = params.pop("fetch_policy") + source_policy: Final[_ParallelAISourcePolicy] = {} if "search_domain_filter" in params: source_policy["include_domains"] = params.pop("search_domain_filter") + if "include_domains" in params: + source_policy["include_domains"] = params.pop("include_domains") + if "exclude_domains" in params: source_policy["exclude_domains"] = params.pop("exclude_domains") + if "after_date" in params: + source_policy["after_date"] = params.pop("after_date") + if source_policy: advanced_settings["source_policy"] = source_policy @@ -170,9 +214,11 @@ class ParallelAISearchConfig(BaseSearchConfig): # unified-spec param with no v1 equivalent params.pop("max_tokens_per_page", None) - result_data: Final[dict] = dict(request_data) - result_data.update(params) - return result_data + # reserved for the provider's own reported usage, which prices the request; + # a caller-supplied value would otherwise set its own cost + params.pop(PARALLEL_AI_USAGE_PARAM, None) + + return {**request_data, **params} def transform_search_response( self, @@ -186,26 +232,49 @@ class ParallelAISearchConfig(BaseSearchConfig): Parallel AI -> LiteLLM mappings: - results[].title -> SearchResult.title - results[].url -> SearchResult.url - - results[].excerpts (array) -> SearchResult.snippet (joined string) + - results[].excerpts (array) -> SearchResult.snippet (joined string); the raw + array is preserved as an extra `excerpts` field on each result - results[].publish_date -> SearchResult.date + - search_id / session_id / warnings are preserved as extra fields on the + response; usage is preserved as `parallel_usage` (the `usage` name is + reserved for LiteLLM's token-usage object) """ - response_json: Final = raw_response.json() + parsed: Final = _ParallelAIV1SearchResponse.model_validate(raw_response.json()) - results: Final = [] - for result in response_json.get("results", []): - excerpts = result.get("excerpts") or [] - snippet = " ... ".join(excerpts) if excerpts else "" + # written unconditionally: leaving a caller-supplied value in place when the + # provider reports no usage would let the caller price its own request + logging_obj.optional_params = { + **logging_obj.optional_params, + PARALLEL_AI_USAGE_PARAM: parsed.usage, + } - search_result = SearchResult( - title=result.get("title") or "", - url=result.get("url") or "", - snippet=snippet, - date=result.get("publish_date"), - last_updated=None, + results: Final = tuple( + SearchResult.model_validate( + MappingProxyType( + { + "title": result.title or "", + "url": result.url or "", + "snippet": " ... ".join(result.excerpts or ()), + "date": result.publish_date, + "last_updated": None, + "excerpts": result.excerpts or (), + } + ) ) - results.append(search_result) - - return SearchResponse( - results=results, - object="search", + for result in parsed.results ) + + extra_fields: Final = MappingProxyType( + { + key: value + for key, value in ( + ("search_id", parsed.search_id), + ("session_id", parsed.session_id), + ("parallel_usage", parsed.usage), + ("warnings", parsed.warnings), + ) + if value is not None + } + ) + + return SearchResponse.model_validate(MappingProxyType({"results": results, "object": "search", **extra_fields})) diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py index d7ad69593c6..ef03e61a858 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py @@ -153,14 +153,17 @@ class VertexAIAnthropicConfig(AnthropicConfig): drop_params: bool, ) -> dict: """ - Override parent method to ensure VertexAI always uses tool-based structured outputs. - VertexAI doesn't support the output_format parameter, so we force all models - to use the tool-based approach for structured outputs. + Override parent method so VertexAI uses tool-based structured outputs + unless the vertex map entry advertises native structured output + (``output_format``, which Vertex AI Claude forwards for those models). """ - # Temporarily override model name to force tool-based approach - # This ensures Claude Sonnet 4.5 uses tools instead of output_format + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + original_model: Final = model - if "response_format" in non_default_params: + native_structured_output: Final = AnthropicModelInfo._get_provider_resolved_capability( + model, "supports_native_structured_output", "vertex_ai" + ) + if "response_format" in non_default_params and native_structured_output is not True: model = "claude-3-sonnet-20240229" # Use a model that will use tool-based approach # Call parent method with potentially modified model name diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 27ff525c15e..a3cfb300ea6 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1451,6 +1451,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, "cache_creation_input_token_cost_above_1hr": 2e-05, @@ -1488,6 +1526,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "global.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, "cache_creation_input_token_cost_above_1hr": 2.2e-05, @@ -1525,6 +1601,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "us.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, "cache_creation_input_token_cost_above_1hr": 2.2e-05, @@ -1562,6 +1676,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "eu.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, @@ -3079,6 +3231,41 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "azure_ai/claude-fable-5-1": { + "supports_mid_conversation_system": true, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "azure_ai/claude-opus-5": { "deprecation_date": "2027-07-08", "supports_mid_conversation_system": true, @@ -13044,6 +13231,47 @@ "supports_native_structured_output": true, "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" }, + "claude-fable-5-1": { + "deprecation_date": "2027-09-01", + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true, + "prompt_cache_min_tokens": 512, + "supports_native_structured_output": true, + "source": "https://platform.claude.com/docs/en/models/fable-5-1/overview" + }, "claude-opus-5": { "deprecation_date": "2027-07-24", "cache_creation_input_token_cost": 6.25e-06, @@ -38328,12 +38556,22 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "parallel_ai/search": { - "input_cost_per_query": 0.004, + "input_cost_per_query": 0.005, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-fast": { + "input_cost_per_query": 0.001, "litellm_provider": "parallel_ai", "mode": "search" }, "parallel_ai/search-pro": { - "input_cost_per_query": 0.009, + "input_cost_per_query": 0.005, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-turbo": { + "input_cost_per_query": 0.001, "litellm_provider": "parallel_ai", "mode": "search" }, @@ -43881,6 +44119,42 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "vertex_ai/claude-fable-5-1": { + "regional_endpoint_uplift_multiplier": 1.1, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-fable-5@default": { "deprecation_date": "2027-06-08", "regional_endpoint_uplift_multiplier": 1.1, @@ -43916,6 +44190,42 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "vertex_ai/claude-fable-5-1@default": { + "regional_endpoint_uplift_multiplier": 1.1, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-opus-5": { "deprecation_date": "2027-01-24", "regional_endpoint_uplift_multiplier": 1.1, diff --git a/litellm/models/user.py b/litellm/models/user.py index 259c3440d87..82f78c28078 100644 --- a/litellm/models/user.py +++ b/litellm/models/user.py @@ -7,7 +7,7 @@ Canonical definition for ``litellm_usertable``. Re-exported from from datetime import datetime -from pydantic import ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from litellm.models.object_permission import LiteLLM_ObjectPermissionTable from litellm.models.organization_membership import ( @@ -67,3 +67,11 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase): if not self.models: return True return model_name in self.models + + +class SCIMPlaceholder(BaseModel): + """A user row keyed by a value that names another account by SSO identity or email.""" + + placeholder_user_id: str + resolved_user_ids: tuple[str, ...] + team_ids: tuple[str, ...] diff --git a/litellm/ocr/main.py b/litellm/ocr/main.py index 58b7259480e..450c05ecd52 100644 --- a/litellm/ocr/main.py +++ b/litellm/ocr/main.py @@ -29,6 +29,7 @@ from litellm.llms.base_llm.ocr.transformation import ( ) from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.rust_bridge import ocr as rust_ocr_bridge +from litellm.rust_bridge.runtime import CoreEngine, execution_hidden_params from litellm.types.router import GenericLiteLLMParams from litellm.utils import ProviderConfigManager, client @@ -68,6 +69,24 @@ _RUST_OCR_PROVIDERS: Final = { } +def _with_core_engine(response: OCRResponse, source: CoreEngine) -> OCRResponse: + raw_hidden_params: Final = cast(object, response._hidden_params) + hidden_params: Final = ( + cast(Mapping[str, object], raw_hidden_params) if isinstance(raw_hidden_params, Mapping) else None + ) + response._hidden_params = execution_hidden_params( # pyright: ignore[reportPrivateUsage] # OCRResponse has no public metadata setter + hidden_params, source + ) + return response + + +async def _await_with_core_engine( + response: Coroutine[object, object, OCRResponse], + source: CoreEngine, +) -> OCRResponse: + return _with_core_engine(await response, source) + + def _prepare_ocr_request( model: str, document: Mapping[str, object], @@ -199,6 +218,12 @@ def _rust_ocr_supported(prepared_request: _PreparedOCRRequest) -> bool: return prepared_request.custom_llm_provider in _RUST_OCR_PROVIDERS +def _rust_ocr_enabled(prepared_request: _PreparedOCRRequest) -> bool: + raw_request_override: Final = prepared_request.litellm_params.get("rust") + request_override: Final = raw_request_override if isinstance(raw_request_override, bool) else None + return rust_ocr_bridge.rust_ocr_enabled(request_override=request_override) + + def _rust_bridge_optional_params( prepared_request: _PreparedOCRRequest, resolve_secret: Callable[[str], str | None], @@ -307,7 +332,7 @@ def _run_rust_ocr( ) if rust_response is None: return None - return OCRResponse.model_validate(rust_response) + return _with_core_engine(OCRResponse.model_validate(rust_response), CoreEngine.RUST) async def _run_rust_aocr( @@ -333,7 +358,7 @@ async def _run_rust_aocr( ) if rust_response is None: return None - return OCRResponse.model_validate(rust_response) + return _with_core_engine(OCRResponse.model_validate(rust_response), CoreEngine.RUST) @client @@ -430,7 +455,7 @@ async def aocr( custom_llm_provider = prepared.custom_llm_provider completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) - if _rust_ocr_supported(prepared) and rust_ocr_bridge.rust_ocr_enabled(): + if _rust_ocr_supported(prepared) and _rust_ocr_enabled(prepared): from litellm.secret_managers.main import get_secret_str rust_response: Final = await _run_rust_aocr( @@ -463,7 +488,7 @@ async def aocr( if response is None: raise ValueError(f"Got an unexpected None response from the OCR API: {response}") - return response + return _with_core_engine(response, CoreEngine.PYTHON) except Exception as e: raise litellm.exception_type( model=model, @@ -702,7 +727,7 @@ def ocr( custom_llm_provider = prepared.custom_llm_provider completion_kwargs.update({"model": model, "custom_llm_provider": custom_llm_provider}) - if _rust_ocr_supported(prepared) and rust_ocr_bridge.rust_ocr_enabled(): + if _rust_ocr_supported(prepared) and _rust_ocr_enabled(prepared): from litellm.secret_managers.main import get_secret_str rust_response: Final = _run_rust_ocr( @@ -729,7 +754,9 @@ def ocr( litellm_params=prepared.litellm_params, ) - return response + if asyncio.iscoroutine(response): + return _await_with_core_engine(response, CoreEngine.PYTHON) + return _with_core_engine(response, CoreEngine.PYTHON) except Exception as e: raise litellm.exception_type( model=model, diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 3efb6429326..d1ef73a15cd 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,13 +1,16 @@ import asyncio import importlib -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal +import anyio import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from litellm._logging import verbose_logger +from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_TOOL_LISTING_TIMEOUT from litellm.exceptions import ( BlockedPiiEntityError, GuardrailRaisedException, @@ -18,8 +21,11 @@ from litellm.proxy._experimental.mcp_server.exceptions import ( MCPUpstreamAuthError, ) from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ( + ServerListOk, + ServerOutcome, classify_list_exception, list_fault_http_status, + outcome_wire_value, ) from litellm.proxy._experimental.mcp_server.ui_session_utils import ( acting_user_auth, @@ -86,8 +92,6 @@ def _connection_error_message(exc: BaseException) -> str: if MCP_AVAILABLE: - from mcp.types import Tool as MCPTool - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, global_mcp_server_manager, @@ -99,6 +103,7 @@ if MCP_AVAILABLE: ListMCPToolsRestAPIResponseObject, MCPInfo, MCPServer, + _aggregate_server_key, # pyright: ignore[reportPrivateUsage] # same per-server key as the tools/list _meta outcomes _apply_toolset_scope, _fire_mcp_tool_call_logging, execute_mcp_tool, @@ -803,9 +808,6 @@ if MCP_AVAILABLE: list(allowed_server_ids_set), _rest_client_ip ) - list_tools_result: Final = [] - error_message = None - # If server_id is specified, only query that specific server if server_id: return await _list_tools_for_single_server( @@ -849,22 +851,19 @@ if MCP_AVAILABLE: else {} ) - # Query all servers the user has access to - errors: Final = [] - for allowed_server_id in allowed_server_ids: - server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id) - if server is None: - continue - - server_auth_header = _get_server_auth_header(server, mcp_server_auth_headers, mcp_auth_header) - user_oauth_extra_headers = await _get_user_oauth_extra_headers( + async def list_server( + server: MCPServer, + ) -> tuple[Sequence[ListMCPToolsRestAPIResponseObject], ServerOutcome]: + server_auth_header: Final = _get_server_auth_header( + server, mcp_server_auth_headers, mcp_auth_header + ) + user_oauth_extra_headers: Final = await _get_user_oauth_extra_headers( server, user_api_key_dict, prefetched_creds=prefetched_oauth_creds, ) - try: - tools_result = await _get_tools_for_single_server( + tools_result: Final = await _get_tools_for_single_server( server, server_auth_header, raw_headers_from_request, @@ -872,24 +871,36 @@ if MCP_AVAILABLE: extra_headers=user_oauth_extra_headers, apply_tool_filters=apply_tool_filters, ) - list_tools_result.extend(tools_result) except Exception as e: verbose_logger.exception("Error getting tools from %s: %s", server.name, e) - errors.append( - f"{get_server_prefix(server)}: {classify_list_exception(e).tag}" - if isinstance(e, (MCPServerListError, MCPUpstreamAuthError)) - else f"{get_server_prefix(server)}: {e}" - ) - continue + return (), classify_list_exception(e) + return tools_result, ServerListOk(tool_count=len(tools_result)) - if errors and not list_tools_result: - error_message = "Failed to get tools from servers: " + "; ".join(errors) - - return { - "tools": list_tools_result, - "error": "partial_failure" if error_message else None, - "message": (error_message if error_message else "Successfully retrieved tools"), - } + # Query all servers the user has access to + queried_servers: Final = tuple( + server + for server in map(global_mcp_server_manager.get_mcp_server_by_id, allowed_server_ids) + if server is not None + ) + listings: Final = tuple([await list_server(server) for server in queried_servers]) + list_tools_result: Final = [tool for tools, _ in listings for tool in tools] + server_outcomes: Final = MappingProxyType( + {_aggregate_server_key(server): outcome for server, (_, outcome) in zip(queried_servers, listings)} + ) + errors: Final = tuple( + f"{key}: {outcome.tag}" for key, outcome in server_outcomes.items() if outcome.tag != "ok" + ) + error_message: Final = ( + "Failed to get tools from servers: " + "; ".join(errors) + if errors and not list_tools_result + else None + ) + return { + "tools": list_tools_result, + "error": "partial_failure" if error_message else None, + "message": (error_message if error_message else "Successfully retrieved tools"), + "server_outcomes": {key: outcome_wire_value(outcome) for key, outcome in server_outcomes.items()}, + } except MCPUpstreamAuthError as e: # Surface upstream pass-through 401/403 challenges to the client so @@ -1173,6 +1184,7 @@ if MCP_AVAILABLE: transport=request.transport, auth_type=request.auth_type, mcp_info=request.mcp_info, + timeout=request.timeout, command=request.command, args=request.args, env=request.env, @@ -1402,11 +1414,28 @@ if MCP_AVAILABLE: oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers) async def _list_tools_operation(client): - async def _list_tools_session_operation(session): - return await session.list_tools() - - list_tools_response: Final = await client.run_with_session(_list_tools_session_operation) - list_tools_result: Final[list[MCPTool]] = list_tools_response.tools + # Bound the whole pagination walk: without this the preview is limited only by the + # per-request timeout times the page cap. max() keeps the pre-pagination guarantee + # that a single slow page within the client timeout still succeeds, and a + # per-server timeout above the global default extends the deadline with it. + listing_deadline: Final = max( + getattr(client, "timeout", MCP_CLIENT_TIMEOUT) or MCP_CLIENT_TIMEOUT, + MCP_TOOL_LISTING_TIMEOUT, + ) + list_tools_result = None # rebind-ok: set inside the timeout scope below + with anyio.move_on_after(listing_deadline): + list_tools_result = await client.list_tools(raise_on_error=True) # rebind-ok: fills the init above + if list_tools_result is None: + verbose_logger.warning( + "MCP tools/list preview timed out after %s seconds while paginating upstream tools", + listing_deadline, + ) + return { # mutable-ok: error response payload + "status": "error", + "error": True, + "message": f"Timed out listing tools after {listing_deadline} seconds. " + "The MCP server may be responding slowly or paginating excessively.", + } model_dumped_tools: Final[list[dict]] = [tool.model_dump() for tool in list_tools_result] return { "tools": model_dumped_tools, diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5af45b29226..13c7a4c7cfa 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -32002,6 +32002,62 @@ "title": "SCIMPatchOperation", "type": "object" }, + "SCIMPlaceholder": { + "description": "A user row keyed by a value that names another account by SSO identity or email.", + "properties": { + "placeholder_user_id": { + "title": "Placeholder User Id", + "type": "string" + }, + "resolved_user_ids": { + "items": { + "type": "string" + }, + "title": "Resolved User Ids", + "type": "array" + }, + "team_ids": { + "items": { + "type": "string" + }, + "title": "Team Ids", + "type": "array" + } + }, + "required": [ + "placeholder_user_id", + "resolved_user_ids", + "team_ids" + ], + "title": "SCIMPlaceholder", + "type": "object" + }, + "SCIMPlaceholderMergeResult": { + "properties": { + "merged_into_user_id": { + "title": "Merged Into User Id", + "type": "string" + }, + "placeholder_user_id": { + "title": "Placeholder User Id", + "type": "string" + }, + "team_ids": { + "items": { + "type": "string" + }, + "title": "Team Ids", + "type": "array" + } + }, + "required": [ + "placeholder_user_id", + "merged_into_user_id", + "team_ids" + ], + "title": "SCIMPlaceholderMergeResult", + "type": "object" + }, "SCIMServiceProviderConfig": { "properties": { "authenticationSchemes": { @@ -33641,6 +33697,129 @@ "scim" ] } + }, + "/scim/v2/placeholders": { + "get": { + "description": "List user rows whose id is another account's SSO identity or email.\n\nAn earlier release provisioned a group member it could not match as a user keyed\nby the raw member value, and that row now shadows the account the value really\nnames, so every push of that member is refused. This lists those rows so an\noperator can fold each one into the account it shadows with\n``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of\nits own or owns virtual keys is left out: someone uses that account.", + "operationId": "list_placeholders_scim_v2_placeholders_get", + "parameters": [ + { + "in": "query", + "name": "feature", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Feature" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SCIMPlaceholder" + }, + "title": "Response List Placeholders Scim V2 Placeholders Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Placeholders", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/placeholders/{user_id}/merge": { + "post": { + "description": "Fold a placeholder user into the one account its id names by SSO identity or email.\n\nThe account is added to every team the placeholder is on, then the placeholder is\ndeleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group\npush resolves the member value to the real account. Refused with 409 when the row\nhas an SSO identity of its own, owns virtual keys, or names no account or several.", + "operationId": "merge_placeholder_scim_v2_placeholders__user_id__merge_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User ID", + "type": "string" + } + }, + { + "in": "query", + "name": "feature", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Feature" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCIMPlaceholderMergeResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Merge Placeholder", + "tags": [ + "scim" + ] + } } } }, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c549f48126e..18714256a8f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1216,6 +1216,13 @@ class GenerateKeyRequest(KeyRequestBase): organization_id: str | None = None project_id: str | None = None + @field_validator("team_id", mode="before") + @classmethod + def treat_cleared_team_id_as_unset(cls, v: object) -> object: + if v == "": + return None + return v + class GenerateKeyResponse(KeyRequestBase): key: str @@ -2436,9 +2443,22 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): database_socket_timeout: float | None = Field( None, description=( - "Prisma `socket_timeout` URL param (seconds). When set, an idle/slow " - "connection that has not produced data within this window is closed. " - "This is the main knob for capping idle DB connections from LiteLLM." + "Prisma `socket_timeout` URL param (seconds). When set, an in-flight " + "operation that has not produced data within this window is aborted. " + "For capping how long idle pooled connections are kept, see " + "`database_max_idle_connection_lifetime`." + ), + ) + database_max_idle_connection_lifetime: float | None = Field( + 60, + description=( + "Prisma `max_idle_connection_lifetime` URL param (seconds). A pooled " + "connection idle longer than this is closed and replaced instead of " + "being handed to the next request. Defaults to 60 so connections are " + "recycled before common infra idle timeouts (AWS NLB / RDS Proxy " + "~350s, many LBs 60-350s) silently drop them and requests fail with " + "`Error { kind: Closed }`. A value pinned on the DATABASE_URL or set " + "via `database_extra_connection_params` takes precedence." ), ) database_extra_connection_params: dict[str, Any] | None = Field( diff --git a/litellm/proxy/anthropic_endpoints/streaming_model_restamp.py b/litellm/proxy/anthropic_endpoints/streaming_model_restamp.py new file mode 100644 index 00000000000..7da5e5099fc --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/streaming_model_restamp.py @@ -0,0 +1,174 @@ +""" +Restamp the public ``model`` on the Anthropic Messages ``message_start`` event, the only +stream event carrying a model, so streamed responses report the requested model like +non-streaming ones do. + +Chunks reach the serializer either as already-encoded SSE frames (``bytes``/``str``, the +provider passthrough path) or as event dicts (fake-stream and agentic paths). +""" + +import json +import re +from collections.abc import Mapping +from typing import Final + +from pydantic import TypeAdapter, ValidationError + +_MESSAGE_START_EVENT: Final = "message_start" +_MESSAGE_START_MARKER: Final = b"message_start" +_SSE_DATA_FIELD: Final = "data:" +_SSE_FRAME_END_PATTERN: Final = re.compile(rb"\r\n\r\n|\r\r|\n\n") +_MAX_HELD_BYTES: Final = 65536 +_PING_MARKERS: Final = (b"event: ping", b'"type": "ping"', b'"type":"ping"') + +_EVENT_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + + +def _restamped_event(event: Mapping[str, object], requested_model: str) -> Mapping[str, object] | None: + message: Final = event.get("message") + if event.get("type") != _MESSAGE_START_EVENT or not isinstance(message, dict): + return None + if message.get("model") == requested_model: + return None + return {**event, "message": {**message, "model": requested_model}} # mutable-ok: SSE payload, re-serialized as is + + +def _restamped_data_line(line: str, requested_model: str) -> str | None: + stripped: Final = line.strip() + if not stripped.startswith(_SSE_DATA_FIELD): + return None + payload: Final = stripped[len(_SSE_DATA_FIELD) :].strip() + if not payload or payload == "[DONE]": + return None + try: + event: Final = _EVENT_ADAPTER.validate_json(payload) + except ValidationError: + return None + restamped: Final = _restamped_event(event, requested_model) + if restamped is None: + return None + terminator: Final = line[len(line.rstrip("\r\n")) :] + return f"data: {json.dumps(restamped, separators=(',', ':'))}{terminator}" + + +def _restamped_frame(frame: str, requested_model: str) -> str | None: + lines: Final = frame.splitlines(keepends=True) + restamped: Final = tuple(_restamped_data_line(line, requested_model) for line in lines) + if all(line is None for line in restamped): + return None + return "".join(new if new is not None else old for new, old in zip(restamped, lines)) + + +def restamp_anthropic_stream_chunk_model(chunk: object, requested_model: str) -> object: + """ + Return ``chunk`` with the ``message_start`` model replaced by ``requested_model``. + + Chunks that carry no model are returned unchanged. + """ + if isinstance(chunk, dict): + try: + event: Final = _EVENT_ADAPTER.validate_python(chunk) + except ValidationError: + return chunk + return _restamped_event(event, requested_model) or chunk + + if isinstance(chunk, (bytes, bytearray)): + if _MESSAGE_START_EVENT.encode() not in chunk: + return chunk + restamped_bytes: Final = _restamped_frame(chunk.decode("utf-8", errors="ignore"), requested_model) + return chunk if restamped_bytes is None else restamped_bytes.encode("utf-8") + + if isinstance(chunk, str): + if _MESSAGE_START_EVENT not in chunk: + return chunk + restamped_text: Final = _restamped_frame(chunk, requested_model) + return chunk if restamped_text is None else restamped_text + + return chunk + + +def _is_ping_frame(frame: bytes) -> bool: + return any(marker in frame for marker in _PING_MARKERS) + + +class AnthropicStreamModelRestamper: + """ + Per-stream restamper for the encoded passthrough path, where chunks are raw + transport reads: the ``message_start`` SSE frame can arrive split across + chunks or coalesced with later frames. Complete frames (``\\n\\n``, + ``\\r\\n\\r\\n``, or ``\\r\\r`` terminated) are emitted as their terminator + closes them and an incomplete tail is held until it completes, so the + restamp never misses a torn frame; ``flush`` returns whatever is still held + when the stream ends so no bytes are swallowed. Once ``message_start`` has + been handled, or the first real event proves the stream carries none, every + later chunk passes through untouched. + """ + + def __init__(self, requested_model: str) -> None: + self._requested_model: Final = requested_model + self._held = b"" + self._armed = True + + def process(self, chunk: object) -> object: + if not self._armed: + return chunk + if isinstance(chunk, (bytes, bytearray)): + return self._process_encoded(bytes(chunk)) + if isinstance(chunk, str): + return self._process_encoded(chunk.encode("utf-8")) + restamped: Final = restamp_anthropic_stream_chunk_model(chunk, self._requested_model) + if isinstance(chunk, dict) and chunk.get("type") not in (None, "ping"): + self._armed = False + return restamped + + def flush(self) -> bytes: + held: Final = self._held + self._held = b"" + self._armed = False + if not held: + return b"" + restamped: Final = restamp_anthropic_stream_chunk_model(held, self._requested_model) + return restamped if isinstance(restamped, bytes) else held + + def _process_encoded(self, data: bytes) -> bytes: + combined: Final = self._held + data + boundaries: Final = tuple(match.end() for match in _SSE_FRAME_END_PATTERN.finditer(combined)) + if not boundaries: + if len(combined) > _MAX_HELD_BYTES: + self._held = b"" + self._armed = False + return combined + self._held = combined + return b"" + emitted: Final = self._restamped_closed_block(combined[: boundaries[-1]]) + tail: Final = combined[boundaries[-1] :] + if not self._armed: + self._held = b"" + return emitted + tail + self._held = tail + return emitted + + def _restamped_closed_block(self, closed: bytes) -> bytes: + boundaries: Final = tuple(match.end() for match in _SSE_FRAME_END_PATTERN.finditer(closed)) + frames: Final = tuple(closed[start:end] for start, end in zip((0, *boundaries[:-1]), boundaries)) + decider: Final = next( + ( + index + for index, frame in enumerate(frames) + if _MESSAGE_START_MARKER in frame or (b"data:" in frame and not _is_ping_frame(frame)) + ), + None, + ) + if decider is None: + return closed + self._armed = False + if _MESSAGE_START_MARKER not in frames[decider]: + return closed + restamped_text: Final = _restamped_frame( + frames[decider].decode("utf-8", errors="ignore"), self._requested_model + ) + if restamped_text is None: + return closed + return b"".join( + restamped_text.encode("utf-8") if index == decider else frame for index, frame in enumerate(frames) + ) diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index 45e05d353fb..c591cbabee1 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -9,6 +9,7 @@ import click import requests from .auth import context_secret_vault, get_stored_api_key, login +from .cmd_quoting import quote_for_cmd ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL" ANTHROPIC_AUTH_TOKEN_ENV: Final = "ANTHROPIC_AUTH_TOKEN" @@ -151,31 +152,9 @@ def verify_proxy_key( _WINDOWS_SHIM_SUFFIXES: Final[frozenset[str]] = frozenset({".cmd", ".bat"}) -_CMD_PERCENT_GUARD: Final = "%%cd:~,%" _CMD_LINE_BREAKS: Final = ("\r", "\n") -def _double_trailing_backslashes(segment: str) -> str: - bare: Final = segment.rstrip("\\") - return bare + "\\" * 2 * (len(segment) - len(bare)) - - -def _quote_for_cmd(token: str) -> str: - """Quote one token so both parsers that read it see the original text. - - Follows the algorithm the Rust standard library settled on for batch files - after CVE-2024-24576. Two parsers see this token: cmd.exe, which ends a - quoted string on a lone `"` and so wants an embedded one doubled, and the - shim's own interpreter, which re-splits `%*` under C runtime rules where a - backslash escapes the quote that follows it, so every backslash run standing - before a quote is doubled. Quoting cannot stop cmd expanding `%VAR%`, so each - `%` is prefixed with `%%cd:~,`: the zero-length substring of the always - defined `cd` expands to nothing and leaves no `%` pair for cmd to match. - """ - escaped: Final = '""'.join(_double_trailing_backslashes(part) for part in token.split('"')) - return '"' + escaped.replace("%", _CMD_PERCENT_GUARD) + '"' - - def _windows_command(path: str, args: Sequence[str]) -> str | tuple[str, ...]: """Build what CreateProcess runs, routing batch shims through cmd.exe. @@ -202,7 +181,7 @@ def _windows_command(path: str, args: Sequence[str]) -> str | tuple[str, ...]: f"Cannot pass an argument containing a line break to `{os.path.basename(path)}` on " "Windows: cmd.exe ends the command line there, so the agent would silently lose it." ) - inner: Final = " ".join(_quote_for_cmd(token) for token in (path, *rest)) + inner: Final = " ".join(quote_for_cmd(token) for token in (path, *rest)) return f'cmd.exe /d /e:on /v:off /s /c "{inner}"' diff --git a/litellm/proxy/client/cli/commands/claude_settings.py b/litellm/proxy/client/cli/commands/claude_settings.py index ea1fa019c83..46af641636e 100644 --- a/litellm/proxy/client/cli/commands/claude_settings.py +++ b/litellm/proxy/client/cli/commands/claude_settings.py @@ -8,6 +8,7 @@ live here rather than in either command module. import shlex import shutil +import sys from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path @@ -17,6 +18,8 @@ from pydantic import JsonValue, TypeAdapter, ValidationError from litellm.litellm_core_utils.private_json import write_private_json +from .cmd_quoting import quote_for_cmd + ENV_KEY: Final = "env" API_KEY_HELPER_KEY: Final = "apiKeyHelper" ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL" @@ -87,9 +90,12 @@ def merge_claude_settings( return {**settings, ENV_KEY: env, API_KEY_HELPER_KEY: api_key_helper} -def resolve_api_key_helper(base_url: str) -> str: +def resolve_api_key_helper(base_url: str, platform: str = sys.platform) -> str: """Build the shell command Claude Code should run for its apiKeyHelper. + Claude Code hands the string to the system shell, `sh` on POSIX and cmd.exe + on Windows, so every token is quoted for the shell that will read it. + Resolves `lite` to an absolute path so the helper works regardless of the PATH visible to whatever subprocess Claude Code spawns it from. Passing --base-url explicitly (rather than relying on the bare invocation Claude @@ -106,7 +112,8 @@ def resolve_api_key_helper(base_url: str) -> str: raise ClaudeSettingsError( "Could not find `lite` on your PATH. Claude Code's apiKeyHelper needs an absolute path to it." ) - return f"{shlex.quote(lite_path)} --base-url {shlex.quote(base_url)} auth print-token" + quote: Final = quote_for_cmd if platform.startswith("win") else shlex.quote + return " ".join(quote(token) for token in (lite_path, "--base-url", base_url, "auth", "print-token")) def write_claude_settings(base_url: str, settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None: diff --git a/litellm/proxy/client/cli/commands/cmd_quoting.py b/litellm/proxy/client/cli/commands/cmd_quoting.py new file mode 100644 index 00000000000..efd6d584527 --- /dev/null +++ b/litellm/proxy/client/cli/commands/cmd_quoting.py @@ -0,0 +1,26 @@ +"""Quoting for command lines that cmd.exe reads before handing them to a program.""" + +from typing import Final + +_CMD_PERCENT_GUARD: Final = "%%cd:~,%" + + +def _double_trailing_backslashes(segment: str) -> str: + bare: Final = segment.rstrip("\\") + return bare + "\\" * 2 * (len(segment) - len(bare)) + + +def quote_for_cmd(token: str) -> str: + """Quote one token so both parsers that read it see the original text. + + Follows the algorithm the Rust standard library settled on for batch files + after CVE-2024-24576. Two parsers see this token: cmd.exe, which ends a + quoted string on a lone `"` and so wants an embedded one doubled, and the + program's own C runtime argv split, where a backslash escapes the quote that + follows it, so every backslash run standing before a quote is doubled. + Quoting cannot stop cmd expanding `%VAR%`, so each `%` is prefixed with + `%%cd:~,`: the zero-length substring of the always defined `cd` expands to + nothing and leaves no `%` pair for cmd to match. + """ + escaped: Final = '""'.join(_double_trailing_backslashes(part) for part in token.split('"')) + return '"' + escaped.replace("%", _CMD_PERCENT_GUARD) + '"' diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 1d860b02875..c631cc7d74a 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -176,6 +176,9 @@ if TYPE_CHECKING: ProxyConfig = _ProxyConfig else: ProxyConfig = Any +from litellm.proxy.anthropic_endpoints.streaming_model_restamp import ( + AnthropicStreamModelRestamper, +) from litellm.proxy.litellm_pre_call_utils import ( add_litellm_data_to_request, refresh_proxy_server_request_body_snapshot, @@ -1550,6 +1553,16 @@ class ProxyBaseLLMRequestProcessing: logging_obj=litellm_logging_obj, use_logging_obj=read_timing_from_logging_obj, ) + core_engine_value: Final = hidden_params.get("core_engine") + core_engine: Final = ( + core_engine_value + if isinstance(core_engine_value, str) and core_engine_value in ("python", "rust") + else "python" + ) + reserved_core_headers: Final = frozenset({"x-litellm-core", "x-litellm-rust"}) + forwarded_headers: Final = { + key: value for key, value in kwargs.items() if key.lower() not in reserved_core_headers + } cost_breakdown: Final = _get_cost_breakdown_from_logging_obj( litellm_logging_obj=litellm_logging_obj, response_cost=response_cost @@ -1634,7 +1647,9 @@ class ProxyBaseLLMRequestProcessing: str(fastest_response_batch_completion) if fastest_response_batch_completion is not None else None ), "x-litellm-timeout": str(timeout) if timeout is not None else None, - **{k: str(v) for k, v in kwargs.items()}, + **{key: str(value) for key, value in forwarded_headers.items()}, + "x-litellm-core": core_engine, + **({"x-litellm-rust": "true"} if core_engine == "rust" else {}), } if request_data: remaining_tokens_header: Final = get_remaining_tokens_and_requests_from_request_data(request_data) @@ -2490,6 +2505,9 @@ class ProxyBaseLLMRequestProcessing: request_data=self.data, proxy_logging_obj=proxy_logging_obj, request=request, + restamp_model=( + None if _should_return_raw_model_name(self.data) else requested_model_from_client + ), ) return await create_response( generator=wrap_sse_stream_with_keepalive_pings( @@ -3442,6 +3460,16 @@ class ProxyBaseLLMRequestProcessing: else: return chunk + @staticmethod + def _sse_chunk_serializer(restamper: AnthropicStreamModelRestamper | None) -> StreamChunkSerializer: + if restamper is None: + return ProxyBaseLLMRequestProcessing.return_sse_chunk + + def serialize(chunk: object) -> str: + return ProxyBaseLLMRequestProcessing.return_sse_chunk(restamper.process(chunk)) + + return serialize + @staticmethod async def _finalize_streaming_generator_cleanup( request: Request | None, @@ -3502,11 +3530,16 @@ class ProxyBaseLLMRequestProcessing: serialize_chunk: StreamChunkSerializer, serialize_error: StreamErrorSerializer, request: Request | None = None, + flush_tail: Callable[[], bytes] | None = None, ) -> AsyncGenerator[str, None]: """ Shared streaming data generator: runs proxy iterator hook, per-chunk hook, cost injection, then yields chunks via serialize_chunk; on exception runs failure hook and yields via serialize_error. Use for SSE or NDJSON. + + ``flush_tail`` runs once after the upstream iterator completes cleanly and + its non-empty result is yielded, so a serializer that buffers bytes across + chunks can emit anything still held at end of stream. """ verbose_proxy_logger.debug("inside generator") # Resolve per-stream (not per-chunk) whether the heavy per-chunk path @@ -3569,6 +3602,9 @@ class ProxyBaseLLMRequestProcessing: # so it must not suppress that refund. delivered_chunk = delivered_chunk or chunk != STREAM_SSE_KEEPALIVE_PING_BYTES yield serialize_chunk(chunk) + held_tail: Final = flush_tail() if flush_tail is not None else b"" + if held_tail: + yield serialize_chunk(held_tail) stream_completed = True except (asyncio.CancelledError, GeneratorExit): # Client disconnected mid-stream. CancelledError / GeneratorExit @@ -3579,8 +3615,7 @@ class ProxyBaseLLMRequestProcessing: # billing and release exactly once. This is the outermost generator # Starlette closes on disconnect, so the nested iterator hook (which # only sees GeneratorExit on GC) cannot own the refund. - if not stream_completed: - client_disconnected = True + client_disconnected = not stream_completed if not delivered_chunk and not _withheld_provider_output(response): from litellm.proxy.spend_tracking.budget_reservation import ( release_budget_reservation_on_cancel, @@ -3634,6 +3669,7 @@ class ProxyBaseLLMRequestProcessing: request_data: dict, proxy_logging_obj: ProxyLogging, request: Request | None = None, + restamp_model: str | None = None, ) -> AsyncGenerator[str, None]: """ Anthropic /messages and Google /generateContent streaming data generator require SSE events. @@ -3642,17 +3678,23 @@ class ProxyBaseLLMRequestProcessing: SSE serializers directly (rather than re-wrapping it in another ``async for: yield`` trampoline), so a streamed chunk traverses one fewer async-generator layer / coroutine resume on the hot path. + + ``restamp_model`` publishes that name on the Anthropic ``message_start`` + event in place of the provider's model, matching what the non-streaming + response reports. """ + restamper: Final = AnthropicStreamModelRestamper(restamp_model) if restamp_model else None return ProxyBaseLLMRequestProcessing.async_streaming_data_generator( response=response, user_api_key_dict=user_api_key_dict, request_data=request_data, proxy_logging_obj=proxy_logging_obj, - serialize_chunk=ProxyBaseLLMRequestProcessing.return_sse_chunk, + serialize_chunk=ProxyBaseLLMRequestProcessing._sse_chunk_serializer(restamper), serialize_error=lambda proxy_exc: ( f"{STREAM_SSE_DATA_PREFIX}{json.dumps({'error': proxy_exc.to_dict()})}\n\n" ), request=request, + flush_tail=None if restamper is None else restamper.flush, ) @overload diff --git a/litellm/proxy/db/db_url_settings.py b/litellm/proxy/db/db_url_settings.py index 1a39016b3a3..01f66e4f3c5 100644 --- a/litellm/proxy/db/db_url_settings.py +++ b/litellm/proxy/db/db_url_settings.py @@ -82,10 +82,30 @@ CONNECTION_PARAM_KEYS: Final[frozenset[str]] = frozenset( "pool_timeout", "connect_timeout", "socket_timeout", + "max_idle_connection_lifetime", "pgbouncer", } ) +# Quaint never tests pooled connections on checkout and keeps them idle for +# 300s by default, past many infra idle timeouts, so dead sockets surface as +# `Error { kind: Closed }`. 60s recycles them first; explicit values win. +DEFAULT_MAX_IDLE_CONNECTION_LIFETIME: Final = 60 +IDLE_LIFETIME_DEFAULT_PARAMS: Final[Mapping[str, int]] = MappingProxyType( + {"max_idle_connection_lifetime": DEFAULT_MAX_IDLE_CONNECTION_LIFETIME} +) + + +def idle_lifetime_params(configured: float | None) -> Mapping[str, str | int | float]: + """The `max_idle_connection_lifetime` to add to URLs that do not pin one. + + Applied via ``add_missing_query_params`` so a URL-pinned value always wins, + whether the operator configured `database_max_idle_connection_lifetime` or not. + """ + if configured is None: + return IDLE_LIFETIME_DEFAULT_PARAMS + return MappingProxyType({"max_idle_connection_lifetime": configured}) + def add_missing_query_params(url: str, params: Mapping[str, str | int | float]) -> str: """Return ``url`` with the ``params`` it does not already carry appended. diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py new file mode 100644 index 00000000000..75ea16f7a88 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING, Final + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .alice import AliceGuardrail + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"): + import litellm + + _alice_guardrail_callback: Final = AliceGuardrail( + api_key=litellm_params.api_key, + api_base=litellm_params.api_base, + unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"), + guardrail_name=guardrail.get("guardrail_name", ""), + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + ) + + litellm.logging_callback_manager.add_litellm_callback(_alice_guardrail_callback) + return _alice_guardrail_callback + + +guardrail_initializer_registry: Final = { # mutable-ok: module-level registry, built once and never mutated + SupportedGuardrailIntegrations.ALICE.value: initialize_guardrail, +} + + +guardrail_class_registry: Final = { # mutable-ok: module-level registry, built once and never mutated + SupportedGuardrailIntegrations.ALICE.value: AliceGuardrail, +} diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py b/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py new file mode 100644 index 00000000000..27018769909 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py @@ -0,0 +1,369 @@ +# +-------------------------------------------------------------+ +# +# Use Alice for your LLM calls +# https://alice.io/ +# +# +-------------------------------------------------------------+ + +import json +import os +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, # noqa: TID251 # **kwargs forwards verbatim to CustomGuardrail.__init__; see ruff-strict.toml + Final, + Literal, + Optional, +) + +import httpx +from typing_extensions import NotRequired, ReadOnly, TypedDict + +from litellm._logging import verbose_proxy_logger +from litellm.exceptions import GuardrailRaisedException, Timeout +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import GenericGuardrailAPIInputs + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + +GUARDRAIL_NAME: Final = "alice" + +_DEFAULT_API_BASE: Final = "https://api.alice.io" +_EVALUATE_PATH: Final = "/v2/evaluate/litellm" + +_VERDICT_ALLOW: Final = "ALLOW" +_VERDICT_BLOCK: Final = "BLOCK" +_VERDICT_MASK: Final = "MASK" +_VERDICT_DETECT: Final = "DETECT" +_KNOWN_VERDICTS: Final = frozenset({_VERDICT_ALLOW, _VERDICT_BLOCK, _VERDICT_MASK, _VERDICT_DETECT}) + +_DEFAULT_BLOCK_MESSAGE: Final = "Blocked by your organization's content policy." + +# apply_guardrail selects nothing: it forwards whichever of these came populated and lets Alice +# decide what is worth evaluating. Only skip the call when every one of them is empty — there is +# then genuinely nothing to send. +_SELECTABLE_INPUT_FIELDS: Final = ("texts", "images", "tools", "tool_calls", "structured_messages") + +# Caps on the outbound copy of request_data. A payload deeper or wider than this is malformed +# rather than large, and serializing it would cost more than the evaluation it feeds. +_MAX_DEPTH: Final = 12 +_MAX_ITEMS: Final = 5000 + +# request_data carries the caller's raw credentials under these keys, at any nesting depth — +# a real captured payload puts inbound headers at request_data["proxy_server_request"]["headers"], +# again under ["metadata"]["headers"] / ["litellm_metadata"]["headers"], and again under +# ["metadata"]["requester_metadata"]["headers"], any of which can carry an Authorization or +# x-api-key value. LiteLLM's own spend-log sanitizer excludes `secret_fields` for the same reason +# (spend_tracking_utils._SENSITIVE_REQUEST_BODY_KEYS): `secret_fields.raw_headers` holds the +# caller's Authorization / x-api-key in the clear, and `api_key` can carry a forwarded provider +# credential. Stripping by key name rather than by path means a new nesting path can never +# reintroduce the leak. Posting any of these to a third-party guardrail endpoint would be worse +# than what the proxy already refuses to persist in its own audit trail — so none of them leave +# the process. +_CREDENTIAL_KEYS_TO_STRIP: Final = frozenset( + {"secret_fields", "api_key", "raw_headers", "headers", "provider_specific_header"} +) + + +class AliceReplacement(TypedDict): + """A masked substitution, positional against the texts that were submitted.""" + + index: ReadOnly[NotRequired[int]] + text: ReadOnly[NotRequired[str]] + + +class AliceVerdict(TypedDict): + """Body returned by Alice's LiteLLM evaluate endpoint.""" + + verdict: ReadOnly[NotRequired[str]] + categories: ReadOnly[NotRequired["tuple[str, ...]"]] + correlation_id: ReadOnly[NotRequired[str]] + message: ReadOnly[NotRequired[str]] + replacements: ReadOnly[NotRequired["tuple[AliceReplacement, ...]"]] + + +class AliceGuardrailMissingSecrets(Exception): + """Raised when the Alice API key is not configured.""" + + +class AliceGuardrail(CustomGuardrail): + """ + Alice — policy-based guardrails for prompts and model responses. + + This forwards the hook's arguments as it received them and enforces the verdict that comes + back, with one deliberate exception: any key named `secret_fields`, `api_key`, `raw_headers`, + `headers`, or `provider_specific_header` is dropped from `request_data` at any nesting depth + before it is serialized, and never reaches Alice. Short of that, it selects nothing and + renames nothing: which parts of a conversation are worth evaluating, and how a verdict is + reached, are decided by Alice — so changing either is a change on their side rather than a + LiteLLM upgrade. A batch with nothing selectable at all (no `texts`, `images`, `tools`, + `tool_calls`, or `structured_messages`) still skips the call, since there would be nothing to + send. + + Known limitation: the unified guardrail's `streaming_transform_mode` defaults to + `block_only`, whose streaming path discards any returned text rewrite. A MASK verdict is + therefore a no-op on a streamed response — the original, unmasked text still reaches the + caller — while BLOCK continues to function on both streamed and non-streamed responses. + This is `during_call`'s documented behavior generally, not specific to Alice; configure a + masking-aware `streaming_transform_mode` if that gap matters for your traffic. + + Alice evaluates against policies configured per *application*, and one proxy typically fronts + several, so the application is named on the virtual key rather than in this config: + + curl $PROXY/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \\ + -d '{"key_alias": "payments-bot", + "metadata": {"alice_app_id": "payments-bot"}}' + + Alice reads that off the authenticated key. Because the proxy strips caller-supplied + `user_api_key_*` from the request before a guardrail sees it, a caller cannot point its own + traffic at an application with laxer policies than the one its key was issued for. + + Configuration example (litellm config YAML): + guardrails: + - guardrail_name: alice + litellm_params: + guardrail: alice + mode: [pre_call, post_call] + api_key: os.environ/ALICE_API_KEY + api_base: https://api.alice.io # optional + unreachable_fallback: fail_closed # optional + """ + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", + **kwargs: Any, # kwargs-ok: forwarded verbatim to CustomGuardrail.__init__, whose param list is wide and evolving + ) -> None: + self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) + + alice_api_key: Final = api_key or os.environ.get("ALICE_API_KEY") + if not alice_api_key: + raise AliceGuardrailMissingSecrets( + "Alice API key is required. Set the `ALICE_API_KEY` environment variable or " + "pass `api_key` in the guardrail config." + ) + self.alice_api_key: str = alice_api_key + + base: Final = (api_base or os.environ.get("ALICE_API_BASE") or _DEFAULT_API_BASE).rstrip("/") + self.api_base: str = f"{base}{_EVALUATE_PATH}" + self.unreachable_fallback: Literal["fail_closed", "fail_open"] = unreachable_fallback + + if "supported_event_hooks" not in kwargs: + kwargs["supported_event_hooks"] = [ # mutable-ok: CustomGuardrail.__init__ requires a list here + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, + GuardrailEventHooks.post_call, + ] + + super().__init__(**kwargs) + + @log_guardrail_information + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict[str, object], # mutable-ok: overrides CustomGuardrail.apply_guardrail's plain-dict contract + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + if not any(inputs.get(field) for field in _SELECTABLE_INPUT_FIELDS): + return inputs + + try: + verdict: AliceVerdict = await self._evaluate( + inputs=inputs, request_data=request_data, input_type=input_type + ) + except Timeout as e: + return self._on_unreachable(e, inputs) + except httpx.HTTPStatusError as e: + status_code: Final = getattr(getattr(e, "response", None), "status_code", None) + # Any 5xx is an outage on Alice's side, not our misconfiguration — route the whole + # class through the configured policy. A 4xx (rejected credential, bad request) is + # ours to fix and must never fail open, so it is deliberately left to propagate. + if isinstance(status_code, int) and 500 <= status_code < 600: + return self._on_unreachable(e, inputs) + raise + except httpx.RequestError as e: + return self._on_unreachable(e, inputs) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as e: + # A body that cannot be decoded, cannot be parsed as JSON, or parses to something + # other than an object, is as unreachable as a dropped connection: this deployment's + # policy decides, not a raw exception. UnicodeDecodeError is named explicitly because + # it is a sibling of JSONDecodeError under ValueError, not a subclass of it. + return self._on_unreachable(e, inputs) + + return self._enforce(verdict, inputs) + + async def _evaluate( + self, + inputs: GenericGuardrailAPIInputs, + request_data: Mapping[str, object], + input_type: str, + ) -> AliceVerdict: + response: Final = await self.async_handler.post( + url=self.api_base, + json={ # mutable-ok: one-shot HTTP request body, never mutated after construction + "input_type": input_type, + "inputs": _json_safe(inputs), + "request_data": _json_safe(request_data, strip_keys=_CREDENTIAL_KEYS_TO_STRIP), + }, + headers={ # mutable-ok: one-shot HTTP headers, never mutated after construction + "Content-Type": "application/json", + "af-api-key": self.alice_api_key, + }, + ) + response.raise_for_status() + body = response.json() + if not isinstance(body, dict): + raise TypeError("Alice returned a non-object body") + return body + + def _enforce(self, verdict: AliceVerdict, inputs: GenericGuardrailAPIInputs) -> GenericGuardrailAPIInputs: + """Act on the verdict. An answer we cannot read is treated as unavailable, never as a pass.""" + name: Final = verdict.get("verdict") + if name not in _KNOWN_VERDICTS: + return self._on_unreachable(ValueError(f"unrecognized verdict: {name!r}"), inputs) + + if name == _VERDICT_BLOCK: + raise GuardrailRaisedException( + guardrail_name=GUARDRAIL_NAME, + message=verdict.get("message") or _DEFAULT_BLOCK_MESSAGE, + should_wrap_with_default_message=False, + blocked_content=True, + ) + + if name == _VERDICT_DETECT: + # Recorded by Alice and allowed through. The correlation id is what ties this request + # to that record; the evaluated text itself is never logged. + verbose_proxy_logger.warning( + "Alice guardrail: detection recorded, request allowed (correlation_id=%s, categories=%s)", + verdict.get("correlation_id"), + verdict.get("categories"), + ) + return inputs + + if name == _VERDICT_MASK: + self._apply_replacements(verdict, inputs) + + return inputs + + def _apply_replacements(self, verdict: AliceVerdict, inputs: GenericGuardrailAPIInputs) -> None: + """ + Write each replacement onto the text it names. + + Only `texts` is touched. The chat translation layer maps a returned `texts` list back onto + the request positionally, but takes a different branch entirely when `structured_messages` + comes back as a new object — which would drop these edits. + + All-or-nothing: a single out-of-range or malformed replacement blocks the whole verdict + rather than being silently skipped, so content Alice meant to replace can never reach the + model unmasked alongside content that was replaced. + """ + texts: Final = inputs.get("texts") or [] # mutable-ok: empty-list fallback, replaced wholesale below + replacements: Final = verdict.get("replacements") or [] # mutable-ok: empty-list fallback for iteration only + + if not replacements: + raise self._mask_rejected(verdict) + + for replacement in replacements: + index = replacement.get("index") + text = replacement.get("text") + if not (isinstance(index, int) and isinstance(text, str) and 0 <= index < len(texts)): + raise self._mask_rejected(verdict) + texts[index] = text # mutable-ok: item assignment into the local working copy above + + inputs["texts"] = texts + + def _mask_rejected(self, verdict: AliceVerdict) -> GuardrailRaisedException: + """A MASK verdict that cannot be applied in full is refused outright, never partially — + see `_apply_replacements`.""" + return GuardrailRaisedException( + guardrail_name=GUARDRAIL_NAME, + message=verdict.get("message") or _DEFAULT_BLOCK_MESSAGE, + should_wrap_with_default_message=False, + blocked_content=True, + ) + + def _on_unreachable(self, error: Exception, inputs: GenericGuardrailAPIInputs) -> GenericGuardrailAPIInputs: + """Apply the configured policy when Alice cannot be reached or cannot be understood.""" + if self.unreachable_fallback == "fail_open": + verbose_proxy_logger.critical( + "Alice guardrail unreachable, allowing request per unreachable_fallback: %s", + error, + ) + return inputs + raise GuardrailRaisedException( + guardrail_name=GUARDRAIL_NAME, + message="Alice guardrail is unavailable and this request cannot be checked", + should_wrap_with_default_message=False, + ) from error + + @staticmethod + def get_config_model() -> type | None: + from litellm.types.proxy.guardrails.guardrail_hooks.alice import ( + AliceGuardrailConfigModel, + ) + + return AliceGuardrailConfigModel + + +def _json_safe( + value: object, + depth: int = 0, + seen: frozenset[int] = frozenset(), + strip_keys: frozenset[str] = frozenset(), +) -> object: + """ + Copy `value` into something `json.dumps` accepts, dropping only what cannot cross. + + `request_data` carries live Python objects — an OpenTelemetry span among them — so it cannot + be serialized as it stands. What is dropped is decided by a mechanical rule rather than a + field list: a list drifts from what the far side needs, a rule cannot. Serializing naively + raises, and that error would be read as "guardrail unavailable" on every single request. + + `strip_keys` drops a dict key by name at every depth it appears, not just the root — a caller + passes `_CREDENTIAL_KEYS_TO_STRIP` here so a credential nested under any path is caught the + same way a top-level one is, without maintaining a list of paths. The source object is never + mutated: every branch below builds a new container. + """ + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if depth >= _MAX_DEPTH or id(value) in seen: + return None + + nested: Final = seen | {id(value)} # mutable-ok: one-shot set literal, unioned into a frozenset immediately + + if isinstance(value, dict): + out: dict[str, object] = {} # mutable-ok: bounded accumulator local to this call, never escapes as-is + for key, item in list(value.items())[:_MAX_ITEMS]: # mutable-ok: list() only to slice an unordered view + if isinstance(key, str) and key not in strip_keys: + out[key] = _json_safe(item, depth + 1, nested, strip_keys) + return out + + if isinstance(value, (list, tuple, set, frozenset)): + return [ # mutable-ok: return value is a one-shot list, discarded by the caller after use + _json_safe(item, depth + 1, nested, strip_keys) + for item in list(value)[:_MAX_ITEMS] # mutable-ok: list() only to slice an unordered view + ] + + dump: Final = getattr(value, "model_dump", None) + if callable(dump): + try: + return _json_safe(dump(mode="json"), depth + 1, nested, strip_keys) + except Exception: # noqa: BLE001 # a model that will not dump is one we drop + return None + + # Everything json.dumps handles natively — str, int, float, bool, None, dict, list — is + # caught above, and a dict/list subclass is caught by isinstance. So whatever reaches here + # (bytes, datetime, an OpenTelemetry span) cannot cross the wire. + return None diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 0237d82a0d9..30526d30dc5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -31,6 +31,7 @@ from litellm.caching import DualCache from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS from litellm.exceptions import ModifyResponseException from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys from litellm.litellm_core_utils.litellm_logging import ( _get_masked_values, # pyright: ignore[reportPrivateUsage] # the shared header-masking helper has no public name @@ -215,6 +216,16 @@ def _redact_assessment_match_fields(assessments: list[dict]) -> list[dict]: return redacted if isinstance(redacted, list) else assessments +_RESPONSES_API_CALL_TYPES: Final = frozenset({CallTypes.responses, CallTypes.aresponses}) + + +def _is_responses_api_route(request_route: str | None) -> bool: + if request_route is None: + return False + call_types: Final = get_call_types_for_route(request_route) + return call_types is not None and any(call_type in _RESPONSES_API_CALL_TYPES for call_type in call_types) + + class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # During-call must use async_moderation_hook (not unified apply_guardrail), otherwise # OpenAI translation always passes input_type="request" and spend/UI show PRE-CALL. @@ -2709,6 +2720,24 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): yield streamed_chunk return + # Responses-API events are neither chat-completions chunks nor raw + # Anthropic SSE, so the assembly below cannot scan them; the unified + # guardrail's translation layer can, with buffering semantics kept. + if _is_responses_api_route(user_api_key_dict.request_route): + from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( + UnifiedLLMGuardrails, + ) + + async for translated_chunk in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=response, + request_data=request_data, + guardrail_to_apply=self, + buffer_until_moderated_default=True, + ): + yield translated_chunk + return + # Import here to avoid circular imports from litellm.llms.base_llm.base_model_iterator import MockResponseIterator from litellm.main import stream_chunk_builder diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 14d2332a7eb..ca66640bf46 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -54,7 +54,10 @@ from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, publish_config_change, ) -from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + encrypt_value_helper, +) from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin from litellm.proxy.management_endpoints.team_endpoints import ( @@ -701,6 +704,12 @@ async def patch_model( param="blocked", ) + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=patch_data.litellm_params, + user_api_key_dict=user_api_key_dict, + existing_litellm_params=db_model.litellm_params, + ) + _raise_on_strategy_router_write_violation( incoming_params=patch_data.litellm_params, existing_params=db_model.litellm_params, @@ -1464,6 +1473,32 @@ class ModelManagementAuthChecks: ) return True + @staticmethod + def can_user_attach_credential( + litellm_params: GenericLiteLLMParams | None, + user_api_key_dict: UserAPIKeyAuth, + existing_litellm_params: GenericLiteLLMParams | None = None, + ) -> Literal[True]: + if litellm_params is None or litellm_params.litellm_credential_name is None: + return True + if existing_litellm_params is not None and existing_litellm_params.litellm_credential_name is not None: + existing_credential_name: Final = decrypt_value_helper( + value=existing_litellm_params.litellm_credential_name, + key="litellm_credential_name", + exception_type="debug", + return_original_value=True, + ) + if litellm_params.litellm_credential_name == existing_credential_name: + return True + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + return True + raise ProxyException( + message=f"Only a proxy admin can attach a stored credential (litellm_credential_name) to a model. Your role={user_api_key_dict.user_role}.", + type=ProxyErrorTypes.auth_error.value, + code=status.HTTP_403_FORBIDDEN, + param="litellm_credential_name", + ) + @staticmethod async def allow_team_model_action( model_params: Deployment | updateDeployment, @@ -1786,6 +1821,11 @@ async def add_new_model( premium_user=premium_user, ) + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=model_params.litellm_params, + user_api_key_dict=user_api_key_dict, + ) + _raise_on_strategy_router_write_violation( incoming_params=model_params.litellm_params, existing_params=None, @@ -1958,6 +1998,12 @@ async def update_model( premium_user=premium_user, ) + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=model_params.litellm_params, + user_api_key_dict=user_api_key_dict, + existing_litellm_params=deployment.litellm_params, + ) + _raise_on_strategy_router_write_violation( incoming_params=model_params.litellm_params, existing_params=deployment.litellm_params, diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index ded57815e91..069f86c852c 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -29,6 +29,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.models.user import SCIMPlaceholder from litellm.proxy._types import ( LiteLLM_TeamTable, LiteLLM_UserTable, @@ -585,6 +586,37 @@ async def _users_named_by_member_value( return tuple(dict.fromkeys(row.user_id for row in rows)) +async def _accounts_named_by_member_value(value: str, prisma_client: PrismaClient) -> tuple[str, ...]: + """Every user id this member value names, by user id, SSO identity or email. + + Classification needs to know whether the value is one account's ``user_id`` and + whether it names any other account, so all three fields are read in one pass. The + id is compared exactly and unstripped, as a primary key lookup would; the + identities compare as ``_users_named_by_member_value`` describes. Two rows are + enough to tell one account from several, so the read stops there. Only a full + read that lacks the row keyed by the value leaves that row's existence open, and + only then is the id read on its own. + """ + subject: Final = value.strip() + email: Final[_CaseInsensitiveMatch] = {"equals": subject, "mode": "insensitive"} + users: Final = _table(UserRepository(prisma_client)) + rows: Final = await users.find_many( + where={ # mutable-ok: Prisma filter + "OR": [ # mutable-ok: Prisma filter + {"user_id": value}, # mutable-ok: Prisma filter + {"sso_user_id": subject}, # mutable-ok: Prisma filter + {"user_email": email}, # mutable-ok: Prisma filter + ], + }, + take=2, + ) + named: Final = tuple(dict.fromkeys(row.user_id for row in rows)) + if len(named) < 2 or value in named: + return named + keyed: Final = await users.find_unique(where={"user_id": value}) + return named if keyed is None else (value, *named) + + async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient) -> _ClassifiedGroupMember: """ Decide what a single SCIM group member refers to. @@ -627,11 +659,9 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient if member_type == "group": return _SkippedGroupMember(value=value, reason="nested_group") - user: Final = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": value}) - if user is not None: - shared_with: Final = tuple( - other for other in await _users_named_by_member_value(value, prisma_client) if other != value - ) + named: Final = await _accounts_named_by_member_value(value, prisma_client) + if value in named: + shared_with: Final = tuple(other for other in named if other != value) if shared_with: verbose_proxy_logger.warning( "SCIM: group member '%s' is one account's user id and is also account '%s' by SSO identity or email, " @@ -651,7 +681,6 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient if team is not None and _team_metadata_has_scim_provenance(team.metadata): return _SkippedGroupMember(value=value, reason="existing_team") - named: Final = await _users_named_by_member_value(value, prisma_client) if len(named) == 1: verbose_proxy_logger.info( "SCIM: group member '%s' matched user_id '%s' by SSO identity or email", @@ -1834,6 +1863,89 @@ async def delete_user( raise handle_exception_on_proxy(e) +@scim_router.get( + "/placeholders", + response_model=tuple[SCIMPlaceholder, ...], + dependencies=(Depends(user_api_key_auth),), +) +async def list_placeholders() -> tuple[SCIMPlaceholder, ...]: + """ + List user rows whose id is another account's SSO identity or email. + + An earlier release provisioned a group member it could not match as a user keyed + by the raw member value, and that row now shadows the account the value really + names, so every push of that member is refused. This lists those rows so an + operator can fold each one into the account it shadows with + ``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of + its own or owns virtual keys is left out: someone uses that account. + """ + try: + prisma_client: Final = await _get_prisma_client_or_raise_exception() + async with prisma_client.tx() as tx: + return await UserRepository(prisma_client).find_shadowing_placeholders(tx) + except Exception as e: + raise handle_exception_on_proxy(e) + + +def _placeholder_rejection(placeholder: LiteLLM_UserTable, resolved: tuple[str, ...], key_count: int) -> str | None: + if placeholder.sso_user_id is not None: + return f"User '{placeholder.user_id}' has an SSO identity of its own, so it is an account someone signs in to" + if key_count: + return f"User '{placeholder.user_id}' owns {key_count} virtual keys. Move or delete them before merging it" + if not resolved: + return f"User '{placeholder.user_id}' shadows no account: no other user has that id as SSO identity or email" + if len(resolved) > 1: + return ( + f"User '{placeholder.user_id}' names {len(resolved)} accounts ({', '.join(resolved)}). Resolve that first" + ) + return None + + +@scim_router.post( + "/placeholders/{user_id}/merge", + response_model=SCIMPlaceholderMergeResult, + dependencies=(Depends(user_api_key_auth),), +) +async def merge_placeholder( + user_id: str = Path(..., title="User ID"), +) -> SCIMPlaceholderMergeResult: + """ + Fold a placeholder user into the one account its id names by SSO identity or email. + + The account is added to every team the placeholder is on, then the placeholder is + deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group + push resolves the member value to the real account. Refused with 409 when the row + has an SSO identity of its own, owns virtual keys, or names no account or several. + """ + try: + prisma_client: Final = await _get_prisma_client_or_raise_exception() + placeholder: Final = await _check_user_exists(user_id) + resolved: Final = tuple( + other for other in await _users_named_by_member_value(user_id, prisma_client, take=None) if other != user_id + ) + owned_keys: Final[_UserIdWhere] = {"user_id": user_id} + keys: Final = await _table(VerificationTokenRepository(prisma_client)).find_many(where=owned_keys) + rejection: Final = _placeholder_rejection(placeholder, resolved, len(keys)) + if rejection is not None: + detail: Final[_ScimErrorDetail] = {"error": rejection} + raise HTTPException(status_code=409, detail=detail) + + target_user_id: Final = resolved[0] + team_ids: Final = tuple(placeholder.teams) + for team_id in team_ids: + await _add_user_to_team(user_id=target_user_id, team_id=team_id) + await delete_user(user_id=user_id) + await _recompute_scim_member_roles(prisma_client, (target_user_id,)) + verbose_proxy_logger.info( + "SCIM: merged placeholder user '%s' into '%s', moving teams %s", user_id, target_user_id, team_ids + ) + return SCIMPlaceholderMergeResult( + placeholder_user_id=user_id, merged_into_user_id=target_user_id, team_ids=team_ids + ) + except Exception as e: + raise handle_exception_on_proxy(e) + + def _parse_member_entry(entry: object) -> SCIMMember | None: """Parse one entry of a SCIM patch value, or None when it carries no id.""" if isinstance(entry, str): diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index ff306eb65f1..79d5d0a016f 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -812,6 +812,8 @@ def _resolve_team_callback_wiring( else { # mutable-ok: Logging arg **callback_vars, TRUSTED_CALLBACK_VARS_FIELD: callback_vars, + "metadata": {}, # mutable-ok: Logging arg + "model_info": {}, # mutable-ok: Logging arg } ) return _TeamCallbackWiring( diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 86f6853a625..23932ba7c8c 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -913,14 +913,13 @@ class ProxyInitializationHelpers: envvar="ENFORCE_PRISMA_MIGRATION_CHECK", ) @click.option( - "--use_v2_migration_resolver/--use_legacy_migration_resolver", - default=True, + "--use_v2_migration_resolver", + is_flag=True, + default=False, help=( - "Which database migration resolver to run at startup. The default v2 " - "resolver avoids the diff-and-force recovery path that can cause schema " - "thrashing during rolling deploys where two LiteLLM versions contend for " - "the same DB. Pass --use_legacy_migration_resolver, or set " - "USE_V2_MIGRATION_RESOLVER=false, to fall back to v1." + "Opt into the v2 migration resolver. Avoids the diff-and-force recovery " + "path that can cause schema thrashing during rolling deploys where two " + "LiteLLM versions contend for the same DB. Default is the v1 resolver." ), envvar="USE_V2_MIGRATION_RESOLVER", ) @@ -1226,6 +1225,7 @@ def run_server( if os.getenv("DATABASE_URL", None) is not None or os.getenv("DIRECT_URL", None) is not None: from litellm.proxy.db.db_url_settings import ( add_missing_query_params, + idle_lifetime_params, reader_shareable_params, unsupported_db_scheme, unsupported_db_scheme_message, @@ -1254,6 +1254,9 @@ def run_server( disable_prepared_statements=db_disable_prepared_statements, extra_params=db_extra_connection_params, ) + lifetime_params: Final = idle_lifetime_params( + general_settings.get("database_max_idle_connection_lifetime") + ) if os.getenv("DATABASE_URL", None) is not None: database_url = get_secret("DATABASE_URL", default_value=None) resolved_url: Final[str | None] = str(database_url) if database_url else None @@ -1271,11 +1274,11 @@ def run_server( writer_url, connection_url_params, ) - os.environ["DATABASE_URL"] = modified_url + os.environ["DATABASE_URL"] = add_missing_query_params(modified_url, lifetime_params) if os.getenv("DIRECT_URL", None) is not None: database_url = os.getenv("DIRECT_URL") modified_url = append_query_params(database_url, connection_url_params) - os.environ["DIRECT_URL"] = modified_url + os.environ["DIRECT_URL"] = add_missing_query_params(modified_url, lifetime_params) # The reader pool is a real pool against the same configured cap, so it # gets the allowlisted pool params. Schema-affecting ones, including any # the operator smuggled in through database_extra_connection_params, stay @@ -1289,10 +1292,13 @@ def run_server( db_lock_timeout, ) os.environ["DATABASE_URL_READ_REPLICA"] = add_missing_query_params( - _with_query_value(read_replica_url, "options", reader_options) - if reader_options - else read_replica_url, - reader_shareable_params(connection_url_params), + add_missing_query_params( + _with_query_value(read_replica_url, "options", reader_options) + if reader_options + else read_replica_url, + reader_shareable_params(connection_url_params), + ), + lifetime_params, ) subprocess.run(["prisma"], capture_output=True) is_prisma_runnable = True @@ -1311,11 +1317,10 @@ def run_server( else: if not use_v2_migration_resolver: print( - "\033[1;33mLiteLLM Proxy: Using the legacy (v1) migration resolver. " - "The default v2 resolver is safer: it avoids the diff-and-force " - "recovery that caused schema thrashing during rolling deploys. " - "Remove --use_legacy_migration_resolver / " - "USE_V2_MIGRATION_RESOLVER=false to switch back to it.\033[0m" + "\033[1;33mLiteLLM Proxy: Using default (v1) migration resolver. " + "If your deployment has seen schema thrashing during rolling " + "deploys, try --use_v2_migration_resolver (safer: avoids the " + "diff-and-force recovery that caused the thrash).\033[0m" ) try: setup_ok: Final = PrismaManager.setup_database( @@ -1323,10 +1328,10 @@ def run_server( use_v2_resolver=use_v2_migration_resolver, ) except RuntimeError as e: - # Raised on unrecoverable migration errors: permission - # failures from either resolver, the v2 resolver's - # non-idempotent failures, and any `prisma db push` - # against a partitioned LiteLLM_SpendLogs. + # Raised on unrecoverable migration errors: the v2 + # resolver's non-idempotent failures and permission + # issues, and any `prisma db push` against a + # partitioned LiteLLM_SpendLogs. print( f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {e}\033[0m", file=sys.stderr, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 70bf8fd0554..77a80ea0052 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -39,7 +39,7 @@ from typing import ( import anyio import websockets import websockets.exceptions -from pydantic import BaseModel, Json, JsonValue +from pydantic import BaseModel, Json, JsonValue, ValidationError from typing_extensions import NotRequired, ReadOnly, assert_never from litellm._uuid import uuid @@ -253,6 +253,7 @@ from litellm.constants import ( PROXY_BUDGET_RESCHEDULER_MAX_TIME, PROXY_BUDGET_RESCHEDULER_MIN_TIME, PROXY_CONFIG_RELOAD_INTERVAL_SECONDS, + USER_SPEND_ALERTS_JOB_ID, WEEKLY_SPEND_REPORT_JOB_ID, ) from litellm.exceptions import RejectedRequestError @@ -9866,6 +9867,35 @@ class ProxyStartupEvent: replace_existing=True, ) + slack_alerting_args: Final = proxy_logging_obj.slack_alerting_instance.alerting_args + user_spend_check_interval: Final = ( + slack_alerting_args.user_spend_check_interval + if isinstance(slack_alerting_args, SlackAlertingArgs) # pyright: ignore[reportUnnecessaryIsInstance] # tests inject a mock slack_alerting_instance + else SlackAlertingArgs().user_spend_check_interval + ) + + async def _scheduled_user_spend_alerts() -> None: + if ( + await pod_lock_manager.acquire_lock( + cronjob_id=USER_SPEND_ALERTS_JOB_ID, + ttl=max(user_spend_check_interval - 60, 60), + allow_reentrant=False, + ) + is False + ): + return + await proxy_logging_obj.slack_alerting_instance.send_user_spend_alerts() + + scheduler.add_job( + _scheduled_user_spend_alerts, + "interval", + seconds=user_spend_check_interval, + next_run_time=datetime.now(timezone.utc) + timedelta(seconds=10 + random.randint(0, 60)), + id=USER_SPEND_ALERTS_JOB_ID, + replace_existing=True, + misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, + ) + if os.getenv("PROMETHEUS_URL"): from zoneinfo import ZoneInfo @@ -12481,11 +12511,21 @@ async def supported_openai_params(model: str): --header 'Authorization: Bearer sk-1234' ``` """ + from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider + + global llm_router try: - model, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model) + resolved_models: Final = llm_router.resolved_litellm_models(model) if llm_router is not None else () + target_model: Final = resolved_models[0] if resolved_models else model + declared_provider: Final = declared_authenticating_provider(target_model) + litellm_model, custom_llm_provider = ( + (target_model.removeprefix(f"{declared_provider}/"), declared_provider) + if declared_provider is not None + else litellm.get_llm_provider(model=target_model)[:2] + ) return { "supported_openai_params": litellm.get_supported_openai_params( - model=model, custom_llm_provider=custom_llm_provider + model=litellm_model, custom_llm_provider=custom_llm_provider ) } except Exception: @@ -14962,17 +15002,25 @@ async def alerting_settings( alerting_args_dict = {} alerting_values = None - allowed_args: Final = { - "slack_alerting": {"type": "Boolean"}, - "daily_report_frequency": {"type": "Integer"}, - "report_check_interval": {"type": "Integer"}, - "budget_alert_ttl": {"type": "Integer"}, - "outage_alert_ttl": {"type": "Integer"}, - "region_outage_alert_ttl": {"type": "Integer"}, - "minor_outage_alert_threshold": {"type": "Integer"}, - "major_outage_alert_threshold": {"type": "Integer"}, - "max_outage_alert_list_size": {"type": "Integer"}, - } + allowed_args: Final = MappingProxyType( + { + "slack_alerting": "Boolean", + "daily_report_frequency": "Integer", + "report_check_interval": "Integer", + "budget_alert_ttl": "Integer", + "outage_alert_ttl": "Integer", + "region_outage_alert_ttl": "Integer", + "minor_outage_alert_threshold": "Integer", + "major_outage_alert_threshold": "Integer", + "max_outage_alert_list_size": "Integer", + "daily_spend_per_user_threshold": "Float", + "monthly_spend_per_user_threshold": "Float", + "spend_anomaly_multiplier": "Float", + "spend_anomaly_baseline_days": "Integer", + "spend_anomaly_min_spend": "Float", + "user_spend_check_interval": "Integer", + } + ) _slack_alerting: Final[SlackAlerting] = proxy_logging_obj.slack_alerting_instance _slack_alerting_args_dict: Final = _slack_alerting.alerting_args.model_dump() @@ -14987,7 +15035,7 @@ async def alerting_settings( _response_obj = ConfigList( field_name="slack_alerting", - field_type=allowed_args["slack_alerting"]["type"], + field_type=allowed_args["slack_alerting"], field_description="Enable slack alerting for monitoring proxy in production: llm outages, budgets, spend tracking failures.", field_value=is_slack_enabled, stored_in_db=True if alerting_values is not None else False, @@ -15006,7 +15054,7 @@ async def alerting_settings( _response_obj = ConfigList( field_name=field_name, - field_type=allowed_args[field_name]["type"], + field_type=allowed_args[field_name], field_description=field_info.description or "", field_value=_slack_alerting_args_dict.get(field_name, None), stored_in_db=_stored_in_db, @@ -16434,6 +16482,16 @@ async def update_config_general_settings( detail={"error": f"Invalid type of field value={type(data.field_value)} passed in."}, ) + if data.field_name == "alerting_args": + try: + SlackAlertingArgs.model_validate(data.field_value) + except ValidationError as e: + errors: Final = "; ".join(f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in e.errors()) + raise HTTPException( + status_code=400, + detail={"error": f"Invalid alerting_args: {errors}"}, + ) + ## get general settings from db db_general_settings: Final = await _config_param_table(prisma_client).find_first( where={"param_name": "general_settings"} diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index f4d8fd7d906..91d2ece7a51 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -120,13 +120,15 @@ async def _apply_over_budget_reservation_policy( applied_entries: list[dict[str, float | str]], reservation_cost: float, current_spend: float, + fail_closed_budget_enforcement: bool = False, ) -> float: """ Decide what to do when a counter is over budget, and return the reservation cost to carry into the next counter. Three outcomes: an over-budget key that opted into throttling releases its own reservation (the rate limiter slows it) and keeps the cost; a partially-remaining budget resizes the reservation - down to what is left; anything else hard-blocks by raising. + down to what is left, unless strict enforcement is on, because the known + estimate already does not fit; anything else hard-blocks by raising. """ if _key_reservation_should_release_for_throttle(counter.counter_key, valid_token): await _release_applied_entries_best_effort(entries=[entry], default_reserved_cost=reservation_cost) @@ -134,21 +136,36 @@ async def _apply_over_budget_reservation_policy( return reservation_cost remaining_before_reservation: Final = counter.max_budget - (current_spend - reservation_cost) - if remaining_before_reservation > 1e-12: - await _resize_applied_reservation( - entries=applied_entries, - current_reserved_cost=reservation_cost, - new_reserved_cost=remaining_before_reservation, + if remaining_before_reservation <= 1e-12: + _raise_counter_budget_exceeded(counter=counter, current_cost=current_spend) + if fail_closed_budget_enforcement and current_spend - counter.max_budget > 1e-12: + _raise_counter_budget_exceeded( + counter=counter, + current_cost=current_spend - reservation_cost, + estimated_cost=reservation_cost, ) - return remaining_before_reservation + await _resize_applied_reservation( + entries=applied_entries, + current_reserved_cost=reservation_cost, + new_reserved_cost=remaining_before_reservation, + ) + return remaining_before_reservation + +def _raise_counter_budget_exceeded( + counter: _BudgetCounter, + current_cost: float, + estimated_cost: float | None = None, +) -> NoReturn: + estimate_detail: Final = "" if estimated_cost is None else f"Estimated request cost: {estimated_cost}, " raise litellm.BudgetExceededError( - current_cost=current_spend, + current_cost=current_cost, max_budget=counter.max_budget, message=( "Budget has been exceeded! " f"{counter.entity_type}={counter.entity_id} " - f"Current cost: {current_spend}, " + f"Current cost: {current_cost}, " + f"{estimate_detail}" f"Max budget: {counter.max_budget}" ), entity_type=_COUNTER_ENTITY_TYPES.get(counter.entity_type), @@ -258,6 +275,7 @@ async def reserve_budget_for_request( applied_entries=applied_entries, reservation_cost=reservation_cost, current_spend=current_spend, + fail_closed_budget_enforcement=fail_closed_budget_enforcement, ) continue except Exception: diff --git a/litellm/repositories/user_repository.py b/litellm/repositories/user_repository.py index 9df1bceac9c..87eb45f262d 100644 --- a/litellm/repositories/user_repository.py +++ b/litellm/repositories/user_repository.py @@ -6,15 +6,34 @@ import json from collections.abc import Mapping from typing import TYPE_CHECKING, Final -from litellm.models.user import LiteLLM_UserTable +from pydantic import TypeAdapter + +from litellm.models.user import LiteLLM_UserTable, SCIMPlaceholder from litellm.repositories.base_repository import BaseRepository, DbRecord, record_to_dict from litellm.repositories.prisma_protocols import TableActions if TYPE_CHECKING: + from prisma import Prisma from prisma import models as prisma_models _JSON_ENCODED_COLUMNS: Final = frozenset({"metadata", "model_spend", "model_max_budget"}) +_SHADOWING_PLACEHOLDERS_SQL: Final = """ +SELECT p.user_id AS placeholder_user_id, + array_agg(r.user_id ORDER BY r.user_id) AS resolved_user_ids, + p.teams AS team_ids +FROM "LiteLLM_UserTable" p +JOIN "LiteLLM_UserTable" r + ON r.user_id <> p.user_id + AND (r.sso_user_id = p.user_id OR LOWER(r.user_email) = LOWER(p.user_id)) +WHERE p.sso_user_id IS NULL + AND NOT EXISTS (SELECT 1 FROM "LiteLLM_VerificationToken" k WHERE k.user_id = p.user_id) +GROUP BY p.user_id, p.teams +ORDER BY p.user_id +""" + +_PLACEHOLDER_ROWS_ADAPTER: Final = TypeAdapter(tuple[SCIMPlaceholder, ...]) + class UserRepository(BaseRepository[LiteLLM_UserTable]): """Repository for user database operations.""" @@ -59,6 +78,11 @@ class UserRepository(BaseRepository[LiteLLM_UserTable]): """Find all users in a team.""" return await self.find_many(where={"teams": {"has": team_id}}) + async def find_shadowing_placeholders(self, tx: "Prisma") -> tuple[SCIMPlaceholder, ...]: + """Users with no SSO id and no virtual keys whose id is another user's SSO id or email.""" + rows: Final = await tx.query_raw(_SHADOWING_PLACEHOLDERS_SQL) + return _PLACEHOLDER_ROWS_ADAPTER.validate_python(rows) + async def count_billable_users(self) -> int: """Number of users that count toward the license seat limit. diff --git a/litellm/responses/litellm_completion_transformation/custom_tools.py b/litellm/responses/litellm_completion_transformation/custom_tools.py index 90491739bb0..4aa489d9e50 100644 --- a/litellm/responses/litellm_completion_transformation/custom_tools.py +++ b/litellm/responses/litellm_completion_transformation/custom_tools.py @@ -17,6 +17,7 @@ logic. import json from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final from pydantic import BaseModel, TypeAdapter, ValidationError @@ -28,6 +29,15 @@ from litellm.types.llms.openai import ( _MAX_ARGUMENTS_LEN: Final = 1_000_000 +TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE: Final = MappingProxyType({"function_call": "fc", "custom_tool_call": "ctc"}) + + +def openai_shaped_tool_call_item_id(item_type: str, tool_id: str) -> str: + prefix: Final = TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE.get(item_type) + if prefix is None or not tool_id or tool_id.startswith(prefix): + return tool_id + return f"{prefix}_{tool_id}" + def extract_custom_tool_names(tools: Sequence[object] | None) -> set[str]: """Extract names of tools originally defined as ``type: "custom"``.""" @@ -103,7 +113,7 @@ def build_tool_call_item_kwargs( item_type: Final = "custom_tool_call" if custom else "function_call" kwargs: Final[dict[str, str]] = { "type": item_type, - "id": call_id, + "id": openai_shaped_tool_call_item_id(item_type, call_id), "call_id": call_id, "name": name, "status": status, diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index b2edf2bf9ed..db1c3acbefb 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -114,6 +114,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._pending_tool_events: list[BaseLiteLLMOpenAIResponseObject] = [] self._tool_output_index_by_call_id: dict[str, int] = {} self._tool_args_by_call_id: dict[str, str] = {} + self._tool_item_id_by_call_id: dict[str, str] = {} # mutable-ok: filled per call id as tool call events stream self._tool_call_id_by_index: dict[int, str] = {} self._ambiguous_tool_call_indexes: set[int] = set() self._next_tool_output_index: int = 1 # output_index=0 reserved for the message item @@ -227,6 +228,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 names = self._custom_tool_names item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names) + self._tool_item_id_by_call_id[call_id] = item_kwargs["id"] if tool_namespace: item_kwargs["namespace"] = tool_namespace event = OutputItemAddedEvent( @@ -248,7 +250,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 delta_event: BaseLiteLLMOpenAIResponseObject = FunctionCallArgumentsDeltaEvent( type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA, - item_id=call_id, + item_id=self._tool_item_id_by_call_id.get(call_id, call_id), output_index=output_index, delta=delta_chunk, ) @@ -300,6 +302,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 names = self._custom_tool_names item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names) + self._tool_item_id_by_call_id[call_id] = item_kwargs["id"] if tool_namespace: item_kwargs["namespace"] = tool_namespace event = OutputItemAddedEvent( @@ -325,7 +328,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 delta_event = FunctionCallArgumentsDeltaEvent( type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA, - item_id=call_id, + item_id=self._tool_item_id_by_call_id.get(call_id, call_id), output_index=output_index, delta=delta_chunk, ) @@ -335,7 +338,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 done_event = FunctionCallArgumentsDoneEvent( type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE, - item_id=call_id, + item_id=self._tool_item_id_by_call_id.get(call_id, call_id), output_index=output_index, arguments=final_args, ) @@ -345,6 +348,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self._sequence_number += 1 names = self._custom_tool_names item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names) + item_kwargs["id"] = self._tool_item_id_by_call_id.setdefault(call_id, item_kwargs["id"]) if tool_namespace: item_kwargs["namespace"] = tool_namespace item_done_event = OutputItemDoneEvent( diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3b7810e97e5..5f3e88bb12f 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -93,6 +93,7 @@ from .custom_tools import ( convert_custom_tool_to_function_tool, extract_custom_tool_names, is_custom_tool_call, + openai_shaped_tool_call_item_id, serialize_tool_call_arguments, unwrap_custom_tool_arguments, validated_allowed_callers, @@ -2034,7 +2035,7 @@ class LiteLLMCompletionResponsesConfig: custom_item = CustomToolCallOutputItem( type="custom_tool_call", call_id=tool_id, - id=tool_id, + id=openai_shaped_tool_call_item_id("custom_tool_call", tool_id), name=tool_name, input=input_str, status=function_definition.get("status") or "completed", @@ -2065,7 +2066,7 @@ class LiteLLMCompletionResponsesConfig: name=tool_name, arguments=tool_arguments, call_id=tool_id, - id=tool_id, + id=openai_shaped_tool_call_item_id("function_call", tool_id), type="function_call", status=function_definition.get("status") or "completed", ) @@ -2502,8 +2503,7 @@ class LiteLLMCompletionResponsesConfig: choice=choice, ) message_output_items.extend(image_generation_items) - else: - # Regular message output + elif choice.message.content is not None: message_output_items.append( GenericResponseOutputItem( type="message", diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index de802b95086..82abac3e772 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -1023,7 +1023,7 @@ class MockResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): transformed: ResponsesAPIResponse, logging_obj: LiteLLMLoggingObj, ) -> None: - self._events: list[ResponsesAPIStreamingResponse] = _build_synthetic_response_events( + self._events: Sequence[ResponsesAPIStreamingResponse] = build_synthetic_response_events( transformed=transformed, logging_obj=logging_obj, chunk_size=self.CHUNK_SIZE, @@ -1090,7 +1090,7 @@ class CachedResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator): transformed: ResponsesAPIResponse, logging_obj: LiteLLMLoggingObj, ) -> None: - self._events = _build_synthetic_response_events( + self._events = build_synthetic_response_events( transformed=transformed, logging_obj=logging_obj, chunk_size=MockResponsesAPIStreamingIterator.CHUNK_SIZE, @@ -1274,10 +1274,10 @@ def _add_text_like_part_events( ) -def _build_synthetic_response_events( +def build_synthetic_response_events( *, transformed: ResponsesAPIResponse, - logging_obj: LiteLLMLoggingObj, + logging_obj: LiteLLMLoggingObj | None, chunk_size: int, ) -> list[ResponsesAPIStreamingResponse]: openai_types: Final = _get_openai_response_types() diff --git a/litellm/router.py b/litellm/router.py index edff8294c3e..23d8907fb49 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -22,7 +22,7 @@ import traceback import weakref from collections import defaultdict from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping, Sequence -from functools import lru_cache +from functools import lru_cache, partial from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypeVar, Union, cast @@ -143,7 +143,11 @@ from litellm.router_utils.cooldown_handlers import ( from litellm.router_utils.fallback_event_handlers import ( AttemptedFallbackTargets, _check_non_standard_fallback_format, - get_fallback_model_group, + clear_pre_routing_selection, + fallback_lookup_groups, + get_fallback_model_group_for_lookup_groups, + get_pre_routing_selection, + record_pre_routing_selection, run_async_fallback, ) from litellm.router_utils.get_retry_from_policy import ( @@ -821,6 +825,7 @@ class Router: self._zero_cost_cache: dict[str, bool] = {} self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None self._init_routing_groups(None) + self._provider_unresolved_deployments: tuple[Callable[[], Deployment | None], ...] = () self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds self.model_group_affinity_config = model_group_affinity_config @@ -4918,6 +4923,19 @@ class Router: ) response = await response + if self._should_raise_anthropic_refusal_error( + model=model, + original_generic_function=original_generic_function, + response=response, + kwargs=kwargs, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.utils import ( + safeguard_refusal_error, + ) + + refusal_details: Final = cast(dict, response["stop_details"]) # cast-ok: gate verified the shape + raise safeguard_refusal_error(model=model, stop_details=refusal_details) + self.success_calls[model_name] += 1 verbose_router_logger.info("ageneric_api_call_with_fallbacks(model=%s)\x1b[32m 200 OK\x1b[0m", model_name) @@ -4964,6 +4982,11 @@ class Router: # fallback to the original reference for any non-picklable value. # The original_generic_function is preserved so the per-attempt # helper knows which underlying API to call on fallback. + # The pre-routing hook stamps its tier selection into this bucket during the primary + # attempt; seeding it before the snapshot gives both the live kwargs and the copy a + # bucket, so the post-call carry-over below always has somewhere to read and write. + kwargs.setdefault("litellm_metadata", {}) # mutable-ok: shared bucket # rebind-ok: stamp must be readable here + fallback_kwargs: Final[dict[str, object]] = kwargs.copy() if isinstance(fallback_kwargs.get("litellm_metadata"), dict): fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"]) @@ -4973,6 +4996,14 @@ class Router: response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs) + # The snapshot predates the pre-routing hook, so the tier it stamped into the live kwargs + # is carried over write-or-clear: a stale or caller-supplied selection left in the copy + # would key the mid-stream fallback lookup off a tier this attempt never routed to. + clear_pre_routing_selection(fallback_kwargs) + live_pre_routing_selection: Final = get_pre_routing_selection(kwargs) + if live_pre_routing_selection is not None: + record_pre_routing_selection(fallback_kwargs, live_pre_routing_selection) + if kwargs.get("stream") and isinstance(response, BaseResponsesAPIStreamingIterator): return await self._aresponses_streaming_iterator( response=response, @@ -5030,6 +5061,10 @@ class Router: from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( aclose_if_supported, parse_anthropic_error_event, + parse_anthropic_refusal_stop_details, + ) + from litellm.llms.anthropic.experimental_pass_through.messages.utils import ( + safeguard_refusal_error, ) source_iterator: Final = response @@ -5068,13 +5103,35 @@ class Router: continue if _anthropic_stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)): has_generated_content = True # rebind-ok: real content seen, or the buffer cap was hit - error_event = parse_anthropic_error_event(chunk) + # A transport can split one SSE data line across byte chunks, so pre-content + # detection parses the accumulated buffer plus the current chunk, never the + # chunk alone; the buffer is already capped, which bounds this window too. + parse_window = ( # rebind-ok: freshly computed each iteration, never carried over + b"".join(c for c in (*buffered_lifecycle_chunks, chunk) if isinstance(c, (bytes, bytearray))) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime + if not has_generated_content and isinstance(chunk, (bytes, bytearray)) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime + else chunk + ) + error_event = parse_anthropic_error_event(parse_window) retriable_pending_error = ( # rebind-ok: freshly computed each iteration, never carried over not has_generated_content and error_event is not None and _is_retriable_anthropic_status(error_event[2]) and not _anthropic_stream_error_is_gateway_verdict(chunk) ) + refusal_stop_details = ( # rebind-ok: freshly computed each iteration, never carried over + parse_anthropic_refusal_stop_details(parse_window) + if not has_generated_content and error_event is None + else None + ) + if refusal_stop_details is not None and self._has_content_policy_fallback(model, initial_kwargs): + refusal_error = safeguard_refusal_error(model=model, stop_details=refusal_stop_details) + raise MidStreamFallbackError( + message=refusal_error.message, + model=model, + llm_provider="anthropic", + original_exception=refusal_error, + is_pre_first_chunk=True, + ) if not has_generated_content and not retriable_pending_error and error_event is None: buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk) continue @@ -5186,8 +5243,13 @@ class Router: kwargs=initial_kwargs, metadata_variable_name="litellm_metadata", ) + # The content-policy dispatch branch matches on the trigger's own type, so a refusal's + # MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted. + fallback_trigger: Final[Exception] = ( + e.original_exception if isinstance(e.original_exception, litellm.ContentPolicyViolationError) else e + ) fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success - e=e, + e=fallback_trigger, disable_fallbacks=False, fallbacks=fallbacks, context_window_fallbacks=context_window_fallbacks, @@ -5243,6 +5305,11 @@ class Router: # share, leaking primary-deployment metadata into the mid-stream # fallback request. safe_deep_copy avoids deep-copying the full # kwargs (which can hold non-deepcopyable logging handles/clients). + # The pre-routing hook stamps its tier selection into this bucket during the primary + # attempt; seeding it before the snapshot gives both the live kwargs and the copy a + # bucket, so the post-call carry-over below always has somewhere to read and write. + kwargs.setdefault("litellm_metadata", {}) # mutable-ok: shared bucket # rebind-ok: stamp must be readable here + fallback_kwargs: Final[dict[str, object]] = kwargs.copy() # mutable-ok: mutated below before re-entry if isinstance(fallback_kwargs.get("litellm_metadata"), dict): fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"]) @@ -5252,6 +5319,14 @@ class Router: response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs) + # The snapshot predates the pre-routing hook, so the tier it stamped into the live kwargs + # is carried over write-or-clear: a stale or caller-supplied selection left in the copy + # would key the mid-stream fallback lookup off a tier this attempt never routed to. + clear_pre_routing_selection(fallback_kwargs) + live_pre_routing_selection: Final = get_pre_routing_selection(kwargs) + if live_pre_routing_selection is not None: + record_pre_routing_selection(fallback_kwargs, live_pre_routing_selection) + if kwargs.get("stream") and hasattr(response, "__aiter__"): return await self._aanthropic_messages_streaming_iterator( response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator @@ -6807,6 +6882,9 @@ class Router: original_exception: Final = e fallback_model_group = None original_model_group: Final[str | None] = kwargs.get("model") + # A pre-routing hook (complexity / auto / adaptive / quality routers) picks a tier + # behind the router name, and fallbacks are configured per tier, not per router. + lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group) fallback_failure_exception_str = "" if disable_fallbacks is True or original_model_group is None: @@ -6851,15 +6929,15 @@ class Router: ] # Get external fallbacks — handle both standard and non-standard formats external_fallback_group: list | None = None - if fallbacks is not None and model_group is not None: + if fallbacks is not None and lookup_groups: if _check_non_standard_fallback_format(fallbacks=fallbacks): # Non-standard formats (e.g. ["claude-3-haiku"] or # [{"model": "...", "messages": [...]}]) are passed through directly external_fallback_group = fallbacks else: - external_fallback_group, generic_idx = get_fallback_model_group( + external_fallback_group, generic_idx = get_fallback_model_group_for_lookup_groups( fallbacks=fallbacks, - model_group=cast(str, model_group), + lookup_groups=lookup_groups, ) if external_fallback_group is None and generic_idx is not None: external_fallback_group = fallbacks[generic_idx]["*"] @@ -6917,9 +6995,9 @@ class Router: if isinstance(e, litellm.ContextWindowExceededError): if context_window_fallbacks is not None: context_window_fallback_model_group: Final[list[str] | None] = ( - self._get_fallback_model_group_from_fallbacks( + self._get_fallback_model_group_for_lookup_groups( fallbacks=context_window_fallbacks, - model_group=model_group, + lookup_groups=lookup_groups, ) ) if context_window_fallback_model_group is None: @@ -6950,9 +7028,9 @@ class Router: elif isinstance(e, litellm.ContentPolicyViolationError): if content_policy_fallbacks is not None: content_policy_fallback_model_group: Final[list[str] | None] = ( - self._get_fallback_model_group_from_fallbacks( + self._get_fallback_model_group_for_lookup_groups( fallbacks=content_policy_fallbacks, - model_group=model_group, + lookup_groups=lookup_groups, ) ) if content_policy_fallback_model_group is None: @@ -6979,14 +7057,14 @@ class Router: if litellm.expose_router_debug_in_errors: e.message += f"\n{error_message}" - if fallbacks is not None and model_group is not None: + if fallbacks is not None and lookup_groups: verbose_router_logger.debug("inside model fallbacks: %s", mask_sensitive_structure(fallbacks)) ( fallback_model_group, generic_fallback_idx, - ) = get_fallback_model_group( + ) = get_fallback_model_group_for_lookup_groups( fallbacks=fallbacks, # if fallbacks = [{"gpt-3.5-turbo": ["claude-3-haiku"]}] - model_group=cast(str, model_group), + lookup_groups=lookup_groups, ) ## if none, check for generic fallback if fallback_model_group is None and generic_fallback_idx is not None: @@ -6995,12 +7073,12 @@ class Router: if fallback_model_group is None: masked_fallbacks: Final = mask_sensitive_structure(fallbacks) verbose_router_logger.info( - "No fallback model group found for original model_group=%s. Fallbacks=%s", - model_group, + "No fallback model group found for lookup_groups=%s. Fallbacks=%s", + " -> ".join(lookup_groups), masked_fallbacks, ) if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors: - original_exception.message += f"No fallback model group found for original model_group={model_group}. Fallbacks={masked_fallbacks}" + original_exception.message += f"No fallback model group found for lookup_groups={' -> '.join(lookup_groups)}. Fallbacks={masked_fallbacks}" raise original_exception input_kwargs.update( @@ -7046,6 +7124,7 @@ class Router: If it fails after num_retries, fall back to another model group """ model_group: Final[str | None] = kwargs.get("model") + clear_pre_routing_selection(kwargs) # pyright: ignore[reportUnknownArgumentType] # **kwargs is untyped at this boundary if not isinstance(kwargs.get("attempted_targets"), AttemptedFallbackTargets): _fallback_metadata_key: Final = _get_router_metadata_variable_name( function_name=getattr(kwargs.get("original_function"), "__name__", None) @@ -7471,6 +7550,24 @@ class Router: break return fallback_model_group + def _get_fallback_model_group_for_lookup_groups( + self, + fallbacks: list[dict[str, list[str]]], # mutable-ok: mirrors the sibling resolver's contract + lookup_groups: tuple[str, ...], + ) -> list[str] | None: # mutable-ok: mirrors the sibling resolver's contract + """First lookup group whose exact-key chain resolves (tier first, then requested group).""" + return next( + ( + resolved + for resolved in ( + self._get_fallback_model_group_from_fallbacks(fallbacks=fallbacks, model_group=group) + for group in lookup_groups + ) + if resolved is not None + ), + None, + ) + def _get_first_default_fallback(self) -> str | None: """ Returns the first model from the default_fallbacks list, if it exists. @@ -7886,6 +7983,31 @@ class Router: return True return False + def _has_content_policy_fallback(self, model_group: str, kwargs: Mapping[str, Any]) -> bool: + """ + Whether a content-policy fallback would resolve for this request, keyed the same way + async_function_with_fallbacks_common_utils resolves it: the tier a pre-routing hook + selected wins over the requested group. Raising without this returning True would turn + a deliverable response into an error the fallback chain cannot recover from. + """ + content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self.content_policy_fallbacks) + if content_policy_fallbacks is not None: + return ( + self._get_fallback_model_group_for_lookup_groups( + fallbacks=content_policy_fallbacks, + lookup_groups=fallback_lookup_groups(kwargs, model_group), + ) + is not None + ) + if self._has_default_fallbacks(): + return True + verbose_router_logger.debug( + "No content-policy fallback available. Returning original response. model=%s, content_policy_fallbacks=%s", + model_group, + content_policy_fallbacks, + ) + return False + def _should_raise_content_policy_error(self, model: str, response: ModelResponse, kwargs: dict) -> bool: """ Determines if a content policy error should be raised. @@ -7898,27 +8020,26 @@ class Router: if response.choices[0].finish_reason != "content_filter": return False - content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self.content_policy_fallbacks) + return self._has_content_policy_fallback(model, kwargs) - ### ONLY RAISE ERROR IF CP FALLBACK AVAILABLE ### - if content_policy_fallbacks is not None: - fallback_model_group = None - for item in content_policy_fallbacks: # [{"gpt-3.5-turbo": ["gpt-4"]}] - if list(item.keys())[0] == model: - fallback_model_group = item[model] - break - - if fallback_model_group is not None: - return True - elif self._has_default_fallbacks(): # default fallbacks set - return True - - verbose_router_logger.debug( - "Content Policy Error occurred. No available fallbacks. Returning original response. model=%s, content_policy_fallbacks=%s", - model, - content_policy_fallbacks, + def _should_raise_anthropic_refusal_error( + self, model: str, original_generic_function: Callable, response: object, kwargs: Mapping[str, Any] + ) -> bool: + """ + The /v1/messages twin of _should_raise_content_policy_error: an Anthropic safeguard + refusal (stop_reason "refusal" carrying stop_details) re-enters the fallback chain only + when a content-policy fallback is configured; a plain refusal without stop_details, or + any response with nothing configured, is returned to the client unchanged. + """ + from litellm.llms.anthropic.experimental_pass_through.messages.utils import ( + get_safeguard_refusal_stop_details, ) - return False + + if getattr(original_generic_function, "__name__", "") != "anthropic_messages": + return False + if get_safeguard_refusal_stop_details(response) is None: + return False + return self._has_content_policy_fallback(model, kwargs) def _get_healthy_deployments(self, model: str, parent_otel_span: Span | None): _all_deployments: list = [] @@ -8165,6 +8286,52 @@ class Router: if backend_value is not None: model_info[field] = backend_value + @staticmethod + def _inherit_builtin_base_rates_for_off_peak( + model_info: dict, # mutable-ok: cost-map entry filled in place + backend_model: str, + custom_llm_provider: str | None, + ) -> None: + """Fill missing pricing fields on a deployment entry that only sets + ``off_peak_pricing``, from the backend model's built-in cost map entry. + + Cost lookup selects the deployment-scoped entry over the shared backend + entry only when the deployment entry carries a base pricing field, and + ``off_peak_pricing`` is deliberately kept off the shared entry, so a + deployment spelling out only its off-peak schedule would otherwise + never receive the discount. The backend model's entire canonical cost + map entry is copied, field by field, so threshold, tiered, + service-tier, cache, character, and per-second rates as well as + companion billing fields like ``web_search_billing_unit`` and the + regional uplift multipliers all carry over, and peak-hour billing + through the deployment entry matches the shared backend entry exactly. + The raw ``litellm.model_cost`` entry is the copy source rather than + ``get_model_info``'s view of it, since that view synthesizes zero flat + token rates for backends without one and storing those would mark a + tiered-only backend explicitly priced free. Values are deep-copied to + keep the builtin entry isolated. User-specified fields always win; + no-op when any base pricing field is already set or the backend model + has no canonical entry. + """ + if not model_info.get("off_peak_pricing"): + return + if any( + model_info.get(field) is not None + for field in ("input_cost_per_token", "input_cost_per_second", "tiered_pricing") + ): + return + try: + backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model + return + backend_entry: Final = litellm.model_cost.get(backend_info.get("key") or "") + if not isinstance(backend_entry, dict): + return + for field, backend_value in backend_entry.items(): + if model_info.get(field) is not None or backend_value is None: + continue + model_info[field] = copy.deepcopy(backend_value) + @staticmethod def _inherit_builtin_tiered_output_rate( model_info: dict, backend_model: str, custom_llm_provider: str | None @@ -8253,6 +8420,11 @@ class Router: if deployment.litellm_params.get(field) is not None: _model_info[field] = deployment.litellm_params[field] + Router._inherit_builtin_base_rates_for_off_peak( + model_info=_model_info, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) if _model_info.get("input_cost_per_token") is not None: Router._inherit_builtin_cache_pricing( model_info=_model_info, @@ -8301,6 +8473,19 @@ class Router: return deployment except Exception as e: if self.ignore_invalid_deployments: + if isinstance(e, litellm.BadRequestError): + self._provider_unresolved_deployments = ( + *self._provider_unresolved_deployments, + partial( + self._create_deployment, + deployment_info=deployment_info, + _model_name=_model_name, + _litellm_params=_litellm_params, + _model_info=_model_info, + declared_id=declared_id, + duplicate_ids=duplicate_ids, + ), + ) verbose_router_logger.exception( "Error creating deployment: %s, ignoring and continuing with other deployments.", e ) @@ -8730,6 +8915,7 @@ class Router: self.quality_routers = {} self.complexity_routers = {} self.auto_routers = {} + self._provider_unresolved_deployments = () self._invalidate_model_group_info_cache() self._invalidate_access_groups_cache() # we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works @@ -8994,6 +9180,11 @@ class Router: if field_value is not None: _model_info_dict[field] = field_value + Router._inherit_builtin_base_rates_for_off_peak( + model_info=_model_info_dict, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) if _model_info_dict.get("input_cost_per_token") is not None: Router._inherit_builtin_cache_pricing( model_info=_model_info_dict, @@ -9249,6 +9440,11 @@ class Router: field_value = deployment.litellm_params.get(field) if field_value is not None: model_info[field] = field_value + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) if model_info.get("input_cost_per_token") is not None: Router._inherit_builtin_cache_pricing( model_info=model_info, @@ -9342,8 +9538,12 @@ class Router: """Re-assert this router's deployments onto a freshly fetched catalog. Reads ``model_list`` at call time, so only deployments the router still - serves are restored. + serves are restored, plus any config deployment the fresh catalog now resolves. """ + provider_unresolved: Final = self._provider_unresolved_deployments + self._provider_unresolved_deployments = () + for create_deployment in provider_unresolved: + create_deployment() for entry in tuple(self.model_list): try: deployment = entry if isinstance(entry, Deployment) else Deployment(**entry) @@ -12026,6 +12226,7 @@ class Router: if pre_routing_hook_response is not None: model = pre_routing_hook_response.model messages = pre_routing_hook_response.messages + record_pre_routing_selection(request_kwargs, model) if pre_routing_hook_response.litellm_params: accepted_tier_params: Final = self._tier_params_the_target_accepts( model, pre_routing_hook_response.litellm_params, request_kwargs @@ -12141,6 +12342,7 @@ class Router: if pre_routing_hook_response is not None: model = pre_routing_hook_response.model messages = pre_routing_hook_response.messages + record_pre_routing_selection(request_kwargs, model) if pre_routing_hook_response.litellm_params: accepted_tier_params: Final = self._tier_params_the_target_accepts( model, pre_routing_hook_response.litellm_params, request_kwargs diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 924574537f3..3d37ca216a7 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -214,6 +214,91 @@ def _check_stripped_model_group(model_group: str, fallback_key: str) -> bool: return False +PRE_ROUTING_SELECTED_MODEL_KEY: Final = "pre_routing_selected_model" +_ROUTER_METADATA_BUCKETS: Final = ("metadata", "litellm_metadata") + + +def record_pre_routing_selection(request_kwargs: Mapping[str, Any] | None, selected_model: str) -> None: + """ + Remember which model a pre-routing hook picked, so fallback lookup can key off it. + + Fallback resolution runs on an outer kwargs dict that ``**kwargs`` already copied, so + writing the model there is invisible by the time routing picks a tier. The metadata + buckets are nested dicts shared by reference across those copies, which is how the + router already carries values back up. + + The write goes through the proxy-internal bucket resolver, never into both buckets: + on /v1/messages the top-level ``metadata`` dict is the provider's own request field, + so a blanket write would forward the tier stamp upstream. + """ + from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs + + if request_kwargs is None: + return + bucket: Final = request_kwargs.get(get_metadata_variable_name_from_kwargs(request_kwargs)) + if isinstance(bucket, dict): + bucket[PRE_ROUTING_SELECTED_MODEL_KEY] = selected_model + + +def clear_pre_routing_selection(request_kwargs: Mapping[str, object] | None) -> None: + """ + Drop any selection the router did not make itself on this hop. + + The buckets carry whatever the caller sent, so an inbound value is the caller + choosing a fallback chain rather than the router choosing a tier. A fallback hop + also inherits the previous hop's selection, which would key its own failure off + the tier that already failed. Clearing at the start of every hop leaves only a + value the pre-routing hook wrote while routing that hop. + """ + if request_kwargs is None: + return + for bucket in (request_kwargs.get(name) for name in _ROUTER_METADATA_BUCKETS): + if isinstance(bucket, dict) and PRE_ROUTING_SELECTED_MODEL_KEY in bucket: + del bucket[PRE_ROUTING_SELECTED_MODEL_KEY] + + +def get_pre_routing_selection(kwargs: Mapping[str, Any]) -> str | None: + """The model a pre-routing hook selected for this request, if one did.""" + buckets: Final = (kwargs.get(name) for name in _ROUTER_METADATA_BUCKETS) + selections: Final = (bucket.get(PRE_ROUTING_SELECTED_MODEL_KEY) for bucket in buckets if isinstance(bucket, dict)) + return next((selected for selected in selections if isinstance(selected, str) and selected), None) + + +def fallback_lookup_groups(kwargs: Mapping[str, Any], model_group: str | None) -> tuple[str, ...]: + """ + Ordered keys for resolving a fallback chain: the tier a pre-routing hook selected wins, + and the requested group still resolves when no tier-keyed chain exists, so configs keyed + on the router name (the documented contract) keep working behind auto-routers. + """ + ordered: Final = (get_pre_routing_selection(kwargs), model_group) + return tuple(dict.fromkeys(group for group in ordered if group)) + + +def _resolved_a_specific_chain( + fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract + result: tuple[list[str] | None, int | None], # mutable-ok: mirrors get_fallback_model_group's contract +) -> bool: + resolved, generic_idx = result + if resolved is None: + return False + return generic_idx is None or resolved is not fallbacks[generic_idx]["*"] + + +def get_fallback_model_group_for_lookup_groups( + fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract + lookup_groups: tuple[str, ...], +) -> tuple[list[str] | None, int | None]: # mutable-ok: mirrors get_fallback_model_group's contract + """ + First lookup group with a specifically-keyed chain wins; the generic "*" chain applies + only after every group missed, so a catch-all cannot shadow a later group's own chain. + """ + results: Final = tuple(get_fallback_model_group(fallbacks=fallbacks, model_group=group) for group in lookup_groups) + specific: Final = next((result for result in results if _resolved_a_specific_chain(fallbacks, result)), None) + if specific is not None: + return specific + return next((result for result in results if result[0] is not None), (None, None)) + + def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[list[str] | None, int | None]: """ Returns: @@ -412,6 +497,7 @@ async def run_async_fallback( # LOGGING kwargs = litellm_router.log_retry(kwargs=kwargs, e=original_exception) verbose_router_logger.info("Falling back to model_group = %s", mask_sensitive_structure(mg)) + kwargs.pop("_target_order", None) # rebind-ok: next hop must not inherit the previous order target if isinstance(mg, str): kwargs["model"] = mg elif isinstance(mg, dict): diff --git a/litellm/router_utils/pattern_match_deployments.py b/litellm/router_utils/pattern_match_deployments.py index 0d5ef01bc04..0775e0a4039 100644 --- a/litellm/router_utils/pattern_match_deployments.py +++ b/litellm/router_utils/pattern_match_deployments.py @@ -8,7 +8,7 @@ from re import Match from typing import Final from litellm._logging import verbose_router_logger -from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider, get_llm_provider class PatternUtils: @@ -204,7 +204,7 @@ class PatternMatchRouter: return litellm_deployment_litellm_model - def get_pattern(self, model: str, custom_llm_provider: str | None = None) -> list[dict] | None: + def get_pattern(self, model: str | None, custom_llm_provider: str | None = None) -> list[dict] | None: """ Check if a pattern exists for the given model and custom llm provider @@ -215,18 +215,17 @@ class PatternMatchRouter: Returns: bool: True if pattern exists, False otherwise """ - if custom_llm_provider is None: - try: - ( - _, - custom_llm_provider, - _, - _, - ) = get_llm_provider(model=model) - except Exception: - # get_llm_provider raises exception when provider is unknown - pass - return self.route(model) or self.route(f"{custom_llm_provider}/{model}") + provider: Final = ( + custom_llm_provider or declared_authenticating_provider(model) or self._resolved_provider(model) + ) + return self.route(model) or self.route(f"{provider}/{model}") + + @staticmethod + def _resolved_provider(model: str | None) -> str | None: + try: + return get_llm_provider(model=model)[1] if model else None + except Exception: # noqa: BLE001 # get_llm_provider raises when the provider is unknown; the name then routes as-is + return None def get_deployments_by_pattern(self, model: str, custom_llm_provider: str | None = None) -> list[dict]: """ diff --git a/litellm/router_utils/pre_call_checks/deployment_affinity_check.py b/litellm/router_utils/pre_call_checks/deployment_affinity_check.py index 7fb90ab89de..b1e9dbdefa8 100644 --- a/litellm/router_utils/pre_call_checks/deployment_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/deployment_affinity_check.py @@ -427,6 +427,8 @@ class DeploymentAffinityCheck(CustomLogger): """ request_kwargs = request_kwargs or {} typed_healthy_deployments: Final = cast(list[dict], healthy_deployments) + if request_kwargs.get("_target_order") is not None: + return typed_healthy_deployments ( enable_user_key, 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 6e8406b2ec7..0788c8db710 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 @@ -58,6 +58,9 @@ class PromptCachingDeploymentCheck(CustomLogger): request_kwargs: dict | None = None, parent_otel_span: Span | None = None, ) -> list[dict]: + if request_kwargs is not None and request_kwargs.get("_target_order") is not None: + return healthy_deployments + if messages is not None and is_prompt_caching_valid_prompt( messages=messages, model=model, diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py index d96defbbcd6..ab5ef5853c9 100644 --- a/litellm/router_utils/search_api_router.py +++ b/litellm/router_utils/search_api_router.py @@ -9,6 +9,7 @@ import random import traceback from collections.abc import Callable from functools import partial +from types import MappingProxyType from typing import Any, Final from litellm._logging import verbose_router_logger @@ -214,6 +215,15 @@ class SearchAPIRouter: api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( tool_litellm_params=litellm_params, ) + protected_params: Final = frozenset(("search_provider", "api_key", "api_base")) + search_params: Final = MappingProxyType( + { + key: value + for params in (litellm_params, kwargs) + for key, value in params.items() + if key not in protected_params and value is not None + } + ) verbose_router_logger.debug("Selected search tool with provider: %s", search_provider) @@ -222,7 +232,7 @@ class SearchAPIRouter: search_provider=search_provider, api_key=api_key, api_base=api_base, - **kwargs, + **search_params, ) return response diff --git a/litellm/rust_bridge/__init__.py b/litellm/rust_bridge/__init__.py index 3da5b98449b..e6d8ffef48c 100644 --- a/litellm/rust_bridge/__init__.py +++ b/litellm/rust_bridge/__init__.py @@ -1,9 +1,9 @@ """LiteLLM Rust bridge package.""" +from litellm.rust_bridge.configuration import use_litellm_rust from litellm.rust_bridge.loader import ( get_native_bridge, native_bridge_available, ) -from litellm.rust_bridge.ocr import use_litellm_rust __all__ = ["get_native_bridge", "native_bridge_available", "use_litellm_rust"] diff --git a/litellm/rust_bridge/bindings.py b/litellm/rust_bridge/bindings.py new file mode 100644 index 00000000000..25b0e1a17e7 --- /dev/null +++ b/litellm/rust_bridge/bindings.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Final, Generic, TypeVar, cast + +from litellm.rust_bridge.loader import get_native_bridge + +BindingT = TypeVar("BindingT") + + +class Unset: + pass + + +UNSET: Final = Unset() + + +class NativeBinding(Generic[BindingT]): + """Resolve one native attribute with an explicit, resettable test override.""" + + def __init__(self, attribute: str) -> None: + self._attribute: Final = attribute + self._override: BindingT | None | Unset = UNSET + + def load(self) -> BindingT | None: + if not isinstance(self._override, Unset): + return self._override + native: Final = get_native_bridge() + if native is None: + return None + return cast(BindingT | None, getattr(native, self._attribute, None)) + + def override(self, value: BindingT | None) -> None: + self._override = value + + def reset(self) -> None: + self._override = UNSET + + def update(self, value: BindingT | None | Unset) -> None: + if isinstance(value, Unset): + return + if value is None: + self.reset() + else: + self.override(value) + + +def native_exception_types() -> tuple[type[BaseException], type[BaseException]] | None: + native: Final = get_native_bridge() + if native is None: + return None + declined: Final = getattr(native, "RustBridgeDeclined", None) + upstream: Final = getattr(native, "RustUpstreamError", None) + if not isinstance(declined, type) or not isinstance(upstream, type): + return None + return declined, upstream diff --git a/litellm/rust_bridge/chat_completions.py b/litellm/rust_bridge/chat_completions.py index fe9ce15775f..5fb89ae5848 100644 --- a/litellm/rust_bridge/chat_completions.py +++ b/litellm/rust_bridge/chat_completions.py @@ -13,21 +13,33 @@ retrying it there would bill the customer for the same work twice. from __future__ import annotations import json -import os from collections.abc import Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Final, Protocol +from typing import TYPE_CHECKING, Final, Protocol, cast import httpx from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger -from litellm.exceptions import APIError from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( convert_to_model_response_object, ) from litellm.llms.bedrock.request_metadata import bedrock_request_metadata_is_owned -from litellm.rust_bridge.loader import get_native_bridge +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.configuration import rust_enabled +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + CoreEngine, + ExecutionResult, + FallbackMode, + RustDeclined, + RustHandled, + aattempt, + ainvoke, + attempt, + execution_hidden_params, + identity, + invoke, +) from litellm.rust_bridge.timeouts import timeout_to_seconds from litellm.types.utils import ModelResponse @@ -42,10 +54,6 @@ RUST_CHAT_COMPLETIONS_PROVIDERS: Final = frozenset({"anthropic", "bedrock"}) # rather than narrowing an unparameterized `Mapping` and typing the result Any. _LITELLM_METADATA_ADAPTER: Final = TypeAdapter(Mapping[str, object]) -RUST_RESPONSE_HEADER: Final = "x-litellm-rust" - -_TRUTHY_ENV_VALUES: Final = frozenset({"1", "true", "yes", "on"}) - class RustChatCompletions(Protocol): def __call__( @@ -128,71 +136,34 @@ def response_logger( return log -class _Unset: - pass - - -_UNSET: Final[_Unset] = _Unset() - - -@dataclass(slots=True) -class _RustChatCompletionsState: - chat_completions: RustChatCompletions | None = None - achat_completions: RustAchatCompletions | None = None - decline: RustChatCompletionsDecline | None = None - - -_STATE: Final[_RustChatCompletionsState] = _RustChatCompletionsState() +_CHAT_COMPLETIONS: Final = NativeBinding[RustChatCompletions]("chat_completions") +_ACHAT_COMPLETIONS: Final = NativeBinding[RustAchatCompletions]("achat_completions") +_DECLINE: Final = NativeBinding[RustChatCompletionsDecline]("chat_completions_decline") def set_rust_chat_completions( *, - chat_completions: RustChatCompletions | None | _Unset = _UNSET, - achat_completions: RustAchatCompletions | None | _Unset = _UNSET, - decline: RustChatCompletionsDecline | None | _Unset = _UNSET, + chat_completions: RustChatCompletions | None | Unset = UNSET, + achat_completions: RustAchatCompletions | None | Unset = UNSET, + decline: RustChatCompletionsDecline | None | Unset = UNSET, ) -> None: """Inject the native callables, so tests can supply a double instead of patching module attributes.""" - if not isinstance(chat_completions, _Unset): - _STATE.chat_completions = chat_completions - if not isinstance(achat_completions, _Unset): - _STATE.achat_completions = achat_completions - if not isinstance(decline, _Unset): - _STATE.decline = decline + _CHAT_COMPLETIONS.update(chat_completions) + _ACHAT_COMPLETIONS.update(achat_completions) + _DECLINE.update(decline) def load_rust_chat_completions() -> RustChatCompletions | None: - if _STATE.chat_completions is not None: - return _STATE.chat_completions - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - loaded: RustChatCompletions | None = getattr(native_bridge, "chat_completions", None) - return loaded + return _CHAT_COMPLETIONS.load() def load_rust_achat_completions() -> RustAchatCompletions | None: - if _STATE.achat_completions is not None: - return _STATE.achat_completions - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - loaded: RustAchatCompletions | None = getattr(native_bridge, "achat_completions", None) - return loaded - - -def _env_enables_rust() -> bool: - return os.getenv("LITELLM_RUST", "").strip().lower() in _TRUTHY_ENV_VALUES + return _ACHAT_COMPLETIONS.load() def _load_rust_decline() -> RustChatCompletionsDecline | None: - if _STATE.decline is not None: - return _STATE.decline - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - loaded: RustChatCompletionsDecline | None = getattr(native_bridge, "chat_completions_decline", None) - return loaded + return _DECLINE.load() def _anthropic_user_id_reaches_the_body(litellm_params: Mapping[str, object] | None) -> bool: @@ -253,8 +224,8 @@ def rust_chat_completions_accepts( return False if stream: return False - opted_in: Final = litellm_params is not None and litellm_params.get("rust") is True - if not opted_in and not _env_enables_rust(): + request_override: Final = litellm_params.get("rust") if litellm_params is not None else None + if not rust_enabled(request_override=request_override if isinstance(request_override, bool) else None): return False if _litellm_metadata_reaches_the_provider(custom_llm_provider, litellm_params): verbose_logger.debug("Rust chat completions declined (litellm metadata user_id); using the Python path") @@ -262,64 +233,35 @@ def rust_chat_completions_accepts( decline: Final = _load_rust_decline() if decline is None: return False - try: - reason: Final = decline( + gate_result: Final = attempt( + native_call=lambda: decline( model=model, messages=messages, optional_params=optional_params, custom_llm_provider=custom_llm_provider, - ) - except Exception as rust_error: # noqa: BLE001 # rollout-safety fallback: any Rust bridge failure must fall back to the Python path + ), + adapt=identity, + context=BridgeErrorContext( + route="chat completions capability check", + provider=custom_llm_provider or "", + model=model, + ), + ) + if isinstance(gate_result, RustDeclined): verbose_logger.debug( - "Rust chat completions gate raised %s; staying on the Python path", - type(rust_error).__name__, + "Rust chat completions declined (%s); using the Python path", + gate_result.reason, ) return False + if not isinstance(gate_result, RustHandled): + return False + reason: Final = gate_result.value if reason is not None: verbose_logger.debug("Rust chat completions declined (%s); using the Python path", reason) return False return True -def _reraise_or_decline( - rust_error: BaseException, - *, - model: str, - custom_llm_provider: str | None, -) -> None: - """Re-raise a failure the provider already saw, or return so the caller declines. - - A request that never reached the provider is safe to serve on the Python - path. One that did is not: the provider has already done the work, so a - second attempt bills for it twice. Those surface as an `APIError` carrying - the upstream status, which LiteLLM's exception mapping already understands. - """ - from litellm.rust_bridge.errors import native_bridge_exceptions, rust_upstream_failure - - native_bridge: Final = get_native_bridge() - exceptions: Final = native_bridge_exceptions(native_bridge) - if exceptions is None: - verbose_logger.debug( - "Rust chat completions bridge raised %s; falling back to Python path", - type(rust_error).__name__, - ) - return - upstream_failure: Final = rust_upstream_failure(rust_error, native_bridge) - if upstream_failure is not None: - raise APIError( - status_code=upstream_failure.status_code, - message=f"litellm rust chat completions: {upstream_failure.message}", - llm_provider=custom_llm_provider or "", - model=model, - ) - if not isinstance(rust_error, exceptions.declined): - raise rust_error - verbose_logger.debug( - "Rust chat completions declined before calling the provider (%s); using the Python path", - rust_error, - ) - - def _build_model_response( rust_response: Mapping[str, object], model_response: ModelResponse, @@ -327,13 +269,31 @@ def _build_model_response( built: Final = convert_to_model_response_object( response_object=dict(rust_response), # mutable-ok: the converter takes a real dict and rewrites it model_response_object=model_response, - hidden_params={"additional_headers": {RUST_RESPONSE_HEADER: "true"}}, # mutable-ok: rewritten by the converter + hidden_params=execution_hidden_params(None, CoreEngine.RUST), # mutable-ok: rewritten by the converter ) if not isinstance(built, ModelResponse): raise TypeError(f"expected a ModelResponse from the rust path, got {type(built).__name__}") return built +def _unwrap_execution(result: ExecutionResult[object]) -> object: + value: Final = result.value + if isinstance(value, ModelResponse): + raw_hidden_params: Final = cast( + object, + value._hidden_params, # pyright: ignore[reportPrivateUsage] # ModelResponse has no public metadata getter + ) + hidden_params: Final = ( + cast(Mapping[str, object], raw_hidden_params) # cast-ok: guarded by the mapping check + if isinstance(raw_hidden_params, Mapping) + else None + ) + value._hidden_params = execution_hidden_params( # pyright: ignore[reportPrivateUsage] # ModelResponse has no public metadata setter + hidden_params, result.source + ) + return value + + def chat_completions( *, model: str, @@ -348,10 +308,10 @@ def chat_completions( on_response: ResponseObserver, ) -> ModelResponse | None: rust_chat_completions: Final = load_rust_chat_completions() - if rust_chat_completions is None: - return None - try: - rust_response: Final = rust_chat_completions( + native_call: Final = ( + None + if rust_chat_completions is None + else lambda: rust_chat_completions( model=model, messages=messages, optional_params=optional_params, @@ -361,11 +321,18 @@ def chat_completions( extra_headers=extra_headers, timeout_seconds=timeout_to_seconds(timeout), ) - except Exception as rust_error: # noqa: BLE001 # rollout safety: the helper re-raises anything the provider already saw - _reraise_or_decline(rust_error, model=model, custom_llm_provider=custom_llm_provider) - return None - on_response(rust_response) - return _build_model_response(rust_response, model_response) + ) + + def adapt(rust_response: Mapping[str, object]) -> ModelResponse: + on_response(rust_response) + return _build_model_response(rust_response, model_response) + + result: Final = attempt( + native_call=native_call, + adapt=adapt, + context=BridgeErrorContext(route="chat completions", provider=custom_llm_provider or "", model=model), + ) + return result.value if isinstance(result, RustHandled) else None async def achat_completions( @@ -382,10 +349,10 @@ async def achat_completions( on_response: ResponseObserver, ) -> ModelResponse | None: rust_achat_completions: Final = load_rust_achat_completions() - if rust_achat_completions is None: - return None - try: - rust_response: Final = await rust_achat_completions( + native_call: Final = ( + None + if rust_achat_completions is None + else lambda: rust_achat_completions( model=model, messages=messages, optional_params=optional_params, @@ -395,11 +362,63 @@ async def achat_completions( extra_headers=extra_headers, timeout_seconds=timeout_to_seconds(timeout), ) - except Exception as rust_error: # noqa: BLE001 # rollout safety: the helper re-raises anything the provider already saw - _reraise_or_decline(rust_error, model=model, custom_llm_provider=custom_llm_provider) - return None - on_response(rust_response) - return _build_model_response(rust_response, model_response) + ) + + def adapt(rust_response: Mapping[str, object]) -> ModelResponse: + on_response(rust_response) + return _build_model_response(rust_response, model_response) + + result: Final = await aattempt( + native_call=native_call, + adapt=adapt, + context=BridgeErrorContext(route="chat completions", provider=custom_llm_provider or "", model=model), + ) + return result.value if isinstance(result, RustHandled) else None + + +def chat_completions_or_fallback( + *, + model: str, + messages: Sequence[object], + optional_params: Mapping[str, object], + model_response: ModelResponse, + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: Mapping[str, object] | None, + timeout: float | httpx.Timeout | None, + on_response: ResponseObserver, + python_fallback: Callable[[], object], +) -> object: + rust_chat_completions: Final = load_rust_chat_completions() + native_call: Final = ( + None + if rust_chat_completions is None + else lambda: rust_chat_completions( + model=model, + messages=messages, + optional_params=optional_params, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) + ) + + def adapt(rust_response: Mapping[str, object]) -> object: + on_response(rust_response) + return _build_model_response(rust_response, model_response) + + return _unwrap_execution( + invoke( + native_call=native_call, + fallback=python_fallback, + adapt=adapt, + mode=FallbackMode.PYTHON, + context=BridgeErrorContext(route="chat completions", provider=custom_llm_provider or "", model=model), + ) + ) async def achat_completions_or_fallback( @@ -416,26 +435,32 @@ async def achat_completions_or_fallback( on_response: ResponseObserver, python_fallback: Callable[[], Awaitable[object]], ) -> object: - """Await the Rust path, falling back to the caller's own Python path when - the bridge is unavailable or the call fails. - - The caller supplies the fallback, so the bridge stays free of provider - dispatch. This exists because a caller that dispatches asynchronously has - already returned a coroutine by the time a Rust failure surfaces, and so - cannot fall back on its own. - """ - response: Final = await achat_completions( - model=model, - messages=messages, - optional_params=optional_params, - model_response=model_response, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - timeout=timeout, - on_response=on_response, + rust_achat_completions: Final = load_rust_achat_completions() + native_call: Final = ( + None + if rust_achat_completions is None + else lambda: rust_achat_completions( + model=model, + messages=messages, + optional_params=optional_params, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) + ) + + def adapt(rust_response: Mapping[str, object]) -> object: + on_response(rust_response) + return _build_model_response(rust_response, model_response) + + return _unwrap_execution( + await ainvoke( + native_call=native_call, + fallback=python_fallback, + adapt=adapt, + mode=FallbackMode.PYTHON, + context=BridgeErrorContext(route="chat completions", provider=custom_llm_provider or "", model=model), + ) ) - if response is not None: - return response - return await python_fallback() diff --git a/litellm/rust_bridge/configuration.py b/litellm/rust_bridge/configuration.py new file mode 100644 index 00000000000..8ae208c836a --- /dev/null +++ b/litellm/rust_bridge/configuration.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import os +import warnings +from typing import TYPE_CHECKING, Final + +from litellm.rust_bridge.bindings import UNSET, Unset + +if TYPE_CHECKING: + from litellm.rust_bridge.messages import RustAmessages, RustMessages + from litellm.rust_bridge.ocr import RustAocr, RustOcr + from litellm.rust_bridge.responses_websocket import RustResponsesWebSocketConnection + from litellm.rust_bridge.transcription import RustAtranscription, RustTranscription + +DEFAULT_RUST_ENABLED: Final = False +_TRUE_ENV_VALUES: Final = frozenset({"1", "true", "yes", "on"}) +_FALSE_ENV_VALUES: Final = frozenset({"0", "false", "no", "off"}) +_GLOBAL_ENV_NAME: Final = "LITELLM_RUST" +_LEGACY_OCR_ENV_NAME: Final = "LITELLM_USE_RUST_OCR" + + +class _RustConfiguration: + def __init__(self) -> None: + self.override: bool | None = None + + +_CONFIGURATION: Final = _RustConfiguration() + + +def _parse_env_bool(name: str, value: str | None) -> bool | None: + if value is None: + return None + normalized: Final = value.strip().lower() + if normalized in _TRUE_ENV_VALUES: + return True + if normalized in _FALSE_ENV_VALUES: + return False + accepted: Final = ", ".join(sorted(_TRUE_ENV_VALUES | _FALSE_ENV_VALUES)) + raise ValueError(f"{name} must be one of: {accepted}") + + +def resolve_rust_enabled( + *, + request_override: bool | None, + process_override: bool | None, + environment_override: bool | None, + legacy_ocr_override: bool | None = None, + release_default: bool = DEFAULT_RUST_ENABLED, +) -> bool: + if request_override is not None: + return request_override + if process_override is not None: + return process_override + if environment_override is not None: + return environment_override + if legacy_ocr_override is not None: + return legacy_ocr_override + return release_default + + +def rust_enabled(*, request_override: bool | None = None) -> bool: + if request_override is not None: + return request_override + process_override: Final = _CONFIGURATION.override + if process_override is not None: + return process_override + return resolve_rust_enabled( + request_override=None, + process_override=None, + environment_override=_parse_env_bool(_GLOBAL_ENV_NAME, os.getenv(_GLOBAL_ENV_NAME)), + ) + + +def rust_ocr_enabled(*, request_override: bool | None = None) -> bool: + if request_override is not None: + return request_override + process_override: Final = _CONFIGURATION.override + if process_override is not None: + return process_override + global_override: Final = _parse_env_bool(_GLOBAL_ENV_NAME, os.getenv(_GLOBAL_ENV_NAME)) + legacy_override: Final = ( + None if global_override is not None else _parse_env_bool(_LEGACY_OCR_ENV_NAME, os.getenv(_LEGACY_OCR_ENV_NAME)) + ) + if legacy_override is not None: + warnings.warn( + f"{_LEGACY_OCR_ENV_NAME} is deprecated; use {_GLOBAL_ENV_NAME} instead", + DeprecationWarning, + stacklevel=2, + ) + return resolve_rust_enabled( + request_override=None, + process_override=None, + environment_override=global_override, + legacy_ocr_override=legacy_override, + ) + + +def reset_rust_configuration() -> None: + _CONFIGURATION.override = None + + +def use_litellm_rust( + enabled: bool = True, + *, + ocr: RustOcr | None | Unset = UNSET, + aocr: RustAocr | None | Unset = UNSET, + messages: RustMessages | None | Unset = UNSET, + amessages: RustAmessages | None | Unset = UNSET, + responses_websocket: type[RustResponsesWebSocketConnection] | None | Unset = UNSET, + transcription: RustTranscription | None | Unset = UNSET, + atranscription: RustAtranscription | None | Unset = UNSET, +) -> None: + """Set the process override for optional Rust paths. + + Rust-only paths, including Bedrock transcription, are not controlled by this switch. + """ + _CONFIGURATION.override = enabled + bindings: Final = (ocr, aocr, messages, amessages, responses_websocket, transcription, atranscription) + if all(isinstance(binding, Unset) for binding in bindings): + return + warnings.warn( + "Injecting Rust bridge implementations through use_litellm_rust() is deprecated; " + "use the internal bridge setters in tests", + DeprecationWarning, + stacklevel=2, + ) + + if not isinstance(ocr, Unset) or not isinstance(aocr, Unset): + from litellm.rust_bridge.ocr import set_rust_ocr + + if not isinstance(ocr, Unset): + set_rust_ocr(ocr=ocr) + if not isinstance(aocr, Unset): + set_rust_ocr(aocr=aocr) + if not isinstance(messages, Unset) or not isinstance(amessages, Unset): + from litellm.rust_bridge.messages import set_rust_messages + + if not isinstance(messages, Unset): + set_rust_messages(messages=messages) + if not isinstance(amessages, Unset): + set_rust_messages(amessages=amessages) + if not isinstance(responses_websocket, Unset): + from litellm.rust_bridge.responses_websocket import set_rust_responses_websocket + + set_rust_responses_websocket(connection=responses_websocket) + if not isinstance(transcription, Unset) or not isinstance(atranscription, Unset): + from litellm.rust_bridge.transcription import configure_rust_transcription + + if not isinstance(transcription, Unset): + configure_rust_transcription(transcription=transcription) + if not isinstance(atranscription, Unset): + configure_rust_transcription(atranscription=atranscription) diff --git a/litellm/rust_bridge/errors.py b/litellm/rust_bridge/errors.py deleted file mode 100644 index 82d7339bc84..00000000000 --- a/litellm/rust_bridge/errors.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - - -@dataclass(frozen=True, slots=True) -class NativeBridgeExceptions: - declined: type[BaseException] - upstream: type[BaseException] - - -@dataclass(frozen=True, slots=True) -class RustUpstreamFailure: - status_code: int - message: str - - -def native_bridge_exceptions(native_bridge: object | None) -> NativeBridgeExceptions | None: - if native_bridge is None: - return None - declined: Final = getattr(native_bridge, "RustBridgeDeclined", None) - upstream: Final = getattr(native_bridge, "RustUpstreamError", None) - if not isinstance(declined, type) or not isinstance(upstream, type): - return None - return NativeBridgeExceptions(declined=declined, upstream=upstream) - - -def rust_upstream_failure( - error: BaseException, - native_bridge: object | None, -) -> RustUpstreamFailure | None: - exceptions: Final = native_bridge_exceptions(native_bridge) - if exceptions is None or not isinstance(error, exceptions.upstream): - return None - status: Final = error.args[0] if error.args else 0 - message: Final = error.args[1] if len(error.args) > 1 else "" - return RustUpstreamFailure(status_code=int(status) or 500, message=str(message)) diff --git a/litellm/rust_bridge/messages.py b/litellm/rust_bridge/messages.py index 40d0ddf622b..82ff6cabcb7 100644 --- a/litellm/rust_bridge/messages.py +++ b/litellm/rust_bridge/messages.py @@ -3,11 +3,19 @@ from __future__ import annotations from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Final, Protocol, cast +from typing import Final, Protocol import httpx +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + FallbackMode, + ainvoke, + async_none, + identity, + invoke, +) from litellm.rust_bridge.timeouts import timeout_to_seconds @@ -39,53 +47,33 @@ class RustAmessages(Protocol): raise NotImplementedError -class _Unset: - pass - - -_UNSET: Final[_Unset] = _Unset() - - -@dataclass(slots=True) -class _RustMessagesState: - messages: RustMessages | None = None - amessages: RustAmessages | None = None - - -_STATE: Final[_RustMessagesState] = _RustMessagesState() +_MESSAGES: Final = NativeBinding[RustMessages]("messages") +_AMESSAGES: Final = NativeBinding[RustAmessages]("amessages") def set_rust_messages( *, - messages: RustMessages | None | _Unset = _UNSET, - amessages: RustAmessages | None | _Unset = _UNSET, + messages: RustMessages | None | Unset = UNSET, + amessages: RustAmessages | None | Unset = UNSET, ) -> None: - if not isinstance(messages, _Unset): - _STATE.messages = messages - if not isinstance(amessages, _Unset): - _STATE.amessages = amessages + _MESSAGES.update(messages) + _AMESSAGES.update(amessages) def load_rust_messages() -> RustMessages | None: - if _STATE.messages is not None: - return _STATE.messages - from litellm.rust_bridge import get_native_bridge - - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - return cast(RustMessages, getattr(native_bridge, "messages", None)) + return _MESSAGES.load() def load_rust_amessages() -> RustAmessages | None: - if _STATE.amessages is not None: - return _STATE.amessages - from litellm.rust_bridge import get_native_bridge + return _AMESSAGES.load() - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - return cast(RustAmessages, getattr(native_bridge, "amessages", None)) + +def _context(model: str, custom_llm_provider: str | None) -> BridgeErrorContext: + return BridgeErrorContext( + route="messages", + provider=custom_llm_provider or "anthropic", + model=model, + ) def messages( @@ -98,18 +86,26 @@ def messages( extra_headers: dict[str, object] | None, timeout: float | httpx.Timeout | None, ) -> dict[str, object] | None: - rust_messages: Final = load_rust_messages() - if rust_messages is None: - return None - return rust_messages( - model=model, - body=body, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - timeout_seconds=timeout_to_seconds(timeout), - ) + native: Final = load_rust_messages() + return invoke( + native_call=( + None + if native is None + else lambda: native( + model=model, + body=body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) + ), + fallback=lambda: None, + adapt=identity, + mode=FallbackMode.PYTHON, + context=_context(model, custom_llm_provider), + ).value async def amessages( @@ -122,15 +118,25 @@ async def amessages( extra_headers: dict[str, object] | None, timeout: float | httpx.Timeout | None, ) -> dict[str, object] | None: - rust_amessages: Final = load_rust_amessages() - if rust_amessages is None: - return None - return await rust_amessages( - model=model, - body=body, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - timeout_seconds=timeout_to_seconds(timeout), - ) + native: Final = load_rust_amessages() + return ( + await ainvoke( + native_call=( + None + if native is None + else lambda: native( + model=model, + body=body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) + ), + fallback=async_none, + adapt=identity, + mode=FallbackMode.PYTHON, + context=_context(model, custom_llm_provider), + ) + ).value diff --git a/litellm/rust_bridge/ocr.py b/litellm/rust_bridge/ocr.py index 52e92782b44..325dbef3440 100644 --- a/litellm/rust_bridge/ocr.py +++ b/litellm/rust_bridge/ocr.py @@ -2,16 +2,33 @@ from __future__ import annotations -import os from collections.abc import Awaitable -from typing import TYPE_CHECKING, Any, Final, Protocol, cast +from typing import Final, Protocol import httpx +from litellm.rust_bridge import configuration as _configuration +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + FallbackMode, + ainvoke, + async_none, + identity, + invoke, +) from litellm.rust_bridge.timeouts import timeout_to_seconds as _timeout_to_seconds -if TYPE_CHECKING: - from litellm.rust_bridge.messages import RustAmessages, RustMessages +rust_ocr_enabled = _configuration.rust_ocr_enabled +use_litellm_rust = _configuration.use_litellm_rust + + +class _OcrProviderError(Exception): + def __init__(self, status_code: int, message: str, request_url: str | None) -> None: + super().__init__(message) + self.status_code: Final = status_code + request: Final = httpx.Request("POST", request_url) if request_url is not None else None + self.response: Final = httpx.Response(status_code=status_code, request=request) class RustOcr(Protocol): @@ -44,116 +61,25 @@ class RustAocr(Protocol): raise NotImplementedError -class _Unset: - pass +_OCR: Final = NativeBinding[RustOcr]("ocr") +_AOCR: Final = NativeBinding[RustAocr]("aocr") -_UNSET: Final[_Unset] = _Unset() - - -def _env_enables_rust_ocr() -> bool: - return os.getenv("LITELLM_USE_RUST_OCR", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } - - -_rust_ocr_enabled = _env_enables_rust_ocr() -_rust_ocr_impl: RustOcr | None = None -_rust_aocr_impl: RustAocr | None = None - - -class _OcrProviderError(Exception): - def __init__(self, status_code: int, message: str, request_url: str | None) -> None: - super().__init__(message) - self.status_code: Final = status_code - request: Final = httpx.Request("POST", request_url) if request_url is not None else None - self.response: Final = httpx.Response(status_code=status_code, request=request) - - -def _raise_provider_error(error: BaseException, request_url: str | None) -> None: - from litellm.rust_bridge import get_native_bridge - from litellm.rust_bridge.errors import rust_upstream_failure - - failure: Final = rust_upstream_failure(error, get_native_bridge()) - if failure is None: - raise error - raise _OcrProviderError(failure.status_code, failure.message, request_url) from error - - -def use_litellm_rust( - enabled: bool = True, +def set_rust_ocr( *, - ocr: RustOcr | None | _Unset = _UNSET, - aocr: RustAocr | None | _Unset = _UNSET, - messages: RustMessages | None | _Unset = _UNSET, - amessages: RustAmessages | None | _Unset = _UNSET, - responses_websocket: Any | None | _Unset = _UNSET, - transcription: Any | None | _Unset = _UNSET, - atranscription: Any | None | _Unset = _UNSET, + ocr: RustOcr | None | Unset = UNSET, + aocr: RustAocr | None | Unset = UNSET, ) -> None: - global _rust_ocr_enabled, _rust_ocr_impl, _rust_aocr_impl - configuring_ocr: Final = not isinstance(ocr, _Unset) or not isinstance(aocr, _Unset) - configuring_messages: Final = not isinstance(messages, _Unset) or not isinstance(amessages, _Unset) - configuring_responses_websocket: Final = not isinstance(responses_websocket, _Unset) - configuring_transcription: Final = not isinstance(transcription, _Unset) or not isinstance(atranscription, _Unset) - if configuring_ocr or (not configuring_messages and not configuring_responses_websocket): - _rust_ocr_enabled = enabled - if not isinstance(ocr, _Unset): - _rust_ocr_impl = ocr - if not isinstance(aocr, _Unset): - _rust_aocr_impl = aocr - if configuring_transcription: - from litellm.rust_bridge.transcription import configure_rust_transcription - - configure_rust_transcription( - enabled=enabled, - transcription=transcription, - atranscription=atranscription, - ) - if not configuring_messages and not configuring_responses_websocket: - return - if configuring_messages: - from litellm.rust_bridge.messages import set_rust_messages - - if not isinstance(messages, _Unset) and not isinstance(amessages, _Unset): - set_rust_messages(messages=messages, amessages=amessages) - elif not isinstance(messages, _Unset): - set_rust_messages(messages=messages) - else: - set_rust_messages(amessages=amessages) - if configuring_responses_websocket: - from litellm.rust_bridge.responses_websocket import set_rust_responses_websocket - - set_rust_responses_websocket(connection=responses_websocket) - - -def rust_ocr_enabled() -> bool: - return _rust_ocr_enabled + _OCR.update(ocr) + _AOCR.update(aocr) def load_rust_ocr() -> RustOcr | None: - if _rust_ocr_impl is not None: - return _rust_ocr_impl - from litellm.rust_bridge import get_native_bridge - - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - return cast(RustOcr, native_bridge.ocr) + return _OCR.load() def load_rust_aocr() -> RustAocr | None: - if _rust_aocr_impl is not None: - return _rust_aocr_impl - from litellm.rust_bridge import get_native_bridge - - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - return cast(RustAocr, getattr(native_bridge, "aocr", None)) + return _AOCR.load() def ocr( @@ -169,10 +95,10 @@ def ocr( request_url: str | None = None, ) -> dict[str, object] | None: rust_ocr: Final = load_rust_ocr() - if rust_ocr is None: - return None - try: - return rust_ocr( + native_call: Final = ( + None + if rust_ocr is None + else lambda: rust_ocr( model=model, document=document, api_key=api_key, @@ -182,8 +108,19 @@ def ocr( optional_params=optional_params, timeout_seconds=_timeout_to_seconds(timeout), ) - except Exception as error: - _raise_provider_error(error, request_url) + ) + return invoke( + native_call=native_call, + fallback=lambda: None, + adapt=identity, + mode=FallbackMode.PYTHON, + context=BridgeErrorContext( + route="ocr", + provider=custom_llm_provider or "", + model=model, + upstream_error=lambda status, message: _OcrProviderError(status, message, request_url), + ), + ).value async def aocr( @@ -199,10 +136,10 @@ async def aocr( request_url: str | None = None, ) -> dict[str, object] | None: rust_aocr: Final = load_rust_aocr() - if rust_aocr is None: - return None - try: - return await rust_aocr( + native_call: Final = ( + None + if rust_aocr is None + else lambda: rust_aocr( model=model, document=document, api_key=api_key, @@ -212,5 +149,18 @@ async def aocr( optional_params=optional_params, timeout_seconds=_timeout_to_seconds(timeout), ) - except Exception as error: - _raise_provider_error(error, request_url) + ) + return ( + await ainvoke( + native_call=native_call, + fallback=async_none, + adapt=identity, + mode=FallbackMode.PYTHON, + context=BridgeErrorContext( + route="ocr", + provider=custom_llm_provider or "", + model=model, + upstream_error=lambda status, message: _OcrProviderError(status, message, request_url), + ), + ) + ).value diff --git a/litellm/rust_bridge/responses_websocket.py b/litellm/rust_bridge/responses_websocket.py index 0634867af1c..54e8ee4bb2a 100644 --- a/litellm/rust_bridge/responses_websocket.py +++ b/litellm/rust_bridge/responses_websocket.py @@ -2,20 +2,35 @@ from __future__ import annotations -from dataclasses import dataclass +import json +from collections.abc import Mapping +from types import MappingProxyType from typing import Final, Protocol import httpx +from pydantic import TypeAdapter from websockets.exceptions import ConnectionClosedOK -from litellm.rust_bridge.loader import get_native_bridge +from litellm.rust_bridge import streaming +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + CoreEngine, + ExecutionResult, + FallbackMode, + acall, + ainvoke, + async_none, +) from litellm.rust_bridge.timeouts import timeout_to_seconds +_EVENT_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + class RustResponsesWebSocket(Protocol): - async def send_text(self, text: str) -> None: ... + async def send_event(self, event: Mapping[str, object]) -> None: ... - async def recv_text(self) -> str | None: ... + async def recv_event(self) -> Mapping[str, object] | None: ... async def close(self) -> None: ... @@ -24,79 +39,85 @@ class RustResponsesWebSocketConnection(Protocol): @classmethod async def connect( cls, - url: str, - headers: dict[str, str], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, timeout_seconds: float | None, ) -> RustResponsesWebSocket: ... -class _Unset: - pass - - -_UNSET: Final[_Unset] = _Unset() - - -@dataclass(slots=True) -class _RustResponsesWebSocketState: - connection: RustResponsesWebSocketConnection | None = None - - -_STATE: Final[_RustResponsesWebSocketState] = _RustResponsesWebSocketState() +_CONNECTION: Final = NativeBinding[type[RustResponsesWebSocketConnection]]("ResponsesWebSocketSession") def set_rust_responses_websocket( *, - connection: RustResponsesWebSocketConnection | None | _Unset = _UNSET, + connection: type[RustResponsesWebSocketConnection] | None | Unset = UNSET, ) -> None: - if not isinstance(connection, _Unset): - _STATE.connection = connection + _CONNECTION.update(connection) -def load_rust_responses_websocket() -> RustResponsesWebSocketConnection | None: - if _STATE.connection is not None: - return _STATE.connection - native_bridge: Final = get_native_bridge() - if native_bridge is None: - return None - connection_type: Final[RustResponsesWebSocketConnection | None] = getattr( - native_bridge, "ResponsesWebSocketConnection", None - ) - return connection_type +def load_rust_responses_websocket() -> type[RustResponsesWebSocketConnection] | None: + return _CONNECTION.load() class _ConnectionAdapter: - def __init__(self, connection: RustResponsesWebSocket): - self._connection: Final[RustResponsesWebSocket] = connection + def __init__(self, connection: RustResponsesWebSocket, context: BridgeErrorContext): + self._connection: Final = connection + self._context: Final = context + self.core_engine: Final = CoreEngine.RUST async def send(self, text: str) -> None: - await self._connection.send_text(text) + event: Final = _EVENT_ADAPTER.validate_json(text) + await acall( + lambda: self._connection.send_event(event), + self._context, + ) async def recv(self) -> str: - message: Final = await self._connection.recv_text() - if message is None: + event: Final = await acall( + self._connection.recv_event, + self._context, + ) + if event is None: raise ConnectionClosedOK(None, None) - return message + return json.dumps(dict(event), separators=(",", ":")) # mutable-ok: JSON needs a concrete dict async def close(self) -> None: - await self._connection.close() + await acall( + self._connection.close, + self._context, + ) async def connect( *, - url: str, - headers: dict[str, str], + provider: str, + api_key: str | None, + api_base: str | None, + headers: Mapping[str, str], timeout: float | httpx.Timeout | None, -) -> _ConnectionAdapter | None: +) -> ExecutionResult[_ConnectionAdapter | None]: + context: Final = BridgeErrorContext(route="responses websocket", provider=provider, model="") + if not streaming.supports_streaming("responses", provider, "websocket"): + return ExecutionResult(value=None, source=CoreEngine.PYTHON) connection_type: Final = load_rust_responses_websocket() - if connection_type is None: - return None - try: - connection: Final = await connection_type.connect( - url=url, - headers=headers, - timeout_seconds=timeout_to_seconds(timeout), + credentials: Final = None if api_key is None else MappingProxyType({"api_key": api_key}) + native_call: Final = ( + None + if connection_type is None + else lambda: connection_type.connect( + provider, + credentials, + api_base, + headers, + timeout_to_seconds(timeout), ) - except Exception: # noqa: BLE001 # bridge failures must fall back to Python - return None - return _ConnectionAdapter(connection) + ) + return await ainvoke( + native_call=native_call, + fallback=async_none, + adapt=lambda connection: _ConnectionAdapter(connection, context), + mode=FallbackMode.PYTHON, + context=context, + ) diff --git a/litellm/rust_bridge/runtime.py b/litellm/rust_bridge/runtime.py new file mode 100644 index 00000000000..b04447dee3d --- /dev/null +++ b/litellm/rust_bridge/runtime.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Final, Generic, NoReturn, TypeAlias, TypeVar, cast + +from litellm.exceptions import APIError +from litellm.rust_bridge.bindings import native_exception_types + +NativeT = TypeVar("NativeT") +ResultT = TypeVar("ResultT") + +CORE_ENGINE_HIDDEN_PARAM: Final = "core_engine" +CORE_ENGINE_HEADER: Final = "x-litellm-core" +LEGACY_RUST_HEADER: Final = "x-litellm-rust" + + +class FallbackMode(Enum): + PYTHON = "python" + RUST_REQUIRED = "rust_required" + + +class CoreEngine(str, Enum): + PYTHON = "python" + RUST = "rust" + + +@dataclass(frozen=True, slots=True) +class ExecutionResult(Generic[ResultT]): + value: ResultT + source: CoreEngine + + +@dataclass(frozen=True, slots=True) +class RustHandled(Generic[ResultT]): + value: ResultT + + +@dataclass(frozen=True, slots=True) +class RustDeclined: + reason: str + + +@dataclass(frozen=True, slots=True) +class RustUnavailable: + pass + + +RustAttempt: TypeAlias = RustHandled[ResultT] | RustDeclined | RustUnavailable + + +@dataclass(frozen=True, slots=True) +class BridgeErrorContext: + route: str + provider: str + model: str + upstream_error: Callable[[int, str], Exception] | None = None + + +def execution_headers(source: CoreEngine) -> dict[str, str]: + if source is CoreEngine.RUST: + return { # mutable-ok: response adapters require a mutable header dict + CORE_ENGINE_HEADER: source.value, + LEGACY_RUST_HEADER: "true", + } + return {CORE_ENGINE_HEADER: source.value} # mutable-ok: response adapters require a mutable header dict + + +def execution_additional_headers( + additional_headers: Mapping[str, object] | None, + source: CoreEngine, +) -> dict[str, str]: + existing: Final = additional_headers or {} # mutable-ok: empty default is local and never mutated + reserved: Final = frozenset({CORE_ENGINE_HEADER, LEGACY_RUST_HEADER}) + preserved: Final = { # mutable-ok: response adapters require a mutable header dict + str(name): str(value) for name, value in existing.items() if str(name).lower() not in reserved + } + return { # mutable-ok: response adapters require a mutable header dict + **preserved, + **execution_headers(source), + } + + +def execution_hidden_params( + hidden_params: Mapping[str, object] | None, + source: CoreEngine, +) -> dict[str, object]: + existing: Final = hidden_params or {} # mutable-ok: empty default is local and never mutated + raw_headers: Final = existing.get("additional_headers") + additional_headers: Final[Mapping[str, object]] = ( + cast(Mapping[str, object], raw_headers) # cast-ok: the isinstance check validates the mapping boundary + if isinstance(raw_headers, Mapping) + else {} # mutable-ok: empty default is local and never mutated + ) + return { # mutable-ok: response objects require mutable hidden params + **existing, + CORE_ENGINE_HIDDEN_PARAM: source.value, + "additional_headers": execution_additional_headers(additional_headers, source), + } + + +def invoke( + *, + native_call: Callable[[], NativeT] | None, + fallback: Callable[[], ResultT], + adapt: Callable[[NativeT], ResultT], + mode: FallbackMode, + context: BridgeErrorContext, +) -> ExecutionResult[ResultT]: + result: Final = attempt(native_call=native_call, adapt=adapt, context=context) + if isinstance(result, RustHandled): + return ExecutionResult(value=result.value, source=CoreEngine.RUST) + if mode is FallbackMode.PYTHON: + return ExecutionResult(value=fallback(), source=CoreEngine.PYTHON) + _raise_required(result, context) + + +async def ainvoke( + *, + native_call: Callable[[], Awaitable[NativeT]] | None, + fallback: Callable[[], Awaitable[ResultT]], + adapt: Callable[[NativeT], ResultT], + mode: FallbackMode, + context: BridgeErrorContext, +) -> ExecutionResult[ResultT]: + result: Final = await aattempt(native_call=native_call, adapt=adapt, context=context) + if isinstance(result, RustHandled): + return ExecutionResult(value=result.value, source=CoreEngine.RUST) + if mode is FallbackMode.PYTHON: + return ExecutionResult(value=await fallback(), source=CoreEngine.PYTHON) + _raise_required(result, context) + + +def attempt( + *, + native_call: Callable[[], NativeT] | None, + adapt: Callable[[NativeT], ResultT], + context: BridgeErrorContext, +) -> RustAttempt[ResultT]: + if native_call is None: + return RustUnavailable() + exceptions: Final = native_exception_types() + if exceptions is None: + return RustHandled(adapt(native_call())) + declined, upstream = exceptions + try: + value: Final = native_call() + except declined as error: + return RustDeclined(reason=_decline_reason(error)) + except upstream as error: + _raise_upstream(error, context) + return RustHandled(adapt(value)) + + +async def aattempt( + *, + native_call: Callable[[], Awaitable[NativeT]] | None, + adapt: Callable[[NativeT], ResultT], + context: BridgeErrorContext, +) -> RustAttempt[ResultT]: + if native_call is None: + return RustUnavailable() + exceptions: Final = native_exception_types() + if exceptions is None: + return RustHandled(adapt(await native_call())) + declined, upstream = exceptions + try: + value: Final = await native_call() + except declined as error: + return RustDeclined(reason=_decline_reason(error)) + except upstream as error: + _raise_upstream(error, context) + return RustHandled(adapt(value)) + + +def call(operation: Callable[[], ResultT], context: BridgeErrorContext) -> ResultT: + exceptions: Final = native_exception_types() + if exceptions is None: + return operation() + upstream: Final = exceptions[1] + try: + return operation() + except upstream as error: + _raise_upstream(error, context) + + +async def acall(operation: Callable[[], Awaitable[ResultT]], context: BridgeErrorContext) -> ResultT: + exceptions: Final = native_exception_types() + if exceptions is None: + return await operation() + upstream: Final = exceptions[1] + try: + return await operation() + except upstream as error: + _raise_upstream(error, context) + + +def _decline_reason(error: BaseException) -> str: + reason: Final = error.args[0] if error.args else str(error) + return reason if isinstance(reason, str) else str(reason) + + +def _raise_required( + result: RustDeclined | RustUnavailable, + context: BridgeErrorContext, +) -> NoReturn: + raise RuntimeError(f"Rust {context.route} bridge {_required_reason(result)}") + + +def _required_reason(result: RustDeclined | RustUnavailable) -> str: + match result: + case RustUnavailable(): + return "is unavailable" + case RustDeclined(reason=reason): + return f"declined the request: {reason}" + + +def _raise_upstream(error: BaseException, context: BridgeErrorContext) -> NoReturn: + args: Final = cast(tuple[object, ...], error.args) + status_value: Final = args[0] if args else 0 + message_value: Final = args[1] if len(args) > 1 else str(error) + status: Final = status_value if isinstance(status_value, int) else 0 + message: Final = message_value if isinstance(message_value, str) else str(message_value) + api_error: Final = ( + context.upstream_error(status or 500, message) + if context.upstream_error is not None + else APIError( + status_code=status or 500, + message=f"litellm rust {context.route}: {message}", + llm_provider=context.provider, + model=context.model, + ) + ) + api_error.headers = execution_headers( # pyright: ignore[reportAttributeAccessIssue] # proxy reads exception headers + CoreEngine.RUST + ) + raise api_error from error + + +def identity(value: ResultT) -> ResultT: + return value + + +async def async_none() -> None: + return None diff --git a/litellm/rust_bridge/streaming.py b/litellm/rust_bridge/streaming.py new file mode 100644 index 00000000000..1b7fc1bb5e8 --- /dev/null +++ b/litellm/rust_bridge/streaming.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Iterator, Mapping +from functools import lru_cache +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable + +import httpx + +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + RustHandled, + aattempt, + acall, + attempt, + call, +) +from litellm.rust_bridge.timeouts import timeout_to_seconds + +if TYPE_CHECKING: + from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject, ResponsesAPIStreamingResponse + +StreamApi: TypeAlias = Literal["chat_completions", "messages", "responses"] +StreamTransport: TypeAlias = Literal["http", "websocket"] +Event: TypeAlias = Mapping[str, object] + + +@runtime_checkable +class RustEventStream(Protocol): + @property + def metadata(self) -> Mapping[str, object]: ... + + def next_event(self) -> Event | None: ... + + async def anext_event(self) -> Event | None: ... + + def close(self) -> None: ... + + async def aclose(self) -> None: ... + + +class RustStreamOpen(Protocol): + def __call__( + self, + request: Mapping[str, object], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> object: ... + + +class RustAsyncStreamOpen(Protocol): + async def __call__( + self, + request: Mapping[str, object], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> object: ... + + +class RustStreamCapability(Protocol): + def __call__(self, api: str, provider: str, transport: str) -> bool: ... + + +_CAPABILITY: Final = NativeBinding[RustStreamCapability]("supports_streaming") +_CHAT: Final = NativeBinding[RustStreamOpen]("chat_completions_stream") +_ACHAT: Final = NativeBinding[RustAsyncStreamOpen]("achat_completions_stream") +_MESSAGES: Final = NativeBinding[RustStreamOpen]("messages_stream") +_AMESSAGES: Final = NativeBinding[RustAsyncStreamOpen]("amessages_stream") +_RESPONSES: Final = NativeBinding[RustStreamOpen]("responses_stream") +_ARESPONSES: Final = NativeBinding[RustAsyncStreamOpen]("aresponses_stream") + + +def set_rust_streaming( + *, + capability: RustStreamCapability | None | Unset = UNSET, + chat: RustStreamOpen | None | Unset = UNSET, + achat: RustAsyncStreamOpen | None | Unset = UNSET, + messages: RustStreamOpen | None | Unset = UNSET, + amessages: RustAsyncStreamOpen | None | Unset = UNSET, + responses: RustStreamOpen | None | Unset = UNSET, + aresponses: RustAsyncStreamOpen | None | Unset = UNSET, +) -> None: + """Override native stream bindings in tests; passing ``None`` restores discovery.""" + + _CAPABILITY.update(capability) + _CHAT.update(chat) + _ACHAT.update(achat) + _MESSAGES.update(messages) + _AMESSAGES.update(amessages) + _RESPONSES.update(responses) + _ARESPONSES.update(aresponses) + + +def supports_streaming(api: StreamApi, provider: str, transport: StreamTransport = "http") -> bool: + capability: Final = _CAPABILITY.load() + if capability is None: + return False + try: + return capability(api, provider, transport) is True + except Exception: # noqa: BLE001 # extension-defined errors cannot be named statically + return False + + +def _sync_opener(api: StreamApi) -> RustStreamOpen | None: + match api: + case "chat_completions": + return _CHAT.load() + case "messages": + return _MESSAGES.load() + case "responses": + return _RESPONSES.load() + + +def _async_opener(api: StreamApi) -> RustAsyncStreamOpen | None: + match api: + case "chat_completions": + return _ACHAT.load() + case "messages": + return _AMESSAGES.load() + case "responses": + return _ARESPONSES.load() + + +def _context(api: StreamApi, provider: str, request: Mapping[str, object]) -> BridgeErrorContext: + model: Final = request.get("model") + return BridgeErrorContext( + route=f"{api.replace('_', ' ')} stream", + provider=provider, + model=model if isinstance(model, str) else "", + ) + + +def _adapt_stream( + stream: object, + *, + context: BridgeErrorContext, +) -> TypedEventStreamAdapter: + if not isinstance(stream, RustEventStream): + raise TypeError("native stream opener returned an invalid stream") + return TypedEventStreamAdapter(stream, context) + + +class TypedEventStreamAdapter: + def __init__(self, stream: RustEventStream, context: BridgeErrorContext) -> None: + self._stream: Final = stream + self._context: Final = context + self.metadata: Final = stream.metadata + self._mode: Literal["sync", "async"] | None = None + + def _claim(self, mode: Literal["sync", "async"]) -> None: + if self._mode is None: + self._mode = mode + return + if self._mode != mode: + raise RuntimeError("native stream cannot mix synchronous and asynchronous consumption") + + def __iter__(self) -> Iterator[Event]: + self._claim("sync") + return self + + def __next__(self) -> Event: + self._claim("sync") + event: Final = call(self._stream.next_event, self._context) + if event is None: + raise StopIteration + return event + + def __aiter__(self) -> AsyncIterator[Event]: + self._claim("async") + return self + + async def __anext__(self) -> Event: + self._claim("async") + event: Final = await acall(self._stream.anext_event, self._context) + if event is None: + raise StopAsyncIteration + return event + + def close(self) -> None: + call(self._stream.close, self._context) + + async def aclose(self) -> None: + await acall(self._stream.aclose, self._context) + + +class MessagesSseStreamAdapter: + def __init__(self, events: TypedEventStreamAdapter) -> None: + self._events: Final = events + self.metadata: Final = events.metadata + + def __iter__(self) -> Iterator[bytes]: + return (_event_to_sse(event) for event in self._events) + + async def __aiter__(self) -> AsyncIterator[bytes]: + async for event in self._events: + yield _event_to_sse(event) + + def close(self) -> None: + self._events.close() + + async def aclose(self) -> None: + await self._events.aclose() + + +class ResponsesSdkEventStreamAdapter: + def __init__(self, events: TypedEventStreamAdapter) -> None: + self._events: Final = events + self.metadata: Final = events.metadata + + def __iter__(self) -> Iterator[ResponsesAPIStreamingResponse]: + return (_responses_event_to_sdk(event) for event in self._events) + + async def __aiter__(self) -> AsyncIterator[ResponsesAPIStreamingResponse]: + async for event in self._events: + yield _responses_event_to_sdk(event) + + def close(self) -> None: + self._events.close() + + async def aclose(self) -> None: + await self._events.aclose() + + +def _responses_event_to_sdk(event: Event) -> ResponsesAPIStreamingResponse: + from litellm.types.llms.openai import GenericEvent + + event_type: Final = event.get("type") + model: Final = _responses_event_models().get(event_type) if isinstance(event_type, str) else None + return (model or GenericEvent).model_validate(event) + + +@lru_cache(maxsize=1) +def _responses_event_models() -> Mapping[str, type[BaseLiteLLMOpenAIResponseObject]]: + from litellm.types.llms import openai as openai_types + + return MappingProxyType( + { + "response.created": openai_types.ResponseCreatedEvent, + "response.in_progress": openai_types.ResponseInProgressEvent, + "response.completed": openai_types.ResponseCompletedEvent, + "response.failed": openai_types.ResponseFailedEvent, + "response.incomplete": openai_types.ResponseIncompleteEvent, + "response.reasoning_summary_part.added": openai_types.ResponsePartAddedEvent, + "response.reasoning_summary_text.delta": openai_types.ReasoningSummaryTextDeltaEvent, + "response.reasoning_summary_text.done": openai_types.ReasoningSummaryTextDoneEvent, + "response.reasoning_summary_part.done": openai_types.ReasoningSummaryPartDoneEvent, + "response.output_item.added": openai_types.OutputItemAddedEvent, + "response.output_item.done": openai_types.OutputItemDoneEvent, + "response.content_part.added": openai_types.ContentPartAddedEvent, + "response.content_part.done": openai_types.ContentPartDoneEvent, + "response.output_text.delta": openai_types.OutputTextDeltaEvent, + "response.output_text.annotation.added": openai_types.OutputTextAnnotationAddedEvent, + "response.output_text.done": openai_types.OutputTextDoneEvent, + "response.refusal.delta": openai_types.RefusalDeltaEvent, + "response.refusal.done": openai_types.RefusalDoneEvent, + "response.function_call_arguments.delta": openai_types.FunctionCallArgumentsDeltaEvent, + "response.function_call_arguments.done": openai_types.FunctionCallArgumentsDoneEvent, + "response.file_search_call.in_progress": openai_types.FileSearchCallInProgressEvent, + "response.file_search_call.searching": openai_types.FileSearchCallSearchingEvent, + "response.file_search_call.completed": openai_types.FileSearchCallCompletedEvent, + "response.web_search_call.in_progress": openai_types.WebSearchCallInProgressEvent, + "response.web_search_call.searching": openai_types.WebSearchCallSearchingEvent, + "response.web_search_call.completed": openai_types.WebSearchCallCompletedEvent, + "response.mcp_list_tools.in_progress": openai_types.MCPListToolsInProgressEvent, + "response.mcp_list_tools.completed": openai_types.MCPListToolsCompletedEvent, + "response.mcp_list_tools.failed": openai_types.MCPListToolsFailedEvent, + "response.mcp_call.in_progress": openai_types.MCPCallInProgressEvent, + "response.mcp_call_arguments.delta": openai_types.MCPCallArgumentsDeltaEvent, + "response.mcp_call_arguments.done": openai_types.MCPCallArgumentsDoneEvent, + "response.mcp_call.completed": openai_types.MCPCallCompletedEvent, + "response.mcp_call.failed": openai_types.MCPCallFailedEvent, + "image_generation.partial_image": openai_types.ImageGenerationPartialImageEvent, + "error": openai_types.ErrorEvent, + } + ) + + +def _event_to_sse(event: Event) -> bytes: + payload: Final = dict(event) # mutable-ok: JSON requires a concrete dict + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode() + + +def open_stream( + *, + api: StreamApi, + provider: str, + request: Mapping[str, object], + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout: float | httpx.Timeout | None, +) -> TypedEventStreamAdapter | None: + if not supports_streaming(api, provider): + return None + opener: Final = _sync_opener(api) + context: Final = _context(api, provider, request) + result: Final = attempt( + native_call=( + None + if opener is None + else lambda: opener( + request, + provider, + credentials, + api_base, + extra_headers, + timeout_to_seconds(timeout), + ) + ), + adapt=lambda stream: _adapt_stream(stream, context=context), + context=context, + ) + return result.value if isinstance(result, RustHandled) else None + + +async def aopen_stream( + *, + api: StreamApi, + provider: str, + request: Mapping[str, object], + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout: float | httpx.Timeout | None, +) -> TypedEventStreamAdapter | None: + if not supports_streaming(api, provider): + return None + opener: Final = _async_opener(api) + context: Final = _context(api, provider, request) + result: Final = await aattempt( + native_call=( + None + if opener is None + else lambda: opener( + request, + provider, + credentials, + api_base, + extra_headers, + timeout_to_seconds(timeout), + ) + ), + adapt=lambda stream: _adapt_stream(stream, context=context), + context=context, + ) + return result.value if isinstance(result, RustHandled) else None diff --git a/litellm/rust_bridge/transcription.py b/litellm/rust_bridge/transcription.py index 3d71f6f8a50..295b0d4ad01 100644 --- a/litellm/rust_bridge/transcription.py +++ b/litellm/rust_bridge/transcription.py @@ -1,11 +1,19 @@ from __future__ import annotations from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Final, Protocol, cast +from typing import Final, Protocol import httpx +from litellm.rust_bridge.bindings import UNSET, NativeBinding, Unset +from litellm.rust_bridge.runtime import ( + BridgeErrorContext, + FallbackMode, + ainvoke, + async_none, + identity, + invoke, +) from litellm.rust_bridge.timeouts import timeout_to_seconds @@ -39,62 +47,27 @@ class RustAtranscription(Protocol): raise NotImplementedError -class _Unset: - pass - - -_UNSET: Final[_Unset] = _Unset() - - -@dataclass -class _RustTranscriptionState: - transcription: RustTranscription | None = None - atranscription: RustAtranscription | None = None - - -_STATE: Final = _RustTranscriptionState() +_TRANSCRIPTION: Final = NativeBinding[RustTranscription]("transcription") +_ATRANSCRIPTION: Final = NativeBinding[RustAtranscription]("atranscription") def configure_rust_transcription( enabled: bool = True, *, - transcription: RustTranscription | None | _Unset = _UNSET, - atranscription: RustAtranscription | None | _Unset = _UNSET, + transcription: RustTranscription | None | Unset = UNSET, + atranscription: RustAtranscription | None | Unset = UNSET, ) -> None: - if not isinstance(transcription, _Unset): - _STATE.transcription = transcription - if not isinstance(atranscription, _Unset): - _STATE.atranscription = atranscription + _ = enabled + _TRANSCRIPTION.update(transcription) + _ATRANSCRIPTION.update(atranscription) def load_rust_transcription() -> RustTranscription | None: - if _STATE.transcription is not None: - return _STATE.transcription - from litellm.rust_bridge import get_native_bridge - - native_bridge: Final = get_native_bridge() - return ( - None - if native_bridge is None - else cast( # cast-ok: native extension protocol is runtime-defined - RustTranscription, getattr(native_bridge, "transcription", None) - ) - ) + return _TRANSCRIPTION.load() def load_rust_atranscription() -> RustAtranscription | None: - if _STATE.atranscription is not None: - return _STATE.atranscription - from litellm.rust_bridge import get_native_bridge - - native_bridge: Final = get_native_bridge() - return ( - None - if native_bridge is None - else cast( # cast-ok: native extension protocol is runtime-defined - RustAtranscription, getattr(native_bridge, "atranscription", None) - ) - ) + return _ATRANSCRIPTION.load() def transcription( @@ -109,18 +82,27 @@ def transcription( timeout: float | httpx.Timeout | None, ) -> dict[str, object] | None: rust_transcription: Final = load_rust_transcription() - if rust_transcription is None: - return None - return rust_transcription( - model=model, - audio=audio, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - optional_params=optional_params, - timeout_seconds=timeout_to_seconds(timeout), + native_call: Final = ( + None + if rust_transcription is None + else lambda: rust_transcription( + model=model, + audio=audio, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + optional_params=optional_params, + timeout_seconds=timeout_to_seconds(timeout), + ) ) + return invoke( + native_call=native_call, + fallback=lambda: None, + adapt=identity, + mode=FallbackMode.RUST_REQUIRED, + context=BridgeErrorContext(route="audio transcription", provider=custom_llm_provider or "", model=model), + ).value async def atranscription( @@ -135,15 +117,26 @@ async def atranscription( timeout: float | httpx.Timeout | None, ) -> dict[str, object] | None: rust_atranscription: Final = load_rust_atranscription() - if rust_atranscription is None: - return None - return await rust_atranscription( - model=model, - audio=audio, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - optional_params=optional_params, - timeout_seconds=timeout_to_seconds(timeout), + native_call: Final = ( + None + if rust_atranscription is None + else lambda: rust_atranscription( + model=model, + audio=audio, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + optional_params=optional_params, + timeout_seconds=timeout_to_seconds(timeout), + ) ) + return ( + await ainvoke( + native_call=native_call, + fallback=async_none, + adapt=identity, + mode=FallbackMode.RUST_REQUIRED, + context=BridgeErrorContext(route="audio transcription", provider=custom_llm_provider or "", model=model), + ) + ).value diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py index 84461115e8e..21f27075e0f 100644 --- a/litellm/search/cost_calculator.py +++ b/litellm/search/cost_calculator.py @@ -2,16 +2,37 @@ Cost calculation for search providers. """ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final +from pydantic import TypeAdapter, ValidationError + from litellm.utils import get_model_info +PROVIDER_USAGE_ADAPTER: Final[TypeAdapter[tuple[Mapping[str, object], ...]]] = TypeAdapter( + tuple[Mapping[str, object], ...] +) +EMPTY_OPTIONAL_PARAMS: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _provider_usage( + optional_params: Mapping[str, object] | None, + usage_param: str, +) -> tuple[Mapping[str, object], ...] | None: + params: Final = optional_params if optional_params is not None else EMPTY_OPTIONAL_PARAMS + raw_usage: Final[object] = params.get(usage_param) + try: + return PROVIDER_USAGE_ADAPTER.validate_python(raw_usage) + except ValidationError: + return None + def search_provider_cost_per_query( model: str, custom_llm_provider: str | None = None, number_of_queries: int = 1, - optional_params: dict | None = None, + optional_params: Mapping[str, object] | None = None, ) -> tuple[float, float]: """ Calculate cost for search-only providers. @@ -28,6 +49,18 @@ def search_provider_cost_per_query( Returns: Tuple of (input_cost, output_cost) where output_cost is always 0.0 """ + if custom_llm_provider == "parallel_ai": + from litellm.llms.parallel_ai.search.cost_calculator import ( + PARALLEL_AI_USAGE_PARAM, + parallel_ai_search_cost, + ) + + input_cost: Final = parallel_ai_search_cost( + optional_params=optional_params if optional_params is not None else EMPTY_OPTIONAL_PARAMS, + usage=_provider_usage(optional_params, PARALLEL_AI_USAGE_PARAM), + ) + return (input_cost, 0.0) + model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) # Check for tiered pricing (e.g., Exa AI based on max_results) diff --git a/litellm/setup_wizard.py b/litellm/setup_wizard.py index d6b3dfa3285..dd147aaccee 100644 --- a/litellm/setup_wizard.py +++ b/litellm/setup_wizard.py @@ -53,11 +53,12 @@ PROVIDERS: Final[list[dict]] = [ { "id": "anthropic", "name": "Anthropic", - "description": "Claude Fable 5, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, Haiku 4.5", + "description": "Claude Fable 5.1, Fable 5, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, Haiku 4.5", "env_key": "ANTHROPIC_API_KEY", "key_hint": "sk-ant-...", "test_model": "claude-haiku-4-5-20251001", "models": [ + "claude-fable-5-1", "claude-fable-5", "claude-opus-5", "claude-sonnet-5", diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 4cf4fa62eff..c17103da890 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -136,6 +136,7 @@ class SupportedGuardrailIntegrations(Enum): HEADROOM = "headroom" COMPRESR = "compresr" STRAIKER = "straiker" + ALICE = "alice" class Role(Enum): diff --git a/litellm/types/integrations/datadog_llm_obs.py b/litellm/types/integrations/datadog_llm_obs.py index 7853dda1213..bae876dfdd9 100644 --- a/litellm/types/integrations/datadog_llm_obs.py +++ b/litellm/types/integrations/datadog_llm_obs.py @@ -4,21 +4,58 @@ Payloads for Datadog LLM Observability Service (LLMObs) API Reference: https://docs.datadoghq.com/llm_observability/setup/api/?tab=example#api-standards """ +from collections.abc import Sequence from typing import Any, Literal -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict from litellm.types.integrations.custom_logger import StandardCustomLoggerInitParams +class ToolCall(TypedDict, total=False): + """A tool call on a message, as LLM Obs names its fields.""" + + name: ReadOnly[str] + arguments: ReadOnly[dict[str, Any] | str] # parsed object, or the raw string when it will not parse to one + tool_id: ReadOnly[str] + type: ReadOnly[str] + + +class ToolResult(TypedDict, total=False): + """The result of a tool call, as LLM Obs names its fields.""" + + name: ReadOnly[str] + result: ReadOnly[str] + tool_id: ReadOnly[str] + type: ReadOnly[str] + + +class ToolDefinition(TypedDict, total=False): + """A tool the model was offered on the request.""" + + name: ReadOnly[str] + description: ReadOnly[str] + schema: ReadOnly[dict[str, Any]] + + +class Message(TypedDict, total=False): + """A message on a span, as LLM Obs names its fields.""" + + content: ReadOnly[str] + role: ReadOnly[str] + reasoning_content: ReadOnly[str] + tool_calls: ReadOnly[Sequence[ToolCall]] + tool_results: ReadOnly[Sequence[ToolResult]] + + class InputMeta(TypedDict): - messages: list[ - dict[str, Any] # changed to fit with tool calls + messages: Sequence[ + Message | dict[str, Any] # changed to fit with tool calls ] # Relevant Issue: https://github.com/BerriAI/litellm/issues/9494 class OutputMeta(TypedDict): - messages: list[Any] + messages: Sequence[Any] class DDLLMObsError(TypedDict, total=False): @@ -36,6 +73,7 @@ class Meta(TypedDict, total=False): output: OutputMeta # The span's output information. metadata: dict[str, Any] error: DDLLMObsError | None # Error information on the span + tool_definitions: ReadOnly[Sequence[ToolDefinition]] # The tools offered to the model on this request class LLMMetrics(TypedDict, total=False): @@ -45,6 +83,9 @@ class LLMMetrics(TypedDict, total=False): time_to_first_token: float time_per_output_token: float total_cost: float + cache_read_input_tokens: ReadOnly[float] + cache_write_input_tokens: ReadOnly[float] + non_cached_input_tokens: ReadOnly[float] class LLMObsPayload(TypedDict, total=False): diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 01ed8b08571..8498b6f6d00 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -270,6 +270,10 @@ DEFINED_PROMETHEUS_METRICS = Literal[ "litellm_deployment_rpm_limit", "litellm_remaining_api_key_requests_for_model", "litellm_remaining_api_key_tokens_for_model", + "litellm_api_key_rate_limit_allowed_metric", + "litellm_api_key_rate_limit_used_metric", + "litellm_team_rate_limit_allowed_metric", + "litellm_team_rate_limit_used_metric", "litellm_llm_api_failed_requests_metric", "litellm_callback_logging_failures_metric", "litellm_in_flight_requests", @@ -775,6 +779,22 @@ class PrometheusMetricLabels: UserAPIKeyLabelNames.MODEL_ID.value, ] + litellm_api_key_rate_limit_allowed_metric: ClassVar[tuple[str, ...]] = ( + UserAPIKeyLabelNames.API_KEY_HASH.value, + UserAPIKeyLabelNames.API_KEY_ALIAS.value, + UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value, + ) + + litellm_api_key_rate_limit_used_metric = litellm_api_key_rate_limit_allowed_metric + + litellm_team_rate_limit_allowed_metric: ClassVar[tuple[str, ...]] = ( + UserAPIKeyLabelNames.TEAM.value, + UserAPIKeyLabelNames.TEAM_ALIAS.value, + UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value, + ) + + litellm_team_rate_limit_used_metric = litellm_team_rate_limit_allowed_metric + litellm_llm_api_failed_requests_metric = [ UserAPIKeyLabelNames.END_USER.value, UserAPIKeyLabelNames.API_KEY_HASH.value, diff --git a/litellm/types/integrations/slack_alerting.py b/litellm/types/integrations/slack_alerting.py index b1b7bc3541a..64c0c530e9b 100644 --- a/litellm/types/integrations/slack_alerting.py +++ b/litellm/types/integrations/slack_alerting.py @@ -91,6 +91,40 @@ class SlackAlertingArgs(LiteLLMPydanticObjectBase): default=False, description="If true, the alerting payload will be printed to the console.", ) + daily_spend_per_user_threshold: float | None = Field( + default=None, + gt=0, + allow_inf_nan=False, + description="Alert when a user's spend for the current day (UTC) crosses this USD amount. Off by default.", + ) + monthly_spend_per_user_threshold: float | None = Field( + default=None, + gt=0, + allow_inf_nan=False, + description="Alert when a user's spend for the current calendar month (UTC) crosses this USD amount. Off by default.", + ) + spend_anomaly_multiplier: float = Field( + default=3.0, + gt=0, + allow_inf_nan=False, + description="Flag a user's spend as anomalous when today's spend exceeds this multiple of their trailing daily average.", + ) + spend_anomaly_baseline_days: int = Field( + default=7, + ge=1, + description="Number of trailing days used to compute a user's daily average spend for anomaly detection.", + ) + spend_anomaly_min_spend: float = Field( + default=10.0, + gt=0, + allow_inf_nan=False, + description="Minimum spend (USD) a user must reach today before an anomaly alert can fire. Reduces false positives.", + ) + user_spend_check_interval: int = Field( + default=3600, + ge=60, + description="How often (in seconds) to check per-user spend thresholds and anomalies. Default is hourly.", + ) class DeploymentMetrics(LiteLLMPydanticObjectBase): @@ -138,6 +172,8 @@ class AlertType(str, Enum): budget_alerts = "budget_alerts" spend_reports = "spend_reports" failed_tracking_spend = "failed_tracking_spend" + user_spend_thresholds = "user_spend_thresholds" + user_spend_anomalies = "user_spend_anomalies" # Database alerts db_exceptions = "db_exceptions" @@ -182,6 +218,7 @@ DEFAULT_ALERT_TYPES: Final[list[AlertType]] = [ AlertType.budget_alerts, AlertType.spend_reports, AlertType.failed_tracking_spend, + AlertType.user_spend_thresholds, # Database alerts AlertType.db_exceptions, # Report alerts diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 42ca3fd6d4b..4fe1dafc73b 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -78,6 +78,16 @@ class AnthropicUsage(TypedDict, total=False): server_tool_use: NotRequired[ReadOnly[ServerToolUsage]] +class AnthropicStopDetails(TypedDict, total=False): + """ + Safeguard verdict accompanying a `stop_reason: "refusal"` response: + https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback + """ + + category: ReadOnly[str | None] + explanation: ReadOnly[str | None] + + class AnthropicMessagesResponse(TypedDict, total=False): """ Anthropic Messages API Response: https://docs.anthropic.com/en/api/messages @@ -90,7 +100,8 @@ class AnthropicMessagesResponse(TypedDict, total=False): id: str model: str | None # This represents the Model type from Anthropic role: Literal["assistant"] | None - stop_reason: Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None + stop_reason: Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal"] | None + stop_details: NotRequired[ReadOnly[AnthropicStopDetails | None]] stop_sequence: str | None type: Literal["message"] | None usage: AnthropicUsage | None diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/alice.py b/litellm/types/proxy/guardrails/guardrail_hooks/alice.py new file mode 100644 index 00000000000..73d31673dab --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/alice.py @@ -0,0 +1,21 @@ +from pydantic import Field + +from .base import GuardrailConfigModel + + +class AliceGuardrailConfigModel(GuardrailConfigModel): + api_key: str | None = Field( + default=None, + description=("The API key for Alice. If not provided, the `ALICE_API_KEY` environment variable is checked."), + ) + api_base: str | None = Field( + default=None, + description=( + "The API base URL for Alice. If not provided, the `ALICE_API_BASE` environment " + "variable is checked, then `https://api.alice.io`." + ), + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Alice" diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index 1612ea03817..7825684cfe5 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -150,6 +150,12 @@ class SCIMGroup(SCIMResource): members: list[SCIMMember] | None = None +class SCIMPlaceholderMergeResult(BaseModel): + placeholder_user_id: str + merged_into_user_id: str + team_ids: tuple[str, ...] + + # SCIM List Response Models class SCIMListResponse(BaseModel): schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] diff --git a/litellm/types/router.py b/litellm/types/router.py index e0957383aac..91c0c00cc9b 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -290,6 +290,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): """ custom_llm_provider: str | None = None + rust: bool | None = None tpm: int | None = None rpm: int | None = None itpm: int | None = None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index addb7b730de..5783a39b30c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -193,6 +193,38 @@ class AgenticLoopParams(TypedDict, total=False): """The LLM provider name (e.g., 'bedrock', 'anthropic')""" +class OffPeakWindow(TypedDict, total=False): + """One off-peak rule: UTC time-of-day windows, optionally restricted to weekdays. + + hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of them; a window may wrap past + midnight and an equal-ended window covers the whole day. weekdays is a list of days the + rule applies on, as ISO-8601 numbers (1 = Monday .. 7 = Sunday) or English day names; + omitted means every day. The weekday is read on the calendar named by the block's + weekday_timezone. + """ + + hours_utc: ReadOnly[str | Sequence[str]] + weekdays: ReadOnly[Sequence[int | str]] + + +class OffPeakPricing(TypedDict, total=False): + """Time-windowed off-peak rates for providers that discount by time of day (e.g. DeepSeek). + + hours_utc is a "HH:MM-HH:MM" string in UTC, or a list of them for multiple daily windows, + applying on every day of the week; a window may wrap past midnight. windows adds + day-of-week-qualified rules (e.g. weekend-only whole-day off-peak), matched as a union + with hours_utc. weekday_timezone names the IANA calendar weekdays are read on, defaulting + to UTC. Any rate left unset falls back to the standard rate. + """ + + hours_utc: ReadOnly[str | Sequence[str]] + windows: ReadOnly[Sequence[OffPeakWindow]] + weekday_timezone: ReadOnly[str] + input_cost_per_token: ReadOnly[float] + output_cost_per_token: ReadOnly[float] + cache_read_input_token_cost: ReadOnly[float] + + class ModelInfoBase(ProviderSpecificModelInfo, total=False): key: Required[str] # the key in litellm.model_cost which is returned @@ -225,6 +257,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): # Smallest prefix this model will actually cache, whatever caching mechanism its provider uses. # Absent means the provider-agnostic default applies; see MINIMUM_PROMPT_CACHE_TOKEN_COUNT. prompt_cache_min_tokens: int | None + off_peak_pricing: ReadOnly[OffPeakPricing | None] # time-windowed off-peak rates input_cost_per_character: float | None # only for vertex ai models input_cost_per_audio_token: float | None input_cost_per_token_above_128k_tokens: float | None # only for vertex ai models @@ -3486,17 +3519,22 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): return {k: v for k, v in model_info.items() if k not in cls.model_fields} -SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = frozenset( - ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__ -) - frozenset(CustomPricingLiteLLMParams.model_fields) +DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"}) + +SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = ( + frozenset(ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__) + - frozenset(CustomPricingLiteLLMParams.model_fields) + - DEPLOYMENT_SCOPED_PRICING_FIELDS +) def shared_backend_model_info(model_info: dict[str, Any]) -> dict[str, Any]: """Return only the fields safe to register under a shared ``{provider}/{model}`` key in ``litellm.model_cost``: cost-map schema fields (``ModelInfoBase``) minus - per-deployment pricing overrides. Per-deployment metadata (``id``, - ``access_via_team_ids``, arbitrary custom keys) never belongs on the shared key; - it stays under the deployment's unique model id. + per-deployment pricing overrides and deployment-scoped pricing blocks such as + ``off_peak_pricing``. Per-deployment metadata (``id``, ``access_via_team_ids``, + arbitrary custom keys) never belongs on the shared key; it stays under the + deployment's unique model id. """ return {k: v for k, v in model_info.items() if k in SHARED_BACKEND_MODEL_INFO_FIELDS} @@ -3598,6 +3636,8 @@ all_litellm_params = ( "client", "rpm", "tpm", + "default_api_key_rpm_limit", + "default_api_key_tpm_limit", "itpm", "otpm", "max_parallel_requests", diff --git a/litellm/utils.py b/litellm/utils.py index 028682661f7..252b6756937 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2869,10 +2869,9 @@ def _update_dictionary(existing_dict: dict, new_dict: dict) -> dict: elif isinstance(v, dict): existing_nested_dict = existing_dict.get(k) if isinstance(existing_nested_dict, dict): - existing_nested_dict.update(v) - existing_dict[k] = existing_nested_dict + existing_dict[k] = {**existing_nested_dict, **v} # mutable-ok: copy-on-write merge else: - existing_dict[k] = v + existing_dict[k] = dict(v) # mutable-ok: detached copy, never the caller's dict by reference else: existing_dict[k] = v @@ -4877,11 +4876,7 @@ def _get_deployment_order(deployment: dict | Any) -> int | None: def _get_order_filtered_deployments(healthy_deployments: list[dict], target_order: int | None = None) -> list: if target_order is not None: - filtered: Final = [d for d in healthy_deployments if _get_deployment_order(d) == target_order] - if filtered: - return filtered - # target_order doesn't match any deployment (e.g., external fallback model) — return all - return healthy_deployments + return [d for d in healthy_deployments if _get_deployment_order(d) == target_order] # Default: pick min order group _valid_orders: Final[list[int]] = [ @@ -5860,6 +5855,7 @@ def _get_model_info_helper( cache_creation_input_token_cost_above_1hr=_model_info.get( "cache_creation_input_token_cost_above_1hr", None ), + off_peak_pricing=_model_info.get("off_peak_pricing", None), input_cost_per_character=_model_info.get("input_cost_per_character", None), input_cost_per_token_above_128k_tokens=_model_info.get("input_cost_per_token_above_128k_tokens", None), input_cost_per_token_above_200k_tokens=_model_info.get("input_cost_per_token_above_200k_tokens", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 27ff525c15e..a3cfb300ea6 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1451,6 +1451,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, "cache_creation_input_token_cost_above_1hr": 2e-05, @@ -1488,6 +1526,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "global.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, "cache_creation_input_token_cost_above_1hr": 2.2e-05, @@ -1525,6 +1601,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "us.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, "cache_creation_input_token_cost_above_1hr": 2.2e-05, @@ -1562,6 +1676,44 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "eu.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_1hr": 2.2e-05, + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, @@ -3079,6 +3231,41 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "azure_ai/claude-fable-5-1": { + "supports_mid_conversation_system": true, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "azure_ai/claude-opus-5": { "deprecation_date": "2027-07-08", "supports_mid_conversation_system": true, @@ -13044,6 +13231,47 @@ "supports_native_structured_output": true, "source": "https://docs.anthropic.com/en/docs/about-claude/models/overview" }, + "claude-fable-5-1": { + "deprecation_date": "2027-09-01", + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1 + }, + "supports_output_config": true, + "prompt_cache_min_tokens": 512, + "supports_native_structured_output": true, + "source": "https://platform.claude.com/docs/en/models/fable-5-1/overview" + }, "claude-opus-5": { "deprecation_date": "2027-07-24", "cache_creation_input_token_cost": 6.25e-06, @@ -38328,12 +38556,22 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "parallel_ai/search": { - "input_cost_per_query": 0.004, + "input_cost_per_query": 0.005, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-fast": { + "input_cost_per_query": 0.001, "litellm_provider": "parallel_ai", "mode": "search" }, "parallel_ai/search-pro": { - "input_cost_per_query": 0.009, + "input_cost_per_query": 0.005, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-turbo": { + "input_cost_per_query": 0.001, "litellm_provider": "parallel_ai", "mode": "search" }, @@ -43881,6 +44119,42 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "vertex_ai/claude-fable-5-1": { + "regional_endpoint_uplift_multiplier": 1.1, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-fable-5@default": { "deprecation_date": "2027-06-08", "regional_endpoint_uplift_multiplier": 1.1, @@ -43916,6 +44190,42 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 512 }, + "vertex_ai/claude-fable-5-1@default": { + "regional_endpoint_uplift_multiplier": 1.1, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_1hr": 2e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-opus-5": { "deprecation_date": "2027-01-24", "regional_endpoint_uplift_multiplier": 1.1, diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 3f6d3b4f910..9e370e5406a 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -662,6 +662,9 @@ "supports_embedding_image_input": { "type": "boolean" }, + "supports_forced_tool_use": { + "type": "boolean" + }, "supports_function_calling": { "type": "boolean" }, diff --git a/ruff-strict.toml b/ruff-strict.toml index 7afc5da71ee..ae092bdde7d 100644 --- a/ruff-strict.toml +++ b/ruff-strict.toml @@ -26,6 +26,10 @@ external = [ # caught a real mismatch, confirming Any is correct here, not a shortcut. "litellm/litellm_core_utils/litellm_logging.py" = ["ANN401"] "litellm/utils.py" = ["ANN401"] +# `**kwargs` forwards verbatim to CustomGuardrail.__init__, whose param list is wide and +# grows over time; typing it concretely (`object`) broke that forwarding call outright — +# basedpyright turned every named param into a reportArgumentType error. Any is correct here. +"litellm/proxy/guardrails/guardrail_hooks/alice/alice.py" = ["ANN401"] [lint.mccabe] max-complexity = 15 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000000..a1598ccbb34 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 37e940460f6..790956156b0 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -63,6 +63,7 @@ IGNORE_FUNCTIONS = [ "json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks. "_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible). "_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible). + "_json_safe", # max depth set (_MAX_DEPTH) plus a seen-ids cycle guard for self-referential input. ] diff --git a/tests/code_coverage_tests/router_code_coverage.py b/tests/code_coverage_tests/router_code_coverage.py index c541c035db7..a5e00799519 100644 --- a/tests/code_coverage_tests/router_code_coverage.py +++ b/tests/code_coverage_tests/router_code_coverage.py @@ -81,6 +81,7 @@ ignored_function_names = [ "_merge_tools_from_deployment", # Tested indirectly via _update_kwargs_with_deployment (test files lack "router" in name) "_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name) "has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call + "_resolved_provider", # Tested via get_pattern in test_pattern_match_deployments.py (file lacks "router" in name) ] diff --git a/tests/e2e/junit_properties.py b/tests/e2e/junit_properties.py index e4f59f5c4d2..c5971c5362c 100644 --- a/tests/e2e/junit_properties.py +++ b/tests/e2e/junit_properties.py @@ -2,10 +2,16 @@ The e2e suite ships results to Loki/Grafana from a standard pytest JUnit report (`--junitxml=e2e-report.xml`), not a bespoke log line. JUnit already records -outcome, duration, and node id for every ``; the only signals it cannot -derive on its own are the normalized suite package and the coverage-registry cell -ids a test covers. Those ride along as JUnit `` entries via each item's -`user_properties`, attached in `conftest.py::pytest_collection_modifyitems`. +outcome, duration, and node id for every ``; the signals it cannot +derive on its own are the normalized suite package, the coverage-registry cell +ids a test covers, and where the test's source lives. Those ride along as JUnit +`` entries via each item's `user_properties`, attached in +`conftest.py::pytest_collection_modifyitems`. + +`source` is a property rather than the `file=` / `line=` attributes pytest used +to write, because the `xunit2` family this suite runs on drops those, and +switching families would change the XML for every consumer of it -- the +Buildkite Test Engine upload and the Loki pipeline included. """ from __future__ import annotations @@ -14,22 +20,61 @@ from collections.abc import Iterable import pytest +# Hardcoded because the runner image copies tests/e2e/ to /app/e2e, so nothing +# at runtime names this suite's place in the repo. test_junit_properties.py +# fails from a checkout if it moves. +SUITE_ROOT = "tests/e2e" + + +def suite_parts(path_part: str) -> tuple[str, ...]: + """Path components of a suite file relative to tests/e2e, however it ran. + + Pytest paths are rootdir-relative, and rootdir moves with the invocation: a + repo-root run gives `tests/e2e/logging/test_x.py`, a suite-cwd run (the + runner image) gives `logging/test_x.py`. Both collapse to the same tuple. + """ + raw = tuple(p for p in path_part.replace("\\", "/").split("/") if p and p != ".") + return raw[2:] if len(raw) >= 3 and raw[0] == "tests" and raw[1] == "e2e" else raw + def package_from_nodeid(nodeid: str) -> str: - """Top-level suite package under tests/e2e/, or 'root' for top-level files. - - Pytest nodeids are relative to the invocation cwd. Repo-root runs look like - `tests/e2e/logging/...`; suite-cwd runs look like `logging/...`. Strip the - `tests/e2e` prefix so package is the suite dir either way. - """ - path_part = nodeid.split("::", 1)[0].replace("\\", "/") - raw = tuple(p for p in path_part.split("/") if p and p != ".") - parts = raw[2:] if len(raw) >= 3 and raw[0] == "tests" and raw[1] == "e2e" else raw + """Top-level suite package under tests/e2e/, or 'root' for top-level files.""" + parts = suite_parts(nodeid.split("::", 1)[0]) if len(parts) <= 1: return "root" return parts[0] +def source_from_location(path: str, lineno: int | None) -> str: + """Repo-relative `path:line` for a test, or '' when nothing is linkable. + + `pytest.Item.location` gives a rootdir-relative path and a ZERO-based line. + The path is re-rooted at SUITE_ROOT so consumers need not know how pytest was + started, and the line is emitted ONE-based to match editors, tracebacks and + code hosts. A decorated test anchors at its first decorator, which is where + pytest reports it. + + Empty rather than a guess for anything unlinkable: no line, a path reaching + upward, or a path carrying a colon, which is both how an absolute Windows + path arrives and a character `path:line` has no way to represent. + """ + if lineno is None: + return "" + normalized = path.replace("\\", "/") + if normalized.startswith("/") or ":" in normalized or ".." in normalized.split("/"): + return "" + parts = suite_parts(normalized) + if not parts: + return "" + return f"{'/'.join((SUITE_ROOT, *parts))}:{lineno + 1}" + + +def source_from_item(item: pytest.Item) -> str: + """Read the repo-relative `path:line` off a pytest Item's reported location.""" + path, lineno, _ = item.location + return source_from_location(path, lineno) + + def dedupe_covers(marker_args: Iterable[tuple[object, ...]]) -> tuple[str, ...]: """Flatten @pytest.mark.covers arg lists into unique, order-preserving cell ids, dropping anything that is not a non-empty string.""" @@ -43,10 +88,12 @@ def covers_from_item(item: pytest.Item) -> tuple[str, ...]: def result_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]: """The custom signals a standard reporter cannot derive: the normalized suite - package and the comma-joined coverage-registry cell ids this test covers.""" + package, the comma-joined coverage-registry cell ids this test covers, and the + repo-relative `path:line` its source sits at.""" return ( ("package", package_from_nodeid(item.nodeid)), ("covers", ",".join(covers_from_item(item))), + ("source", source_from_item(item)), ) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 967144dfb16..79d9e011f7e 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -806,6 +806,7 @@ class LiteLLMParamsBody(BaseModel): mock_response: str | None = None timeout: float | None = None tpm: int | None = None + weight: int | None = None ModelMode = Literal["batch", "realtime", "image_generation"] @@ -820,6 +821,7 @@ class ModelInfoBody(BaseModel): mode: ModelMode | None = None access_groups: list[str] | None = None team_id: str | None = None + allowed_fails_policy: dict[str, int] | None = None class ModelNewBody(BaseModel): diff --git a/tests/e2e/router/reliability_support.py b/tests/e2e/router/reliability_support.py index e342aa363ca..5822058003c 100644 --- a/tests/e2e/router/reliability_support.py +++ b/tests/e2e/router/reliability_support.py @@ -19,6 +19,8 @@ from models import ( ChatMessage, ChatResponse, LiteLLMParamsBody, + ModelInfoBody, + ModelNewBody, ReliabilityChatBody, RouterSettingsOverride, ) @@ -26,6 +28,18 @@ from models import ( REAL_MODEL = "openai/gpt-5.5" REAL_KEY = "os.environ/OPENAI_API_KEY" +# The smallest-context chat model OpenAI still serves (16385 tokens). A prompt +# past that limit comes back as a real `context_length_exceeded` 400, which is +# what litellm maps to ContextWindowExceededError. +SMALL_CONTEXT_MODEL = "openai/gpt-3.5-turbo" +SMALL_CONTEXT_LIMIT_TOKENS = 16385 + + +def oversized_prompt(marker: str) -> str: + """A prompt comfortably past SMALL_CONTEXT_MODEL's context limit, so the + provider refuses it on length rather than answering a truncated version.""" + return f"{marker} " + ("token " * (SMALL_CONTEXT_LIMIT_TOKENS + 4000)) + def create_bad_base_deployment(proxy: ProxyClient, name: str) -> str: """Register a deployment pointing at an unreachable base, so every call to it @@ -40,6 +54,38 @@ def create_timeout_deployment(proxy: ProxyClient, name: str) -> str: return proxy.create_model(name, LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001)) +def create_small_context_deployment(proxy: ProxyClient, name: str) -> str: + """Register a deployment on the smallest-context model OpenAI still serves, so an + oversized prompt earns a real context-window refusal from the provider.""" + return proxy.create_model(name, LiteLLMParamsBody(model=SMALL_CONTEXT_MODEL, api_key=REAL_KEY)) + + +def create_always_timing_out_deployment(proxy: ProxyClient, name: str) -> str: + """The always-picked half of a retry pair: a 1ms deadline the backend always + exceeds, all of the model group's shuffle weight, and a cooldown policy that + benches it on its first Timeout so the retry cannot land on it again.""" + return proxy.register_model( + ModelNewBody( + model_name=name, + litellm_params=LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001, weight=1), + model_info=ModelInfoBody(allowed_fails_policy={"TimeoutErrorAllowedFails": 0}), + ) + ) + + +def create_zero_weight_backup_deployment(proxy: ProxyClient, name: str) -> str: + """The other half of a retry pair: healthy, but weight 0, so the weighted shuffle + never opens on it. It is reachable only once its sibling is benched and the + weighted pick falls through to a uniform one over what is left.""" + return proxy.register_model( + ModelNewBody( + model_name=name, + litellm_params=LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, weight=0), + model_info=ModelInfoBody(), + ) + ) + + def chat_override( proxy: ProxyClient, key: str, diff --git a/tests/e2e/router/test_reliability_fallbacks_e2e.py b/tests/e2e/router/test_reliability_fallbacks_e2e.py index d3ce62f8f95..8cece41ce2d 100644 --- a/tests/e2e/router/test_reliability_fallbacks_e2e.py +++ b/tests/e2e/router/test_reliability_fallbacks_e2e.py @@ -9,6 +9,10 @@ in the x-litellm-attempted-fallbacks header. Empty content is accepted only when `finish_reason == "length"` and the response billed completion tokens, since gpt-5.5 counts reasoning against max_tokens and can consume the whole budget before emitting any text; a fallback that produced nothing at all still fails. + +The context-window case is a different reroute from a plain failure: the provider +refuses the prompt on length, and `context_window_fallbacks` is the setting that +reroutes it, not `fallbacks`. """ from __future__ import annotations @@ -25,8 +29,10 @@ from reliability_support import ( completion_tokens_of, content_of, create_bad_base_deployment, + create_small_context_deployment, create_timeout_deployment, finish_reason_of, + oversized_prompt, reasoning_tokens_of, ) @@ -82,3 +88,17 @@ class TestReliabilityFallbacks: override=RouterSettingsOverride(fallbacks=[{primary: ["gpt-5.5"]}]), ) _assert_served_by_fallback(resp) + + @pytest.mark.covers("reliability.fallback.context_window.routes_to_fallback") + def test_context_window_routes_to_fallback( + self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str + ) -> None: + primary = f"reliability-ctxfail-{unique_marker()}" + model_id = create_small_context_deployment(client.proxy, primary) + resources.defer(lambda: client.proxy.delete_model(model_id)) + + resp = chat_override( + client.proxy, scoped_key, primary, oversized_prompt(unique_marker()), + override=RouterSettingsOverride(context_window_fallbacks=[{primary: ["gpt-5.5"]}]), + ) + _assert_served_by_fallback(resp) diff --git a/tests/e2e/router/test_reliability_retries_e2e.py b/tests/e2e/router/test_reliability_retries_e2e.py new file mode 100644 index 00000000000..5441412935c --- /dev/null +++ b/tests/e2e/router/test_reliability_retries_e2e.py @@ -0,0 +1,73 @@ +"""Live e2e: a request that fails on its first deployment is retried inside its own +model group and still comes back a completion. + +The model group is a pair: an always-timing-out deployment that holds all of the +group's shuffle weight, and a healthy backup at weight 0. The weighted pick always +opens on the timing-out one, its first Timeout benches it (an +`allowed_fails_policy` of `TimeoutErrorAllowedFails: 0`), and the retry falls +through to the only deployment left. So the customer sees a completion and the +proxy reports that it took a retry to get there, with no random first pick in the +middle of it. +""" + +from __future__ import annotations + +import pytest + +from complexity_router_client import ComplexityRouterClient +from e2e_config import unique_marker +from lifecycle import ResourceManager +from models import RouterSettingsOverride +from reliability_support import ( + chat_override, + completion_tokens_of, + content_of, + create_always_timing_out_deployment, + create_zero_weight_backup_deployment, + finish_reason_of, +) + +pytestmark = pytest.mark.e2e + + +class TestReliabilityRetries: + @pytest.mark.covers("reliability.retry.timeout.succeeds_within_retries") + def test_timeout_on_first_deployment_succeeds_on_retry( + self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str + ) -> None: + group = f"reliability-retry-{unique_marker()}" + timing_out = create_always_timing_out_deployment(client.proxy, group) + resources.defer(lambda: client.proxy.delete_model(timing_out)) + backup = create_zero_weight_backup_deployment(client.proxy, group) + resources.defer(lambda: client.proxy.delete_model(backup)) + + resp = chat_override( + client.proxy, + scoped_key, + group, + f"say hi {unique_marker()}", + override=RouterSettingsOverride(num_retries=2), + ) + + assert resp.status_code == 200, ( + f"the retry should have landed on the healthy backup, got {resp.status_code}: {resp.body[:300]}" + ) + + attempted = resp.headers.get("x-litellm-attempted-retries") + assert attempted is not None, "response is missing the x-litellm-attempted-retries header" + assert int(attempted) >= 1, ( + f"x-litellm-attempted-retries is {attempted!r}; a 200 with no retry means the request never " + "opened on the timing-out deployment, so this proves nothing about retries" + ) + + content = content_of(resp) + finish_reason = finish_reason_of(resp) + completion_tokens = completion_tokens_of(resp) or 0 + assert isinstance(content, str), ( + f"the retry should have returned a completion body, got content {content!r} (body={resp.body[:300]})" + ) + assert content or (finish_reason == "length" and completion_tokens > 0), ( + f"the retry returned empty content with finish_reason={finish_reason!r}, " + f"completion_tokens={completion_tokens}; empty content is only acceptable when the budget " + f"was spent on non-visible reasoning (body={resp.body[:300]})" + ) diff --git a/tests/e2e/test_junit_properties.py b/tests/e2e/test_junit_properties.py new file mode 100644 index 00000000000..c0596177cc1 --- /dev/null +++ b/tests/e2e/test_junit_properties.py @@ -0,0 +1,146 @@ +"""Harness coverage for the custom JUnit properties. + +No proxy and no ``e2e`` marker. Pins the two normalizations that have to agree +about where a suite file lives -- ``package_from_nodeid`` (strip the suite root) +and ``source_from_location`` (re-root at it) -- across both ways the suite is +launched, plus the one-based line offset and the refusal to emit a path that +escapes the suite. The consumers of these properties are the Loki/Grafana +rollups and, for ``source``, the status page's per-test links to GitHub. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from junit_properties import ( + SUITE_ROOT, + attach_result_properties, + dedupe_covers, + package_from_nodeid, + result_properties, + source_from_location, + suite_parts, +) + + +class FakeMarker: + def __init__(self, name: str, *args: object) -> None: + self.name = name + self.args = args + + +class FakeItem: + """The three attributes junit_properties reads off a pytest Item.""" + + def __init__( + self, nodeid: str, location: tuple[str, int | None, str], markers: tuple[FakeMarker, ...] = () + ) -> None: + self.nodeid = nodeid + self.location = location + self.user_properties: list[tuple[str, str]] = [] + self._markers = markers + + def iter_markers(self, name: str): + return (marker for marker in self._markers if marker.name == name) + + +def repo_root() -> Path | None: + """The litellm checkout above this file, or None when there isn't one.""" + return next((p for p in Path(__file__).resolve().parents if (p / ".git").exists()), None) + + +class TestSuiteParts: + @pytest.mark.parametrize( + "path", + ["logging/test_x.py", "tests/e2e/logging/test_x.py", "./logging/test_x.py", "tests\\e2e\\logging\\test_x.py"], + ) + def test_both_invocation_shapes_collapse_to_the_same_components(self, path: str) -> None: + """A repo-root run and a suite-cwd run report the same file differently; + every downstream signal has to see one spelling.""" + assert suite_parts(path) == ("logging", "test_x.py") + + def test_top_level_suite_file_keeps_its_single_component(self) -> None: + assert suite_parts("tests/e2e/test_fixture_mode.py") == ("test_fixture_mode.py",) + + +class TestPackageFromNodeid: + @pytest.mark.parametrize( + ("nodeid", "expected"), + [ + ("logging/test_x.py::TestFoo::test_bar", "logging"), + ("tests/e2e/logging/test_x.py::TestFoo::test_bar", "logging"), + ("quota_management/spend_tracking/test_x.py::test_bar", "quota_management"), + ("test_fixture_mode.py::TestParseFixtureMode::test_known_values_normalize", "root"), + ("tests/e2e/test_fixture_mode.py::test_bar", "root"), + ], + ) + def test_package_is_the_first_dir_under_the_suite_root(self, nodeid: str, expected: str) -> None: + assert package_from_nodeid(nodeid) == expected + + +class TestSourceFromLocation: + @pytest.mark.parametrize("path", ["a2a/test_a2a_agent_e2e.py", "tests/e2e/a2a/test_a2a_agent_e2e.py"]) + def test_path_is_repo_relative_however_pytest_was_started(self, path: str) -> None: + assert source_from_location(path, 40) == "tests/e2e/a2a/test_a2a_agent_e2e.py:41" + + def test_line_is_emitted_one_based(self) -> None: + """pytest.Item.location counts from 0; editors, tracebacks and GitHub's + #L anchor all count from 1, and an off-by-one lands on the decorator.""" + assert source_from_location("a2a/test_x.py", 0) == "tests/e2e/a2a/test_x.py:1" + + def test_top_level_suite_file_sits_directly_under_the_suite_root(self) -> None: + assert source_from_location("test_fixture_mode.py", 39) == "tests/e2e/test_fixture_mode.py:40" + + @pytest.mark.parametrize( + ("path", "lineno"), + [ + ("a2a/test_x.py", None), + ("/app/e2e/a2a/test_x.py", 40), + ("C:\\app\\e2e\\a2a\\test_x.py", 40), + ("../conftest.py", 40), + ("", 40), + ], + ) + def test_nothing_linkable_yields_empty_rather_than_a_guess(self, path: str, lineno: int | None) -> None: + """A colon is rejected on two counts: it is how a Windows absolute path + arrives, and `path:line` cannot represent one in the path half.""" + assert source_from_location(path, lineno) == "" + + +class TestResultProperties: + def test_every_test_carries_package_covers_and_source(self) -> None: + item = FakeItem( + "logging/test_x.py::TestFoo::test_bar", + ("logging/test_x.py", 40, "TestFoo.test_bar"), + (FakeMarker("covers", "LOG-1", "LOG-2"),), + ) + assert result_properties(item) == ( + ("package", "logging"), + ("covers", "LOG-1,LOG-2"), + ("source", "tests/e2e/logging/test_x.py:41"), + ) + + def test_attach_is_idempotent(self) -> None: + """Collection can run the hook more than once; a second pass must not + double the entries in the report.""" + item = FakeItem("logging/test_x.py::test_bar", ("logging/test_x.py", 40, "test_bar")) + attach_result_properties(item) + attach_result_properties(item) + assert [name for name, _ in item.user_properties] == ["package", "covers", "source"] + + +class TestSuiteRoot: + def test_suite_root_names_this_file_s_real_home(self) -> None: + """SUITE_ROOT is hardcoded because the runner image has no repo to read it + from. Where there IS a checkout, prove the constant still points at us -- + otherwise a moved tests/e2e/ ships links that 404.""" + root = repo_root() + if root is None: + pytest.skip("no checkout above this file (the runner image copies tests/e2e/ to /app/e2e)") + assert (root / SUITE_ROOT / Path(__file__).name).resolve() == Path(__file__).resolve() + + +class TestDedupeCovers: + def test_ids_are_unique_order_preserving_and_non_empty_strings(self) -> None: + assert dedupe_covers([("A", "B"), ("B", ""), ("C", 7)]) == ("A", "B", "C") diff --git a/tests/e2e/ui/helpers/premium.ts b/tests/e2e/ui/helpers/premium.ts new file mode 100644 index 00000000000..28bc2e58bbc --- /dev/null +++ b/tests/e2e/ui/helpers/premium.ts @@ -0,0 +1,20 @@ +import * as fs from "fs"; +import { ADMIN_STORAGE_PATH } from "../constants"; + +/** + * Whether the proxy under test is licensed, read from the admin session JWT's `premium_user` + * claim. That is the same value the dashboard reads to enable premium-gated controls, so it + * describes the proxy Playwright is pointed at rather than the environment the runner happens + * to have, which are not the same machine when E2E_UI_BASE_URL points elsewhere. + */ +export function proxyIsPremium(): boolean { + const storage = JSON.parse(fs.readFileSync(ADMIN_STORAGE_PATH, "utf-8")) as { + cookies?: { name: string; value: string }[]; + }; + const token = storage.cookies?.find((cookie) => cookie.name === "token")?.value; + const payload = token?.split(".")[1]; + if (!payload) { + return false; + } + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")).premium_user === true; +} diff --git a/tests/e2e/ui/helpers/traffic.ts b/tests/e2e/ui/helpers/traffic.ts index ebd3c9a417f..25eb671fd0e 100644 --- a/tests/e2e/ui/helpers/traffic.ts +++ b/tests/e2e/ui/helpers/traffic.ts @@ -4,12 +4,16 @@ import { APIRequestContext, expect } from "@playwright/test"; export const CHAT_MODEL_A = "fake-openai-gpt-4"; export const CHAT_MODEL_B = "fake-anthropic-claude"; +/** The deployment each of those models routes to, as spend logs and usage breakdowns name it. */ +export const DEPLOYMENT_MODEL_A = "openai/fake-gpt-4"; +export const DEPLOYMENT_MODEL_B = "openai/fake-claude"; + /** The only completion text fixtures/mock_llm_server/server.py ever returns. */ export const MOCK_RESPONSE_TEXT = "This is a mock response."; export const masterKey = (): string => process.env.LITELLM_MASTER_KEY || "sk-1234"; -const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? ""; +export const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? ""; interface ChatOptions { model: string; @@ -114,13 +118,54 @@ export async function waitForSpendLogByPrompt( const isoDay = (d: Date): string => d.toISOString().slice(0, 10); +interface DailyActivityKey { + metrics?: { api_requests?: number }; +} + +interface DailyActivityPage { + results?: { breakdown?: { api_keys?: Record } }[]; + metadata?: { total_pages?: number }; +} + +const requestsOnPage = (body: DailyActivityPage, keyToken: string): number => + (body.results ?? []).reduce((sum, day) => sum + (day.breakdown?.api_keys?.[keyToken]?.metrics?.api_requests ?? 0), 0); + +/** + * The route paginates its per-key breakdown. Reading only the first page finds a key while the + * database is small and stops finding it once a run has generated more keys than one page holds, + * which reads as "the rollup is not running" when the rollup is fine. + */ +async function keyRequestsInDailyActivity( + request: APIRequestContext, + query: string, + keyToken: string, + page = 1, + seen = 0, +): Promise { + const res = await request.get(`${rootPath()}/user/daily/activity?${query}&page=${page}`, { + headers: { Authorization: `Bearer ${masterKey()}` }, + }); + if (!res.ok()) { + return seen; + } + const body = (await res.json()) as DailyActivityPage; + const total = seen + requestsOnPage(body, keyToken); + return page >= (body.metadata?.total_pages ?? 1) + ? total + : keyRequestsInDailyActivity(request, query, keyToken, page + 1, total); +} + /** * The Usage page reads /user/daily/activity, a rollup written by a background job, and fetches it once * on mount. Navigating before the rollup lands leaves a stale render that never refreshes. + * + * The rollup lands request by request, so waiting only for the key to appear leaves a caller that + * sent several requests reading a partial count. Pass `minRequests` to wait for all of them. */ export async function waitForKeyInDailyActivity( request: APIRequestContext, keyToken: string, + minRequests = 1, timeoutMs = 120_000, ): Promise { const now = new Date(); @@ -129,25 +174,17 @@ export async function waitForKeyInDailyActivity( const query = `start_date=${isoDay(start)}&end_date=${isoDay(now)}`; const deadline = Date.now() + timeoutMs; - let lastStatus = 0; - while (Date.now() < deadline) { - const res = await request.get(`${rootPath()}/user/daily/activity?${query}`, { - headers: { Authorization: `Bearer ${masterKey()}` }, - }); - lastStatus = res.status(); - if (res.ok()) { - const body = await res.json(); - const seen = (body?.results ?? []).some( - (day: { breakdown?: { api_keys?: Record } }) => keyToken in (day.breakdown?.api_keys ?? {}), + for (;;) { + const seen = await keyRequestsInDailyActivity(request, query, keyToken); + if (seen >= minRequests) { + return; + } + if (Date.now() >= deadline) { + throw new Error( + `key ${keyToken} reached ${seen} of ${minRequests} requests in /user/daily/activity across every page; ` + + "the daily spend rollup may not be running", ); - if (seen) { - return; - } } await new Promise((r) => setTimeout(r, 3_000)); } - throw new Error( - `key ${keyToken} never appeared in /user/daily/activity (last status ${lastStatus}); ` + - "the daily spend rollup may not be running", - ); } diff --git a/tests/e2e/ui/tests/logs/logsFilters.spec.ts b/tests/e2e/ui/tests/logs/logsFilters.spec.ts new file mode 100644 index 00000000000..7174f339296 --- /dev/null +++ b/tests/e2e/ui/tests/logs/logsFilters.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; +import { Page } from "../../fixtures/pages"; +import { + CHAT_MODEL_A, + CHAT_MODEL_B, + createVirtualKey, + sendChatCompletion, + waitForSpendLog, +} from "../../helpers/traffic"; + +/** + * Every test mints its own key and asserts against request ids it generated, so a filter that + * quietly does nothing shows up as the other key's row still being on screen, and concurrent + * specs' traffic cannot decide the outcome. + */ + +const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */ +const requestLogsRows = (page: PlaywrightPage): Locator => + page.locator("table").filter({ visible: true }).first().locator("tbody tr"); + +const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true }); + +async function openLogs(page: PlaywrightPage): Promise { + await navigateToPage(page, Page.Logs); + await dismissFeedbackPopup(page); + await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 }); +} + +async function openFilterDrawer(page: PlaywrightPage): Promise { + await visibleTestId(page, "datatable-filters-trigger").click(); + const drawer = page.getByRole("dialog", { name: "Filters" }); + await expect(drawer).toBeVisible({ timeout: 10_000 }); + return drawer; +} + +/** Picks a value in one of the drawer's searchable comboboxes and applies the filter. */ +async function applyComboboxFilter( + page: PlaywrightPage, + drawer: Locator, + comboboxLabel: string, + value: string, +): Promise { + await drawer.getByRole("combobox", { name: comboboxLabel }).click(); + await page.keyboard.type(value); + await page.getByRole("option", { name: value, exact: true }).first().click(); + await drawer.getByRole("button", { name: "Apply Filters" }).click(); + await expect(drawer).not.toBeVisible({ timeout: 10_000 }); +} + +/** A request the key is not entitled to make, so the proxy refuses it and logs the refusal. */ +async function sendDeniedCompletion(request: APIRequestContext, apiKey: string): Promise { + const res = await request.post("/v1/chat/completions", { + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + data: { model: CHAT_MODEL_B, messages: [{ role: "user", content: "denied" }] }, + }); + expect(res.status(), "a model outside the key's allow-list is refused").toBe(403); +} + +test.describe("Logs page filters", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("the Key Alias filter narrows the table to that key's requests", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const mine = await createVirtualKey(request, { key_alias: `e2e-logs-mine-${suffix}` }); + const theirs = await createVirtualKey(request, { key_alias: `e2e-logs-theirs-${suffix}` }); + + const myRequestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `logs-filter-mine-${suffix}`, + apiKey: mine.key, + }); + const theirRequestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `logs-filter-theirs-${suffix}`, + apiKey: theirs.key, + }); + await waitForSpendLog(request, myRequestId); + await waitForSpendLog(request, theirRequestId); + + await openLogs(page); + const drawer = await openFilterDrawer(page); + await applyComboboxFilter(page, drawer, "Search a key alias", mine.alias!); + + await expect(requestLogsRows(page).filter({ hasText: myRequestId })).toHaveCount(1, { timeout: 30_000 }); + // The filter is only doing its job if the other key's request is gone, not merely if ours is present. + await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(0, { timeout: 10_000 }); + }); + + test("the Status filter narrows the table to the refused request", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const alias = `e2e-logs-status-${suffix}`; + const scoped = await createVirtualKey(request, { key_alias: alias, models: [CHAT_MODEL_A] }); + + const servedRequestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `logs-filter-served-${suffix}`, + apiKey: scoped.key, + }); + await sendDeniedCompletion(request, scoped.key); + await waitForSpendLog(request, servedRequestId); + + await openLogs(page); + const drawer = await openFilterDrawer(page); + await drawer.getByRole("combobox", { name: "Search a key alias" }).click(); + await page.keyboard.type(alias); + await page.getByRole("option", { name: alias, exact: true }).first().click(); + // The Status field labels its group, not the trigger, so it is addressed by the value it shows. + await drawer.getByRole("combobox").filter({ hasText: "All Statuses" }).click(); + await page.getByRole("option", { name: "Failure", exact: true }).click(); + await drawer.getByRole("button", { name: "Apply Filters" }).click(); + await expect(drawer).not.toBeVisible({ timeout: 10_000 }); + + // Both requests were made by this key, so a Status filter that does nothing leaves the served one on screen. + await expect(requestLogsRows(page)).toHaveCount(1, { timeout: 30_000 }); + await expect(requestLogsRows(page)).toContainText("Failure"); + await expect(requestLogsRows(page).filter({ hasText: servedRequestId })).toHaveCount(0); + }); + + test("Reset Filters brings back the rows a filter hid", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const mine = await createVirtualKey(request, { key_alias: `e2e-logs-reset-mine-${suffix}` }); + const theirs = await createVirtualKey(request, { key_alias: `e2e-logs-reset-theirs-${suffix}` }); + + const myRequestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `logs-reset-mine-${suffix}`, + apiKey: mine.key, + }); + const theirRequestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `logs-reset-theirs-${suffix}`, + apiKey: theirs.key, + }); + await waitForSpendLog(request, myRequestId); + await waitForSpendLog(request, theirRequestId); + + await openLogs(page); + const drawer = await openFilterDrawer(page); + await applyComboboxFilter(page, drawer, "Search a key alias", mine.alias!); + await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(0, { timeout: 30_000 }); + + // A filter you cannot clear is a page that looks empty forever, which is how it reads to a user. + await page.getByRole("button", { name: "Reset Filters" }).filter({ visible: true }).click(); + + await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(1, { timeout: 30_000 }); + await expect(requestLogsRows(page).filter({ hasText: myRequestId })).toHaveCount(1, { timeout: 10_000 }); + }); +}); diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index 073d3c0b79c..de25ec1aac5 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -5,6 +5,11 @@ import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; import { captureRequestBody, readBack } from "../../helpers/roundTrip"; import { sendChatCompletion } from "../../helpers/traffic"; +import { proxyIsPremium } from "../../helpers/premium"; + +/** Four probes 13s apart span 39s, one PROXY_CONFIG_RELOAD_INTERVAL_SECONDS (30s) plus margin. */ +const CREDENTIAL_PROBE_SUCCESSES = 4; +const CREDENTIAL_PROBE_SPACING_MS = 13_000; /** The mock LLM as the proxy reaches it: same host locally, a sidecar in the deployed stack. */ const MOCK_LLM_BASE = `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`; @@ -35,7 +40,10 @@ async function selectProvider(page: PlaywrightPage, providerName: string) { const providerDropdown = page.getByRole("combobox", { name: "Provider", exact: true }); await providerDropdown.click(); await providerDropdown.fill(providerName); - await page.getByRole("option").filter({ hasText: exactly(providerName) }).click(); + await page + .getByRole("option") + .filter({ hasText: exactly(providerName) }) + .click(); await expect(providerDropdown).toHaveValue(providerName); } @@ -78,6 +86,9 @@ test.describe("Add Model", () => { }); test("Edit team model TPM and RPM limits", async ({ page }) => { + // /model/new refuses a team-scoped deployment on an unlicensed proxy, so there this fails in + // setup on a product gate rather than on a regression in the edit it covers. + test.skip(!proxyIsPremium(), "proxy under test is unlicensed — team-scoped models are premium"); const masterKey = users[Role.ProxyAdmin].password; const modelName = `e2e-team-model-${Date.now()}`; @@ -226,8 +237,11 @@ test.describe("Add Model", () => { }); expect(createCred.ok(), `POST /credentials failed (${createCred.status()}): ${await createCred.text()}`).toBe(true); - // Multi-instance stacks propagate a new credential to the probe-serving instances on a periodic - // sync; consecutive successes guard against a load balancer alternating synced and stale replicas + // The proxy's periodic credential refresh prunes its in-memory list against a database snapshot + // it took before this credential landed, so a credential that resolves right after POST + // /credentials can stop resolving until the refresh after that. Successes spanning a whole + // PROXY_CONFIG_RELOAD_INTERVAL_SECONDS prove it survived a refresh, after which it stays. + // Resolution fails open onto the ambient key, so losing it reads as a confusing upstream 404. let consecutiveProbeSuccesses = 0; await expect .poll( @@ -249,11 +263,12 @@ test.describe("Add Model", () => { return consecutiveProbeSuccesses; }, { - message: `stored credential ${credentialName} never became usable for a connection test`, - timeout: 60_000, + message: `stored credential ${credentialName} never stayed usable across a config reload`, + intervals: [0, CREDENTIAL_PROBE_SPACING_MS], + timeout: 110_000, }, ) - .toBeGreaterThanOrEqual(3); + .toBeGreaterThanOrEqual(CREDENTIAL_PROBE_SUCCESSES); try { await navigateToPage(page, Page.Models); @@ -458,7 +473,7 @@ test.describe("Add Model", () => { await page.waitForLoadState("networkidle"); await page.getByPlaceholder("Search model names").fill("cohere"); - + // Clearer failure than timing out on a row assertion when the table is empty. await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, { timeout: 15_000, @@ -466,10 +481,7 @@ test.describe("Add Model", () => { // Pin to one row carrying both the name and the team, so the sibling test's // team-less cohere row can't satisfy it. - const teamCohereRow = page - .getByRole("row") - .filter({ hasText: "cohere/" }) - .filter({ hasText: E2E_TEAM_CRUD_ID }); + const teamCohereRow = page.getByRole("row").filter({ hasText: "cohere/" }).filter({ hasText: E2E_TEAM_CRUD_ID }); await expect(teamCohereRow).toHaveCount(1, { timeout: 15_000 }); } finally { await deleteTeamScopedCohereModels(); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index f5bee68f245..deb7ae70d07 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -1,15 +1,17 @@ import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; import { ADMIN_STORAGE_PATH, - E2E_DELETE_KEY_ALIAS, E2E_REGENERATE_KEY_ALIAS, E2E_UPDATE_LIMITS_KEY_ALIAS, E2E_INTERNAL_USER_KEY_ALIAS, E2E_TEAM_CRUD_ALIAS, + E2E_TEAM_CRUD_ID, } from "../../constants"; import { Page } from "../../fixtures/pages"; import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; import { captureRequestBody, readBack } from "../../helpers/roundTrip"; +import { masterKey } from "../../helpers/traffic"; +import { proxyIsPremium } from "../../helpers/premium"; /** * Looks a key up by alias, undefined when none carries it. `return_full_object=true` is what makes @@ -23,6 +25,17 @@ async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise row.key_alias === alias); } +/** A key this test owns, so deleting it costs the suite nothing on a retry or a second run. */ +async function createDeletableKey(page: PlaywrightPage): Promise { + const alias = `e2e-delete-key-${Date.now()}`; + const res = await page.request.post("/key/generate", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { key_alias: alias, team_id: E2E_TEAM_CRUD_ID }, + }); + expect(res.ok(), `POST /key/generate failed (${res.status()}): ${await res.text()}`).toBe(true); + return alias; +} + test.describe("Proxy Admin - Keys", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); @@ -67,6 +80,9 @@ test.describe("Proxy Admin - Keys", () => { }); test("Regenerate key", async ({ page }) => { + // The Regenerate Key button renders disabled when the proxy is unlicensed, so without one this + // fails on a product gate rather than on a regression. + test.skip(!proxyIsPremium(), "proxy under test is unlicensed — Regenerate Key is premium-gated"); await navigateToPage(page, Page.ApiKeys); await dismissFeedbackPopup(page); @@ -143,12 +159,16 @@ test.describe("Proxy Admin - Keys", () => { }); test("Delete key", async ({ page }) => { + // Deleting the seeded key leaves nothing for the next attempt, so the retries CI runs with are + // guaranteed to fail and the suite cannot run twice against one database. Bring our own. + const alias = await createDeletableKey(page); + await navigateToPage(page, Page.ApiKeys); await dismissFeedbackPopup(page); - const keyRow = page.getByRole("row").filter({ hasText: E2E_DELETE_KEY_ALIAS }); + const keyRow = page.getByRole("row").filter({ hasText: alias }); await expect(keyRow).toBeVisible({ timeout: 10_000 }); - await keyRow.getByRole("button", { name: E2E_DELETE_KEY_ALIAS }).click(); + await keyRow.getByRole("button", { name: alias }).click(); await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 }); @@ -157,7 +177,7 @@ test.describe("Proxy Admin - Keys", () => { const modal = page.getByRole("dialog", { name: "Delete Key" }); await expect(modal).toBeVisible({ timeout: 5_000 }); - await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS); + await modal.locator("input").fill(alias); const deleteButton = modal.getByRole("button", { name: "Delete", exact: true }); await expect(deleteButton).toBeEnabled(); @@ -167,8 +187,8 @@ test.describe("Proxy Admin - Keys", () => { // The key is gone when the management API stops returning it, not when the toast says so. await expect - .poll(async () => await findKeyByAlias(page, E2E_DELETE_KEY_ALIAS), { - message: `key ${E2E_DELETE_KEY_ALIAS} still readable from /key/list after delete`, + .poll(async () => await findKeyByAlias(page, alias), { + message: `key ${alias} still readable from /key/list after delete`, timeout: 15_000, }) .toBeUndefined(); diff --git a/tests/e2e/ui/tests/proxy-admin/teamSettings.spec.ts b/tests/e2e/ui/tests/proxy-admin/teamSettings.spec.ts new file mode 100644 index 00000000000..e71945a4ccd --- /dev/null +++ b/tests/e2e/ui/tests/proxy-admin/teamSettings.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation"; +import { readBack } from "../../helpers/roundTrip"; +import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, masterKey } from "../../helpers/traffic"; + +interface TeamInfo { + team_id: string; + team_alias: string; + models: string[]; + max_budget: number | null; + tpm_limit: number | null; + rpm_limit: number | null; + metadata: Record | null; + members_with_roles: { user_id?: string; role?: string }[]; +} + +/** + * Each test owns a team it created, rather than editing a seeded one, so a save that clobbers a + * field cannot take another spec's fixture down with it. + */ +async function createTeam(page: PlaywrightPage, alias: string, members: string[] = []): Promise { + const res = await page.request.post("/team/new", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { + team_alias: alias, + models: [CHAT_MODEL_A], + members_with_roles: members.map((user_id) => ({ user_id, role: "user" })), + }, + }); + expect(res.ok(), `POST /team/new failed (${res.status()}): ${await res.text()}`).toBe(true); + return (await res.json()).team_id as string; +} + +/** + * A member of this test's own, not one of the seeded users. Putting a seeded user on an extra team + * changes what every spec that asserts on their memberships sees. + */ +async function createMember(page: PlaywrightPage, userId: string): Promise { + const res = await page.request.post("/user/new", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { user_id: userId, user_role: "internal_user", auto_create_key: false }, + }); + expect(res.ok(), `POST /user/new failed (${res.status()}): ${await res.text()}`).toBe(true); + return userId; +} + +async function teamInfo(page: PlaywrightPage, teamId: string): Promise { + const body = await readBack<{ team_info: TeamInfo }>(page, `/team/info?team_id=${encodeURIComponent(teamId)}`); + return body.team_info; +} + +async function openTeamSettings(page: PlaywrightPage, teamId: string): Promise { + await navigateToPage(page, Page.Teams); + await dismissFeedbackPopup(page); + await clickTeamId(page, teamId); + await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Edit Settings" }).click(); + await expect(page.getByRole("button", { name: "Save Changes" })).toBeVisible({ timeout: 10_000 }); +} + +test.describe("Proxy Admin - Team settings", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("Setting a team's spend cap and rate limits leaves its models and members alone", async ({ page }) => { + const stamp = Date.now(); + const alias = `e2e-team-limits-${stamp}`; + const member = await createMember(page, `e2e-team-limits-member-${stamp}`); + const teamId = await createTeam(page, alias, [member]); + const before = await teamInfo(page, teamId); + + await openTeamSettings(page, teamId); + + await page.getByRole("spinbutton", { name: "Max Budget (USD)" }).fill("42.5"); + await page.getByRole("spinbutton", { name: "Tokens per minute Limit (TPM)" }).fill("7000"); + await page.getByRole("spinbutton", { name: "Requests per minute Limit (RPM)" }).fill("70"); + await page.getByRole("button", { name: "Save Changes" }).click(); + + await expect + .poll( + async () => { + const team = await teamInfo(page, teamId); + return [team.max_budget, team.tpm_limit, team.rpm_limit]; + }, + { message: "team limits did not persist", timeout: 20_000 }, + ) + .toEqual([42.5, 7000, 70]); + + // The Settings form posts the whole team. A field it fails to seed goes back as null, and + // the toast still says success, so pin the fields this edit had no business touching. + const after = await teamInfo(page, teamId); + expect(after.models, "model access untouched by a limits edit").toEqual(before.models); + expect( + after.members_with_roles.map((member) => member.user_id).sort(), + "membership untouched by a limits edit", + ).toEqual(before.members_with_roles.map((member) => member.user_id).sort()); + }); + + test("A model alias added on the Settings tab serves traffic under the alias name", async ({ page }) => { + const stamp = Date.now(); + const alias = `e2e-team-alias-${stamp}`; + const modelAlias = `e2e-alias-${stamp}`; + const teamId = await createTeam(page, alias); + + await openTeamSettings(page, teamId); + + await page.getByRole("textbox", { name: "Alias Name" }).fill(modelAlias); + await page.getByRole("combobox", { name: "Select target model" }).click(); + await page.getByRole("option", { name: CHAT_MODEL_A, exact: true }).first().click(); + await page.getByRole("button", { name: "Add Alias" }).click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + + await expect + .poll(async () => (await teamInfo(page, teamId)).models, { message: "team lost its models", timeout: 20_000 }) + .toEqual([CHAT_MODEL_A]); + + const keyRes = await page.request.post("/key/generate", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { team_id: teamId, key_alias: `e2e-alias-key-${stamp}` }, + }); + expect(keyRes.ok(), `POST /key/generate failed (${keyRes.status()})`).toBe(true); + const teamKey = (await keyRes.json()).key as string; + + // An alias the team can see but cannot call is the actual complaint; the readback alone + // would pass for an alias the router never resolves. + const served = await page.request.post("/v1/chat/completions", { + headers: { Authorization: `Bearer ${teamKey}`, "Content-Type": "application/json" }, + data: { model: modelAlias, messages: [{ role: "user", content: "ping" }] }, + }); + expect(served.status(), `a team key calling ${modelAlias} is served`).toBe(200); + expect((await served.json()).choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT); + }); + + test("Team metadata added as key-value pairs survives a reload", async ({ page }) => { + const stamp = Date.now(); + const alias = `e2e-team-metadata-${stamp}`; + const metadataValue = `cost-center-${stamp}`; + const teamId = await createTeam(page, alias); + + await openTeamSettings(page, teamId); + + await page.getByRole("button", { name: "Add Key-Value Pair" }).click(); + await page.getByPlaceholder("Key", { exact: true }).last().fill("owner"); + await page.getByPlaceholder("Value", { exact: true }).last().fill(metadataValue); + await page.getByRole("button", { name: "Save Changes" }).click(); + + await expect + .poll(async () => (await teamInfo(page, teamId)).metadata?.owner, { + message: "team metadata did not persist", + timeout: 20_000, + }) + .toBe(metadataValue); + + // Reopening the form is the step that catches metadata the page writes but cannot read back. + await page.reload(); + await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Edit Settings" }).click(); + await expect(page.getByPlaceholder("Key", { exact: true })).toHaveValue("owner", { timeout: 15_000 }); + await expect(page.getByPlaceholder("Value", { exact: true })).toHaveValue(metadataValue); + }); +}); diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 7383b452162..303e4488e09 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -1,14 +1,9 @@ import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; -import { - ADMIN_STORAGE_PATH, - E2E_TEAM_CRUD_ID, - E2E_TEAM_DELETE_ALIAS, - E2E_TEAM_NO_ADMIN_ID, - E2E_TEAM_ORG_ID, -} from "../../constants"; +import { ADMIN_STORAGE_PATH, E2E_TEAM_CRUD_ID, E2E_TEAM_NO_ADMIN_ID, E2E_TEAM_ORG_ID } from "../../constants"; import { Page } from "../../fixtures/pages"; import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation"; import { readBack } from "../../helpers/roundTrip"; +import { masterKey } from "../../helpers/traffic"; /** GET /team/list returns a bare array of teams, each carrying team_alias/team_id. */ async function findTeamByAlias(page: PlaywrightPage, alias: string): Promise | undefined> { @@ -25,6 +20,17 @@ async function teamMemberEmails(page: PlaywrightPage, teamId: string): Promise member.user_email ?? "").filter(Boolean); } +/** A team this test owns, so deleting it costs the suite nothing on a retry or a second run. */ +async function createDeletableTeam(page: PlaywrightPage): Promise { + const alias = `e2e-delete-team-${Date.now()}`; + const res = await page.request.post("/team/new", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { team_alias: alias, models: ["fake-openai-gpt-4"] }, + }); + expect(res.ok(), `POST /team/new failed (${res.status()}): ${await res.text()}`).toBe(true); + return alias; +} + test.describe("Proxy Admin - Teams", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); @@ -121,10 +127,14 @@ test.describe("Proxy Admin - Teams", () => { }); test("Delete a team", async ({ page }) => { + // Deleting the seeded team leaves nothing for the next attempt, so the retries CI runs with are + // guaranteed to fail and the suite cannot run twice against one database. Bring our own. + const alias = await createDeletableTeam(page); + await navigateToPage(page, Page.Teams); await dismissFeedbackPopup(page); - const teamRow = page.locator("tr", { hasText: E2E_TEAM_DELETE_ALIAS }).first(); + const teamRow = page.locator("tr", { hasText: alias }).first(); await expect(teamRow).toBeVisible({ timeout: 10_000 }); // Actions live in a kebab menu: open it, then click "Delete team". await teamRow.locator('[data-testid^="team-actions-"]').click(); @@ -132,15 +142,15 @@ test.describe("Proxy Admin - Teams", () => { const modal = page.getByRole("dialog", { name: "Delete Team?" }); await expect(modal).toBeVisible({ timeout: 5_000 }); - await modal.locator("input").fill(E2E_TEAM_DELETE_ALIAS); + await modal.locator("input").fill(alias); await modal.getByRole("button", { name: /Force Delete|Delete/i }).click(); await expect(teamRow).not.toBeVisible({ timeout: 10_000 }); // A row vanishing is local state, which happens whether or not the delete landed. await expect - .poll(async () => await findTeamByAlias(page, E2E_TEAM_DELETE_ALIAS), { - message: `team ${E2E_TEAM_DELETE_ALIAS} still readable from /team/list after delete`, + .poll(async () => await findTeamByAlias(page, alias), { + message: `team ${alias} still readable from /team/list after delete`, timeout: 15_000, }) .toBeUndefined(); diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index 26a6fa50b4b..f3c031f0172 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -35,7 +35,44 @@ async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise row.key_alias === alias); } +/** A member this test adds itself, so removing it costs the suite nothing on a retry or a re-run. */ +async function addRemovableMember(page: PlaywrightPage, registerForCleanup: string[]): Promise { + const userId = `e2e-removable-${Date.now()}`; + // Claimed before the call: /user/new can persist the user and still answer non-2xx, and the id is + // ours either way, so registering it up front is what no failure path can skip. + registerForCleanup.push(userId); + const created = await page.request.post("/user/new", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { user_id: userId, user_role: "internal_user", auto_create_key: false }, + }); + expect(created.ok(), `POST /user/new failed (${created.status()}): ${await created.text()}`).toBe(true); + + const added = await page.request.post("/team/member_add", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { team_id: E2E_TEAM_CRUD_ID, member: { user_id: userId, role: "user" } }, + }); + expect(added.ok(), `POST /team/member_add failed (${added.status()}): ${await added.text()}`).toBe(true); + return userId; +} + test.describe("Team Admin", () => { + const createdMembers: string[] = []; + + test.afterEach(async ({ page }) => { + // Runs on the failure path too, which a call at the end of the test body would not. Ids are + // claimed before the user is created, so the delete is attempted unconditionally and only its + // own 404 counts as never persisted; any other answer is a cleanup failure worth reporting + // rather than a reason to leave the user behind. + for (const userId of createdMembers.splice(0)) { + const deleted = await page.request.post("/user/delete", { + headers: { Authorization: `Bearer ${masterKey()}` }, + data: { user_ids: [userId] }, + }); + const settled = deleted.ok() || deleted.status() === 404; + expect(settled, `POST /user/delete for ${userId} (${deleted.status()}): ${await deleted.text()}`).toBe(true); + } + }); + test.use({ storageState: TEAM_ADMIN_STORAGE_PATH }); test("Team admin can see all team keys including internal user keys", async ({ page }) => { @@ -95,6 +132,10 @@ test.describe("Team Admin", () => { }); test("Team admin can remove a member from their team", async ({ page }) => { + // Removing the seeded member leaves nothing for the next attempt, so the retries CI runs with + // are guaranteed to fail and the suite cannot run twice against one database. Bring our own. + const memberId = await addRemovableMember(page, createdMembers); + await navigateToPage(page, Page.Teams); await dismissFeedbackPopup(page); @@ -102,9 +143,9 @@ test.describe("Team Admin", () => { await page.getByRole("tab", { name: "Members" }).click(); - // Seeded members appear in the roster by user_id (members_with_roles has no - // email), so match the row on the user_id rather than the email. - const row = page.locator("tr", { hasText: "e2e-removable-member" }).first(); + // Members appear in the roster by user_id (members_with_roles has no email), so match + // the row on the user_id rather than the email. + const row = page.locator("tr", { hasText: memberId }).first(); await expect(row).toBeVisible({ timeout: 10_000 }); await row.getByTestId("delete-member").click(); @@ -117,7 +158,7 @@ test.describe("Team Admin", () => { // Removing the wrong member is exactly what a success toast hides, so pin both halves. expect(remove.team_id, "delete targets the team being viewed").toBe(E2E_TEAM_CRUD_ID); expect([remove.user_id, remove.user_email], "delete identifies the member whose row was clicked").toContain( - "e2e-removable-member", + memberId, ); await expect(page.getByText("Team member removed successfully").first()).toBeVisible({ timeout: 10_000 }); @@ -128,7 +169,7 @@ test.describe("Team Admin", () => { message: "removed member is still on the team", timeout: 15_000, }) - .not.toContain("e2e-removable-member"); + .not.toContain(memberId); }); test("Team admin sees all team models in the Playground model dropdown", async ({ page, request }) => { diff --git a/tests/e2e/ui/tests/usage/usageActivityTabs.spec.ts b/tests/e2e/ui/tests/usage/usageActivityTabs.spec.ts new file mode 100644 index 00000000000..2ee5ae3e392 --- /dev/null +++ b/tests/e2e/ui/tests/usage/usageActivityTabs.spec.ts @@ -0,0 +1,135 @@ +import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; +import { Page } from "../../fixtures/pages"; +import { + CHAT_MODEL_A, + CHAT_MODEL_B, + DEPLOYMENT_MODEL_A, + DEPLOYMENT_MODEL_B, + createVirtualKey, + masterKey, + rootPath, + sendChatCompletion, + waitForKeyInDailyActivity, + waitForSpendLog, +} from "../../helpers/traffic"; + +/** + * Covers the per-entity breakdowns on /ui/usage. The page-level totals move with every other spec's + * traffic, so each assertion is scoped to a key this test minted and to the requests it sent. + */ + +/** Each breakdown renders one expandable card per entity, named " $x.xx N requests". */ +const entityCard = (page: PlaywrightPage, tab: string, name: string): Locator => + page.getByRole("tabpanel", { name: tab }).getByRole("button", { name: new RegExp(`^${name}\\s`) }); + +async function openUsageTab(page: PlaywrightPage, tab: string): Promise { + await navigateToPage(page, Page.NewUsage); + await dismissFeedbackPopup(page); + await page.getByRole("tab", { name: tab }).click(); + const panel = page.getByRole("tabpanel", { name: tab }); + await expect(panel).toBeVisible({ timeout: 30_000 }); + return panel; +} + +/** Sends `count` completions on one model and waits for each to reach the spend log. */ +async function sendTraffic( + request: Parameters[0], + apiKey: string, + model: string, + count: number, + label: string, +): Promise { + for (let i = 0; i < count; i++) { + const requestId = await sendChatCompletion(request, { model, prompt: `${label} ${i}`, apiKey }); + await waitForSpendLog(request, requestId); + } +} + +test.describe("Usage page activity tabs", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("Key Activity breaks a key's traffic down by model", async ({ page, request }) => { + const alias = `e2e-usage-keyact-${Date.now()}`; + const { key, token } = await createVirtualKey(request, { key_alias: alias }); + + // An uneven split, so a breakdown that lumps everything into one row or attributes to the + // wrong model cannot land on these numbers by accident. + await sendTraffic(request, key, CHAT_MODEL_A, 2, alias); + await sendTraffic(request, key, CHAT_MODEL_B, 1, alias); + await waitForKeyInDailyActivity(request, token, 3); + + await openUsageTab(page, "Key Activity"); + + const card = entityCard(page, "Key Activity", alias); + await expect(card, `${alias} missing from Key Activity`).toBeVisible({ timeout: 30_000 }); + await expect(card).toContainText("3 requests"); + + // Every key gets a card, and the page opens the first one. Scope to this key's own section, + // which the collapsible renders as the trigger's next sibling. + await card.click(); + const details = card.locator("xpath=following-sibling::*[1]"); + const successfulFor = (model: string) => + details.getByRole("row").filter({ hasText: model }).getByRole("cell").nth(2); // Model | Spend | Successful | Failed | Tokens + + await expect(successfulFor(DEPLOYMENT_MODEL_A)).toHaveText("2", { timeout: 20_000 }); + await expect(successfulFor(DEPLOYMENT_MODEL_B)).toHaveText("1"); + }); + + test("Model Activity can name its models by deployment instead of by public name", async ({ page, request }) => { + const alias = `e2e-usage-modelact-${Date.now()}`; + const { key, token } = await createVirtualKey(request, { key_alias: alias }); + await sendTraffic(request, key, CHAT_MODEL_A, 1, alias); + await waitForKeyInDailyActivity(request, token); + + const panel = await openUsageTab(page, "Model Activity"); + + await expect(entityCard(page, "Model Activity", CHAT_MODEL_A), `${CHAT_MODEL_A} missing`).toBeVisible({ + timeout: 30_000, + }); + // Nothing is published under the deployment's name, so its absence here is what makes the + // toggle below a real change of key rather than a relabelled button. + await expect(entityCard(page, "Model Activity", DEPLOYMENT_MODEL_A)).toHaveCount(0); + + // Admins reconcile provider bills against the deployment, not the name their users call. + await panel.getByRole("button", { name: "Litellm Model Name" }).click(); + await expect(entityCard(page, "Model Activity", DEPLOYMENT_MODEL_A)).toBeVisible({ timeout: 20_000 }); + }); + + test("Filter by user narrows Key Activity to that user's keys", async ({ page, request }) => { + const stamp = Date.now(); + const email = `e2e-usage-owner-${stamp}@test.local`; + const ownedAlias = `e2e-usage-owned-${stamp}`; + const otherAlias = `e2e-usage-other-${stamp}`; + + const userRes = await request.post(`${rootPath()}/user/new`, { + headers: { Authorization: `Bearer ${masterKey()}`, "Content-Type": "application/json" }, + data: { user_email: email, user_role: "internal_user", auto_create_key: false }, + }); + expect(userRes.ok(), `POST /user/new failed (${userRes.status()})`).toBe(true); + const userId = (await userRes.json()).user_id as string; + + const owned = await createVirtualKey(request, { key_alias: ownedAlias, user_id: userId }); + const other = await createVirtualKey(request, { key_alias: otherAlias }); + await sendTraffic(request, owned.key, CHAT_MODEL_A, 1, ownedAlias); + await sendTraffic(request, other.key, CHAT_MODEL_A, 1, otherAlias); + await waitForKeyInDailyActivity(request, owned.token); + await waitForKeyInDailyActivity(request, other.token); + + await openUsageTab(page, "Key Activity"); + await expect(entityCard(page, "Key Activity", otherAlias)).toBeVisible({ timeout: 30_000 }); + + await page.getByRole("combobox", { name: "Search users by email" }).click(); + await page.keyboard.type(email); + await page + .getByRole("option", { name: new RegExp(email) }) + .first() + .click(); + + // The filter earns its place only by dropping the other key; the owned key showing up + // proves nothing on a page that already listed every key. + await expect(entityCard(page, "Key Activity", otherAlias)).toHaveCount(0, { timeout: 30_000 }); + await expect(entityCard(page, "Key Activity", ownedAlias)).toBeVisible({ timeout: 20_000 }); + }); +}); diff --git a/tests/e2e/ui/tests/usage/usagePage.spec.ts b/tests/e2e/ui/tests/usage/usagePage.spec.ts index f61ab018e1b..3d057cfa2c9 100644 --- a/tests/e2e/ui/tests/usage/usagePage.spec.ts +++ b/tests/e2e/ui/tests/usage/usagePage.spec.ts @@ -1,10 +1,11 @@ -import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test"; +import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test"; import { ADMIN_STORAGE_PATH } from "../../constants"; import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; import { - CHAT_MODEL_A, createVirtualKey, + masterKey, + rootPath, sendChatCompletion, waitForKeyInDailyActivity, waitForSpendLog, @@ -27,9 +28,105 @@ async function openUsage(page: PlaywrightPage): Promise { return card; } +/** The upstream fixtures/config.yml points its models at, so the mock server answers this too. */ +const MOCK_DEPLOYMENT = "openai/fake-gpt-4"; + +/** A deployment whose traffic costs real money, so the key that used it outranks the $0 crowd. */ +async function createPricedDeployment( + request: APIRequestContext, + label: string, + registerForCleanup: string[], +): Promise<{ modelName: string }> { + const modelName = `e2e-usage-priced-${label}`; + // Claimed before the call: /model/new can persist the deployment and still answer non-2xx, so a + // name recorded up front is the only registration no response shape can skip. + registerForCleanup.push(modelName); + const res = await request.post("/model/new", { + headers: { Authorization: `Bearer ${masterKey()}`, "Content-Type": "application/json" }, + data: { + model_name: modelName, + litellm_params: { + model: MOCK_DEPLOYMENT, + api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`, + api_key: "fake-key", + input_cost_per_token: 0.01, + output_cost_per_token: 0.01, + }, + }, + }); + expect(res.ok(), `POST /model/new failed (${res.status()}): ${await res.text()}`).toBe(true); + + // /model/new returns once the row is written, but the router only picks the deployment up on its + // next refresh, so sending traffic straight away can still get "no healthy deployments". A ping + // that fails writes no spend log, so retrying it costs the ranking this test asserts nothing. + await expect + .poll( + async () => { + const ping = await request.post(`${rootPath()}/v1/chat/completions`, { + headers: { Authorization: `Bearer ${masterKey()}`, "Content-Type": "application/json" }, + data: { model: modelName, messages: [{ role: "user", content: "readiness ping" }] }, + }); + return ping.ok(); + }, + { message: `deployment ${modelName} never became routable`, timeout: 60_000 }, + ) + .toBe(true); + + return { modelName }; +} + test.describe("Usage page", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); + const pricedDeployments: string[] = []; + + test.afterEach(async ({ request }) => { + // A deployment left behind keeps its custom pricing, so it goes on changing what later runs + // route and what they cost. Runs on the failure path too, which the test body would not. + // Resolved by name rather than by a returned id, so a create that persisted without answering + // 2xx is still cleaned up. /model/info serves the router, and /model/new answers 2xx even when + // its in-request router reload failed, so the search-backed listing is what covers a deployment + // that reached the database only. Absent from both means it never persisted. + const names = pricedDeployments.splice(0); + if (names.length === 0) return; + const auth = { Authorization: `Bearer ${masterKey()}`, "Content-Type": "application/json" }; + + type Lookup = + | { readonly listed: true; readonly id: string | undefined } + | { readonly listed: false; readonly status: number }; + + const idIn = async (path: string, name: string): Promise => { + const listed = await request.get(path, { headers: auth }); + if (!listed.ok()) return { listed: false, status: listed.status() }; + const deployments = ((await listed.json()).data ?? []) as { + model_name?: string; + model_info?: { id?: string }; + }[]; + return { listed: true, id: deployments.find((d) => d.model_name === name)?.model_info?.id }; + }; + + const remove = async (name: string, id: string) => { + const deleted = await request.post(`${rootPath()}/model/delete`, { headers: auth, data: { id } }); + expect(deleted.ok(), `POST /model/delete for ${name} (${deleted.status()})`).toBe(true); + }; + + for (const name of names) { + const fromRouter = await idIn(`${rootPath()}/model/info`, name); + if (fromRouter.listed && fromRouter.id !== undefined) { + await remove(name, fromRouter.id); + continue; + } + const search = encodeURIComponent(name); + const fromDb = await idIn(`${rootPath()}/v2/model/info?search=${search}`, name); + expect( + fromDb.listed, + `GET /v2/model/info?search=${search} (${fromDb.listed ? 200 : fromDb.status}), so ${name} could not be checked`, + ).toBe(true); + if (!fromDb.listed || fromDb.id === undefined) continue; + await remove(name, fromDb.id); + } + }); + test("Top Virtual Keys lists a key that served traffic, toggles views, and opens key info", async ({ page, request, @@ -39,8 +136,13 @@ test.describe("Usage page", () => { key_alias: alias, }); + // Top Virtual Keys ranks by spend, and every mock deployment costs $0, so once a run has more + // keys than the list shows, whether this one makes the cut is down to how ties happen to sort. + // Give it a priced deployment of its own so it earns its place. + const { modelName } = await createPricedDeployment(request, alias, pricedDeployments); + const requestId = await sendChatCompletion(request, { - model: CHAT_MODEL_A, + model: modelName, prompt: `usage ping for ${alias}`, apiKey: key, }); diff --git a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py index 05886e4b7f6..58cde4c8103 100644 --- a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py +++ b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py @@ -40,6 +40,24 @@ def prometheus_logger() -> PrometheusLogger: return PrometheusLogger() +@pytest.fixture +def known_model_router(): + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5-mini", + "litellm_params": {"model": "openai/gpt-5-mini", "api_key": "fake-key"}, + }, + { + "model_name": "us/azure/openai/gpt-5-mini", + "litellm_params": {"model": "openai/gpt-5-mini", "api_key": "fake-key"}, + }, + ] + ) + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + yield router + + def create_standard_logging_payload() -> StandardLoggingPayload: return StandardLoggingPayload( id="test_id", @@ -741,7 +759,7 @@ async def test_async_log_failure_event(prometheus_logger): @pytest.mark.asyncio -async def test_async_log_failure_event_litellm_side_rate_limit(prometheus_logger): +async def test_async_log_failure_event_litellm_side_rate_limit(prometheus_logger, known_model_router): """LiteLLM-side reject (no deployment picked) routes the requested model into `requested_model` and skips the partial-outage flag.""" standard_logging_object = create_standard_logging_payload() @@ -786,7 +804,7 @@ async def test_async_log_failure_event_litellm_side_rate_limit(prometheus_logger @pytest.mark.asyncio -async def test_async_post_call_failure_hook(prometheus_logger): +async def test_async_post_call_failure_hook(prometheus_logger, known_model_router): """ Test for the async_post_call_failure_hook method @@ -1069,7 +1087,7 @@ def test_set_llm_deployment_success_metrics(prometheus_logger): @pytest.mark.asyncio -async def test_log_success_fallback_event(prometheus_logger): +async def test_log_success_fallback_event(prometheus_logger, known_model_router): prometheus_logger.litellm_deployment_successful_fallbacks = MagicMock() original_model_group = "gpt-5-mini" @@ -1107,7 +1125,7 @@ async def test_log_success_fallback_event(prometheus_logger): @pytest.mark.asyncio -async def test_log_failure_fallback_event(prometheus_logger): +async def test_log_failure_fallback_event(prometheus_logger, known_model_router): prometheus_logger.litellm_deployment_failed_fallbacks = MagicMock() original_model_group = "gpt-5-mini" diff --git a/tests/litellm-proxy-extras/test_setup_database_fail_fast.py b/tests/litellm-proxy-extras/test_setup_database_fail_fast.py deleted file mode 100644 index ef447315a8c..00000000000 --- a/tests/litellm-proxy-extras/test_setup_database_fail_fast.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Regression tests for ProxyExtrasDBManager's v2 migration resolver. - -v2 is the proxy CLI default; v1 stays reachable via the `use_v2_resolver` -kwarg, which still defaults to False for direct callers. -""" - -import subprocess -from unittest.mock import patch - -import pytest - -from litellm_proxy_extras.utils import ( - _PRISMA_ATTEMPTS, - ProxyExtrasDBManager, - _max_migration_timestamp, - _migration_timestamp, -) - - -def _fake_migrate_deploy_failure(returncode: int, stderr: str): - def _run(*args, **kwargs): - raise subprocess.CalledProcessError( - returncode=returncode, - cmd=args[0], - stderr=stderr, - output="", - ) - - return _run - - -def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path): - """v2: a permission failure during migrate deploy raises RuntimeError.""" - monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") - monkeypatch.setattr( - ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None - ) - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - stderr = ( - "Error: P3018\nMigration name: 20250326162113_baseline\n" - "Database error code: 42501\npermission denied for schema public" - ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): - with pytest.raises(RuntimeError, match="permission"): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - -def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path): - """v2: a non-idempotent migration failure raises (no silent recovery).""" - monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") - monkeypatch.setattr( - ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None - ) - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - stderr = ( - "Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n" - 'Reason: syntax error at or near "BRKN" LINE 42' - ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): - with pytest.raises(RuntimeError, match="cannot be auto-recovered"): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - -def test_strip_prisma_query_params_removes_connection_limit(): - """DATABASE_URLs with Prisma-specific params should be parseable by psycopg.""" - url = "postgresql://u:p@h:5432/db?connection_limit=100&pool_timeout=60&sslmode=require" - stripped = ProxyExtrasDBManager._strip_prisma_query_params(url) - assert "connection_limit" not in stripped - assert "pool_timeout" not in stripped - assert "sslmode=require" in stripped - - -def test_strip_prisma_query_params_passthrough_no_query(): - """URLs without query strings are returned unchanged.""" - url = "postgresql://u:p@h:5432/db" - assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url - - -def test_migration_timestamp_extracts_leading_digits(): - assert _migration_timestamp("20260101000000_add_foo") == 20260101000000 - assert _migration_timestamp("20250326162113_baseline") == 20250326162113 - - -def test_migration_timestamp_returns_zero_on_malformed(): - assert _migration_timestamp("0_init") == 0 - assert _migration_timestamp("not_a_migration") == 0 - - -def test_max_migration_timestamp(): - names = {"20250326000000_a", "20260415000000_b", "20251115000000_c"} - assert _max_migration_timestamp(names) == 20260415000000 - - -def test_max_migration_timestamp_empty_set(): - assert _max_migration_timestamp(set()) == 0 - - -def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path): - """v1 (default) continues to call _resolve_all_migrations on the happy path. - - This is the existing buggy behavior — we're not fixing it in v1, only - offering v2 as opt-in. This test pins the default so that a future - inadvertent default flip is caught. - """ - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - # Stub `prisma migrate deploy` to claim success with pending migrations - # applied, which is the code path that triggers the legacy post-migration - # sanity check (a call to _resolve_all_migrations). - class FakeResult: - stdout = "Applied migration.\n" - stderr = "" - - def fake_run(cmd, *args, **kwargs): - return FakeResult() - - resolve_called = {"n": 0} - - def fake_resolve(*args, **kwargs): - resolve_called["n"] += 1 - - monkeypatch.setattr("subprocess.run", fake_run) - monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve) - - ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set - assert ok is True - assert resolve_called["n"] == 1, "v1 default should still invoke the legacy path" - - -def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_path): - """v2: a failing `prisma db push` must raise RuntimeError, not leak - CalledProcessError past proxy_cli.py's `except RuntimeError`.""" - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - stderr = "db push error" - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): - with pytest.raises(RuntimeError, match="prisma db push failed"): - ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) - - -def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path): - """_warn_if_db_ahead_of_head must never raise — it's informational. - - Non-connection DB errors (e.g. InsufficientPrivilege from a user - without SELECT on _prisma_migrations) must be caught, not propagated. - """ - import psycopg - - monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - class _FakeConn: - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def execute(self, *a, **kw): - # Simulate an InsufficientPrivilege (subclass of DatabaseError). - raise psycopg.errors.InsufficientPrivilege("permission denied") - - connects = {"n": 0} - - def _fake_connect(*a, **kw): - connects["n"] += 1 - return _FakeConn() - - monkeypatch.setattr("psycopg.connect", _fake_connect) - - assert ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path)) is None - assert connects["n"] == 1, "the failing query must actually have been reached" - - -def test_v2_resolve_specific_migration_failure_raises_runtime_error( - monkeypatch, tmp_path -): - """If marking a migration as applied fails inside P3009 idempotent - recovery, the subprocess error must be re-raised as RuntimeError so - proxy_cli.py catches it cleanly (instead of leaking CalledProcessError).""" - monkeypatch.setattr( - ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None - ) - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - monkeypatch.setattr( - ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None - ) - - # First call: migrate deploy -> P3009 idempotent error. - # Recovery path tries _resolve_specific_migration; that also raises. - def _failing_resolve(*a, **kw): - raise subprocess.CalledProcessError( - returncode=1, - cmd="prisma migrate resolve --applied", - stderr="resolve failed", - output="", - ) - - monkeypatch.setattr( - ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve - ) - - stderr = ( - "Error: P3009\nMigration `20260101000000_some_migration` failed\n" - "relation already exists" - ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): - with pytest.raises( - RuntimeError, match=r"Failed to mark migration .* as applied" - ): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - -def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path): - """v2 must never call _resolve_all_migrations — that's the bug it fixes.""" - monkeypatch.setattr( - ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None - ) - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - - class FakeResult: - stdout = "Applied migration.\n" - stderr = "" - - monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult()) - - resolve_called = {"n": 0} - monkeypatch.setattr( - ProxyExtrasDBManager, - "_resolve_all_migrations", - lambda *a, **kw: resolve_called.__setitem__("n", resolve_called["n"] + 1), - ) - - ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - assert ok is True - assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery" - - -_DEADLOCK_STDERR = ( - "Error: ERROR: deadlock detected\n" - "DETAIL: Process 277 waits for ExclusiveLock on advisory lock " - "[17556,0,72707369,1]; blocked by process 278.\n" - "Process 278 waits for ShareLock on virtual transaction 3/1041; " - "blocked by process 277." -) - - -class _DeployApplied: - stdout = "All migrations have been successfully applied." - stderr = "" - returncode = 0 - - -def _deploy_only(deploy_side_effect): - """subprocess.run stand-in that only intercepts `prisma migrate deploy`. - - Scoped by argv so the Prisma toolchain check cannot consume the mock first. - """ - deploys = {"n": 0} - - def _run(*args, **kwargs): - cmd = args[0] if args else kwargs.get("args", []) - if list(cmd)[-2:] == ["migrate", "deploy"]: - deploys["n"] += 1 - return deploy_side_effect(deploys["n"], cmd) - return _DeployApplied() - - return _run, deploys - - -def _prepare_v2_resolver(monkeypatch, tmp_path): - monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") - monkeypatch.setattr( - ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None - ) - monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) - (tmp_path / "schema.prisma").write_text("// stub") - monkeypatch.setattr("time.sleep", lambda *_a, **_k: None) - - -def test_v2_retries_transient_advisory_lock_deadlock(monkeypatch, tmp_path): - """v2: replicas racing `migrate deploy` deadlock on Prisma's advisory - lock, which is transient and must be retried rather than kill the boot.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - if n == 1: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr=_DEADLOCK_STDERR, output="" - ) - return _DeployApplied() - - run, deploys = _deploy_only(_side_effect) - with patch("subprocess.run", side_effect=run): - ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - assert ok is True - assert deploys["n"] == 2, "the deadlocked deploy must be retried, not raised" - - -def test_v2_persistent_advisory_lock_deadlock_eventually_raises(monkeypatch, tmp_path): - """v2: the deadlock retry is bounded, so a deadlock that never clears - still raises instead of looping or reporting success.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr=_DEADLOCK_STDERR, output="" - ) - - run, deploys = _deploy_only(_side_effect) - with patch("subprocess.run", side_effect=run): - with pytest.raises(RuntimeError, match="after 4 attempts"): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - assert deploys["n"] == 4 - - -@pytest.mark.parametrize( - "stderr", - [ - "Error: P1001: Can't reach database server at `db`:`5432`", - "Error: P1002: The database server was reached but timed out.", - ], -) -def test_v2_retries_transient_database_connectivity_errors(monkeypatch, tmp_path, stderr): - """v2: a database not accepting connections yet is retried, not fatal.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - if n == 1: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr=stderr, output="" - ) - return _DeployApplied() - - run, deploys = _deploy_only(_side_effect) - with patch("subprocess.run", side_effect=run): - ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - assert ok is True - assert deploys["n"] == 2, "an unreachable database must be retried, not raised" - - -def test_v2_unreachable_database_still_fails_after_the_retries(monkeypatch, tmp_path): - """v2: a genuinely unreachable database still raises once the attempts - are spent, rather than passing as a successful migration.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - raise subprocess.CalledProcessError( - returncode=1, - cmd=cmd, - stderr="Error: P1001: Can't reach database server at `db`:`5432`", - output="", - ) - - run, deploys = _deploy_only(_side_effect) - with patch("subprocess.run", side_effect=run): - with pytest.raises(RuntimeError, match="after 4 attempts"): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - assert deploys["n"] == 4 - - -def test_v2_exhausted_retries_report_the_prisma_error(monkeypatch, tmp_path, caplog): - """v2: retrying must not swallow Prisma's stderr, which is captured and is - the only place the cause appears for an operator or a boot-log grep.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - stderr = "Error: P1001: Can't reach database server at `wrong`:`5432`" - - def _side_effect(n, cmd): - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr=stderr, output="" - ) - - run, _ = _deploy_only(_side_effect) - with caplog.at_level("INFO", logger="litellm_proxy_extras"): - with patch("subprocess.run", side_effect=run): - with pytest.raises(RuntimeError) as exc_info: - ProxyExtrasDBManager.setup_database( - use_migrate=True, use_v2_resolver=True - ) - - assert "P1001" in str(exc_info.value) - assert "P1001" in caplog.text - - -def test_v2_db_push_retries_transient_failures(monkeypatch, tmp_path): - """v2: `prisma db push` retries a transient failure like v1 did. - - Reached from the migrations Job (USE_PRISMA_DB_PUSH=true), not from the - proxy CLI, whose --use_prisma_db_push has its own loop in prisma_client. - """ - _prepare_v2_resolver(monkeypatch, tmp_path) - - pushes = {"n": 0} - - def _run(*args, **kwargs): - cmd = list(args[0] if args else kwargs.get("args", [])) - if cmd[-3:] != ["db", "push", "--accept-data-loss"]: - return _DeployApplied() - pushes["n"] += 1 - if pushes["n"] == 1: - raise subprocess.CalledProcessError( - returncode=1, - cmd=cmd, - stderr="Error: P1001: Can't reach database server at `db`:`5432`", - output="", - ) - return _DeployApplied() - - monkeypatch.setattr( - ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False - ) - with patch("subprocess.run", side_effect=_run): - ok = ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) - - assert ok is True - assert pushes["n"] == 2 - - -def test_v2_db_push_retries_are_bounded_and_report_the_prisma_error( - monkeypatch, tmp_path -): - """v2: a database that never comes back stops after _PRISMA_ATTEMPTS and - surfaces the prisma error, rather than retrying the boot forever.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - pushes = {"n": 0} - - def _run(*args, **kwargs): - cmd = list(args[0] if args else kwargs.get("args", [])) - if cmd[-3:] != ["db", "push", "--accept-data-loss"]: - return _DeployApplied() - pushes["n"] += 1 - raise subprocess.CalledProcessError( - returncode=1, - cmd=cmd, - stderr="Error: P1001: Can't reach database server at `db`:`5432`", - output="", - ) - - monkeypatch.setattr( - ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False - ) - with patch("subprocess.run", side_effect=_run): - with pytest.raises(RuntimeError) as exc: - ProxyExtrasDBManager.setup_database( - use_migrate=False, use_v2_resolver=True - ) - - assert pushes["n"] == _PRISMA_ATTEMPTS - assert "P1001" in str(exc.value) - - -def _db_push_only(push_side_effect): - """subprocess.run stand-in that only intercepts `prisma db push`.""" - pushes = {"n": 0} - - def _run(*args, **kwargs): - cmd = list(args[0] if args else kwargs.get("args", [])) - if cmd[-3:] != ["db", "push", "--accept-data-loss"]: - return _DeployApplied() - pushes["n"] += 1 - return push_side_effect(pushes["n"], cmd) - - return _run, pushes - - -def _timed_out_for_real(): - """Capture what subprocess.run really puts on a TimeoutExpired. - - Under text=True it still leaves stderr as bytes, unlike CalledProcessError, - so hardcoding a str here would test a shape production never sees. Derived - at import, before any test patches subprocess.run. - """ - try: - subprocess.run( - ["sh", "-c", "echo 'Error: P1001 unreachable' >&2; sleep 5"], - timeout=0.2, - check=True, - capture_output=True, - text=True, - ) - except subprocess.TimeoutExpired as e: - return e - raise AssertionError("the helper command was supposed to time out") - - -_TIMEOUT_TEMPLATE = _timed_out_for_real() - - -def _real_timeout_expired(cmd): - return subprocess.TimeoutExpired( - cmd=cmd, - timeout=_TIMEOUT_TEMPLATE.timeout, - output=_TIMEOUT_TEMPLATE.stdout, - stderr=_TIMEOUT_TEMPLATE.stderr, - ) - - -def test_v2_db_push_retries_a_timeout(monkeypatch, tmp_path): - """v2: a `prisma db push` that times out is retried, not turned into a - TypeError by classifying its bytes stderr as if it were text.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - if n == 1: - raise _real_timeout_expired(cmd) - return _DeployApplied() - - monkeypatch.setattr( - ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False - ) - run, pushes = _db_push_only(_side_effect) - with patch("subprocess.run", side_effect=run): - ok = ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) - - assert ok is True - assert pushes["n"] == 2 - - -def test_v2_db_push_timeouts_are_bounded(monkeypatch, tmp_path): - """v2: a `prisma db push` that never stops timing out gives up as a - RuntimeError, which is the only exception proxy_cli.py exits cleanly on.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - raise _real_timeout_expired(cmd) - - monkeypatch.setattr( - ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False - ) - run, pushes = _db_push_only(_side_effect) - with patch("subprocess.run", side_effect=run): - with pytest.raises(RuntimeError, match=r"prisma db push failed after \d+"): - ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) - - assert pushes["n"] == _PRISMA_ATTEMPTS - - -def test_v2_unclassified_failure_is_not_treated_as_transient(monkeypatch, tmp_path): - """v2: an unrecognised deploy failure still raises on the first attempt.""" - _prepare_v2_resolver(monkeypatch, tmp_path) - - def _side_effect(n, cmd): - raise subprocess.CalledProcessError( - returncode=1, - cmd=cmd, - stderr="Error: relation \"LiteLLM_SpendLogs\" does not exist", - output="", - ) - - run, deploys = _deploy_only(_side_effect) - with patch("subprocess.run", side_effect=run): - with pytest.raises(RuntimeError, match="cannot be auto-recovered"): - ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) - - assert deploys["n"] == 1 - - diff --git a/tests/llm_responses_api_testing/test_responses_hooks.py b/tests/llm_responses_api_testing/test_responses_hooks.py index 66dbb29dba5..a86752c0172 100644 --- a/tests/llm_responses_api_testing/test_responses_hooks.py +++ b/tests/llm_responses_api_testing/test_responses_hooks.py @@ -841,7 +841,7 @@ def test_build_synthetic_response_events_covers_annotations_function_calls_and_r ) try: - events = streaming_module._build_synthetic_response_events( + events = streaming_module.build_synthetic_response_events( transformed=transformed, logging_obj=logging_obj, chunk_size=5, diff --git a/tests/llm_translation/conftest.py b/tests/llm_translation/conftest.py index 8532af2851c..567040c1d19 100644 --- a/tests/llm_translation/conftest.py +++ b/tests/llm_translation/conftest.py @@ -44,7 +44,11 @@ _VCR_AUTO_MARKER_SKIP_FILES = frozenset( {"test_vcr_redis_persister.py", "test_ws_vcr.py"} ) -_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = () +_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ( + "test_nvidia_nim.py::test_embedding_nvidia_nim", + "test_litellm_proxy_provider.py::test_litellm_gateway_from_sdk_embedding[False]", + "test_litellm_proxy_provider.py::test_litellm_gateway_from_sdk_embedding[True]", +) _verbose_state = VerboseReporterState() diff --git a/tests/llm_translation/reasoning_effort_grid/grid_spec.py b/tests/llm_translation/reasoning_effort_grid/grid_spec.py index 485f78ed8c7..aa9f66f6665 100644 --- a/tests/llm_translation/reasoning_effort_grid/grid_spec.py +++ b/tests/llm_translation/reasoning_effort_grid/grid_spec.py @@ -163,6 +163,13 @@ _CAPS_NONE: FrozenSet[str] = frozenset() ANTHROPIC_DIRECT_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="claude-fable-5-1", + model="anthropic/claude-fable-5-1", + mode="adaptive", + required_env=_ANTHROPIC_REQ, + caps=_CAPS_XHIGH_MAX, + ), ModelEntry( alias="claude-fable-5", model="anthropic/claude-fable-5", @@ -222,6 +229,19 @@ ANTHROPIC_DIRECT_MODELS: Tuple[ModelEntry, ...] = ( AZURE_AI_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="azure-claude-fable-5-1", + model="azure_ai/claude-fable-5-1", + mode="adaptive", + required_env=_AZURE_FOUNDRY_REQ, + caps=_CAPS_XHIGH_MAX, + fail_reason=( + "claude-fable-5-1 has no deployment on the CI Microsoft Foundry " + "resource yet, so Foundry returns DeploymentNotFound and this cell " + "stays loud in CI. Remove this fail_reason once the deployment " + "exists." + ), + ), ModelEntry( alias="azure-claude-fable-5", model="azure_ai/claude-fable-5", @@ -268,6 +288,20 @@ AZURE_AI_MODELS: Tuple[ModelEntry, ...] = ( VERTEX_AI_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="vertex-claude-fable-5-1", + model="vertex_ai/claude-fable-5-1", + mode="adaptive", + extra_params=(("vertex_location", "global"),), + required_env=_VERTEX_REQ, + caps=_CAPS_XHIGH_MAX, + fail_reason=( + "claude-fable-5-1 availability on the CI Vertex project is not yet " + "confirmed for this brand-new release, so this cell stays loud in " + "CI until verified. Remove this fail_reason once the model is " + "confirmed available on the global Vertex endpoint." + ), + ), ModelEntry( alias="vertex-claude-fable-5", model="vertex_ai/claude-fable-5", @@ -332,6 +366,22 @@ VERTEX_AI_MODELS: Tuple[ModelEntry, ...] = ( BEDROCK_CONVERSE_MODELS: Tuple[ModelEntry, ...] = ( + ModelEntry( + alias="bedrock-claude-fable-5-1", + model="bedrock/converse/us.anthropic.claude-fable-5-1", + mode="adaptive", + extra_params=(("aws_region_name", "us-east-1"),), + required_env=_BEDROCK_REQ, + caps=_CAPS_XHIGH_MAX, + bedrock_effort_ceiling="xhigh", + unavailable_error="is not available for this account", + fail_reason=( + "claude-fable-5-1 access on the CI Bedrock account is not yet " + "confirmed for this brand-new release, so this cell stays loud in " + "CI until verified. Remove this fail_reason once the model is " + "enabled for the account." + ), + ), ModelEntry( alias="bedrock-claude-fable-5", model="bedrock/converse/us.anthropic.claude-fable-5", diff --git a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py index 517e3173b8c..714d544ecd6 100644 --- a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py +++ b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py @@ -201,8 +201,8 @@ async def test_reasoning_effort_grid( def test_grid_cell_count() -> None: - assert len(_PARAMS) == 31 * 11, ( - f"expected 341 cells (31 provider x model combos x 11 efforts), " + assert len(_PARAMS) == 35 * 11, ( + f"expected 385 cells (35 provider x model combos x 11 efforts), " f"got {len(_PARAMS)}" ) diff --git a/tests/llm_translation/test_prompt_factory.py b/tests/llm_translation/test_prompt_factory.py index a90a3df584e..7b03736920b 100644 --- a/tests/llm_translation/test_prompt_factory.py +++ b/tests/llm_translation/test_prompt_factory.py @@ -1446,9 +1446,17 @@ def test_convert_to_anthropic_tool_invoke_sanitizes_invalid_ids(): def test_convert_to_anthropic_tool_invoke_server_tool(): """ - Test that server_tool_use (srvtoolu_) is reconstructed as server_tool_use. + Test that a server tool call (srvtoolu_) with no stored result is replayed + as a regular tool_use block. - Fixes: https://github.com/BerriAI/litellm/issues/17737 + A server_tool_use block is only valid when paired with its result block, so + an unpaired one must degrade to tool_use for Anthropic to accept the replay. + A paired call still becomes server_tool_use, covered by + test_convert_to_anthropic_tool_invoke_with_web_search_results. + + Context: https://github.com/BerriAI/litellm/issues/17737 (original + server_tool_use reconstruction) and LIT-6622 / PR #39144 (unpaired calls + degrade instead of 400ing at Anthropic). """ tool_calls = [ { @@ -1464,7 +1472,7 @@ def test_convert_to_anthropic_tool_invoke_server_tool(): result = convert_to_anthropic_tool_invoke(tool_calls) assert len(result) == 1 - assert result[0]["type"] == "server_tool_use" # NOT tool_use + assert result[0]["type"] == "tool_use" assert result[0]["id"] == "srvtoolu_01ABC123" assert result[0]["name"] == "web_search" assert result[0]["input"] == {"query": "elephant weight"} diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py index ee93009a198..5535a62bb81 100644 --- a/tests/local_testing/conftest.py +++ b/tests/local_testing/conftest.py @@ -90,6 +90,7 @@ _VCR_INCOMPATIBLE_FILES = frozenset( # carry no real provider cost. _VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ( "test_router.py::test_router_text_completion_client", + "test_embedding.py::test_encoding_format_omitted_by_default_for_openai_sdk", ) diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py index 506c58d26b4..fb06ed6b69d 100644 --- a/tests/local_testing/test_basic_python_version.py +++ b/tests/local_testing/test_basic_python_version.py @@ -305,16 +305,14 @@ def _run_proxy_server_smoke_test(extra_proxy_args=None): def test_litellm_proxy_server_config_no_general_settings(): - """Exercises the default (v2) migration resolver.""" + """Exercises the default (v1) migration resolver.""" _run_proxy_server_smoke_test() -def test_litellm_proxy_server_config_no_general_settings_legacy_resolver(): - """Exercises the legacy (v1) migration resolver against a real database. +def test_litellm_proxy_server_config_no_general_settings_v2_resolver(): + """Exercises the opt-in v2 migration resolver. - v2 is the default, so the no-arg test above already covers it. This one is - the only place the v1 opt-out gets real-DB migration plus proxy-boot - coverage, and it runs in a separate CI job against its own Postgres to - avoid collisions with the default variant. + Runs in a separate CI job against a local Postgres to avoid collisions + with the v1 variant when they share a database. """ - _run_proxy_server_smoke_test(extra_proxy_args=["--use_legacy_migration_resolver"]) + _run_proxy_server_smoke_test(extra_proxy_args=["--use_v2_migration_resolver"]) diff --git a/tests/local_testing/test_get_llm_provider.py b/tests/local_testing/test_get_llm_provider.py index cc6209f2bf9..ebad0fbafc5 100644 --- a/tests/local_testing/test_get_llm_provider.py +++ b/tests/local_testing/test_get_llm_provider.py @@ -155,6 +155,11 @@ def test_default_api_base(): continue elif provider == "github" and other_provider.value == "azure": continue + elif ( + provider in ("qwencloud", "qwen_ai_platform") + and other_provider.value == "dashscope" + ): + continue assert other_provider.value not in api_base.replace("/openai", "") diff --git a/tests/mcp_tests/test_mcp_client_unit.py b/tests/mcp_tests/test_mcp_client_unit.py index aadaadd510e..6438525706a 100644 --- a/tests/mcp_tests/test_mcp_client_unit.py +++ b/tests/mcp_tests/test_mcp_client_unit.py @@ -11,7 +11,9 @@ from unittest.mock import AsyncMock, MagicMock, patch, ANY import litellm.experimental_mcp_client.client as mcp_client_module from litellm.experimental_mcp_client.client import MCPClient from litellm.types.mcp import MCPAuth, MCPTransport -from mcp.types import Tool as MCPTool, CallToolResult as MCPCallToolResult +from mcp.types import CallToolResult as MCPCallToolResult +from mcp.types import ListToolsResult, PaginatedRequestParams +from mcp.types import Tool as MCPTool def test_mcp_client_uses_configurable_default_timeout(): @@ -185,6 +187,80 @@ class TestMCPClientUnitTests: mock_session_instance.initialize.assert_called_once() mock_session_instance.list_tools.assert_called_once() + @pytest.mark.asyncio + @patch.object(mcp_client_module, "streamable_http_client") # test-quality-ok: exercises MCPClient wiring; the walk itself is covered sessionless in test_tools.py + @patch.object(mcp_client_module, "ClientSession") # test-quality-ok: exercises MCPClient wiring; the walk itself is covered sessionless in test_tools.py + async def test_list_tools_follows_next_cursor_until_exhausted( + self, + mock_session_class, + mock_transport, + ): + """Test listing tools follows MCP pagination cursors until exhausted.""" + mock_transport_ctx = AsyncMock() + mock_transport.return_value = mock_transport_ctx + mock_transport_instance = MagicMock() + mock_transport_ctx.__aenter__ = AsyncMock(return_value=mock_transport_instance) + + mock_session_ctx = AsyncMock() + mock_session_class.return_value = mock_session_ctx + mock_session_instance = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) + + first_page_tools = [ + MCPTool(name=f"tool_{idx}", description=f"Tool {idx}", inputSchema={}) for idx in range(100) + ] + second_page_tool = MCPTool( + name="tool_100", + description="Tool 100", + inputSchema={}, + ) + mock_session_instance.list_tools.side_effect = [ + ListToolsResult(tools=first_page_tools, nextCursor="page-2"), + ListToolsResult(tools=[second_page_tool]), + ] + + client = MCPClient("http://example.com") + result = await client.list_tools() + + assert result == [*first_page_tools, second_page_tool] + assert mock_session_instance.list_tools.call_count == 2 + second_call_params = mock_session_instance.list_tools.call_args_list[1].kwargs["params"] + assert isinstance(second_call_params, PaginatedRequestParams) + assert second_call_params.cursor == "page-2" + + @pytest.mark.asyncio + @patch.object(mcp_client_module, "streamable_http_client") # test-quality-ok: exercises MCPClient wiring; the walk itself is covered sessionless in test_tools.py + @patch.object(mcp_client_module, "ClientSession") # test-quality-ok: exercises MCPClient wiring; the walk itself is covered sessionless in test_tools.py + async def test_list_tools_swallows_mid_walk_error_without_raise_on_error( + self, + mock_session_class, + mock_transport, + ): + """Test a mid-walk failure returns [] when raise_on_error is False.""" + mock_transport_ctx = AsyncMock() + mock_transport.return_value = mock_transport_ctx + mock_transport_instance = MagicMock() + mock_transport_ctx.__aenter__ = AsyncMock(return_value=mock_transport_instance) + + mock_session_ctx = AsyncMock() + mock_session_class.return_value = mock_session_ctx + mock_session_instance = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) + + mock_session_instance.list_tools.side_effect = [ + ListToolsResult( + tools=[MCPTool(name="tool_0", description="Tool 0", inputSchema={})], + nextCursor="page-2", + ), + RuntimeError("transient upstream failure"), + ] + + client = MCPClient("http://example.com") + result = await client.list_tools() + + assert result == [] + assert mock_session_instance.list_tools.call_count == 2 + @pytest.mark.asyncio @patch.object(mcp_client_module, "streamable_http_client") @patch.object(mcp_client_module, "ClientSession") diff --git a/tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py b/tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py new file mode 100644 index 00000000000..ed21734c5fc --- /dev/null +++ b/tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py @@ -0,0 +1,58 @@ +"""Image-level check that the built proxy image can import the Bedrock realtime SDK. + +Bedrock Nova Sonic (`/v1/realtime`) imports `aws_sdk_bedrock_runtime` lazily on the +first session, so an image whose `uv sync` stages skip the `bedrock-realtime` extra +boots, passes health checks, and then fails every Nova Sonic session with +"Missing aws_sdk_bedrock_runtime". Importing inside the built image is what catches +that class of regression (missing extra, lockfile drift, a stage that syncs a +different set of extras), which a static Dockerfile check cannot. + +Gated on LITELLM_IMAGE like the other image checks in this directory; exercised +where an image has been built (the image-scan workflow). Requires a working docker CLI. +""" + +import os +import shutil +import subprocess +from typing import Final + +import pytest + +IMAGE: Final = os.getenv("LITELLM_IMAGE") +NON_ROOT_UID: Final = "12345:0" +IMPORT_PROBE: Final = "import aws_sdk_bedrock_runtime, smithy_aws_core; print('bedrock-realtime ok')" + +pytestmark = [ + pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"), + pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"), +] + + +def test_image_imports_bedrock_realtime_sdk(): + assert IMAGE is not None + + probe: Final = subprocess.run( + [ + "docker", + "run", + "--rm", + "--network", + "none", + "--user", + NON_ROOT_UID, + "--entrypoint", + "python", + IMAGE, + "-c", + IMPORT_PROBE, + ], + capture_output=True, + text=True, + check=False, + ) + + assert probe.returncode == 0 and "bedrock-realtime ok" in probe.stdout, ( + f"{IMAGE} cannot import aws_sdk_bedrock_runtime as uid {NON_ROOT_UID}, so Bedrock Nova Sonic " + "/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'. Is `--extra bedrock-realtime` " + f"passed to every `uv sync` in its Dockerfile?\nstdout:\n{probe.stdout}\nstderr:\n{probe.stderr}" + ) diff --git a/tests/router_unit_tests/test_router_anthropic_messages_fallback.py b/tests/router_unit_tests/test_router_anthropic_messages_fallback.py new file mode 100644 index 00000000000..0c4d1dfc21e --- /dev/null +++ b/tests/router_unit_tests/test_router_anthropic_messages_fallback.py @@ -0,0 +1,402 @@ +""" +Unit tests for safeguard-refusal fallback on the /v1/messages router surface. + +An Anthropic safeguard refusal is an HTTP 200 whose body carries +stop_reason "refusal" plus a stop_details object; the router converts it +into a ContentPolicyViolationError so the content-policy fallback chain +runs, but only when a matching fallback is configured. A plain refusal +without stop_details, or any refusal with nothing configured, must reach +the client byte-identical. + +The upstream is faked at the HTTP boundary by intercepting the third-party +transport (httpx.AsyncClient.send), so requests run litellm's real +transformation, allowlist, and streaming pipeline end to end. +""" + +import json +from typing import Any, AsyncIterator +from unittest.mock import patch + +import httpx +import pytest + +from litellm import Router +from litellm.router_utils.fallback_event_handlers import ( + PRE_ROUTING_SELECTED_MODEL_KEY, + record_pre_routing_selection, +) + +REFUSAL_RESPONSE: dict[str, Any] = { + "id": "msg_refusal", + "type": "message", + "role": "assistant", + "model": "claude-fable-5", + "content": [], + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": {"category": "cyber", "explanation": "flagged"}, + "usage": {"input_tokens": 25, "output_tokens": 1}, +} + +PLAIN_REFUSAL_RESPONSE: dict[str, Any] = {k: v for k, v in REFUSAL_RESPONSE.items() if k != "stop_details"} + +OK_RESPONSE: dict[str, Any] = { + "id": "msg_ok", + "type": "message", + "role": "assistant", + "model": "claude-opus-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 25, "output_tokens": 2}, +} + + +def _sse(event: str, data: dict[str, Any]) -> bytes: + return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() + + +REFUSAL_STREAM_FRAMES: tuple[bytes, ...] = ( + _sse("message_start", {"type": "message_start", "message": {**REFUSAL_RESPONSE, "stop_reason": None}}), + _sse( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "refusal", "stop_details": {"category": "cyber"}}, + "usage": {"output_tokens": 1}, + }, + ), + _sse("message_stop", {"type": "message_stop"}), +) + +OK_STREAM_FRAMES: tuple[bytes, ...] = ( + _sse("message_start", {"type": "message_start", "message": {**OK_RESPONSE, "stop_reason": None}}), + _sse( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}, + ), + _sse("message_stop", {"type": "message_stop"}), +) + + +def _split_frames_mid_data_line(frames: tuple[bytes, ...]) -> tuple[bytes, ...]: + """Split each frame's data line in half, modeling a transport chunk boundary.""" + return tuple(part for frame in frames for part in (frame[: len(frame) // 2], frame[len(frame) // 2 :])) + + +class _FrameStream(httpx.AsyncByteStream): + def __init__(self, frames: tuple[bytes, ...]) -> None: + self._frames = frames + + async def __aiter__(self) -> AsyncIterator[bytes]: + for frame in self._frames: + yield frame + + async def aclose(self) -> None: + return None + + +class FakeAnthropicUpstream: + """Intercepts the third-party transport (httpx.AsyncClient.send): refuses on fable + models, answers on others. The router deliberately does not forward caller-injected + clients, so the transport is the seam that exercises the real litellm pipeline.""" + + def __init__( + self, + refusal_body: dict[str, Any] = REFUSAL_RESPONSE, + refusal_frames: tuple[bytes, ...] = REFUSAL_STREAM_FRAMES, + ) -> None: + self.refusal_body = refusal_body + self.refusal_frames = refusal_frames + self.calls: list[str] = [] + self.bodies: list[dict[str, Any]] = [] + + async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: + body = json.loads(request.content or b"{}") + model = body.get("model", "") + self.calls.append(model) + self.bodies.append(body) + refuses = "fable" in model + if body.get("stream"): + frames = self.refusal_frames if refuses else OK_STREAM_FRAMES + return httpx.Response( + 200, + stream=_FrameStream(frames), + headers={"content-type": "text/event-stream"}, + request=request, + ) + return httpx.Response(200, json=self.refusal_body if refuses else OK_RESPONSE, request=request) + + def install(self): + async def _send(_client: httpx.AsyncClient, request: httpx.Request, **kwargs: Any) -> httpx.Response: + return await self.send(request, **kwargs) + + return patch("httpx.AsyncClient.send", new=_send) + + +FABLE_TIER = { + "model_name": "fable-tier", + "litellm_params": {"model": "anthropic/claude-fable-5", "api_key": "sk-test"}, +} +OPUS_TARGET = { + "model_name": "opus-target", + "litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "sk-test"}, +} + + +def _router(content_policy_fallbacks: list | None) -> Router: + return Router(model_list=[FABLE_TIER, OPUS_TARGET], content_policy_fallbacks=content_policy_fallbacks) + + +async def _collect(stream: AsyncIterator[bytes]) -> bytes: + return b"".join([chunk async for chunk in stream]) + + +@pytest.mark.asyncio +async def test_non_streaming_refusal_with_fallback_row_returns_fallback_response(): + fake = FakeAnthropicUpstream() + router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) + + with fake.install(): + response = await router.aanthropic_messages( + model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}] + ) + + assert response["stop_reason"] == "end_turn" + assert response["id"] == "msg_ok" + assert len(fake.calls) == 2 + assert "claude-opus-5" in fake.calls[1] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content_policy_fallbacks, upstream_body", + [ + (None, REFUSAL_RESPONSE), + ([{"unrelated-group": ["opus-target"]}], REFUSAL_RESPONSE), + ([{"fable-tier": ["opus-target"]}], PLAIN_REFUSAL_RESPONSE), + ], + ids=["nothing-configured", "row-for-other-group", "refusal-without-stop-details"], +) +async def test_non_streaming_refusal_passes_through_untouched(content_policy_fallbacks, upstream_body): + fake = FakeAnthropicUpstream(refusal_body=upstream_body) + router = _router(content_policy_fallbacks=content_policy_fallbacks) + + with fake.install(): + response = await router.aanthropic_messages( + model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}] + ) + + assert response["stop_reason"] == "refusal" + assert response.get("stop_details") == upstream_body.get("stop_details") + assert len(fake.calls) == 1 + + +@pytest.mark.asyncio +async def test_streaming_refusal_with_fallback_row_streams_fallback_frames(): + fake = FakeAnthropicUpstream() + router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) + + with fake.install(): + stream = await router.aanthropic_messages( + model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}] + ) + body = await _collect(stream) + + assert b'"refusal"' not in body + assert b"text_delta" in body + assert len(fake.calls) == 2 + + +@pytest.mark.asyncio +async def test_streaming_refusal_split_across_chunks_still_falls_back(): + fake = FakeAnthropicUpstream(refusal_frames=_split_frames_mid_data_line(REFUSAL_STREAM_FRAMES)) + router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) + + with fake.install(): + stream = await router.aanthropic_messages( + model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}] + ) + body = await _collect(stream) + + assert b'"refusal"' not in body + assert b"text_delta" in body + assert len(fake.calls) == 2 + + +@pytest.mark.asyncio +async def test_streaming_refusal_without_fallback_row_passes_frames_through(): + fake = FakeAnthropicUpstream() + router = _router(content_policy_fallbacks=None) + + with fake.install(): + stream = await router.aanthropic_messages( + model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}] + ) + body = await _collect(stream) + + assert b'"stop_reason": "refusal"' in body + assert b"stop_details" in body + assert len(fake.calls) == 1 + + +@pytest.mark.asyncio +async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_inbound_metadata(): + """The pre-routing hook's tier stamp must reach the mid-stream fallback lookup even when the + request carries no metadata bucket at all (the snapshot is taken before the request runs).""" + fake = FakeAnthropicUpstream() + smart_router = { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"} + }, + "complexity_router_default_model": "fable-tier", + }, + "model_info": {"id": "router-1", "db_model": True}, + } + router = Router( + model_list=[FABLE_TIER, OPUS_TARGET, smart_router], + content_policy_fallbacks=[{"fable-tier": ["opus-target"]}], + ignore_invalid_deployments=True, + ) + + with fake.install(): + stream = await router.aanthropic_messages( + model="smart-router", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}] + ) + body = await _collect(stream) + + assert b'"refusal"' not in body + assert b"text_delta" in body + assert len(fake.calls) == 2 + + +@pytest.mark.asyncio +async def test_caller_forged_tier_stamp_cannot_pick_the_streaming_fallback_chain(): + fake = FakeAnthropicUpstream() + router = _router(content_policy_fallbacks=[{"forged-tier": ["opus-target"]}]) + + with fake.install(): + stream = await router.aanthropic_messages( + model="fable-tier", + max_tokens=16, + stream=True, + messages=[{"role": "user", "content": "hi"}], + litellm_metadata={PRE_ROUTING_SELECTED_MODEL_KEY: "forged-tier"}, + ) + body = await _collect(stream) + + assert b'"stop_reason": "refusal"' in body + assert len(fake.calls) == 1 + + +@pytest.mark.asyncio +async def test_tier_stamp_never_reaches_provider_bound_metadata(): + """On /v1/messages the top-level metadata dict is Anthropic's own request field, so the + routed-tier stamp must never appear in any upstream body even when the client sends one.""" + fake = FakeAnthropicUpstream() + smart_router = { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"} + }, + "complexity_router_default_model": "fable-tier", + }, + "model_info": {"id": "router-1", "db_model": True}, + } + router = Router( + model_list=[FABLE_TIER, OPUS_TARGET, smart_router], + content_policy_fallbacks=[{"fable-tier": ["opus-target"]}], + ignore_invalid_deployments=True, + ) + + with fake.install(): + response = await router.aanthropic_messages( + model="smart-router", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + metadata={"user_id": "u1"}, + ) + + assert response["stop_reason"] == "end_turn" + assert len(fake.bodies) == 2 + for body in fake.bodies: + assert body.get("metadata") == {"user_id": "u1"} + + +def test_record_pre_routing_selection_writes_only_the_internal_bucket(): + """The Anthropic request's own metadata field must never carry the tier stamp.""" + kwargs = {"metadata": {"user_id": "u1"}, "litellm_metadata": {}} + + record_pre_routing_selection(kwargs, "tier-x") + + assert kwargs["litellm_metadata"] == {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-x"} + assert kwargs["metadata"] == {"user_id": "u1"} + + +def test_refusal_gate_keys_on_pre_routing_tier_stamp(): + router = _router(content_policy_fallbacks=[{"tier-group": ["opus-target"]}]) + + def anthropic_messages(**kwargs: Any) -> None: + return None + + refusal_kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-group"}} + assert ( + router._should_raise_anthropic_refusal_error( + model="router-group", + original_generic_function=anthropic_messages, + response=dict(REFUSAL_RESPONSE), + kwargs=refusal_kwargs, + ) + is True + ) + assert ( + router._should_raise_anthropic_refusal_error( + model="router-group", + original_generic_function=anthropic_messages, + response=dict(REFUSAL_RESPONSE), + kwargs={}, + ) + is False + ) + + +def test_has_content_policy_fallback_default_fallbacks_arm(): + router = Router(model_list=[OPUS_TARGET], fallbacks=[{"*": ["opus-target"]}]) + + assert router._has_content_policy_fallback("any-group", {}) is True + assert router._has_content_policy_fallback("any-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False + + +def test_get_fallback_model_group_for_lookup_groups_orders_tier_before_requested(): + router = _router(content_policy_fallbacks=None) + fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}] + + assert router._get_fallback_model_group_for_lookup_groups( + fallbacks=fallbacks, lookup_groups=("tier1", "smart-router") + ) == ["backup-a"] + assert router._get_fallback_model_group_for_lookup_groups( + fallbacks=fallbacks, lookup_groups=("tier9", "smart-router") + ) == ["backup-b"] + assert router._get_fallback_model_group_for_lookup_groups(fallbacks=fallbacks, lookup_groups=()) is None + + +def test_refusal_gate_ignores_other_generic_call_types(): + router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) + + def aresponses(**kwargs: Any) -> None: + return None + + assert ( + router._should_raise_anthropic_refusal_error( + model="fable-tier", + original_generic_function=aresponses, + response=dict(REFUSAL_RESPONSE), + kwargs={}, + ) + is False + ) diff --git a/tests/router_unit_tests/test_router_helper_utils.py b/tests/router_unit_tests/test_router_helper_utils.py index dcd2e9edf7b..7dbac243d55 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -2294,6 +2294,7 @@ def search_tools(): "search_provider": "perplexity", "api_key": "test-api-key", "api_base": "https://api.perplexity.ai", + "mode": "turbo", }, }, { @@ -2302,6 +2303,7 @@ def search_tools(): "search_provider": "perplexity", "api_key": "test-api-key-2", "api_base": "https://api.perplexity.ai", + "mode": "turbo", }, }, ] @@ -2393,6 +2395,7 @@ async def test_asearch_with_fallbacks_helper(search_tools): assert "search_provider" in kwargs assert kwargs["search_provider"] == "perplexity" assert "api_key" in kwargs + assert kwargs["mode"] == "turbo" assert kwargs["query"] == "helper test query" return mock_response diff --git a/tests/sdk_function_trace/README.md b/tests/sdk_function_trace/README.md new file mode 100644 index 00000000000..37ce3cf8798 --- /dev/null +++ b/tests/sdk_function_trace/README.md @@ -0,0 +1,15 @@ +# Local SDK parity report + +From the repository root: + +```bash +uv run python -m tests.sdk_function_trace.local_parity +``` + +One invocation groups SDK response parity and function-trace parity by Rust SDK route, with separate sync and async rows. Every case is marked non-strict xfail: XPASS means the comparison matched; XFAIL prints a mismatch, unsupported operation, or missing-coverage reason. Ordinary parity failures do not fail the command; collection and runner errors still do + +SDK comparisons call the public Python SDK with `rust=False` and `rust=True` against a local HTTP mock provider. They require the native extension and verify Rust execution attribution so Python fallback cannot count as a match. Responses are compared after excluding hidden metadata and, for chat completions, generated IDs and creation timestamps. No provider credentials or running proxy are needed + +Implemented comparisons cover Mistral OCR and Anthropic chat completions/Messages. Function coverage currently reuses the Mistral OCR transformation trace tests. Other rows explicitly report coverage gaps; the report is not exhaustive provider, streaming, error, or request-shape coverage + +The module is outside pytest's default filename patterns and the entry point refuses to run when `CI` is set. Existing function-parity tests retain their normal failure behavior diff --git a/tests/sdk_function_trace/__init__.py b/tests/sdk_function_trace/__init__.py new file mode 100644 index 00000000000..da62b8041f6 --- /dev/null +++ b/tests/sdk_function_trace/__init__.py @@ -0,0 +1,13 @@ +from tests.sdk_function_trace.harness import ( + TraceScenario, + TraceStep, + assert_function_trace_parity, +) +from tests.sdk_function_trace.profiler import FunctionTraceEvent + +__all__ = [ + "FunctionTraceEvent", + "TraceScenario", + "TraceStep", + "assert_function_trace_parity", +] diff --git a/tests/sdk_function_trace/harness.py b/tests/sdk_function_trace/harness.py new file mode 100644 index 00000000000..8f707402449 --- /dev/null +++ b/tests/sdk_function_trace/harness.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from types import FunctionType +from typing import Final, cast + +from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python + + +@dataclass(frozen=True, slots=True) +class TraceStep: + function: FunctionType + depth: int + + +@dataclass(frozen=True, slots=True) +class TraceScenario: + steps: tuple[TraceStep, ...] + invoke_python: Callable[[], object] + invoke_rust: Callable[[], Sequence[FunctionTraceEvent]] + + +def assert_function_trace_parity(scenario: TraceScenario) -> None: + expected: Final = tuple( + FunctionTraceEvent(function=step.function.__name__, depth=step.depth) for step in scenario.steps + ) + functions: Final = cast(tuple[FunctionType, ...], tuple(step.function for step in scenario.steps)) + with profile_python(functions) as profiler: + scenario.invoke_python() + python_trace: Final = tuple(profiler.events) + rust_trace: Final = tuple(scenario.invoke_rust()) + + if python_trace != expected: + raise AssertionError(f"Python function trace differs: {python_trace!r} != {expected!r}") + if rust_trace != expected: + raise AssertionError(f"Rust function trace differs: {rust_trace!r} != {expected!r}") + if python_trace != rust_trace: + raise AssertionError(f"Python and Rust function traces differ: {python_trace!r} != {rust_trace!r}") diff --git a/tests/sdk_function_trace/local_parity.py b/tests/sdk_function_trace/local_parity.py new file mode 100644 index 00000000000..1cb8f60f58d --- /dev/null +++ b/tests/sdk_function_trace/local_parity.py @@ -0,0 +1,252 @@ +"""Local SDK parity report: uv run python -m tests.sdk_function_trace.local_parity.""" + +from __future__ import annotations + +import asyncio +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal + +import pytest +from _pytest._code.code import ExceptionRepr +from _pytest.terminal import TerminalReporter +from pydantic import BaseModel, JsonValue, TypeAdapter + +from tests.sdk_function_trace.mock_provider import MockProviderResponse, mock_provider + +Route = Literal["/ocr", "/chat/completions", "/v1/messages", "/audio/transcriptions", "/v1/responses (websocket)"] +Kind = Literal["SDK E2E", "Functions"] +JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +ROUTES: Final[tuple[Route, ...]] = ( + "/ocr", + "/chat/completions", + "/v1/messages", + "/audio/transcriptions", + "/v1/responses (websocket)", +) +KINDS: Final[tuple[Kind, ...]] = ("SDK E2E", "Functions") +ANTHROPIC_RESPONSE: Final = MockProviderResponse( + status_code=200, + headers=(("content-type", "application/json"),), + body=json.dumps( + { + "id": "msg_parity", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode(), +) + + +@dataclass(frozen=True, slots=True) +class Case: + route: Route + kind: Kind + asynchronous: bool + + @property + def label(self) -> str: + return f"{self.route} | {self.kind} | {'async' if self.asynchronous else 'sync'}" + + +CASES: Final = tuple( + Case(route, kind, asynchronous) for route in ROUTES for kind in KINDS for asynchronous in (False, True) +) + + +def invoke_sdk(case: Case, *, rust: bool, api_base: str) -> object: + import litellm + from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages, + anthropic_messages_handler, + ) + from tests.test_litellm.ocr import test_function_trace as ocr_trace + + match case.route: + case "/ocr": + if case.asynchronous: + return asyncio.run( + litellm.aocr( + model=f"mistral/{ocr_trace.MODEL}", + document=ocr_trace.DOCUMENT, + api_key="test-key", + api_base=api_base, + pages=[0], + rust=rust, + timeout=5, + ) + ) + return litellm.ocr( + model=f"mistral/{ocr_trace.MODEL}", + document=ocr_trace.DOCUMENT, + api_key="test-key", + api_base=api_base, + pages=[0], + rust=rust, + timeout=5, + ) + case "/chat/completions": + if case.asynchronous: + return asyncio.run( + litellm.acompletion( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + max_tokens=16, + api_key="test-key", + api_base=api_base, + rust=rust, + timeout=5, + num_retries=0, + ) + ) + return litellm.completion( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + max_tokens=16, + api_key="test-key", + api_base=api_base, + rust=rust, + timeout=5, + num_retries=0, + ) + case "/v1/messages": + if case.asynchronous: + return asyncio.run( + anthropic_messages( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + max_tokens=16, + api_key="test-key", + api_base=api_base, + rust=rust, + timeout=5, + ) + ) + return anthropic_messages_handler( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + max_tokens=16, + api_key="test-key", + api_base=api_base, + rust=rust, + timeout=5, + ) + case _: + pytest.xfail("SDK parity scenario not implemented for this route") + + +def response_body(response: object, *, rust: bool, route: Route) -> dict[str, JsonValue]: + raw: Final = response.model_dump(mode="json") if isinstance(response, BaseModel) else response + body: Final = JSON_OBJECT.validate_python(raw) + hidden: Final = JSON_OBJECT.validate_python(getattr(response, "_hidden_params", body.get("_hidden_params", {}))) + expected_engine: Final = "rust" if rust else "python" + if rust or route != "/chat/completions": + assert hidden.get("core_engine") == expected_engine, ( + f"expected {expected_engine} execution, got {hidden.get('core_engine')!r}" + ) + excluded: Final = frozenset( + {"_hidden_params", "id", "created"} if route == "/chat/completions" else {"_hidden_params"} + ) + return {key: value for key, value in body.items() if key not in excluded} + + +def sdk_response(case: Case, *, rust: bool) -> dict[str, JsonValue]: + from tests.test_litellm.ocr import test_function_trace as ocr_trace + + provider_response: Final = ocr_trace.PROVIDER_RESPONSE if case.route == "/ocr" else ANTHROPIC_RESPONSE + with mock_provider(provider_response) as api_base: + return response_body(invoke_sdk(case, rust=rust, api_base=api_base), rust=rust, route=case.route) + + +@pytest.mark.xfail(strict=False, reason="Local migration parity diagnostic") +@pytest.mark.parametrize("case", CASES, ids=tuple(case.label for case in CASES)) +def test_parity(case: Case) -> None: + if os.getenv("CI"): + pytest.skip("Local-only parity report") + from litellm.rust_bridge import get_native_bridge + from tests.test_litellm.ocr import test_function_trace as ocr_trace + + if case.kind == "Functions": + if case.route != "/ocr": + pytest.xfail("Function trace parity not implemented for this route") + ocr_trace.test_mistral_ocr_transformation_function_trace_parity(case.asynchronous) + return + if case.route == "/audio/transcriptions": + pytest.xfail("Bedrock transcription is Rust-only; no Python reference implementation") + if case.route == "/v1/responses (websocket)": + pytest.xfail("Websocket SDK parity scenario not implemented") + assert get_native_bridge() is not None, "Native Rust extension is required" + python: Final = sdk_response(case, rust=False) + native: Final = sdk_response(case, rust=True) + differences: Final = tuple( + key + for key in sorted(python.keys() | native.keys()) + if key not in python or key not in native or python[key] != native[key] + ) + assert python == native, f"Response mismatch in: {', '.join(differences)}\nPython: {python!r}\nRust: {native!r}" + + +class ParityReport: + def __init__(self) -> None: + self.reports: tuple[pytest.TestReport, ...] = () + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + if report.when == "call" or (report.when == "setup" and report.outcome != "passed"): + self.reports = (*self.reports, report) + + def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None: + terminalreporter.section("Local SDK parity: XPASS = matched, XFAIL = mismatch or coverage gap") + for route in ROUTES: + terminalreporter.write_line(f"\n{route}", bold=True) + for report in self.reports: + if not report.nodeid.split("[", 1)[-1].startswith(f"{route} |"): + continue + label: Final = report.nodeid.split(" | ", 1)[-1].removesuffix("]") + status: Final = ( + "XPASS" if report.passed else "XFAIL" if hasattr(report, "wasxfail") else report.outcome.upper() + ) + terminalreporter.write_line(f" {status:7} {label}", green=report.passed, yellow=not report.passed) + if report.longrepr: + reason: Final = ( + report.longrepr.reprcrash.message + if isinstance(report.longrepr, ExceptionRepr) and report.longrepr.reprcrash is not None + else str(report.longrepr) + ) + terminalreporter.write_line( + f" {reason.splitlines()[0].removeprefix('_pytest.outcomes.XFailed: ')}" + ) + + +def main() -> int: + if os.getenv("CI"): + print("Local-only parity report; refusing to run in CI") + return 0 + os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") + return int( + pytest.main( + [ + str(Path(__file__).resolve()), + "--disable-plugin-autoload", + "-q", + "--tb=no", + "--no-header", + "--disable-warnings", + "-W", + "ignore::pytest.PytestConfigWarning", + "-r", + "N", + ], + plugins=[ParityReport()], + ) + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/sdk_function_trace/mock_provider.py b/tests/sdk_function_trace/mock_provider.py new file mode 100644 index 00000000000..37eca665586 --- /dev/null +++ b/tests/sdk_function_trace/mock_provider.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Lock, Thread +from typing import Final, cast + + +@dataclass(frozen=True, slots=True) +class MockProviderResponse: + status_code: int + headers: tuple[tuple[str, str], ...] + body: bytes + + +class _MockProviderServer(ThreadingHTTPServer): + def __init__(self, response: MockProviderResponse) -> None: + super().__init__(("127.0.0.1", 0), _MockProviderHandler) + self.response: Final = response + self._request_count = 0 + self._request_count_lock: Final = Lock() + + def record_request(self) -> None: + with self._request_count_lock: + self._request_count += 1 + + @property + def request_count(self) -> int: + with self._request_count_lock: + return self._request_count + + +class _MockProviderHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + content_length: Final = int(self.headers.get("content-length", "0")) + self.rfile.read(content_length) + server: Final = cast(_MockProviderServer, self.server) + server.record_request() + self.send_response(server.response.status_code) + for name, value in server.response.headers: + self.send_header(name, value) + self.send_header("content-length", str(len(server.response.body))) + self.end_headers() + self.wfile.write(server.response.body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # matches BaseHTTPRequestHandler + pass + + +@contextmanager +def mock_provider(response: MockProviderResponse) -> Generator[str]: + server: Final = _MockProviderServer(response) + thread: Final = Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = cast(tuple[str, int], server.server_address) + try: + yield f"http://{host}:{port}" + finally: + server.shutdown() + server.server_close() + thread.join() + if server.request_count != 1: + raise AssertionError(f"expected one provider request, received {server.request_count}") diff --git a/tests/sdk_function_trace/profiler.py b/tests/sdk_function_trace/profiler.py new file mode 100644 index 00000000000..77c78582087 --- /dev/null +++ b/tests/sdk_function_trace/profiler.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import sys +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from types import FrameType, FunctionType +from typing import Final + + +@dataclass(frozen=True, slots=True) +class FunctionTraceEvent: + function: str + depth: int + + +class PythonProfiler: + def __init__(self, functions: Sequence[FunctionType]) -> None: + self._names_by_code: Final = {function.__code__: function.__name__ for function in functions} + self._seen_frames: Final[set[FrameType]] = set() + self.events: Final[list[FunctionTraceEvent]] = [] + + def __call__(self, frame: FrameType, event: str, _arg: object) -> None: + if event != "call" or frame in self._seen_frames: + return + function_name: Final = self._names_by_code.get(frame.f_code) + if function_name is None: + return + depth: Final = sum(ancestor.f_code in self._names_by_code for ancestor in _frame_ancestors(frame)) + self._seen_frames.add(frame) + self.events.append(FunctionTraceEvent(function=function_name, depth=depth)) + + +def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: + ancestor: Final = frame.f_back + if ancestor is not None: + yield ancestor + yield from _frame_ancestors(ancestor) + + +@contextmanager +def profile_python(functions: Sequence[FunctionType]) -> Generator[PythonProfiler]: + profiler: Final = PythonProfiler(functions) + previous: Final = sys.getprofile() + sys.setprofile(profiler) + try: + yield profiler + finally: + sys.setprofile(previous) diff --git a/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py b/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py index fbd7e36e298..f1a4d584ab6 100644 --- a/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py +++ b/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py @@ -1,13 +1,16 @@ """Tests for the optional Rust-backed Anthropic Messages path.""" import importlib +from types import ModuleType from typing import cast import httpx import pytest import litellm +from litellm.exceptions import APIError from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.rust_bridge import bindings, configuration from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) @@ -99,20 +102,38 @@ class ExplodingAsyncMessages: class RaisingAsyncMessages: - def __init__(self) -> None: + def __init__(self, error: Exception) -> None: self.calls = 0 + self.error = error async def __call__(self, **kwargs: object) -> dict[str, object]: self.calls += 1 - raise RuntimeError("upstream request failed with status 400: bad request") + raise self.error + + +class FakeBridgeDeclined(Exception): + pass + + +class FakeUpstreamError(Exception): + pass + + +def _install_fake_bridge_exceptions(monkeypatch) -> None: + native_bridge = ModuleType("_native") + native_bridge.RustBridgeDeclined = FakeBridgeDeclined + native_bridge.RustUpstreamError = FakeUpstreamError + monkeypatch.setattr(rust_bridge_loader, "_cached_bridge", native_bridge) @pytest.fixture(autouse=True) def _reset_rust_flag(): - litellm.use_litellm_rust(False, messages=None, amessages=None) + rust_messages.set_rust_messages(messages=None, amessages=None) + configuration.reset_rust_configuration() rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL yield - litellm.use_litellm_rust(False, messages=None, amessages=None) + rust_messages.set_rust_messages(messages=None, amessages=None) + configuration.reset_rust_configuration() rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL @@ -122,17 +143,6 @@ def test_load_rust_messages_returns_injected_impl(): assert rust_messages.load_rust_messages() is bridge -def test_configuring_messages_does_not_enable_ocr(): - from litellm.rust_bridge.ocr import rust_ocr_enabled - - litellm.use_litellm_rust(False) - assert rust_ocr_enabled() is False - - litellm.use_litellm_rust(True, messages=RecordingMessages()) - - assert rust_ocr_enabled() is False - - def test_bare_use_litellm_rust_still_toggles_ocr(): from litellm.rust_bridge.ocr import rust_ocr_enabled @@ -150,11 +160,7 @@ def test_load_rust_amessages_returns_injected_impl(): def test_messages_wrapper_returns_none_when_bridge_absent(monkeypatch): - monkeypatch.setattr( - importlib.import_module("litellm.rust_bridge"), - "get_native_bridge", - lambda: None, - ) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) litellm.use_litellm_rust(True) assert rust_messages.load_rust_messages() is None result = rust_messages.messages( @@ -232,7 +238,7 @@ def _gate(**overrides): @pytest.mark.asyncio -async def test_gate_invokes_rust_and_marks_response_header(): +async def test_gate_invokes_rust(): bridge = RecordingAsyncMessages() litellm.use_litellm_rust(True, amessages=bridge) @@ -240,7 +246,6 @@ async def test_gate_invokes_rust_and_marks_response_header(): assert response is not None assert response["id"] == "msg_123" - assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"} call = bridge.calls[0] assert call["model"] == "claude-sonnet-4-5" assert call["body"] == REQUEST_BODY @@ -251,8 +256,9 @@ async def test_gate_invokes_rust_and_marks_response_header(): @pytest.mark.asyncio -async def test_gate_falls_back_to_python_when_bridge_raises(): - bridge = RaisingAsyncMessages() +async def test_gate_falls_back_only_when_bridge_declines(monkeypatch): + _install_fake_bridge_exceptions(monkeypatch) + bridge = RaisingAsyncMessages(FakeBridgeDeclined("unsupported request")) litellm.use_litellm_rust(True, amessages=bridge) response = await _gate() @@ -261,10 +267,49 @@ async def test_gate_falls_back_to_python_when_bridge_raises(): assert bridge.calls == 1 +@pytest.mark.asyncio +async def test_gate_surfaces_an_upstream_failure_without_fallback(monkeypatch): + _install_fake_bridge_exceptions(monkeypatch) + bridge = RaisingAsyncMessages(FakeUpstreamError(429, "429: rate limited")) + litellm.use_litellm_rust(True, amessages=bridge) + + with pytest.raises(APIError) as exc_info: + await _gate() + + assert exc_info.value.status_code == 429 + assert "429: rate limited" in str(exc_info.value) + assert bridge.calls == 1 + + +@pytest.mark.asyncio +async def test_gate_maps_statusless_upstream_failure_to_500_without_fallback(monkeypatch): + _install_fake_bridge_exceptions(monkeypatch) + bridge = RaisingAsyncMessages(FakeUpstreamError(0, "request timed out")) + litellm.use_litellm_rust(True, amessages=bridge) + + with pytest.raises(APIError) as exc_info: + await _gate() + + assert exc_info.value.status_code == 500 + assert "request timed out" in str(exc_info.value) + assert bridge.calls == 1 + + +@pytest.mark.asyncio +async def test_gate_reraises_an_unknown_bridge_failure(): + bridge = RaisingAsyncMessages(RuntimeError("unknown bridge failure")) + litellm.use_litellm_rust(True, amessages=bridge) + + with pytest.raises(RuntimeError, match="unknown bridge failure"): + await _gate() + + assert bridge.calls == 1 + + @pytest.mark.asyncio async def test_gate_skips_rust_when_flag_absent(): bridge = ExplodingAsyncMessages() - litellm.use_litellm_rust(True, amessages=bridge) + rust_messages.set_rust_messages(amessages=bridge) response = await _gate(litellm_params=GenericLiteLLMParams(api_key="sk-azure")) @@ -272,6 +317,18 @@ async def test_gate_skips_rust_when_flag_absent(): assert bridge.calls == 0 +@pytest.mark.asyncio +async def test_gate_uses_process_enable_without_request_override(): + bridge = RecordingAsyncMessages() + rust_messages.set_rust_messages(amessages=bridge) + litellm.use_litellm_rust(True) + + response = await _gate(litellm_params=GenericLiteLLMParams(api_key="sk-azure")) + + assert response is not None + assert bridge.calls[0]["custom_llm_provider"] == "azure_ai" + + @pytest.mark.asyncio async def test_gate_skips_rust_when_flag_false(): bridge = ExplodingAsyncMessages() @@ -297,7 +354,6 @@ async def test_gate_invokes_rust_for_native_anthropic_provider(): ) assert response is not None - assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"} assert bridge.calls[0]["custom_llm_provider"] == "anthropic" assert bridge.calls[0]["api_key"] == "sk-ant" @@ -305,7 +361,7 @@ async def test_gate_invokes_rust_for_native_anthropic_provider(): @pytest.mark.asyncio async def test_gate_invokes_rust_when_env_var_set(monkeypatch): bridge = RecordingAsyncMessages() - litellm.use_litellm_rust(True, amessages=bridge) + rust_messages.set_rust_messages(amessages=bridge) monkeypatch.setenv("LITELLM_RUST", "1") response = await _gate( @@ -320,7 +376,7 @@ async def test_gate_invokes_rust_when_env_var_set(monkeypatch): @pytest.mark.asyncio async def test_gate_env_var_falsey_does_not_enable(monkeypatch): bridge = ExplodingAsyncMessages() - litellm.use_litellm_rust(True, amessages=bridge) + rust_messages.set_rust_messages(amessages=bridge) monkeypatch.setenv("LITELLM_RUST", "0") response = await _gate( @@ -366,7 +422,6 @@ async def test_gate_streams_through_rust_when_eligible_and_strips_stream_flag(): ) assert response is not None - assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"} assert "stream" not in bridge.calls[0]["body"] assert bridge.calls[0]["body"] == REQUEST_BODY @@ -376,7 +431,11 @@ async def test_fake_stream_wraps_rust_response_as_anthropic_sse(): response = cast(AnthropicMessagesResponse, dict(FAKE_MESSAGES_RESPONSE)) stream = BaseLLMHTTPHandler._rust_anthropic_messages_fake_stream(response) - assert stream._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} + assert stream._hidden_params["core_engine"] == "rust" + assert stream._hidden_params["additional_headers"] == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } chunks = [chunk async for chunk in stream] joined = b"".join(chunks) @@ -389,11 +448,7 @@ async def test_fake_stream_wraps_rust_response_as_anthropic_sse(): @pytest.mark.asyncio async def test_gate_falls_back_when_bridge_unavailable(monkeypatch): - monkeypatch.setattr( - importlib.import_module("litellm.rust_bridge"), - "get_native_bridge", - lambda: None, - ) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) litellm.use_litellm_rust(True) response = await _gate() diff --git a/tests/test_litellm/experimental_mcp_client/test_tools.py b/tests/test_litellm/experimental_mcp_client/test_tools.py index 89f67452f29..6645b06664d 100644 --- a/tests/test_litellm/experimental_mcp_client/test_tools.py +++ b/tests/test_litellm/experimental_mcp_client/test_tools.py @@ -8,11 +8,13 @@ from mcp.types import ( CallToolRequestParams, CallToolResult, ListToolsResult, + PaginatedRequestParams, TextContent, ) from mcp.types import Tool as MCPTool from litellm.experimental_mcp_client.tools import ( + list_tools_with_pagination, transform_mcp_tool_to_anthropic_tool, _get_function_arguments, _normalize_mcp_input_schema, @@ -106,6 +108,134 @@ async def test_load_mcp_tools_openai_format(mock_session, mock_list_tools_result mock_session.list_tools.assert_called_once() +@pytest.mark.asyncio() +async def test_load_mcp_tools_follows_pagination(mock_session): + mock_session.list_tools.side_effect = [ + ListToolsResult( + tools=[ + MCPTool(name="tool_a", description="a", inputSchema={}), + MCPTool(name="tool_b", description="b", inputSchema={}), + ], + nextCursor="page-2", + ), + ListToolsResult(tools=[MCPTool(name="tool_c", description="c", inputSchema={})]), + ] + result = await load_mcp_tools(mock_session, format="mcp") + assert [tool.name for tool in result] == ["tool_a", "tool_b", "tool_c"] + assert mock_session.list_tools.call_count == 2 + second_call_params = mock_session.list_tools.call_args_list[1].kwargs["params"] + assert isinstance(second_call_params, PaginatedRequestParams) + assert second_call_params.cursor == "page-2" + + +@pytest.mark.asyncio() +async def test_pagination_walk_stops_at_page_cap(mock_session, monkeypatch): + monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_TOOL_LISTING_MAX_PAGES", 2) + mock_session.list_tools.side_effect = [ + ListToolsResult( + tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + nextCursor="page-2", + ), + ListToolsResult( + tools=[MCPTool(name="tool_1", description="1", inputSchema={})], + nextCursor="page-3", + ), + ListToolsResult(tools=[MCPTool(name="tool_2", description="2", inputSchema={})]), + ] + result = await list_tools_with_pagination(mock_session) + assert [tool.name for tool in result] == ["tool_0", "tool_1"] + assert mock_session.list_tools.call_count == 2 + + +@pytest.mark.asyncio() +async def test_pagination_walk_stops_on_repeated_cursor(mock_session): + mock_session.list_tools.side_effect = [ + ListToolsResult( + tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + nextCursor="same-cursor", + ), + ListToolsResult( + tools=[MCPTool(name="tool_1", description="1", inputSchema={})], + nextCursor="same-cursor", + ), + ] + result = await list_tools_with_pagination(mock_session) + assert [tool.name for tool in result] == ["tool_0", "tool_1"] + assert mock_session.list_tools.call_count == 2 + + +@pytest.mark.asyncio() +async def test_pagination_walk_treats_empty_cursor_as_terminal(mock_session): + mock_session.list_tools.side_effect = [ + ListToolsResult( + tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + nextCursor="", + ), + ] + result = await list_tools_with_pagination(mock_session) + assert [tool.name for tool in result] == ["tool_0"] + mock_session.list_tools.assert_called_once() + + +@pytest.mark.asyncio() +async def test_pagination_walk_stops_at_whole_walk_deadline(mock_session, monkeypatch): + import anyio + + from litellm.experimental_mcp_client.tools import list_tools_with_pagination + + monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_CLIENT_TIMEOUT", 0.2) + monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_TOOL_LISTING_TIMEOUT", 0.2) + + async def slow_page(params=None): + await anyio.sleep(0.15) + idx = int(params.cursor) if params is not None else 0 + return ListToolsResult( + tools=[MCPTool(name=f"tool_{idx}", description=str(idx), inputSchema={})], + nextCursor=str(idx + 1), + ) + + mock_session.list_tools = slow_page + result = await list_tools_with_pagination(mock_session) + + assert [tool.name for tool in result] == ["tool_0"] + + +@pytest.mark.asyncio() +async def test_pagination_walk_honors_explicit_deadline_over_globals(mock_session, monkeypatch): + import anyio + + from litellm.experimental_mcp_client.tools import list_tools_with_pagination + + monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_CLIENT_TIMEOUT", 0.1) + monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_TOOL_LISTING_TIMEOUT", 0.1) + + async def slow_page(params=None): + await anyio.sleep(0.15) + idx = int(params.cursor) if params is not None else 0 + tools = [MCPTool(name=f"tool_{idx}", description=str(idx), inputSchema={})] + if idx == 0: + return ListToolsResult(tools=tools, nextCursor="1") + return ListToolsResult(tools=tools) + + mock_session.list_tools = slow_page + result = await list_tools_with_pagination(mock_session, listing_deadline=2.0) + + assert [tool.name for tool in result] == ["tool_0", "tool_1"] + + +@pytest.mark.asyncio() +async def test_load_mcp_tools_openai_format_spans_pages(mock_session): + mock_session.list_tools.side_effect = [ + ListToolsResult( + tools=[MCPTool(name="tool_a", description="a", inputSchema={})], + nextCursor="page-2", + ), + ListToolsResult(tools=[MCPTool(name="tool_b", description="b", inputSchema={})]), + ] + result = await load_mcp_tools(mock_session, format="openai") + assert [t["function"]["name"] for t in result] == ["tool_a", "tool_b"] + + def test_get_function_arguments(): # Test with string arguments function = {"arguments": '{"test": "value"}'} diff --git a/tests/test_litellm/integrations/SlackAlerting/test_user_spend_alerts.py b/tests/test_litellm/integrations/SlackAlerting/test_user_spend_alerts.py new file mode 100644 index 00000000000..45e1acecec8 --- /dev/null +++ b/tests/test_litellm/integrations/SlackAlerting/test_user_spend_alerts.py @@ -0,0 +1,193 @@ +import datetime +from typing import Final +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import ValidationError + +from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting +from litellm.integrations.SlackAlerting.user_spend_alerts import ( + UserSpendRow, + evaluate_user_spend, +) +from litellm.types.integrations.slack_alerting import ( + DEFAULT_ALERT_TYPES, + AlertType, + SlackAlertingArgs, +) + +TODAY: Final = datetime.date(2026, 8, 15) + + +def _row( + daily_spend: float = 0.0, + monthly_spend: float = 0.0, + baseline_spend: float = 0.0, +) -> UserSpendRow: + return UserSpendRow( + user_id="user-1", + daily_spend=daily_spend, + monthly_spend=monthly_spend, + baseline_spend=baseline_spend, + ) + + +def _evaluate(row: UserSpendRow, args: SlackAlertingArgs, thresholds: bool = True, anomalies: bool = True): + return evaluate_user_spend( + row=row, + args=args, + today=TODAY, + thresholds_enabled=thresholds, + anomalies_enabled=anomalies, + ) + + +def test_daily_threshold_crossed(): + args: Final = SlackAlertingArgs(daily_spend_per_user_threshold=50.0, spend_anomaly_min_spend=1000.0) + events: Final = _evaluate(_row(daily_spend=75.0, monthly_spend=75.0), args) + assert [e.kind for e in events] == ["daily_threshold"] + assert "`$75.00`" in events[0].message + assert "`$50.00`" in events[0].message + assert events[0].alert_type == AlertType.user_spend_thresholds + assert events[0].cache_key == "user_spend_alert_daily_user-1_2026-08-15" + + +def test_daily_threshold_not_crossed(): + args: Final = SlackAlertingArgs(daily_spend_per_user_threshold=50.0, spend_anomaly_min_spend=1000.0) + assert _evaluate(_row(daily_spend=49.99, monthly_spend=49.99), args) == () + + +def test_thresholds_unset_by_default(): + args: Final = SlackAlertingArgs(spend_anomaly_min_spend=1000.0) + assert _evaluate(_row(daily_spend=999.0, monthly_spend=999.0), args) == () + + +def test_monthly_threshold_crossed(): + args: Final = SlackAlertingArgs(monthly_spend_per_user_threshold=200.0, spend_anomaly_min_spend=1000.0) + events: Final = _evaluate(_row(daily_spend=5.0, monthly_spend=250.0), args) + assert [e.kind for e in events] == ["monthly_threshold"] + assert events[0].cache_key == "user_spend_alert_monthly_user-1_2026-08" + + +def test_thresholds_disabled_suppresses_threshold_events(): + args: Final = SlackAlertingArgs( + daily_spend_per_user_threshold=50.0, + monthly_spend_per_user_threshold=200.0, + spend_anomaly_min_spend=1000.0, + ) + assert _evaluate(_row(daily_spend=75.0, monthly_spend=250.0), args, thresholds=False) == () + + +def test_anomaly_detected_above_multiple_of_baseline(): + args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0) + events: Final = _evaluate( + _row(daily_spend=70.0, monthly_spend=100.0, baseline_spend=70.0), args + ) + assert [e.kind for e in events] == ["anomaly"] + assert events[0].alert_type == AlertType.user_spend_anomalies + assert "`$10.00`" in events[0].message + assert events[0].cache_key == "user_spend_alert_anomaly_user-1_2026-08-15" + + +def test_no_anomaly_within_baseline_multiple(): + args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0) + assert ( + _evaluate(_row(daily_spend=25.0, monthly_spend=100.0, baseline_spend=70.0), args) == () + ) + + +def test_no_anomaly_below_min_spend_floor(): + args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0) + assert _evaluate(_row(daily_spend=9.0, monthly_spend=9.0, baseline_spend=0.1), args) == () + + +def test_anomaly_for_new_user_without_baseline(): + args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0) + events: Final = _evaluate(_row(daily_spend=15.0, monthly_spend=15.0), args) + assert [e.kind for e in events] == ["anomaly"] + + +def test_sparse_baseline_averages_over_full_window(): + args: Final = SlackAlertingArgs( + spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0, spend_anomaly_baseline_days=7 + ) + events: Final = _evaluate(_row(daily_spend=13.0, monthly_spend=20.0, baseline_spend=7.0), args) + assert [e.kind for e in events] == ["anomaly"] + + +def test_anomalies_not_in_default_alert_types(): + assert AlertType.user_spend_anomalies not in DEFAULT_ALERT_TYPES + assert AlertType.user_spend_thresholds in DEFAULT_ALERT_TYPES + + +def test_invalid_config_rejected(): + with pytest.raises(ValidationError, match="daily_spend_per_user_threshold"): + SlackAlertingArgs(daily_spend_per_user_threshold=0) + with pytest.raises(ValidationError, match="spend_anomaly_baseline_days"): + SlackAlertingArgs(spend_anomaly_baseline_days=0) + with pytest.raises(ValidationError, match="user_spend_check_interval"): + SlackAlertingArgs(user_spend_check_interval=10) + + +def test_non_finite_config_rejected(): + with pytest.raises(ValidationError, match="daily_spend_per_user_threshold"): + SlackAlertingArgs(daily_spend_per_user_threshold=float("inf")) + with pytest.raises(ValidationError, match="spend_anomaly_multiplier"): + SlackAlertingArgs(spend_anomaly_multiplier=float("nan")) + with pytest.raises(ValidationError, match="spend_anomaly_min_spend"): + SlackAlertingArgs(spend_anomaly_min_spend=float("inf")) + with pytest.raises(ValidationError, match="user_spend_check_interval"): + SlackAlertingArgs(user_spend_check_interval=float("inf")) + + +def test_anomalies_disabled_suppresses_anomaly_events(): + args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0) + assert _evaluate(_row(daily_spend=500.0, monthly_spend=500.0), args, anomalies=False) == () + + +@pytest.mark.asyncio +async def test_send_user_spend_alerts_sends_and_dedupes(): + slack_alerting: Final = SlackAlerting( + alerting=["slack"], + alerting_args={"daily_spend_per_user_threshold": 50.0, "spend_anomaly_min_spend": 1000.0}, + ) + mock_prisma: Final = AsyncMock() + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "user_id": "user-1", + "daily_spend": 75.0, + "monthly_spend": 75.0, + "baseline_spend": 0.0, + }, + { + "user_id": "user-2", + "daily_spend": 60.0, + "monthly_spend": 60.0, + "baseline_spend": 0.0, + }, + ] + ) + with patch.object(slack_alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert: + await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma) + assert mock_send_alert.call_count == 1 + sent_kwargs: Final = mock_send_alert.call_args.kwargs + assert sent_kwargs["alert_type"] == AlertType.user_spend_thresholds + assert "User Daily Spend Threshold Crossed" in sent_kwargs["message"] + assert "`user-1`" in sent_kwargs["message"] + assert "`user-2`" in sent_kwargs["message"] + + await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma) + assert mock_send_alert.call_count == 1 + + +@pytest.mark.asyncio +async def test_send_user_spend_alerts_noop_when_alert_types_disabled(): + slack_alerting: Final = SlackAlerting( + alerting=["slack"], + alert_types=[AlertType.budget_alerts], + alerting_args={"daily_spend_per_user_threshold": 50.0}, + ) + mock_prisma: Final = AsyncMock() + await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma) + mock_prisma.db.query_raw.assert_not_called() diff --git a/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py b/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py new file mode 100644 index 00000000000..2d0605e3b7f --- /dev/null +++ b/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py @@ -0,0 +1,469 @@ +""" +Regression tests for the Datadog LLM Observability payload schema (issue #35786). + +Datadog renders tool calls, tool results and prompt-cache savings only from the fields its +own schema names. These assert on the payload `create_llm_obs_payload` actually hands the +intake, so a regression that moves data back into `meta.metadata` fails here. + +Fixtures mirror what a live proxy run recorded on the callback, including the provider +spelling of prompt-cache counts (`prompt_tokens_details.cached_tokens`). +""" + +import json +import os +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest + +from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + +TOOL_DEFINITION: dict[str, Any] = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, +} + +ASSISTANT_TOOL_CALL: dict[str, Any] = { + "id": "call_abc123", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"Paris","unit":"c"}'}, +} + + +@pytest.fixture +def logger() -> DataDogLLMObsLogger: + with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True): + with patch("asyncio.create_task"): + return DataDogLLMObsLogger() + + +NOT_GIVEN: Any = object() + + +def build_payload( + messages: Any = NOT_GIVEN, + response_message: dict[str, Any] | None = None, + usage_object: dict[str, Any] | None = None, + model_parameters: dict[str, Any] | None = None, + prompt_tokens: int = 4447, +) -> dict[str, Any]: + return { + "standard_logging_object": { + "call_type": "acompletion", + "messages": [{"role": "user", "content": "hi"}] if messages is NOT_GIVEN else messages, + "response": {"choices": [{"message": response_message or {"role": "assistant", "content": "hello"}}]}, + "model_parameters": model_parameters or {}, + "metadata": {"usage_object": usage_object} if usage_object is not None else {}, + "prompt_tokens": prompt_tokens, + "completion_tokens": 507, + "total_tokens": prompt_tokens + 507, + "response_cost": 0.02, + "status": "success", + }, + "litellm_params": {"metadata": {}}, + } + + +def build(logger: DataDogLLMObsLogger, **kwargs: Any) -> dict[str, Any]: + """Build a span and read it back as the JSON the intake receives, not as Python objects.""" + start = datetime(2026, 9, 1, 12, 0, 0) + payload = logger.create_llm_obs_payload(build_payload(**kwargs), start, start + timedelta(seconds=2)) + return json.loads(safe_dumps(payload)) + + +def test_output_tool_calls_use_the_datadog_tool_call_schema(logger: DataDogLLMObsLogger) -> None: + """Datadog reads name/arguments/tool_id off the tool call; OpenAI nests them under `function`.""" + payload = build( + logger, + response_message={"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]}, + ) + + message = payload["meta"]["output"]["messages"][0] + assert message["tool_calls"] == [ + { + "name": "get_weather", + "arguments": {"city": "Paris", "unit": "c"}, + "tool_id": "call_abc123", + "type": "function", + } + ] + assert "function" not in message["tool_calls"][0] + + +def test_tool_calls_are_not_duplicated_into_metadata(logger: DataDogLLMObsLogger) -> None: + """The flat `output_tool_calls.*` keys were a second copy of a fact that now has its own field.""" + payload = build( + logger, + response_message={"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]}, + ) + + assert [key for key in payload["meta"]["metadata"] if "tool_calls." in key] == [] + + +def test_tool_result_message_links_back_to_its_tool_call(logger: DataDogLLMObsLogger) -> None: + """Datadog pairs a result with its call through tool_id, and names the tool from the call.""" + payload = build( + logger, + messages=[ + {"role": "user", "content": "Weather in Paris?"}, + {"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]}, + {"role": "tool", "tool_call_id": "call_abc123", "content": '{"temp_c": 18}'}, + ], + ) + + tool_message = payload["meta"]["input"]["messages"][2] + assert tool_message["tool_results"] == [ + {"name": "get_weather", "result": '{"temp_c": 18}', "tool_id": "call_abc123", "type": "function"} + ] + + +def test_tool_result_without_a_matching_call_still_reports_its_id(logger: DataDogLLMObsLogger) -> None: + """A truncated conversation loses the call, so the name is unknown but the link must survive.""" + payload = build( + logger, + messages=[{"role": "tool", "tool_call_id": "call_orphan", "content": "42"}], + ) + + assert payload["meta"]["input"]["messages"][0]["tool_results"] == [ + {"name": "", "result": "42", "tool_id": "call_orphan", "type": "function"} + ] + + +def test_cache_tokens_are_reported_as_span_metrics(logger: DataDogLLMObsLogger) -> None: + """ + Datadog charts cache savings from span metrics; nested usage_object is not read for it. + + litellm's normalized prompt count includes both cache categories, so the three cache + metrics must partition input_tokens: read + write + non_cached == input. + """ + payload = build( + logger, + usage_object={"prompt_tokens_details": {"cached_tokens": 4300, "cache_write_tokens": 95}}, + ) + + metrics = payload["metrics"] + assert metrics["cache_read_input_tokens"] == 4300.0 + assert metrics["cache_write_input_tokens"] == 95.0 + assert metrics["non_cached_input_tokens"] == 4447.0 - 4300.0 - 95.0 + assert ( + metrics["cache_read_input_tokens"] + metrics["cache_write_input_tokens"] + metrics["non_cached_input_tokens"] + == metrics["input_tokens"] + ) + + +def test_cache_write_tokens_are_not_counted_as_non_cached(logger: DataDogLLMObsLogger) -> None: + """A cache-priming request must not report its primed prefix as full-price uncached input.""" + payload = build(logger, usage_object={"prompt_tokens_details": {"cache_write_tokens": 4000}}) + + assert payload["metrics"]["cache_write_input_tokens"] == 4000.0 + assert payload["metrics"]["non_cached_input_tokens"] == 4447.0 - 4000.0 + assert "cache_read_input_tokens" not in payload["metrics"] + + +def test_a_fully_cached_request_reports_a_zero_non_cached_count(logger: DataDogLLMObsLogger) -> None: + """Zero residual is real data: everything was served from cache. Inconsistent counts clamp to it.""" + payload = build( + logger, + usage_object={"prompt_tokens_details": {"cached_tokens": 4352, "cache_write_tokens": 95}}, + ) + + assert payload["metrics"]["non_cached_input_tokens"] == 0.0 + + +def test_anthropic_top_level_cache_keys_are_read(logger: DataDogLLMObsLogger) -> None: + """A raw Anthropic usage dict records the counts top level, not under prompt_tokens_details.""" + payload = build( + logger, + usage_object={"cache_read_input_tokens": 4300, "cache_creation_input_tokens": 95}, + ) + + metrics = payload["metrics"] + assert metrics["cache_read_input_tokens"] == 4300.0 + assert metrics["cache_write_input_tokens"] == 95.0 + assert metrics["non_cached_input_tokens"] == 4447.0 - 4300.0 - 95.0 + + +def test_cache_metrics_come_from_the_normalized_field_not_the_anthropic_one(logger: DataDogLLMObsLogger) -> None: + """ + litellm normalizes every provider's cache counters into prompt_tokens_details. + + A real cached request from a non-Anthropic provider carries only `cached_tokens`, so + reading the Anthropic-specific `cache_read_input_tokens` key reports nothing for it. + """ + payload = build( + logger, + usage_object={"prompt_tokens_details": {"audio_tokens": None, "cached_tokens": 4096}}, + prompt_tokens=4335, + ) + + assert payload["metrics"]["cache_read_input_tokens"] == 4096.0 + assert payload["metrics"]["non_cached_input_tokens"] == 4335.0 - 4096.0 + + +@pytest.mark.parametrize( + "usage_object", + [ + {"prompt_tokens_details": {"cache_write_tokens": 95}}, + {"prompt_tokens_details": {"cache_creation_tokens": 95}}, + {"cache_creation_input_tokens": 95}, + ], +) +def test_every_spelling_of_cache_write_tokens_is_read( + logger: DataDogLLMObsLogger, usage_object: dict[str, Any] +) -> None: + """A raw usage dict that bypassed litellm's normalizer can carry any provider's spelling.""" + payload = build(logger, usage_object=usage_object) + + assert payload["metrics"]["cache_write_input_tokens"] == 95.0 + + +def test_a_cache_read_does_not_emit_a_zero_cache_write(logger: DataDogLLMObsLogger) -> None: + """A zero write on every cache-read span would drag Datadog's cache-write average to nothing.""" + payload = build(logger, usage_object={"prompt_tokens_details": {"cached_tokens": 4096}}) + + assert payload["metrics"]["cache_read_input_tokens"] == 4096.0 + assert "cache_write_input_tokens" not in payload["metrics"] + + +def test_no_cache_keys_when_the_provider_reports_no_caching(logger: DataDogLLMObsLogger) -> None: + """An uncached request must not gain zero-valued cache metrics that dilute cache dashboards.""" + payload = build(logger, usage_object={"prompt_tokens_details": None}) + + assert "cache_read_input_tokens" not in payload["metrics"] + assert "cache_write_input_tokens" not in payload["metrics"] + assert "non_cached_input_tokens" not in payload["metrics"] + + +def test_tool_definitions_are_sent_on_meta(logger: DataDogLLMObsLogger) -> None: + payload = build(logger, model_parameters={"tools": [TOOL_DEFINITION]}) + + assert payload["meta"]["tool_definitions"] == [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + } + ] + + +def test_tool_definitions_accept_the_bare_anthropic_shape(logger: DataDogLLMObsLogger) -> None: + """The Anthropic surface declares tools unwrapped, with input_schema instead of parameters.""" + payload = build( + logger, + model_parameters={"tools": [{"name": "get_weather", "description": "d", "input_schema": {"type": "object"}}]}, + ) + + assert payload["meta"]["tool_definitions"] == [ + {"name": "get_weather", "description": "d", "schema": {"type": "object"}} + ] + + +def test_meta_omits_tool_definitions_when_no_tools_were_offered(logger: DataDogLLMObsLogger) -> None: + assert "tool_definitions" not in build(logger)["meta"] + + +def test_unparseable_tool_arguments_are_preserved_rather_than_dropped(logger: DataDogLLMObsLogger) -> None: + """A truncated argument string is still the only record of what the model tried to call.""" + payload = build( + logger, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": '{"city":'}}], + }, + ) + + assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == '{"city":' + + +def test_oversized_tool_arguments_ship_unparsed(logger: DataDogLLMObsLogger) -> None: + """ + Decoding attacker-sized compact JSON multiplies memory for a span that is only logging. + + This payload is perfectly valid JSON, so the only reason it arrives as a string is the + size bound; a smaller copy of the same shape comes back as an object below. + """ + oversized = '{"a":"' + "x" * 300_000 + '"}' + + payload = build( + logger, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": oversized}}], + }, + ) + + assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == oversized + + +def test_valid_arguments_below_the_bound_still_parse(logger: DataDogLLMObsLogger) -> None: + """The size bound must not swallow ordinary arguments; this is the oversized test's control.""" + payload = build( + logger, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "c1", "type": "function", "function": {"name": "f", "arguments": '{"a":"' + "x" * 64 + '"}'}} + ], + }, + ) + + assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == {"a": "x" * 64} + + +def test_a_result_is_named_even_when_its_call_had_unparseable_arguments(logger: DataDogLLMObsLogger) -> None: + """Correlating a result to its call reads ids and names, so bad arguments cannot break linking.""" + payload = build( + logger, + messages=[ + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{"}} + ], + }, + {"role": "tool", "tool_call_id": "call_abc123", "content": "18C"}, + ], + ) + + assert payload["meta"]["input"]["messages"][1]["tool_results"] == [ + {"name": "get_weather", "result": "18C", "tool_id": "call_abc123", "type": "function"} + ] + + +def test_deeply_nested_tool_arguments_do_not_drop_the_span(logger: DataDogLLMObsLogger) -> None: + """json.loads raises RecursionError, not JSONDecodeError, on hostile nesting.""" + payload = build( + logger, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "[" * 50_000}}], + }, + ) + + assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == "[" * 50_000 + + +def test_tool_arguments_that_parse_to_a_non_object_stay_a_string(logger: DataDogLLMObsLogger) -> None: + """Datadog types arguments as an object, so a bare JSON scalar must not land there as one.""" + payload = build( + logger, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "42"}}], + }, + ) + + assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == "42" + + +def test_a_tool_without_a_name_is_not_offered_as_a_definition(logger: DataDogLLMObsLogger) -> None: + """A nameless tool cannot be matched to a call, so it is dropped rather than sent blank.""" + payload = build(logger, model_parameters={"tools": [{"function": {"description": "no name"}}, TOOL_DEFINITION]}) + + assert [tool["name"] for tool in payload["meta"]["tool_definitions"]] == ["get_weather"] + + +def test_a_tool_definition_without_a_schema_omits_the_field(logger: DataDogLLMObsLogger) -> None: + """An empty schema object would read as a tool that takes no arguments, which is a different claim.""" + payload = build(logger, model_parameters={"tools": [{"name": "ping", "description": "d"}]}) + + assert payload["meta"]["tool_definitions"] == [{"name": "ping", "description": "d"}] + + +def test_a_non_dict_message_still_reaches_datadog(logger: DataDogLLMObsLogger) -> None: + """Callers can log arbitrary message payloads, and dropping the span over one loses the request.""" + payload = build(logger, messages=["just a bare string"]) + + assert payload["meta"]["input"]["messages"] == [{"input": "just a bare string"}] + + +def test_messages_logged_as_a_bare_string_still_reach_datadog(logger: DataDogLLMObsLogger) -> None: + payload = build(logger, messages="the whole prompt as one string") + + assert payload["meta"]["input"]["messages"] == [{"input": "the whole prompt as one string"}] + + +def test_non_chat_call_types_log_an_empty_input(logger: DataDogLLMObsLogger) -> None: + """Embedding and image calls carry no messages; fabricating an "None" turn misreads in Datadog.""" + payload = build(logger, messages=None) + + assert payload["meta"]["input"]["messages"] == [] + + +def test_anthropic_tool_blocks_map_to_tool_calls_and_results(logger: DataDogLLMObsLogger) -> None: + """/v1/messages carries tool traffic as content blocks, not OpenAI fields.""" + payload = build( + logger, + messages=[ + {"role": "user", "content": [{"type": "text", "text": "Weather in Tokyo?"}]}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Tokyo"}}], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "18C"}]}, + ], + ) + + assistant, result_turn = payload["meta"]["input"]["messages"][1:3] + assert assistant["tool_calls"] == [ + {"name": "get_weather", "arguments": {"city": "Tokyo"}, "tool_id": "toolu_1", "type": "tool_use"} + ] + assert result_turn["tool_results"] == [ + {"name": "get_weather", "result": "18C", "tool_id": "toolu_1", "type": "function"} + ] + + +def test_content_with_no_text_parts_is_preserved_not_blanked(logger: DataDogLLMObsLogger) -> None: + """A content list the mapper does not understand must ride along, not be erased.""" + blocks = [{"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}] + payload = build(logger, messages=[{"role": "user", "content": blocks}]) + + assert payload["meta"]["input"]["messages"][0]["content"] == blocks + + +def test_multimodal_content_parts_are_flattened_to_text(logger: DataDogLLMObsLogger) -> None: + """Datadog types Message.content as a string, so content lists collapse to their text.""" + payload = build( + logger, + messages=[ + {"role": "user", "content": [{"type": "text", "text": "describe "}, {"type": "text", "text": "this"}]} + ], + ) + + assert payload["meta"]["input"]["messages"][0]["content"] == "describe this" + + +def test_mapping_input_messages_does_not_mutate_the_shared_payload(logger: DataDogLLMObsLogger) -> None: + """Sibling callbacks read the same messages list, so flattening must not write through it.""" + messages: list[dict[str, Any]] = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + kwargs = build_payload(messages=messages) + start = datetime(2026, 9, 1, 12, 0, 0) + + logger.create_llm_obs_payload(kwargs, start, start + timedelta(seconds=1)) + + assert messages[0]["content"] == [{"type": "text", "text": "hi"}] + + +def test_reasoning_content_survives_the_mapping(logger: DataDogLLMObsLogger) -> None: + payload = build( + logger, + response_message={"role": "assistant", "content": "answer", "reasoning_content": "thinking"}, + ) + + assert payload["meta"]["output"]["messages"][0]["reasoning_content"] == "thinking" diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index ca628aa3405..99d706a9c44 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -541,6 +541,74 @@ def test_llm_call_adapter_extracts_cache_tokens_from_usage_object(): assert data.usage.cache_read_input_tokens == 3 +def test_llm_call_adapter_normalizes_nested_cache_tokens(): + cases: Final = ( + ({"prompt_tokens_details": {"cached_tokens": 3}}, 3, None), + ({"prompt_cache_hit_tokens": 11}, 11, None), + ({"prompt_tokens_details": {"cache_write_tokens": 7}}, None, 7), + ({"prompt_tokens_details": {"cache_creation_tokens": 13}}, None, 13), + ({"prompt_tokens_details": {"cache_creation_input_tokens": 17}}, None, 17), + ) + for usage_object, expected_read, expected_creation in cases: + case_payload = _sample_payload(metadata={"usage_object": usage_object}) + data = LLMCallSpanData.from_standard_logging_payload(case_payload) + assert data.usage.cache_read_input_tokens == expected_read + assert data.usage.cache_creation_input_tokens == expected_creation + + +def test_llm_call_adapter_prefers_nested_count_over_zero_top_level(): + payload = _sample_payload( + metadata={ + "usage_object": { + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "prompt_tokens_details": {"cached_tokens": 5, "cache_write_tokens": 7}, + } + } + ) + data = LLMCallSpanData.from_standard_logging_payload(payload) + assert data.usage.cache_read_input_tokens == 5 + assert data.usage.cache_creation_input_tokens == 7 + + +def test_llm_call_adapter_ignores_invalid_cache_values_before_valid_fallbacks(): + payload = _sample_payload( + metadata={ + "usage_object": { + "cache_read_input_tokens": -1, + "cache_creation_input_tokens": "5.0", + "prompt_tokens_details": {"cached_tokens": 5, "cache_write_tokens": 7}, + } + } + ) + data = LLMCallSpanData.from_standard_logging_payload(payload) + assert data.usage.cache_read_input_tokens == 5 + assert data.usage.cache_creation_input_tokens == 7 + + +def test_llm_call_adapter_ignores_non_finite_cache_values(): + payload = _sample_payload( + metadata={ + "usage_object": { + "prompt_tokens_details": {"cached_tokens": float("nan")}, + } + } + ) + data = LLMCallSpanData.from_standard_logging_payload(payload) + assert data.usage.cache_read_input_tokens is None + + +def test_llm_call_adapter_preserves_explicit_zero_and_omits_missing_cache_tokens(): + for usage_object, expected_read, expected_creation in ( + ({"prompt_tokens_details": {"cached_tokens": 0}}, 0, None), + ({}, None, None), + ): + case_payload = _sample_payload(metadata={"usage_object": usage_object}) + data = LLMCallSpanData.from_standard_logging_payload(case_payload) + assert data.usage.cache_read_input_tokens == expected_read + assert data.usage.cache_creation_input_tokens == expected_creation + + def test_llm_call_adapter_cache_tokens_none_without_usage_object(): data = LLMCallSpanData.from_standard_logging_payload(_sample_payload()) assert data.usage.cache_creation_input_tokens is None diff --git a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py index 029b097cb75..ea661d2ea78 100644 --- a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py +++ b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py @@ -93,6 +93,7 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent(): logger._increment_token_metrics = MagicMock() logger._increment_remaining_budget_metrics = AsyncMock() logger._set_virtual_key_rate_limit_metrics = MagicMock() + logger._set_key_and_team_rate_limit_metrics = MagicMock() logger._set_latency_metrics = MagicMock() logger.set_llm_deployment_success_metrics = MagicMock() logger._increment_cache_metrics = MagicMock() diff --git a/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py index 9c6d2e018ff..bf1d68c7714 100644 --- a/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py +++ b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py @@ -13,6 +13,7 @@ Covers two follow-up gaps to the unified rate-limit error work: 429s don't silently break when the new class lands. """ +from collections.abc import Mapping from unittest.mock import MagicMock, patch import pytest @@ -471,3 +472,254 @@ def test_should_ignore_non_int_v3_header_values(bad_value): logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( sys.maxsize ) + + +KEY_AND_TEAM_RATE_LIMIT_METRICS = ( + "litellm_api_key_rate_limit_allowed_metric", + "litellm_api_key_rate_limit_used_metric", + "litellm_team_rate_limit_allowed_metric", + "litellm_team_rate_limit_used_metric", +) + + +def _clear_prometheus_registry() -> None: + from prometheus_client import REGISTRY + + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + + +def _collected_samples(metric_name: str) -> dict[tuple[tuple[str, str], ...], float]: + from prometheus_client import REGISTRY + + return { + tuple(sorted(sample.labels.items())): sample.value + for metric in REGISTRY.collect() + for sample in metric.samples + if sample.name == metric_name + } + + +def _success_kwargs_with_rate_limit_headers(additional_headers: Mapping[str, object] | None) -> dict[str, object]: + return { + "model": "claude-haiku-4-5", + "litellm_params": {"metadata": {}}, + "standard_logging_object": { + "id": "t", + "call_type": "completion", + "response_cost": 0.001, + "status": "success", + "total_tokens": 20, + "prompt_tokens": 15, + "completion_tokens": 5, + "startTime": 1.0, + "endTime": 2.0, + "completionStartTime": 1.5, + "model": "claude-haiku-4-5", + "model_id": "model-123", + "model_group": "anthropic-haiku-4-5", + "api_base": "https://api.anthropic.com", + "custom_llm_provider": "anthropic", + "request_tags": [], + "end_user": None, + "cache_hit": False, + "stream": False, + "response": None, + "model_parameters": None, + "metadata": { + "user_api_key_hash": "key-hash", + "user_api_key_alias": "key-alias", + "user_api_key_team_id": "team-id", + "user_api_key_team_alias": "team-alias", + "user_api_key_user_id": "u", + "user_api_key_user_email": "e@x.com", + "user_api_key_org_id": None, + "user_api_key_org_alias": None, + "requester_metadata": None, + "user_api_key_end_user_id": None, + "usage_object": None, + }, + "hidden_params": { + "litellm_overhead_time_ms": None, + "additional_headers": additional_headers, + }, + }, + } + + +async def _run_success_event( + additional_headers: Mapping[str, object] | None, logger: PrometheusLogger | None = None +) -> None: + import datetime + + now = datetime.datetime.now() + await (logger or PrometheusLogger()).async_log_success_event( + _success_kwargs_with_rate_limit_headers(additional_headers), None, now, now + ) + + +@pytest.mark.asyncio +async def test_should_emit_key_and_team_rate_limit_allowed_and_used_from_v3_headers(): + """ + LIT-1672: the v3 limiter mirrors ``x-ratelimit-{api_key,team}-{limit,remaining}-*`` + into the logging payload. The gauges must expose the configured limit as-is + and the window consumption as ``limit - remaining`` for each key / team + dimension, split by ``rate_limit_type``. + """ + _clear_prometheus_registry() + try: + await _run_success_event( + { + "x-ratelimit-api_key-limit-requests": 10, + "x-ratelimit-api_key-remaining-requests": 7, + "x-ratelimit-api_key-limit-tokens": 20000, + "x-ratelimit-api_key-remaining-tokens": 19947, + "x-ratelimit-team-limit-requests": 50, + "x-ratelimit-team-remaining-requests": 47, + "x-ratelimit-team-limit-tokens": 40000, + "x-ratelimit-team-remaining-tokens": 39960, + "x-ratelimit-model_per_key-limit-requests": 5, + "x-ratelimit-model_per_key-remaining-requests": 1, + } + ) + + key_requests = ( + ("api_key_alias", "key-alias"), + ("hashed_api_key", "key-hash"), + ("rate_limit_type", "requests"), + ) + key_tokens = ( + ("api_key_alias", "key-alias"), + ("hashed_api_key", "key-hash"), + ("rate_limit_type", "tokens"), + ) + team_requests = ( + ("rate_limit_type", "requests"), + ("team", "team-id"), + ("team_alias", "team-alias"), + ) + team_tokens = ( + ("rate_limit_type", "tokens"), + ("team", "team-id"), + ("team_alias", "team-alias"), + ) + + assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == { + key_requests: 10, + key_tokens: 20000, + } + assert _collected_samples("litellm_api_key_rate_limit_used_metric") == { + key_requests: 3, + key_tokens: 53, + } + assert _collected_samples("litellm_team_rate_limit_allowed_metric") == { + team_requests: 50, + team_tokens: 40000, + } + assert _collected_samples("litellm_team_rate_limit_used_metric") == { + team_requests: 3, + team_tokens: 40, + } + finally: + _clear_prometheus_registry() + + +@pytest.mark.asyncio +async def test_should_emit_only_the_dimensions_the_limiter_enforced(): + """ + A key with only ``rpm_limit`` set and no team limits produces only the + key/requests headers, so no tokens series and no team series may appear + (a phantom 0 or sys.maxsize series would misreport an unlimited dimension). + """ + _clear_prometheus_registry() + try: + await _run_success_event( + { + "x-ratelimit-api_key-limit-requests": 10, + "x-ratelimit-api_key-remaining-requests": 10, + } + ) + + key_requests = ( + ("api_key_alias", "key-alias"), + ("hashed_api_key", "key-hash"), + ("rate_limit_type", "requests"), + ) + assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == {key_requests: 10} + assert _collected_samples("litellm_api_key_rate_limit_used_metric") == {key_requests: 0} + assert _collected_samples("litellm_team_rate_limit_allowed_metric") == {} + assert _collected_samples("litellm_team_rate_limit_used_metric") == {} + finally: + _clear_prometheus_registry() + + +@pytest.mark.asyncio +async def test_should_drop_key_and_team_series_once_the_limiter_stops_reporting_a_limit(): + """ + Removing a key's ``rpm_limit`` / ``tpm_limit`` (or a team's ``tpm_limit``) + makes the v3 limiter stop emitting that descriptor's headers on later + requests. The old allowed/used samples must disappear instead of keeping + a limit that no longer exists on the scrape. + """ + _clear_prometheus_registry() + try: + logger = PrometheusLogger() + await _run_success_event( + { + "x-ratelimit-api_key-limit-requests": 10, + "x-ratelimit-api_key-remaining-requests": 7, + "x-ratelimit-api_key-limit-tokens": 20000, + "x-ratelimit-api_key-remaining-tokens": 19947, + "x-ratelimit-team-limit-requests": 50, + "x-ratelimit-team-remaining-requests": 47, + "x-ratelimit-team-limit-tokens": 40000, + "x-ratelimit-team-remaining-tokens": 39960, + }, + logger=logger, + ) + await _run_success_event( + { + "x-ratelimit-team-limit-requests": 50, + "x-ratelimit-team-remaining-requests": 46, + }, + logger=logger, + ) + + team_requests = ( + ("rate_limit_type", "requests"), + ("team", "team-id"), + ("team_alias", "team-alias"), + ) + assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == {} + assert _collected_samples("litellm_api_key_rate_limit_used_metric") == {} + assert _collected_samples("litellm_team_rate_limit_allowed_metric") == {team_requests: 50} + assert _collected_samples("litellm_team_rate_limit_used_metric") == {team_requests: 4} + finally: + _clear_prometheus_registry() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "additional_headers", + [ + None, + {"x-ratelimit-model_per_key-remaining-requests": 42}, + {"x-ratelimit-api_key-limit-requests": 10}, + {"x-ratelimit-api_key-limit-requests": "10", "x-ratelimit-api_key-remaining-requests": "7"}, + {"x-ratelimit-team-limit-tokens": True, "x-ratelimit-team-remaining-tokens": 5}, + ], +) +async def test_should_emit_no_key_or_team_rate_limit_series_without_a_complete_int_pair( + additional_headers, +): + _clear_prometheus_registry() + try: + await _run_success_event(additional_headers) + + for metric_name in KEY_AND_TEAM_RATE_LIMIT_METRICS: + assert _collected_samples(metric_name) == {}, metric_name + finally: + _clear_prometheus_registry() diff --git a/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py b/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py new file mode 100644 index 00000000000..519a13751f1 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py @@ -0,0 +1,279 @@ +""" +LIT-6611: every unique client-supplied model name that fails routing used to +mint permanent Prometheus series carrying ``requested_model=""`` on the +proxy request metrics and the deployment metrics, with no eviction. The fix +collapses any requested model the router does not recognize (and no wildcard +pattern matches) into the single ``other`` label bucket, while recognized +names, aliases, and wildcard-matched names keep their own label values. +""" + +import sys +import types +from unittest.mock import patch + +import pytest +from prometheus_client import REGISTRY + +import litellm +from litellm.integrations.prometheus import ( + UNRECOGNIZED_REQUESTED_MODEL_LABEL, + PrometheusLogger, +) +from litellm.proxy._types import UserAPIKeyAuth + + +class _ClientSideError(Exception): + status_code = 400 + + +@pytest.fixture(autouse=True) +def cleanup_prometheus_registry(): + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + + yield + + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + + +@pytest.fixture +def router(): + return litellm.Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake-key"}, + }, + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*", "api_key": "fake-key"}, + }, + ], + model_group_alias={"gpt4o-alias": "gpt-4o-mini"}, + ) + + +@pytest.fixture +def team_router(): + return litellm.Router( + model_list=[ + { + "model_name": "team-internal-gpt", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake-key"}, + "model_info": {"team_id": "team-1", "team_public_model_name": "team-alias-gpt"}, + }, + { + "model_name": "team-internal-bedrock", + "litellm_params": {"model": "openai/*", "api_key": "fake-key"}, + "model_info": {"team_id": "team-1", "team_public_model_name": "team-models/*"}, + }, + ] + ) + + +def _requested_model_values(metric) -> set[str]: + index = metric._labelnames.index("requested_model") + return {sample_key[index] for sample_key in metric._metrics} + + +def _series_count(metric) -> int: + return len(metric._metrics) + + +def _total_value(metric) -> float: + return sum(child._value.get() for child in metric._metrics.values()) + + +async def _fire_proxy_failure(logger: PrometheusLogger, model: str) -> None: + await logger.async_post_call_failure_hook( + request_data={"model": model, "metadata": {}, "proxy_server_request": {}}, + original_exception=_ClientSideError(f"model {model} does not exist"), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key-1"), + ) + + +@pytest.mark.asyncio +async def test_unknown_models_collapse_to_one_series_on_proxy_request_metrics(router): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + for index in range(25): + await _fire_proxy_failure(logger, f"agent-typo-{index}") + + for metric in ( + logger.litellm_proxy_failed_requests_metric, + logger.litellm_proxy_total_requests_metric, + ): + assert _requested_model_values(metric) == {UNRECOGNIZED_REQUESTED_MODEL_LABEL} + assert _series_count(metric) == 1 + assert _total_value(metric) == 25 + + +@pytest.mark.asyncio +async def test_known_alias_and_wildcard_models_keep_their_own_labels(router): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + await _fire_proxy_failure(logger, "gpt-4o-mini") + await _fire_proxy_failure(logger, "gpt4o-alias") + await _fire_proxy_failure(logger, "openai/gpt-4o-audio-preview") + await _fire_proxy_failure(logger, "agent-typo-hallucinated") + + for metric in ( + logger.litellm_proxy_failed_requests_metric, + logger.litellm_proxy_total_requests_metric, + ): + assert _requested_model_values(metric) == { + "gpt-4o-mini", + "gpt4o-alias", + "openai/gpt-4o-audio-preview", + UNRECOGNIZED_REQUESTED_MODEL_LABEL, + } + + +@pytest.mark.asyncio +async def test_team_alias_and_team_wildcard_models_keep_their_own_labels(team_router): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", team_router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + await _fire_proxy_failure(logger, "team-alias-gpt") + await _fire_proxy_failure(logger, "team-models/gpt-4o-audio-preview") + await _fire_proxy_failure(logger, "agent-typo-hallucinated") + + for metric in ( + logger.litellm_proxy_failed_requests_metric, + logger.litellm_proxy_total_requests_metric, + ): + assert _requested_model_values(metric) == { + "team-alias-gpt", + "team-models/gpt-4o-audio-preview", + UNRECOGNIZED_REQUESTED_MODEL_LABEL, + } + + +@pytest.mark.asyncio +async def test_unknown_models_collapse_to_other_when_router_is_unavailable(): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", None, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + await _fire_proxy_failure(logger, "agent-typo-no-router") + await _fire_proxy_failure(logger, "gpt-4o-mini") + + assert _requested_model_values(logger.litellm_proxy_failed_requests_metric) == { + UNRECOGNIZED_REQUESTED_MODEL_LABEL + } + + +@pytest.mark.asyncio +async def test_sdk_router_originated_metrics_keep_labels_without_proxy_router(): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", None, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + logger.set_llm_deployment_failure_metrics( + request_kwargs={ + "model": "sdk-deployment-group", + "litellm_params": {"metadata": {}}, + "standard_logging_object": {}, + "exception": _ClientSideError("model does not exist"), + } + ) + await logger.log_failure_fallback_event( + original_model_group="sdk-fallback-group", + kwargs={"model": "sdk-fallback-group", "metadata": {}}, + original_exception=_ClientSideError("upstream unavailable"), + ) + + assert _requested_model_values(logger.litellm_deployment_failure_responses) == {"sdk-deployment-group"} + assert _requested_model_values(logger.litellm_deployment_failed_fallbacks) == {"sdk-fallback-group"} + + +@pytest.mark.asyncio +async def test_sdk_fallback_labels_survive_non_import_errors_from_proxy_module(monkeypatch): + logger = PrometheusLogger() + broken_proxy_module = types.ModuleType("litellm.proxy.proxy_server") + + def _raise_value_error(_name: str): + raise ValueError("bad proxy env var") + + broken_proxy_module.__getattr__ = _raise_value_error # test-quality-ok: reproduces a proxy_server import raising non-ImportError, no injection seam + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", broken_proxy_module) # test-quality-ok: reproduces a proxy_server import raising non-ImportError, no injection seam + + await logger.log_failure_fallback_event( + original_model_group="sdk-fallback-group", + kwargs={"model": "sdk-fallback-group", "metadata": {}}, + original_exception=_ClientSideError("upstream unavailable"), + ) + + assert _requested_model_values(logger.litellm_deployment_failed_fallbacks) == {"sdk-fallback-group"} + + +def test_unknown_models_collapse_to_one_series_on_deployment_metrics(router): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + for index in range(25): + logger.set_llm_deployment_failure_metrics( + request_kwargs={ + "model": f"agent-typo-{index}", + "litellm_params": {"metadata": {}}, + "standard_logging_object": {}, + "exception": _ClientSideError("model does not exist"), + } + ) + logger.set_llm_deployment_failure_metrics( + request_kwargs={ + "model": "gpt-4o-mini", + "litellm_params": {"metadata": {}}, + "standard_logging_object": {}, + "exception": _ClientSideError("all deployments cooling down"), + } + ) + + for metric in ( + logger.litellm_deployment_failure_responses, + logger.litellm_deployment_total_requests, + ): + assert _requested_model_values(metric) == { + UNRECOGNIZED_REQUESTED_MODEL_LABEL, + "gpt-4o-mini", + } + assert _series_count(metric) == 2 + assert _total_value(metric) == 26 + + +@pytest.mark.asyncio +async def test_fallback_event_requested_model_is_bounded(router): + logger = PrometheusLogger() + kwargs = {"model": "gpt-4o-mini", "metadata": {}} + + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + await logger.log_failure_fallback_event( + original_model_group="agent-typo-hallucinated", + kwargs=kwargs, + original_exception=_ClientSideError("model does not exist"), + ) + await logger.log_success_fallback_event( + original_model_group="agent-typo-hallucinated", + kwargs=kwargs, + original_exception=_ClientSideError("model does not exist"), + ) + await logger.log_failure_fallback_event( + original_model_group="gpt-4o-mini", + kwargs=kwargs, + original_exception=_ClientSideError("upstream unavailable"), + ) + + assert _requested_model_values(logger.litellm_deployment_failed_fallbacks) == { + UNRECOGNIZED_REQUESTED_MODEL_LABEL, + "gpt-4o-mini", + } + assert _requested_model_values(logger.litellm_deployment_successful_fallbacks) == { + UNRECOGNIZED_REQUESTED_MODEL_LABEL + } diff --git a/tests/test_litellm/integrations/test_s3.py b/tests/test_litellm/integrations/test_s3.py index 7e997870852..58b15b79e76 100644 --- a/tests/test_litellm/integrations/test_s3.py +++ b/tests/test_litellm/integrations/test_s3.py @@ -2,26 +2,27 @@ from datetime import datetime from unittest.mock import MagicMock, patch import litellm +from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES, MAX_S3_OBJECT_KEY_BYTES from litellm.integrations.s3 import S3Logger TEST_KMS_KEY_ARN = "arn:aws:kms:us-east-1:111122223333:key/test-key-id" -def _standard_logging_payload() -> dict: +def _standard_logging_payload(response_id: str = "chatcmpl-test-id") -> dict: return { - "id": "chatcmpl-test-id", + "id": response_id, "metadata": {"user_api_key_team_alias": None}, } -def _log_event_kwargs() -> dict: +def _log_event_kwargs(response_id: str = "chatcmpl-test-id") -> dict: return { "litellm_params": {"metadata": {}}, - "standard_logging_object": _standard_logging_payload(), + "standard_logging_object": _standard_logging_payload(response_id), } -def _run_log_event(callback_params: dict) -> MagicMock: +def _run_log_event(callback_params: dict, response_id: str = "chatcmpl-test-id") -> MagicMock: original = litellm.s3_callback_params litellm.s3_callback_params = callback_params try: @@ -30,8 +31,8 @@ def _run_log_event(callback_params: dict) -> MagicMock: mock_boto3_client.return_value = mock_s3_client logger = S3Logger() logger.log_event( - kwargs=_log_event_kwargs(), - response_obj={}, + kwargs=_log_event_kwargs(response_id), + response_obj={"id": response_id}, start_time=datetime(2026, 7, 30, 12, 0, 0), end_time=datetime(2026, 7, 30, 12, 0, 1), print_verbose=lambda *args, **kwargs: None, @@ -154,3 +155,30 @@ def test_non_string_key_id_is_dropped_and_valid_algorithm_is_kept(): put_object_kwargs = mock_s3_client.put_object.call_args.kwargs assert put_object_kwargs["ServerSideEncryption"] == "aws:kms" assert "SSEKMSKeyId" not in put_object_kwargs + + +def test_put_object_key_and_filename_are_bounded_for_an_oversized_response_id(): + """The sync logger bounds both the key and the Content-Disposition filename.""" + mock_s3_client = _run_log_event( + {"s3_bucket_name": "test-bucket", "s3_region_name": "us-west-2", "s3_path": "logs"}, + response_id="resp_" + "A" * 1100, + ) + + put_object_kwargs = mock_s3_client.put_object.call_args.kwargs + assert len(put_object_kwargs["Key"].encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert put_object_kwargs["Key"].startswith("logs/2026-07-30/time-12-00-00-000000_resp_") + filename = put_object_kwargs["ContentDisposition"].removeprefix('inline; filename="').removesuffix('"') + assert len(filename.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES + + +def test_put_object_keeps_the_configured_path_intact_when_only_the_id_has_to_shrink(): + """A long configured s3_path survives whole when the id can be shortened instead.""" + long_path = "litellm-prod-logs/" + "t" * 921 + mock_s3_client = _run_log_event( + {"s3_bucket_name": "test-bucket", "s3_region_name": "us-west-2", "s3_path": long_path}, + response_id="resp_" + "B" * 100, + ) + + key = mock_s3_client.put_object.call_args.kwargs["Key"] + assert key.startswith(long_path + "/2026-07-30/") + assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index 51671d5101e..a037284d7c1 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -1170,6 +1170,294 @@ def test_create_s3_batch_logging_element_flat_key_for_arn_response_id(): assert file_segment.endswith("model-invocation-job_gl18r6skk9yy.json") +# -------------------------------------------------------------- +# object keys bounded to S3's 1024 UTF-8 byte limit +# -------------------------------------------------------------- +def _oversized_response_id() -> str: + return "resp_" + "A" * 1100 + + +def test_s3_object_key_at_the_byte_limit_is_left_alone(): + """A key that still fits is left byte-identical.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + start_time = datetime(2026, 8, 24, 6, 18, 41, 948021) + fixed_len = len("input/2026-08-24/.json") + file_name = "x" * (MAX_S3_OBJECT_KEY_BYTES - fixed_len) + + key = get_s3_object_key(s3_path="input", prefix="", start_time=start_time, s3_file_name=file_name) + + assert key == f"input/2026-08-24/{file_name}.json" + assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES + + +def test_s3_object_key_is_bounded_for_oversized_response_id(): + """An oversized Responses API id is shortened to a readable head plus a digest.""" + import hashlib + + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + start_time = datetime(2026, 8, 24, 6, 18, 41, 948021) + file_name = f"time-06-18-41-948021_{_oversized_response_id()}" + + key = get_s3_object_key(s3_path="input", prefix="DefaultTeamProd/", start_time=start_time, s3_file_name=file_name) + + assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert key.startswith("input/DefaultTeamProd/2026-08-24/time-06-18-41-948021_resp_") + assert key.endswith(f"_{hashlib.sha256(file_name.encode('utf-8')).hexdigest()}.json") + + +@pytest.mark.parametrize( + "s3_path,prefix", + [ + ("input", ""), + ("a" * 900, ""), + ("input", "team-" + "b" * 900 + "/"), + ("c" * 600, "team-" + "d" * 600 + "/key-" + "e" * 600 + "/"), + # many short segments, so the trim lands exactly on the budget edge + ("", "ssss/" * 200), + ], +) +def test_s3_object_key_is_bounded_for_long_paths_and_aliases(s3_path: str, prefix: str): + """Long paths, team aliases and key aliases stay within the cap.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + key = get_s3_object_key( + s3_path=s3_path, + prefix=prefix, + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}", + ) + + assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert key.endswith(".json") + assert "/2026-08-24/" in key or key.startswith("2026-08-24/") + assert "/" not in key.rsplit("2026-08-24/", 1)[1] + + +def test_s3_object_key_trimmed_prefixes_stay_distinct_per_operator(): + """Prefixes that differ only past the trim point keep separate folders.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + start_time = datetime(2026, 8, 24, 6, 18, 41, 948021) + keys = [ + get_s3_object_key( + s3_path="input", + prefix="team-" + "b" * 1000 + suffix + "/", + start_time=start_time, + s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}", + ) + for suffix in ("-one", "-two") + ] + + assert keys[0] != keys[1] + assert all(key.startswith("input/team-" + "b" * 900) for key in keys) + assert all(len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES for key in keys) + + +def test_s3_object_key_bounded_prefix_never_splits_a_multibyte_character(): + """A multibyte prefix is trimmed on a character boundary.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + s3_path = "\u65e5\u672c\u8a9e" * 200 + + key = get_s3_object_key( + s3_path=s3_path, + prefix="\u30c1\u30fc\u30e0" * 200 + "/", + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}", + ) + + assert len(key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert key.startswith(s3_path[:100]) + assert "\ufffd" not in key + + +def test_s3_object_key_stays_unique_for_ids_sharing_a_head(): + """Ids sharing a visible head still get distinct keys.""" + from litellm.integrations.s3 import get_s3_object_key + + start_time = datetime(2026, 8, 24, 6, 18, 41, 948021) + keys = { + get_s3_object_key( + s3_path="input", + prefix="", + start_time=start_time, + s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}{suffix}", + ) + for suffix in ("first", "second", "third") + } + + assert len(keys) == 3 + + +def test_s3_object_key_bounding_matches_the_documented_layout(): + """The bounded key is `//_.json`.""" + import hashlib + + from litellm.integrations.s3 import get_s3_object_key + + file_name = f"time-06-18-41-948021_{_oversized_response_id()}" + + key = get_s3_object_key( + s3_path="input", + prefix="team/", + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name=file_name, + ) + + digest = hashlib.sha256(file_name.encode("utf-8")).hexdigest() + assert key == f"input/team/2026-08-24/{file_name[:64]}_{digest}.json" + + +def test_s3_object_key_keeps_the_configured_prefix_when_only_the_id_overflows(): + """A 940 byte configured prefix survives whole when only the id overflows.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + prefix = "team-" + "b" * 934 + "/" + + key = get_s3_object_key( + s3_path="", + prefix=prefix, + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name=f"time-06-18-41-948021_{_oversized_response_id()}", + ) + + assert key.startswith(prefix + "2026-08-24/") + assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES + + +def test_s3_object_key_spends_the_whole_budget_when_the_prefix_must_be_trimmed(): + """A trimmed prefix keeps every byte the budget allows, not whole segments.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + s3_path = "p" * 400 + "/" + "q" * 600 + + key = get_s3_object_key( + s3_path=s3_path, + prefix="", + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name="time-06-18-41-948021_abc", + ) + + assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES + assert key.startswith("p" * 400 + "/" + "q" * 500) + + +def test_s3_object_key_keeps_a_single_segment_path_as_far_as_it_fits(): + """A path with no separator is kept as far as it fits, never dropped to the bucket root.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + from litellm.integrations.s3 import get_s3_object_key + + key = get_s3_object_key( + s3_path="a" * 1050, + prefix="", + start_time=datetime(2026, 8, 24, 6, 18, 41, 948021), + s3_file_name="time-06-18-41-948021_chatcmpl-xyz", + ) + + assert len(key.encode("utf-8")) == MAX_S3_OBJECT_KEY_BYTES + assert key.startswith("a" * 900) + + +def test_create_s3_batch_logging_element_bounds_key_and_keeps_full_response_id(): + """The batch element bounds the key and keeps the full response id in the payload.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + + logger = S3Logger(s3_use_team_prefix=True, s3_use_key_prefix=True) + response_id = _oversized_response_id() + payload = StandardLoggingPayload( + id=response_id, + metadata={"user_api_key_team_alias": "DefaultTeamProd", "user_api_key_alias": "prod-key"}, + messages=[], + ) + + result = logger.create_s3_batch_logging_element(datetime(2026, 8, 24, 6, 18, 41, 948021), payload) + + assert result is not None + assert len(result.s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert result.s3_object_key.startswith("DefaultTeamProd/prod-key/2026-08-24/") + assert result.payload["id"] == response_id + + +def test_s3_object_download_filename_is_bounded_for_oversized_response_id(): + """The Content-Disposition filename is bounded too, or the PUT fails with MetadataTooLarge.""" + from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES + from litellm.integrations.s3 import get_s3_object_download_filename + + file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), _oversized_response_id()) + + assert len(file_name.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES + assert file_name.startswith("time-2026-08-24T06-18-41-948021_resp_") + assert file_name.endswith(".json") + + +def test_s3_object_download_filenames_stay_distinct_when_shortened(): + """Shortened filenames stay distinct.""" + from litellm.integrations.s3 import get_s3_object_download_filename + + start_time = datetime(2026, 8, 24, 6, 18, 41, 948021) + file_names = { + get_s3_object_download_filename(start_time, _oversized_response_id() + suffix) + for suffix in ("first", "second", "third") + } + + assert len(file_names) == 3 + + +def test_s3_object_download_filename_short_id_is_unchanged(): + """An ordinary response id keeps the filename it had before.""" + from litellm.integrations.s3 import get_s3_object_download_filename + + file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), "resp_abc123") + + assert file_name == "time-2026-08-24T06-18-41-948021_resp_abc123.json" + + +def test_create_s3_batch_logging_element_bounds_the_download_filename(): + """The batch element carries a bounded Content-Disposition filename.""" + from litellm.constants import MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES + + logger = S3Logger() + payload = StandardLoggingPayload(id=_oversized_response_id(), metadata={}, messages=[]) + + result = logger.create_s3_batch_logging_element(datetime(2026, 8, 24, 6, 18, 41, 948021), payload) + + assert result is not None + assert len(result.s3_object_download_filename.encode("utf-8")) <= MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES + + +@pytest.mark.asyncio +async def test_audit_log_object_key_is_bounded_for_a_long_configured_path(): + """Audit log keys are bounded by the same builder.""" + from litellm.constants import MAX_S3_OBJECT_KEY_BYTES + + logger = S3Logger() + logger.s3_path = "audit-archive/" + "z" * 1100 + + await logger.async_log_audit_log_event({"id": "1a4f7bd0-6f1e-4d0a-9b3c-9f2e1d5a7c88"}) + + assert len(logger.log_queue) == 1 + assert len(logger.log_queue[0].s3_object_key.encode("utf-8")) <= MAX_S3_OBJECT_KEY_BYTES + assert logger.log_queue[0].s3_object_key.startswith("audit-archive/" + "z" * 900) + + +def test_s3_object_download_filename_drops_characters_that_break_the_header(): + """A quote or separator in the response id cannot escape the quoted header value.""" + from litellm.integrations.s3 import get_s3_object_download_filename + + file_name = get_s3_object_download_filename(datetime(2026, 8, 24, 6, 18, 41, 948021), 'resp_a"b/c') + + assert file_name == "time-2026-08-24T06-18-41-948021_resp_a_b_c.json" + + # -------------------------------------------------------------- # params_source / s3_callback_params_override (audit-log decoupling) # -------------------------------------------------------------- diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 3c4121977de..0e1c832ebf5 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -32,6 +32,8 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import ( TokenTypeCostBreakdown, _calculate_input_cost, _get_token_base_cost, + _is_off_peak, + _is_within_off_peak_window, calculate_cache_writing_cost, generic_cost_per_token, get_token_type_cost_breakdown, @@ -409,6 +411,377 @@ def test_get_token_base_cost_picks_highest_crossed_tier(): assert prompt_base_cost == 9e-6 +def test_is_within_off_peak_window_same_day(): + from datetime import datetime, timezone + + window = "09:00-17:00" + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 8, 59, tzinfo=timezone.utc)) is False + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 17, 0, tzinfo=timezone.utc)) is False + + +def test_is_within_off_peak_window_wraps_midnight(): + from datetime import datetime, timezone + + window = "16:30-00:30" + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 0, 15, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 16, 30, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 0, 30, tzinfo=timezone.utc)) is False + assert _is_within_off_peak_window(window, datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) is False + + +def test_is_within_off_peak_window_equal_start_and_end_covers_whole_day(): + """An equal start and end is the natural way to spell off-peak all day. It used to take the + non-wrap branch, where start <= now < end can never hold, so it matched nothing and billed at + standard rates around the clock without raising or logging anything.""" + from datetime import datetime, timezone + + for window in ("00:00-00:00", "10:00-10:00"): + for hour in range(24): + assert ( + _is_within_off_peak_window(window, datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc)) is True + ), f"{window} should cover {hour:02d}:00" + + +def test_is_within_off_peak_window_multiple_windows(): + from datetime import datetime, timezone + + # Providers like DeepSeek V4 have more than one daily peak/off-peak window. + windows = ["01:00-05:00", "13:00-16:00"] + assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 3, 0, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 14, 30, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window(windows, datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)) is False + # a malformed entry in the list is ignored, valid entries still match + assert _is_within_off_peak_window(["bad", "13:00-16:00"], datetime(2026, 1, 1, 14, 0, tzinfo=timezone.utc)) is True + assert _is_within_off_peak_window([], datetime(2026, 1, 1, 14, 0, tzinfo=timezone.utc)) is False + + +def test_is_within_off_peak_window_normalizes_timezone_aware_input(): + from datetime import datetime, timedelta, timezone + + # A caller may pass a non-UTC aware datetime; the window is UTC and must be + # evaluated in UTC, not against the caller's wall-clock. 09:00 at UTC+8 is + # 01:00 UTC, inside the 01:00-05:00 window. + tz_plus_8 = timezone(timedelta(hours=8)) + assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 9, 0, tzinfo=tz_plus_8)) is True + assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 12, 0, tzinfo=tz_plus_8)) is True + # 06:00 at UTC+8 is 22:00 UTC the previous day, outside the window + assert _is_within_off_peak_window("01:00-05:00", datetime(2026, 1, 1, 6, 0, tzinfo=tz_plus_8)) is False + + +def test_is_within_off_peak_window_malformed_returns_false(): + from datetime import datetime, timezone + + now = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + assert _is_within_off_peak_window("not-a-window", now) is False + assert _is_within_off_peak_window("16:30", now) is False + assert _is_within_off_peak_window("25:00-26:00", now) is False + + +def test_is_off_peak_weekday_qualified_windows_deepseek_schedule(): + """DeepSeek since 2026-08-23: peak is 01:00-04:00 and 06:00-10:00 UTC on weekdays only, with + weekends off-peak around the clock. The weekday axis is not a filter on one window set; on + two days of seven the off-peak window becomes the whole day, so the schedule needs two + day-qualified rules. The weekend instants inside would-be peak hours are the ones a + time-only implementation bills wrong.""" + from datetime import datetime, timezone + + deepseek = { + "windows": [ + {"hours_utc": ["00:00-01:00", "04:00-06:00", "10:00-00:00"], "weekdays": [1, 2, 3, 4, 5]}, + {"hours_utc": "00:00-00:00", "weekdays": [6, 7]}, + ], + } + peak_instants = [ + datetime(2026, 8, 24, 1, 30, tzinfo=timezone.utc), + datetime(2026, 8, 26, 7, 0, tzinfo=timezone.utc), + datetime(2026, 8, 28, 9, 59, tzinfo=timezone.utc), + ] + off_peak_instants = [ + datetime(2026, 8, 23, 1, 30, tzinfo=timezone.utc), + datetime(2026, 8, 29, 2, 0, tzinfo=timezone.utc), + datetime(2026, 8, 30, 8, 0, tzinfo=timezone.utc), + datetime(2026, 8, 26, 5, 0, tzinfo=timezone.utc), + datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc), + datetime(2026, 8, 24, 0, 30, tzinfo=timezone.utc), + ] + for when in peak_instants: + assert _is_off_peak(deepseek, when) is False, f"{when.isoformat()} should bill peak" + for when in off_peak_instants: + assert _is_off_peak(deepseek, when) is True, f"{when.isoformat()} should bill off-peak" + + +def test_is_off_peak_weekday_timezone_reads_vendor_calendar(): + """The UTC and Asia/Shanghai calendars only disagree about the date over 16:00-24:00 UTC, so + a window in that stretch is the one place a vendor-local weekday differs from a UTC one: + 2026-08-28T16:30Z is Friday in UTC but already Saturday in Beijing.""" + from datetime import datetime, timezone + + shanghai_saturday = { + "weekday_timezone": "Asia/Shanghai", + "windows": [{"hours_utc": "16:00-17:00", "weekdays": [6]}], + } + assert _is_off_peak(shanghai_saturday, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True + assert _is_off_peak(shanghai_saturday, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False + + +def test_is_off_peak_weekdays_default_utc_calendar_and_accept_names(): + from datetime import datetime, timezone + + named_weekend = {"windows": [{"hours_utc": "00:00-00:00", "weekdays": ["Sat", "sunday"]}]} + assert _is_off_peak(named_weekend, datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)) is True + assert _is_off_peak(named_weekend, datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc)) is False + + utc_friday = {"windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]} + assert _is_off_peak(utc_friday, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True + assert _is_off_peak(utc_friday, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False + + +def test_is_off_peak_naive_current_time_read_as_utc(): + from datetime import datetime + + block = {"windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]} + assert _is_off_peak(block, datetime(2026, 8, 28, 16, 30)) is True + assert _is_off_peak(block, datetime(2026, 8, 29, 16, 30)) is False + + +def test_is_off_peak_invalid_weekday_timezone_falls_back_to_utc(): + from datetime import datetime, timezone + + block = {"weekday_timezone": "Not/AZone", "windows": [{"hours_utc": "16:00-17:00", "weekdays": [5]}]} + assert _is_off_peak(block, datetime(2026, 8, 28, 16, 30, tzinfo=timezone.utc)) is True + assert _is_off_peak(block, datetime(2026, 8, 29, 16, 30, tzinfo=timezone.utc)) is False + + +def test_is_off_peak_ignores_malformed_weekday_rules(): + from datetime import datetime, timezone + + when = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc) + assert _is_off_peak({"windows": [{"hours_utc": "00:00-00:00", "weekdays": []}]}, when) is False + assert _is_off_peak({"windows": [{"hours_utc": "00:00-00:00", "weekdays": [0, 8, "noday", True]}]}, when) is False + assert _is_off_peak({"windows": [{"weekdays": [6]}]}, when) is False + assert _is_off_peak({"windows": [{"hours_utc": 1630}]}, when) is False + assert _is_off_peak({"windows": ["00:00-00:00"]}, when) is False + assert _is_off_peak({"windows": "00:00-00:00"}, when) is False + assert _is_off_peak({"hours_utc": 1630}, when) is False + assert _is_off_peak({}, when) is False + + +def test_is_off_peak_flat_hours_and_windows_are_a_union(): + from datetime import datetime, timezone + + block = { + "hours_utc": "04:00-06:00", + "windows": [{"hours_utc": "00:00-00:00", "weekdays": [7]}], + } + assert _is_off_peak(block, datetime(2026, 8, 28, 5, 0, tzinfo=timezone.utc)) is True + assert _is_off_peak(block, datetime(2026, 8, 30, 20, 0, tzinfo=timezone.utc)) is True + assert _is_off_peak(block, datetime(2026, 8, 28, 20, 0, tzinfo=timezone.utc)) is False + + +def test_get_token_base_cost_weekend_only_off_peak_rate(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "off_peak_pricing": { + "windows": [ + {"hours_utc": ["00:00-01:00", "04:00-06:00", "10:00-00:00"], "weekdays": [1, 2, 3, 4, 5]}, + {"hours_utc": "00:00-00:00", "weekdays": [6, 7]}, + ], + "input_cost_per_token": 5e-7, + "output_cost_per_token": 1e-6, + }, + }, + ) + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + + saturday_peak_hours = _get_token_base_cost( + model_info, usage, current_time=datetime(2026, 8, 29, 2, 0, tzinfo=timezone.utc) + ) + assert saturday_peak_hours[:2] == (5e-7, 1e-6) + + monday_same_hours = _get_token_base_cost( + model_info, usage, current_time=datetime(2026, 8, 24, 2, 0, tzinfo=timezone.utc) + ) + assert monday_same_hours[:2] == (1e-6, 2e-6) + + +def test_get_token_base_cost_applies_off_peak_pricing(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_read_input_token_cost": 1e-7, + "off_peak_pricing": { + "hours_utc": "16:30-00:30", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 1e-6, + "cache_read_input_token_cost": 5e-8, + }, + }, + ) + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + + off_peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) + assert off_peak[0] == 5e-7 + assert off_peak[1] == 1e-6 + assert off_peak[4] == 5e-8 + + peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) + assert peak[0] == 1e-6 + assert peak[1] == 2e-6 + assert peak[4] == 1e-7 + + +def test_get_token_base_cost_non_mapping_off_peak_block_bills_standard_rates(): + """A truthy non-mapping off_peak_pricing value (a bare string or a list in + YAML) must bill standard rates rather than raising, matching how every + other malformed piece of the block behaves. + """ + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + when = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + + for malformed_block in ("16:00-19:00", ["16:00-19:00"], 5e-7, True): + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "off_peak_pricing": malformed_block, + }, + ) + result = _get_token_base_cost(model_info, usage, current_time=when) + assert result[0] == 1e-6 + assert result[1] == 2e-6 + + +def test_get_token_base_cost_off_peak_falls_back_to_standard_when_unset(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "off_peak_pricing": {"hours_utc": "16:30-00:30", "input_cost_per_token": 5e-7}, + }, + ) + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + + result = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) + assert result[0] == 5e-7 + assert result[1] == 2e-6 + + +def test_get_token_base_cost_off_peak_wins_over_threshold(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "output_cost_per_token_above_200k_tokens": 4e-6, + "off_peak_pricing": { + "hours_utc": "16:30-00:30", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 1e-6, + }, + }, + ) + usage = Usage(prompt_tokens=250000, completion_tokens=250000, total_tokens=500000) + + off_peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) + assert off_peak[0] == 5e-7 + assert off_peak[1] == 1e-6 + + peak = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) + assert peak[0] == 3e-6 + assert peak[1] == 4e-6 + + +def test_get_model_info_propagates_off_peak_fields(): + model_name = "test-off-peak-model" + off_peak_pricing = { + "hours_utc": "16:30-00:30", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 1e-6, + "cache_read_input_token_cost": 5e-8, + } + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "off_peak_pricing": off_peak_pricing, + } + } + ) + info = litellm.get_model_info(model=model_name) + assert info["off_peak_pricing"] == off_peak_pricing + + +def test_get_token_base_cost_off_peak_wins_over_tiered_pricing(): + """Tiered pricing resolves base rates on its own path and returns early, so off-peak has to + be applied there too or a model carrying both would silently bill the tier rate all day.""" + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-tiered" + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 3e-6, "output_cost_per_token": 6e-6}, + ], + "off_peak_pricing": { + "hours_utc": "16:30-00:30", + "input_cost_per_token": 5e-7, + "output_cost_per_token": 1e-6, + }, + } + } + ) + info = litellm.get_model_info(model=model_name) + usage = Usage(prompt_tokens=1_000, completion_tokens=100, total_tokens=1_100) + + inside = _get_token_base_cost(info, usage, current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc)) + assert inside[:2] == (5e-7, 1e-6) + + outside = _get_token_base_cost(info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) + assert outside[:2] == (3e-6, 6e-6) + + def test_generic_cost_per_token_gpt54_above_272k_tokens(_local_model_cost_map): """GPT-5.4/5.4-pro: prompts >272K input tokens priced at 2x input, 1.5x output.""" model = "gpt-5.4" diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 9ab66d55f3f..c037f928593 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1435,6 +1435,80 @@ class TestFlattenTopLevelSchemaCombinators: assert schema == snapshot +class TestToolWithFlattenedParameters: + def _anyof_tool(self): + return { + "type": "function", + "function": { + "name": "automation_update", + "description": "Update an automation", + "parameters": { + "type": "object", + "anyOf": [ + { + "properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}}, + "required": ["id", "enabled"], + }, + { + "properties": {"id": {"type": "string"}, "schedule": {"type": "string"}}, + "required": ["id", "schedule"], + }, + ], + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + } + + def test_flattens_anyof_parameters_into_new_tool(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + tool_with_flattened_parameters, + ) + + tool = self._anyof_tool() + result = tool_with_flattened_parameters(tool) + + assert result is not tool + parameters = result["function"]["parameters"] + assert "anyOf" not in parameters + assert parameters["type"] == "object" + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} + assert parameters["required"] == ["id"] + assert result["function"]["name"] == "automation_update" + assert tool == self._anyof_tool() + + def test_clean_parameters_return_the_same_tool_object(self): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + tool_with_flattened_parameters, + ) + + tool = { + "type": "function", + "function": { + "name": "lookup", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + }, + } + + assert tool_with_flattened_parameters(tool) is tool + + @pytest.mark.parametrize( + "tool", + [ + {"type": "function"}, + {"type": "function", "function": "not-a-dict"}, + {"type": "function", "function": {"name": "no_params"}}, + {"type": "function", "function": {"name": "bad_params", "parameters": "not-a-dict"}}, + ], + ) + def test_non_dict_function_or_parameters_return_the_same_tool_object(self, tool): + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + tool_with_flattened_parameters, + ) + + assert tool_with_flattened_parameters(tool) is tool + + class TestRequestContainsImageContent: """One detector for every dialect that reaches pre-routing hooks untranslated.""" diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 72d26f31c60..dd2d45f00c6 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -2932,6 +2932,28 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env) +def test_add_cache_point_tool_block_stands_down_for_model_without_prompt_caching(monkeypatch): + """A tool carrying cache_control must not become a cachePoint for a Bedrock model + whose cost-map entry lacks prompt caching support, since Bedrock rejects the whole + request. An unmapped id keeps emitting so ARN deployments do not lose caching.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + add_cache_point_tool_block, + ) + + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + tool = {"cache_control": {"type": "ephemeral"}} + + assert add_cache_point_tool_block(tool, model="nvidia.nemotron-super-3-120b") is None + assert add_cache_point_tool_block(tool, model="us.nvidia.nemotron-super-3-120b") is None + assert add_cache_point_tool_block( + tool, model="arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123" + ) == {"cachePoint": {"type": "default"}} + assert add_cache_point_tool_block(tool, model="us.anthropic.claude-sonnet-4-5-20250929-v1:0") == { + "cachePoint": {"type": "default"} + } + + def test_bedrock_tools_pt_passes_ttl_for_claude_4_5(monkeypatch): """ End-to-end: _bedrock_tools_pt should produce cachePoint blocks with ttl @@ -3627,3 +3649,67 @@ def test_convert_gemini_tool_call_result_answers_tool_reference_only_result(): ) assert result == {"function_response": {"name": "ToolSearch", "response": {"content": ""}}} + + +def test_convert_to_anthropic_tool_invoke_degrades_unpaired_server_tool_use(): + """A replayed srvtoolu_ call whose server tool result is not available + (e.g. the Responses bridge replays items without provider_specific_fields) + must become a plain client tool_use so the client's tool_result can pair + with it. A dangling server_tool_use makes Anthropic 400 the request with + "unexpected `tool_use_id` found in `tool_result` blocks".""" + from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_invoke + + result = convert_to_anthropic_tool_invoke( + tool_calls=[ + { + "id": "srvtoolu_01Unpaired", + "type": "function", + "function": {"name": "web_search", "arguments": '{"query": "zig version"}'}, + } + ], + web_search_results=None, + tool_results=None, + ) + + assert result == [ + { + "type": "tool_use", + "id": "srvtoolu_01Unpaired", + "name": "web_search", + "input": {"query": "zig version"}, + } + ] + + +def test_convert_to_anthropic_tool_invoke_keeps_paired_server_tool_use(): + """When the paired server tool result is available, the srvtoolu_ call is + still reconstructed as server_tool_use followed by its result block.""" + from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_invoke + + server_result = { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01Paired", + "content": [{"type": "web_search_result", "url": "https://ziglang.org", "title": "Zig"}], + } + + result = convert_to_anthropic_tool_invoke( + tool_calls=[ + { + "id": "srvtoolu_01Paired", + "type": "function", + "function": {"name": "web_search", "arguments": '{"query": "zig version"}'}, + } + ], + web_search_results=[server_result], + tool_results=None, + ) + + assert result == [ + { + "type": "server_tool_use", + "id": "srvtoolu_01Paired", + "name": "web_search", + "input": {"query": "zig version"}, + }, + server_result, + ] diff --git a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py index 0587628e2fe..b1e8163b91d 100644 --- a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py +++ b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py @@ -366,7 +366,7 @@ def test_shipped_rules_stack_adaptive_and_mid_conversation_flags(shipped_cost_ma def test_shipped_rules_flag_unmapped_fable_as_always_on_thinking(shipped_cost_map): """An unmapped Fable/Mythos id picks up ``thinking_always_on`` from the claude-always-on-thinking rule, while other unmapped Claudes stay unflagged.""" - model = "claude-fable-5-1" + model = "claude-fable-6-1" assert model not in litellm.model_cost info = litellm.get_model_info(model, custom_llm_provider="anthropic") assert info["thinking_always_on"] is True @@ -419,7 +419,7 @@ def test_shipped_rules_cover_new_families_like_fable_at_5_plus(shipped_cost_map) """Both version gates accept any claude-- id at major 5 or higher, bare major or major-minor, so a new family shaped like claude-fable-5 gets adaptive thinking and mid-conversation system support without a cost-map entry.""" - model = "claude-fable-5-1" + model = "claude-fable-6-1" assert model not in litellm.model_cost info = litellm.get_model_info(model, custom_llm_provider="anthropic") assert info["supports_mid_conversation_system"] is True diff --git a/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py b/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py index 8c0e8ee5d02..a374e03d1c7 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py +++ b/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py @@ -256,14 +256,12 @@ def test_get_model_cost_map_stamps_loaded_at(monkeypatch): from litellm.litellm_core_utils import get_model_cost_map as module monkeypatch.setattr(module._cost_map_source_info, "loaded_at", None) - monkeypatch.setattr( - module.GetModelCostMap, - "fetch_remote_model_cost_map", - staticmethod(lambda url, timeout=5: _load_root_cost_map()), + client, _calls = _mock_client( + [httpx.Response(200, content=_real_map_bytes())], client_cls=httpx.Client ) before = datetime.now(timezone.utc) - module.get_model_cost_map(url="https://example.invalid/cost_map.json") + module.get_model_cost_map(url="https://example.invalid/cost_map.json", client=client) loaded_at = module.get_model_cost_map_loaded_at() assert loaded_at is not None @@ -308,7 +306,7 @@ def _unset_local_cost_map_env(monkeypatch): monkeypatch.delenv("LITELLM_LOCAL_MODEL_COST_MAP", raising=False) -def _mock_client(outcomes): +def _mock_client(outcomes, client_cls=httpx.AsyncClient): """httpx client over a MockTransport serving one outcome per request; an exception instance is raised.""" calls = {"count": 0} @@ -320,7 +318,7 @@ def _mock_client(outcomes): raise outcome return outcome - return httpx.AsyncClient(transport=httpx.MockTransport(handler)), calls + return client_cls(transport=httpx.MockTransport(handler)), calls @pytest.mark.asyncio @@ -450,3 +448,97 @@ async def test_refetch_respects_local_env_override(monkeypatch): ) assert isinstance(result, ModelCostMapReloaded) assert len(result.model_cost_map) > 100 + + +# --------------------------------------------------------------------------- +# get_model_cost_map: the boot-time load retries transient failures like a reload does +# --------------------------------------------------------------------------- + +from litellm.litellm_core_utils.get_model_cost_map import ( + get_model_cost_map, + get_model_cost_map_source_info, +) + + +class _SyncSleepRecorder: + """Injected in place of time.sleep so the boot path's waits are asserted without delay.""" + + def __init__(self): + self.waits = [] + + def __call__(self, seconds: float) -> None: + self.waits.append(seconds) + + +def test_boot_load_retries_transient_failures_instead_of_falling_back(): + """A refused connection then a 503 at pod boot used to pin the process to the bundled + backup for its lifetime; both are transient and must be retried before giving up.""" + client, calls = _mock_client( + [ + httpx.ConnectError("connection refused"), + httpx.Response(503), + httpx.Response(200, content=_real_map_bytes()), + ], + client_cls=httpx.Client, + ) + sleeper = _SyncSleepRecorder() + + cost_map = get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client) + + assert calls["count"] == 3 + assert len(sleeper.waits) == 2 + assert 2.0 <= sleeper.waits[0] < 3.0 + assert 4.0 <= sleeper.waits[1] < 5.0 + source = get_model_cost_map_source_info() + assert source["source"] == "remote" + assert source["fallback_reason"] is None + assert cost_map.keys() >= _load_root_cost_map().keys() - {"sample_spec", FALLBACK_GENERALIZATIONS_KEY} + + +def test_boot_load_honors_retry_after_then_falls_back_after_max_attempts(): + """An outage longer than the retry budget still ends on the bundled backup, and the + recorded fallback reason says how many attempts were spent so operators can tell.""" + client, calls = _mock_client( + [httpx.Response(429, headers={"Retry-After": "7"})], client_cls=httpx.Client + ) + sleeper = _SyncSleepRecorder() + + cost_map = get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client) + + assert calls["count"] == 3 + assert sleeper.waits == [7.0, 7.0] + source = get_model_cost_map_source_info() + assert source["source"] == "local" + assert "after 3 attempts" in source["fallback_reason"] + assert len(cost_map) > 100 + + +def test_boot_load_does_not_retry_permanent_failures(): + """A 404 or a malformed URL cannot heal by waiting: one attempt, no sleeps, backup.""" + client, calls = _mock_client([httpx.Response(404)], client_cls=httpx.Client) + sleeper = _SyncSleepRecorder() + + get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client) + assert calls["count"] == 1 + assert sleeper.waits == [] + assert get_model_cost_map_source_info()["source"] == "local" + + get_model_cost_map(url="not a url", sleep=sleeper, rng=random.Random(0)) + assert sleeper.waits == [] + assert get_model_cost_map_source_info()["source"] == "local" + + +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") + + def _fail(request): + raise AssertionError("no HTTP request should be made when local map is forced") + + cost_map = get_model_cost_map( + url=_URL, + sleep=_SyncSleepRecorder(), + client=httpx.Client(transport=httpx.MockTransport(_fail)), + ) + assert len(cost_map) > 100 + assert get_model_cost_map_source_info()["is_env_forced"] is True diff --git a/tests/test_litellm/litellm_core_utils/test_get_supported_openai_params.py b/tests/test_litellm/litellm_core_utils/test_get_supported_openai_params.py index cb4e72ab3ad..722818598af 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_supported_openai_params.py +++ b/tests/test_litellm/litellm_core_utils/test_get_supported_openai_params.py @@ -188,6 +188,8 @@ class TestDeclaredAuthenticatingProvider: ("gpt-4o", "github_copilot", "github_copilot"), ("openai/gpt-4o", None, None), ("gpt-4o", "openai", None), + ("github_copilot", None, None), + ("chatgpt", None, None), ], ) def test_names_only_the_providers_whose_resolution_authenticates(self, model, provider, expected): diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index c7328adb0b3..366f61ded49 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -5479,6 +5479,97 @@ def test_pre_call_redacts_and_masks_raw_request(logging_obj): assert "key=*****" in raw_api_base +def _streaming_logging_obj_with_callbacks(callbacks: list[CustomLogger]): + import datetime + + obj = LitellmLogging( + model="anthropic/claude-opus-5", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="completion", + start_time=datetime.datetime.now(), + litellm_call_id="slot-leak-test", + function_id="slot-leak-test", + ) + obj.model_call_details["litellm_params"] = {"metadata": {}} + return patch.object(obj, "get_combined_callback_list", return_value=callbacks), obj + + +def _assembled_stream_result(): + response = ModelResponse() + response.choices[0].message.content = "hello" + return response + + +@pytest.mark.asyncio +async def test_streaming_success_callbacks_survive_logging_hook_failure(): + """Regression for leaked max_parallel_requests slots: a raising + async_logging_hook must not abort the success-callback loop that + releases the rate-limiter slot.""" + broken = CustomLogger() + broken.async_logging_hook = AsyncMock(side_effect=RuntimeError("broken stream payload")) + releasing = CustomLogger() + releasing.async_log_success_event = AsyncMock() + + patcher, logging_obj = _streaming_logging_obj_with_callbacks([broken, releasing]) + with patcher: + await logging_obj.async_success_handler(result=_assembled_stream_result()) + + releasing.async_log_success_event.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_success_callbacks_survive_cost_calculation_failure(): + releasing = CustomLogger() + releasing.async_log_success_event = AsyncMock() + + patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing]) + with patcher, patch.object( + logging_obj, "_response_cost_calculator", side_effect=ValueError("bad usage block") + ): + await logging_obj.async_success_handler(result=_assembled_stream_result()) + + assert logging_obj.model_call_details["response_cost"] is None + releasing.async_log_success_event.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_success_callbacks_survive_standard_logging_payload_failure(): + releasing = CustomLogger() + releasing.async_log_success_event = AsyncMock() + + patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing]) + with patcher, patch.object( + logging_obj, "_build_standard_logging_payload", side_effect=ValueError("incomplete stream") + ): + await logging_obj.async_success_handler(result=_assembled_stream_result()) + + assert logging_obj.model_call_details.get("standard_logging_object") is None + releasing.async_log_success_event.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_success_callbacks_survive_guardrail_logging_hook_failure(): + from litellm.integrations.custom_guardrail import CustomGuardrail + + skipping = CustomGuardrail(guardrail_name="skipping-guardrail") + skipping.should_run_guardrail = MagicMock(return_value=False) + skipping.async_logging_hook = AsyncMock() + raising = CustomGuardrail(guardrail_name="raising-guardrail") + raising.should_run_guardrail = MagicMock(return_value=True) + raising.async_logging_hook = AsyncMock(side_effect=RuntimeError("guardrail hook failed")) + releasing = CustomLogger() + releasing.async_log_success_event = AsyncMock() + + patcher, logging_obj = _streaming_logging_obj_with_callbacks([skipping, raising, releasing]) + with patcher: + await logging_obj.async_success_handler(result=_assembled_stream_result()) + + skipping.async_logging_hook.assert_not_awaited() + raising.async_logging_hook.assert_awaited_once() + releasing.async_log_success_event.assert_awaited_once() + + def _resolve(custom_llm_provider, litellm_params, optional_params, model): from litellm.litellm_core_utils.litellm_logging import ( _resolve_vertex_location_for_cost, diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index bd750a47f63..1d9a426283a 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -3,11 +3,14 @@ import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest import litellm from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.llms.anthropic.chat.handler import ModelResponseIterator, make_call +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.rust_bridge import bindings as bridge_bindings from litellm.types.llms.openai import ( ChatCompletionToolCallChunk, ChatCompletionToolCallFunctionChunk, @@ -46,6 +49,43 @@ async def test_make_call_passes_logging_obj_to_client_post(): assert call_kwargs.get("logging_obj") is logging_obj +def test_anthropic_completion_does_not_send_deployment_default_limits(): + captured_requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response( + 200, + json={ + "id": "msg_default_limits", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))) + try: + litellm.completion( + model="anthropic/claude-3-5-haiku-20241022", + messages=[{"role": "user", "content": "Hello"}], + api_key="test-key", + client=client, + default_api_key_rpm_limit=60, + default_api_key_tpm_limit=5000000, + ) + finally: + client.close() + + request_body = json.loads(captured_requests[0].content) + assert "default_api_key_rpm_limit" not in request_body + assert "default_api_key_tpm_limit" not in request_body + + def test_redacted_thinking_content_block_delta(): chunk = { "type": "content_block_start", @@ -2279,14 +2319,13 @@ class TestRustChatCompletionsHook: bridge.set_rust_chat_completions(decline=gate, chat_completions=native) return seen - def test_rust_true_serves_the_call_and_stamps_the_header(self): + def test_rust_true_serves_the_call(self): from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion seen = self._inject() response = AnthropicChatCompletion().completion(**self._completion_kwargs()) assert response.choices[0].message.content == "hello from rust" - assert response._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} assert len(seen["call"]) == 1 def test_the_core_receives_the_untranslated_openai_messages(self): @@ -2427,7 +2466,7 @@ class TestRustChatCompletionsHook: def declining_native(**_kwargs): raise _Declined("blank message text") - monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative()) + monkeypatch.setattr(bridge_bindings, "get_native_bridge", lambda: _FakeNative()) bridge.set_rust_chat_completions( decline=lambda **_kwargs: None, chat_completions=declining_native ) @@ -2459,7 +2498,7 @@ class TestRustChatCompletionsHook: RustBridgeDeclined = _Declined RustUpstreamError = type("_Upstream", (Exception,), {}) - monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative()) + monkeypatch.setattr(bridge_bindings, "get_native_bridge", lambda: _FakeNative()) async def declining_native(**_kwargs): raise _Declined("blank message text") @@ -2501,7 +2540,6 @@ class TestRustChatCompletionsHook: ) assert result.choices[0].message.content == "hello from rust" - assert result._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} assert not python_call.called @@ -2519,7 +2557,7 @@ class TestRustChatCompletionsHook: RustBridgeDeclined = _Declined RustUpstreamError = type("_Upstream", (Exception,), {}) - monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative()) + monkeypatch.setattr(bridge_bindings, "get_native_bridge", lambda: _FakeNative()) def declining_native(**_kwargs): raise _Declined("blank message text") diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index c4df46dea83..0f9f8259bef 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -6176,9 +6176,10 @@ def test_is_anthropic_usage_object_rejects_responses_api_usage(): [ # always-on-thinking models reject thinking.type=disabled with a 400 ("claude-fable-5", True), + ("claude-fable-5-1", True), ("claude-mythos-5", True), # unmapped future family member -> claude-always-on-thinking fallback rule - ("claude-fable-5-1", True), + ("claude-fable-6-1", True), # adaptive-capable models that ACCEPT disabled must keep it verbatim ("claude-opus-5", False), ("claude-sonnet-5", False), @@ -6209,6 +6210,110 @@ def test_disabled_thinking_omitted_only_for_always_on_models( assert request["thinking"] == {"type": "disabled"} +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "required"}, {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_raises_clean_error_on_fable_5_1_without_drop_params( + local_model_cost_map, tool_choice, monkeypatch +): + """Fable 5.1 400s on tool_choice type any/tool (thinking is always on and a + forced call would skip it); without drop_params the caller gets a clean + client-side 400 that explains the workaround, not a provider error.""" + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="forced tool use"): + config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=False, + ) + + +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "required"}, {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_with_drop_params( + local_model_cost_map, tool_choice +): + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto"} + + +def test_forced_tool_choice_downgrade_keeps_parallel_tool_calls_flag(local_model_cost_map): + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required", "parallel_tool_calls": False}, + optional_params={}, + model="claude-fable-5-1", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto", "disable_parallel_tool_use": True} + + +@pytest.mark.parametrize("tool_choice, expected_type", [("auto", "auto"), ("none", "none")]) +def test_unforced_tool_choice_forwarded_on_fable_5_1( + local_model_cost_map, tool_choice, expected_type, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=False, + ) + + assert result["tool_choice"]["type"] == expected_type + + +@pytest.mark.parametrize("model", ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]) +def test_forced_tool_choice_forwarded_on_models_that_support_it( + local_model_cost_map, model, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required"}, + optional_params={}, + model=model, + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "any"} + + +def test_forced_tool_choice_gating_driven_by_model_map_flag(local_model_cost_map, monkeypatch): + """The gate must read ``supports_forced_tool_use`` from the model map, not + the model name: a flagged entry gates a model whose name says nothing.""" + monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_forced_tool_use": False}) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required"}, + optional_params={}, + model="claude-zeta-9", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto"} + + def test_anthropic_drop_params_keeps_format_only_output_config(monkeypatch): """``drop_params=True`` must not consume ``output_config.format``: the drop gate is an effort gate and ``format`` is a structured-output field.""" @@ -6249,3 +6354,33 @@ def test_anthropic_drop_params_reduces_mixed_output_config_to_format(monkeypatch ) assert result.get("output_config") == {"format": schema_format} + + +def test_response_format_tool_path_skips_forced_tool_choice_when_unsupported(local_model_cost_map, monkeypatch): + """Backstop: on the tool-based structured-output path, a model flagged + ``supports_forced_tool_use: false`` must not get the forced response-format + tool_choice the provider would 400 on.""" + monkeypatch.setitem( + litellm.model_cost, + "claude-test-no-forced-tools", + {"litellm_provider": "anthropic", "mode": "chat", "supports_forced_tool_use": False}, + ) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "test_schema", + "schema": {"type": "object", "properties": {"result": {"type": "string"}}}, + }, + } + }, + optional_params={}, + model="claude-test-no-forced-tools", + drop_params=False, + ) + + assert "tools" in result + assert "tool_choice" not in result diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py index 2cf7cd142d6..4e6b9ed0188 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py @@ -11,6 +11,7 @@ sys.path.insert( import litellm from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config from litellm.llms.azure.chat.gpt_transformation import AzureOpenAIConfig from litellm.utils import get_optional_params @@ -195,3 +196,91 @@ def test_azure_gpt_5_takes_the_reasoning_path() -> None: assert "presence_penalty" not in mapped assert "logit_bias" not in mapped assert "reasoning_effort" in supported + + +class TestAzureToolSchemaCombinatorFlattening: + """ + Regression tests for LIT-6510: Azure's chat completions validator rejects + tool parameters carrying a top-level anyOf/oneOf/allOf for every model + family, so AzureOpenAIConfig.transform_request must flatten them. + """ + + @staticmethod + def _anyof_tool(): + return { + "type": "function", + "function": { + "name": "automation_update", + "description": "Update an automation", + "parameters": { + "type": "object", + "anyOf": [ + { + "properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}}, + "required": ["id", "enabled"], + }, + { + "properties": {"id": {"type": "string"}, "schedule": {"type": "string"}}, + "required": ["id", "schedule"], + }, + ], + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + } + + def _transform(self, config, model, tools): + return config.transform_request( + model=model, + messages=[{"role": "user", "content": "hi"}], + optional_params={"tools": tools}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + def test_transform_request_flattens_top_level_anyof(self): + request = self._transform(AzureOpenAIConfig(), "gpt-4o", [self._anyof_tool()]) + parameters = request["tools"][0]["function"]["parameters"] + assert "anyOf" not in parameters + assert parameters["type"] == "object" + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} + assert parameters["required"] == ["id"] + assert request["tools"][0]["function"]["name"] == "automation_update" + + def test_gpt5_config_flattens_via_shared_transform(self): + request = self._transform(AzureOpenAIGPT5Config(), "gpt-5.4-mini", [self._anyof_tool()]) + parameters = request["tools"][0]["function"]["parameters"] + assert "anyOf" not in parameters + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} + + def test_caller_tool_dict_is_not_mutated(self): + tool = self._anyof_tool() + self._transform(AzureOpenAIConfig(), "gpt-4o", [tool]) + assert tool == self._anyof_tool() + + def test_clean_object_schema_passes_through_as_same_object(self): + tool = { + "type": "function", + "function": { + "name": "lookup", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + }, + } + request = self._transform(AzureOpenAIConfig(), "gpt-4o", [tool]) + assert request["tools"][0] is tool + + def test_non_dict_tool_entries_pass_through_unchanged(self): + request = self._transform(AzureOpenAIConfig(), "gpt-4o", ["not-a-tool"]) + assert request["tools"] == ["not-a-tool"] + + def test_request_without_tools_is_unchanged(self): + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"temperature": 0.2}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + assert "tools" not in request + assert request["temperature"] == 0.2 diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py index fc7e94a77ba..202f81f1252 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py @@ -23,3 +23,48 @@ async def test_azure_chat_o_series_transformation(): ) print(response) assert response["model"] == "web-interface-o1-mini" + + +def test_azure_o_series_transform_request_flattens_top_level_anyof(): + """Regression test for LIT-6510: the o-series super() chain ends in + OpenAIGPTConfig, whose flatten gate skips provider 'azure', so + AzureOpenAIO1Config must flatten tool schema combinators itself.""" + tool = { + "type": "function", + "function": { + "name": "automation_update", + "description": "Update an automation", + "parameters": { + "type": "object", + "anyOf": [ + { + "properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}}, + "required": ["id", "enabled"], + }, + { + "properties": {"id": {"type": "string"}, "schedule": {"type": "string"}}, + "required": ["id", "schedule"], + }, + ], + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + } + optional_params = {"tools": [tool]} + + request = AzureOpenAIO1Config().transform_request( + model="o3-mini", + messages=[{"role": "user", "content": "hi"}], + optional_params=optional_params, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + parameters = request["tools"][0]["function"]["parameters"] + assert "anyOf" not in parameters + assert parameters["type"] == "object" + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} + assert parameters["required"] == ["id"] + assert "anyOf" in tool["function"]["parameters"] + assert optional_params["tools"][0] is tool diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index a122d97a0f0..41d82e4f960 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -643,3 +643,31 @@ def test_bedrock_chat_invoke_drop_params_still_inlines_for_non_native(local_mode assert "output_config" not in result last_content = result["messages"][-1]["content"] assert json.loads(last_content[-1]["text"]) == schema + + +@pytest.mark.parametrize( + "model", + ["us.anthropic.claude-fable-5-1", "anthropic.claude-fable-5-1"], +) +def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice(local_model_cost_map, model): + """Regression: Bedrock rejects both native ``output_config.format`` and forced + tool_choice for Fable 5.1, so invoke must use the tool-based path without a + forced ``tool_choice``.""" + result = AmazonAnthropicClaudeConfig().map_openai_params( + non_default_params={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "test_schema", + "schema": {"type": "object", "properties": {"result": {"type": "string"}}}, + }, + } + }, + optional_params={}, + model=model, + drop_params=False, + ) + + assert "output_format" not in result + assert "tools" in result + assert "tool_choice" not in result diff --git a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py index 8e67a7e3438..8ac88a8172b 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py @@ -10,10 +10,11 @@ from unittest.mock import MagicMock, patch import httpx import pytest - from botocore.credentials import Credentials + from litellm.llms.bedrock.chat.converse_handler import BedrockConverseLLM from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.rust_bridge import bindings as bridge_bindings from litellm.rust_bridge import chat_completions as bridge from litellm.types.utils import ModelResponse @@ -96,10 +97,8 @@ def _completion_kwargs(**overrides): return kwargs -def _run(**overrides): - with patch.object( - BedrockConverseLLM, "get_credentials", return_value=RESOLVED_CREDENTIALS - ): +def _run(*, credentials: Credentials | None = RESOLVED_CREDENTIALS, **overrides): + with patch.object(BedrockConverseLLM, "get_credentials", return_value=credentials): return BedrockConverseLLM().completion(**_completion_kwargs(**overrides)) @@ -113,12 +112,11 @@ def _recording_logging_obj(): return logging_obj, calls -def test_rust_true_serves_the_call_and_stamps_the_header(): +def test_rust_true_serves_the_call(): seen = _inject() response = _run() assert response.choices[0].message.content == "hello from rust" - assert response._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} assert len(seen["call"]) == 1 @@ -206,7 +204,7 @@ async def test_the_async_path_falls_back_when_the_core_declines(monkeypatch): RustBridgeDeclined = _Declined RustUpstreamError = type("_Upstream", (Exception,), {}) - monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative()) + monkeypatch.setattr(bridge_bindings, "get_native_bridge", lambda: _FakeNative()) async def declining_native(**_kwargs): raise _Declined("blank message text") @@ -256,7 +254,6 @@ async def test_the_async_path_serves_the_rust_response_without_the_fallback(): ) assert result.choices[0].message.content == "hello from rust" - assert result._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} assert not python_call.called @@ -283,7 +280,7 @@ async def test_pre_call_logging_fires_once_even_when_the_rust_path_declines(): return ModelResponse() with ( - patch.object(bridge, "get_native_bridge", lambda: _FakeNative()), + patch.object(bridge_bindings, "get_native_bridge", lambda: _FakeNative()), patch.object( BedrockConverseLLM, "get_credentials", return_value=RESOLVED_CREDENTIALS ), @@ -360,7 +357,7 @@ async def test_async_completion_logs_pre_call_by_default(): def _sync_client_returning_converse_response(): client = MagicMock() - client.post = lambda **_kwargs: httpx.Response( + client.post.side_effect = lambda **_kwargs: httpx.Response( 200, json=CONVERSE_RESPONSE, request=httpx.Request("POST", "https://bedrock-runtime.us-west-2.amazonaws.com"), @@ -390,7 +387,7 @@ def test_pre_call_logging_fires_once_when_the_sync_rust_path_declines(): logging_obj = MagicMock() - with patch.object(bridge, "get_native_bridge", lambda: _FakeNative()): + with patch.object(bridge_bindings, "get_native_bridge", lambda: _FakeNative()): bridge.set_rust_chat_completions( decline=lambda **_kwargs: None, chat_completions=declining_native ) @@ -475,7 +472,7 @@ def test_post_call_is_not_logged_twice_when_the_sync_rust_call_declines(): logging_obj, calls = _recording_logging_obj() - with patch.object(bridge, "get_native_bridge", lambda: _FakeNative()): + with patch.object(bridge_bindings, "get_native_bridge", lambda: _FakeNative()): bridge.set_rust_chat_completions( decline=lambda **_kwargs: None, chat_completions=declining_native ) @@ -487,3 +484,31 @@ def test_post_call_is_not_logged_twice_when_the_sync_rust_call_declines(): assert response.choices[0].message.content == "hi" assert len(calls["post_call"]) == 1 assert "hi" in calls["post_call"][0]["original_response"] + + +def test_bearer_token_auth_serves_when_boto3_resolves_no_sigv4_credentials(monkeypatch): + """With only `AWS_BEARER_TOKEN_BEDROCK` configured boto3 resolves no + credentials at all. Preparing the Rust handoff must not dereference that + None: the bearer token signs the request on its own.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token") + client = _sync_client_returning_converse_response() + + response = _run(credentials=None, litellm_params={}, client=client) + + assert response.choices[0].message.content == "hi" + sent_headers = client.post.call_args.kwargs["headers"] + assert sent_headers["Authorization"] == "Bearer bedrock-bearer-token" + + +def test_the_rust_opt_in_needs_no_sigv4_principal(): + """The core resolves the bearer token itself, so a bearer-only deployment + keeps its opt-in and the gate sees no aws_* credential keys to sign with.""" + seen = _inject() + + response = _run(credentials=None, api_key="bedrock-bearer-token") + + assert response.choices[0].message.content == "hello from rust" + params = seen["call"][0]["optional_params"] + assert not {"aws_access_key_id", "aws_secret_access_key", "aws_session_token"} & params.keys() + assert params["aws_region_name"] == "us-east-1" + assert seen["call"][0]["api_key"] == "bedrock-bearer-token" diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 63f895e1819..70f3153ed7e 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5248,6 +5248,84 @@ def test_cache_control_injection_tool_config_drops_ttl_for_unsupported_model(): assert tools[-1] == {"cachePoint": {"type": "default"}} +@pytest.mark.parametrize( + ("model", "expects_cache_points"), + [ + pytest.param("nvidia.nemotron-super-3-120b", False, id="mapped-model-without-prompt-caching"), + pytest.param("us.nvidia.nemotron-super-3-120b", False, id="regional-prefix-resolves-through-base-model"), + pytest.param( + "us.anthropic.claude-3-5-sonnet-20240620-v1:0", False, id="claude-named-but-not-caching-on-bedrock" + ), + pytest.param("us.anthropic.claude-sonnet-4-5-20250929-v1:0", True, id="mapped-model-with-prompt-caching"), + pytest.param( + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123", + True, + id="unmapped-arn-keeps-emitting", + ), + ], +) +def test_cache_points_emitted_only_for_models_that_support_prompt_caching(model, expects_cache_points, monkeypatch): + """Bedrock rejects cachePoint blocks for models without prompt caching support + ("You invoked an unsupported model or your request did not allow prompt caching"), + and clients like Claude Code attach cache_control to every request, so a map-known + model without the capability must not receive them. Unmapped ids (application + inference profile ARNs, models newer than the map) keep emitting so existing + caching setups never silently degrade.""" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + + body = AmazonConverseConfig().transform_request( + model=model, + messages=[ + {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]}, + {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]}, + ], + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert ("cachePoint" in json.dumps(body)) is expects_cache_points + assert body["system"][0]["text"] == "sys" + assert body["messages"][0]["content"][0]["text"] == "hi" + + +def test_tool_config_cachepoint_not_placed_or_credited_for_model_without_prompt_caching(monkeypatch): + """The tool_config injection point must stand down with the rest of the cachePoint + emission when the model cannot cache, and spend attribution must not credit the + gateway for a breakpoint that was never placed.""" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + + bucket: dict = {"user_api_key": "sk-test"} + data = AmazonConverseConfig()._transform_request_helper( + model="nvidia.nemotron-super-3-120b", + system_content_blocks=[], + optional_params={ + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + } + ], + "cache_control_injection_points": [{"location": "tool_config"}], + }, + messages=[{"role": "user", "content": "hi"}], + litellm_params={"metadata": bucket, "litellm_metadata": None, "model_info": {"id": "dep-bedrock"}}, + ) + + assert "cachePoint" not in json.dumps(data.get("toolConfig", {})) + assert "litellm_gateway_injected_cache" not in bucket + + def test_translate_response_format_json_schema_still_injects_tool(): """ response_format with an explicit json_schema should still use the @@ -6211,7 +6289,7 @@ def test_message_level_cache_control_drops_ttl_for_unsupported_model(ttl_target) result = _bedrock_converse_messages_pt( messages=_agentic_messages_with_ttl(ttl_target), - model="anthropic.claude-3-5-sonnet-20240620-v1:0", + model="anthropic.claude-3-5-sonnet-20241022-v2:0", llm_provider="bedrock_converse", ) @@ -6449,3 +6527,95 @@ def test_disabled_thinking_omitted_for_always_on_models_converse( assert "thinking" not in additional else: assert additional.get("thinking") == {"type": "disabled"} + +@pytest.mark.parametrize( + "model", + ["anthropic.claude-fable-5-1", "us.anthropic.claude-fable-5-1"], +) +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_converse( + local_model_cost_map, model, tool_choice +): + config = AmazonConverseConfig() + + result = config.map_tool_choice_values( + model=model, tool_choice=tool_choice, drop_params=True + ) + + assert result == {"auto": {}} + + +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_raises_clean_error_on_fable_5_1_converse( + local_model_cost_map, tool_choice, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AmazonConverseConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="forced tool use"): + config.map_tool_choice_values( + model="anthropic.claude-fable-5-1", tool_choice=tool_choice, drop_params=False + ) + + +@pytest.mark.parametrize("tool_choice", ["auto", "none"]) +def test_unforced_tool_choice_unaffected_on_fable_5_1_converse(local_model_cost_map, tool_choice): + config = AmazonConverseConfig() + + result = config.map_tool_choice_values( + model="anthropic.claude-fable-5-1", tool_choice=tool_choice, drop_params=True + ) + + assert result == ({"auto": {}} if tool_choice == "auto" else None) + + +@pytest.mark.parametrize( + "model", + ["anthropic.claude-fable-5-1", "us.anthropic.claude-fable-5-1"], +) +def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_converse( + local_model_cost_map, model +): + """Regression: Bedrock rejects both ``outputConfig`` structured output and forced + tool_choice for Fable 5.1, so response_format must map to a tool without a forced + tool_choice.""" + config = AmazonConverseConfig() + + result = config.map_openai_params( + non_default_params={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "test_schema", + "schema": {"type": "object", "properties": {"result": {"type": "string"}}}, + }, + } + }, + optional_params={}, + model=model, + drop_params=False, + ) + + assert "outputConfig" not in result + assert "tools" in result + assert "tool_choice" not in result + assert result.get("json_mode") is True + + +def test_forced_tool_choice_forwarded_on_converse_models_that_support_it( + local_model_cost_map, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AmazonConverseConfig() + + result = config.map_tool_choice_values( + model="anthropic.claude-fable-5", tool_choice="required", drop_params=False + ) + + assert result == {"any": {}} diff --git a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py index 7d07ac947b1..f854d806bdc 100644 --- a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py +++ b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock, patch from botocore.awsrequest import AWSPreparedRequest, AWSRequest from botocore.auth import SigV4Auth from botocore.credentials import Credentials +from botocore.exceptions import NoCredentialsError import litellm from litellm.llms.bedrock.base_aws_llm import ( @@ -801,6 +802,23 @@ def test_get_request_headers_with_sigv4(): assert result == mock_request.prepare.return_value +def test_get_request_headers_without_credentials_or_bearer_token_raises_no_credentials(): + """Bearer-token auth needs no SigV4 principal, so `credentials` may be None. + Reaching the SigV4 branch with neither must fail the way botocore always + has instead of signing with a missing principal.""" + llm = BaseAWSLLM() + + with patch.dict(os.environ, {}, clear=True), pytest.raises(NoCredentialsError): + llm.get_request_headers( + credentials=None, + aws_region_name="us-west-2", + extra_headers=None, + endpoint_url="https://api.example.com", + data='{"prompt": "test"}', + headers={"Content-Type": "application/json"}, + ) + + def test_sigv4_matches_rust_golden_vector(): request = AWSRequest( method="POST", diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 4c92c52d556..7509e35e3f7 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -1,7 +1,11 @@ import asyncio import concurrent.futures +import socket +import sys +from typing import Final import aiohttp +import aiohttp.abc import aiohttp.client_exceptions import aiohttp.http_exceptions import httpx @@ -1140,3 +1144,55 @@ async def test_stopped_loop_session_disposed_synchronously_on_recycle(): finally: await new_session.close() result["loop"].close() + + +class _CancellingResolver(aiohttp.abc.AbstractResolver): + """Cancels the given task (or, by default, aiohttp's shielded DNS child task) mid-lookup.""" + + def __init__(self, task_to_cancel: "asyncio.Task[object] | None" = None): + self._task_to_cancel: Final = task_to_cancel + + async def resolve( + self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET + ) -> list[aiohttp.abc.ResolveResult]: + target: Final = self._task_to_cancel or asyncio.current_task() + assert target is not None + target.cancel() + await asyncio.sleep(0) + raise OSError("resolver finished after the task was cancelled") + + async def close(self) -> None: + return None + + +@pytest.mark.asyncio +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Task.cancelling() is needed to tell the two cancellations apart" +) +async def test_internal_dns_cancellation_maps_to_connect_error(): + """A CancelledError the request task never asked for must surface as a mapped httpx transport error.""" + session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(resolver=_CancellingResolver())) + transport = LiteLLMAiohttpTransport(client=session) + try: + with pytest.raises(httpx.ConnectError): + await transport.handle_async_request(httpx.Request("GET", "http://example.invalid/")) + current = asyncio.current_task() + assert current is not None and current.cancelling() == 0 + finally: + await transport.aclose() + + +@pytest.mark.asyncio +async def test_genuine_request_cancellation_still_propagates(): + """Cancelling the request task itself (client disconnect, shutdown) must still propagate unmapped.""" + current = asyncio.current_task() + assert current is not None + session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(resolver=_CancellingResolver(current))) + transport = LiteLLMAiohttpTransport(client=session) + try: + with pytest.raises(asyncio.CancelledError): + await transport.handle_async_request(httpx.Request("GET", "http://example.invalid/")) + finally: + if sys.version_info >= (3, 11): + current.uncancel() + await transport.aclose() diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 26f841c1146..e571cc4e263 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -2680,10 +2680,16 @@ async def test_generic_http_handler_async_streaming_forwards_provider_response_h (None, GenericLiteLLMParams(rust=True), False), ], ) -def test_the_rust_responses_websocket_needs_both_openai_and_the_rust_flag( +def test_the_rust_responses_websocket_needs_provider_flag_and_typed_capability( custom_llm_provider, litellm_params, expected ): - assert _rust_responses_websocket_enabled(custom_llm_provider, litellm_params) is expected + from litellm.rust_bridge import streaming + + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) + try: + assert _rust_responses_websocket_enabled(custom_llm_provider, litellm_params) is expected + finally: + streaming.set_rust_streaming(capability=None) def test_a_plain_callback_does_not_advertise_a_pre_call_deployment_hook(monkeypatch): diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index a29e0be4655..7dd6065063a 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1559,3 +1559,87 @@ class TestScanOnlyToolResults: assert data["messages"][3]["content"] == "page says [BLOCKED] here" assert data["messages"][3]["tool_call_id"] == "call_1" assert data["messages"][4]["content"] == "and then?" + + +class TestBuildBlockSseChunks: + """build_block_sse_chunks turns a streaming ModifyResponseException into 200 SSE chunks""" + + def _exc(self, original_response=None): + from litellm.exceptions import ModifyResponseException + + return ModifyResponseException( + message="Blocked by policy.", + model="gpt-5.4-mini", + request_data={}, + guardrail_name="test", + original_response=original_response, + ) + + def _payloads(self, chunks): + return [json.loads(chunk.decode().removeprefix("data: ").strip()) for chunk in chunks] + + def test_standalone_block_uses_fresh_identity_and_zero_usage(self): + handler = OpenAIChatCompletionsHandler() + first, final = self._payloads(handler.build_block_sse_chunks(self._exc(), stream_started=False)) + assert first["id"].startswith("chatcmpl-") + assert first["model"] == "gpt-5.4-mini" + assert first["choices"][0]["delta"] == {"role": "assistant", "content": "Blocked by policy."} + assert first["choices"][0]["finish_reason"] is None + assert final["choices"][0]["delta"] == {} + assert final["choices"][0]["finish_reason"] == "content_filter" + assert final["usage"] == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + + def test_continuation_reuses_stream_identity_and_real_usage(self): + handler = OpenAIChatCompletionsHandler() + yielded = [ + {"id": "chatcmpl-live", "created": 1724900000, "model": "gpt-5.4-mini-2026-01-01"}, + ] + original = yielded + [ + {"id": "chatcmpl-live", "usage": {"prompt_tokens": 11, "completion_tokens": 5}}, + ] + first, final = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=original), stream_started=True, responses_so_far=yielded + ) + ) + assert (first["id"], first["created"], first["model"]) == ( + "chatcmpl-live", + 1724900000, + "gpt-5.4-mini-2026-01-01", + ) + assert first["choices"][0]["delta"] == {"content": "Blocked by policy."} + assert final["id"] == "chatcmpl-live" + assert final["usage"] == {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16} + + +class TestCheckStreamingHasEnded: + """_check_streaming_has_ended lets end_of_stream_only withhold the finish chunk until moderation""" + + def test_empty_and_content_only_chunks_are_not_ended(self): + handler = OpenAIChatCompletionsHandler() + assert handler._check_streaming_has_ended([]) is False + content_only = [ + {"id": "chatcmpl-live", "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": None}]}, + {"id": "chatcmpl-live", "choices": []}, + {"id": "chatcmpl-live", "usage": {"prompt_tokens": 1, "completion_tokens": 1}}, + ] + assert handler._check_streaming_has_ended(content_only) is False + + def test_dict_finish_chunk_marks_stream_ended(self): + handler = OpenAIChatCompletionsHandler() + chunks = [ + {"id": "chatcmpl-live", "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": None}]}, + {"id": "chatcmpl-live", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}, + ] + assert handler._check_streaming_has_ended(chunks) is True + + def test_object_finish_chunk_marks_stream_ended(self): + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + handler = OpenAIChatCompletionsHandler() + chunks = [ + ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(content=None), finish_reason="stop")] + ) + ] + assert handler._check_streaming_has_ended(chunks) is True diff --git a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py index 3ef5e39fc5f..9737d63cc26 100644 --- a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py +++ b/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py @@ -871,6 +871,134 @@ class TestCacheControlPreservationForCustomEndpoint: assert all("cache_control" not in m for m in body["messages"]) +class TestToolChoiceWithoutToolsDropped: + def setup_method(self): + self.config = OpenAIGPTConfig() + + @staticmethod + def _pi_compact_summarization_messages(): + return [ + { + "role": "system", + "content": "You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.", + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "\n[User]: Reply with exactly: ok-1\n\n[Assistant]: ok-1\n\n\nThe messages above are a conversation to summarize.", + } + ], + }, + ] + + def _transform(self, optional_params, config=None, model="gpt-5.6-sol"): + return (config or self.config).transform_request( + model=model, + messages=self._pi_compact_summarization_messages(), + optional_params=optional_params, + litellm_params={"custom_llm_provider": "openai", "api_base": None}, + headers={}, + ) + + def test_pi_compact_shape_drops_tool_choice_none_without_tools(self): + body = self._transform( + { + "stream": True, + "stream_options": {"include_usage": True}, + "store": False, + "max_completion_tokens": 13107, + "tool_choice": "none", + } + ) + assert "tool_choice" not in body + assert "tools" not in body + assert body["model"] == "gpt-5.6-sol" + assert body["stream"] is True + assert body["stream_options"] == {"include_usage": True} + assert body["store"] is False + assert body["max_completion_tokens"] == 13107 + + def test_drops_tool_choice_auto_without_tools(self): + body = self._transform({"tool_choice": "auto"}) + assert "tool_choice" not in body + + def test_drops_named_function_tool_choice_without_tools(self): + body = self._transform( + {"tool_choice": {"type": "function", "function": {"name": "get_weather"}}} + ) + assert "tool_choice" not in body + + def test_drops_tool_choice_but_keeps_empty_tools_array(self): + body = self._transform({"tools": [], "tool_choice": "none"}) + assert "tool_choice" not in body + assert body["tools"] == [] + + def test_gpt5_config_drops_tool_choice_without_tools(self): + body = self._transform({"tool_choice": "none"}, config=OpenAIGPT5Config()) + assert "tool_choice" not in body + + @pytest.mark.parametrize( + "tool_choice", + [ + "none", + "auto", + "required", + {"type": "function", "function": {"name": "get_weather"}}, + ], + ) + def test_preserves_tool_choice_when_tools_present(self, tool_choice): + tools = [ + { + "type": "function", + "function": {"name": "get_weather", "parameters": {}}, + } + ] + body = self._transform({"tools": tools, "tool_choice": tool_choice}) + assert body["tool_choice"] == tool_choice + assert body["tools"] == tools + + def test_preserves_tool_choice_with_legacy_functions(self): + functions = [{"name": "get_weather", "parameters": {}}] + body = self._transform({"functions": functions, "tool_choice": "auto"}) + assert body["tool_choice"] == "auto" + assert body["functions"] == functions + + def test_preserves_function_call_without_functions(self): + body = self._transform({"function_call": "none"}) + assert body["function_call"] == "none" + + @pytest.mark.asyncio + async def test_async_transform_drops_tool_choice_without_tools(self): + body = await self.config.async_transform_request( + model="gpt-5.6-sol", + messages=self._pi_compact_summarization_messages(), + optional_params={"stream": True, "tool_choice": "none"}, + litellm_params={"custom_llm_provider": "openai", "api_base": None}, + headers={}, + ) + assert "tool_choice" not in body + + @pytest.mark.asyncio + async def test_async_transform_preserves_tool_choice_when_tools_present(self): + tools = [ + { + "type": "function", + "function": {"name": "get_weather", "parameters": {}}, + } + ] + body = await self.config.async_transform_request( + model="gpt-5.6-sol", + messages=self._pi_compact_summarization_messages(), + optional_params={"tools": tools, "tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "openai", "api_base": None}, + headers={}, + ) + assert body["tool_choice"] == "auto" + assert body["tools"] == tools + + class TestToolMessageImageHoisting: """transform_request moves tool-message images into a following user message (OpenAI-compatible APIs only accept text in role:"tool" messages).""" @@ -1038,3 +1166,118 @@ class TestOpenAIPromptCacheBreakpointChatPath: assert request["messages"][1]["content"] == [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}] assert request["extra_body"] == {"prompt_cache_options": self.EXPLICIT} assert "prompt_cache_options" not in request + + +class TestToolSchemaCombinatorFlatteningForOpenAI: + """ + Regression tests for LIT-6488: OpenAI's chat completions validator rejects + tool parameters carrying a top-level anyOf/oneOf/allOf for every model + family, GPT-5 included, unlike the Responses API. + """ + + def setup_method(self): + self.config = OpenAIGPTConfig() + + @pytest.fixture(autouse=True) + def _clean_openai_base_env(self, monkeypatch): + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.setattr(litellm, "api_base", None, raising=False) + + @staticmethod + def _anyof_tool(): + return { + "type": "function", + "function": { + "name": "automation_update", + "description": "Update an automation", + "parameters": { + "type": "object", + "anyOf": [ + { + "properties": {"id": {"type": "string"}, "enabled": {"type": "boolean"}}, + "required": ["id", "enabled"], + }, + { + "properties": {"id": {"type": "string"}, "schedule": {"type": "string"}}, + "required": ["id", "schedule"], + }, + ], + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + } + + def _transform(self, config, model, litellm_params, tools): + return config.transform_request( + model=model, + messages=[{"role": "user", "content": "hi"}], + optional_params={"tools": tools}, + litellm_params=litellm_params, + headers={}, + ) + + def test_flattens_top_level_anyof_for_hosted_openai(self): + request = self._transform( + self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [self._anyof_tool()] + ) + parameters = request["tools"][0]["function"]["parameters"] + assert "anyOf" not in parameters + assert parameters["type"] == "object" + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} + assert parameters["required"] == ["id"] + assert request["tools"][0]["function"]["name"] == "automation_update" + + def test_gpt5_family_flattens_on_chat_completions(self): + request = self._transform( + OpenAIGPT5Config(), "gpt-5.6", {"custom_llm_provider": "openai", "api_base": None}, [self._anyof_tool()] + ) + assert "anyOf" not in request["tools"][0]["function"]["parameters"] + + def test_custom_api_base_keeps_union(self): + tool = self._anyof_tool() + request = self._transform( + self.config, + "gpt-4o", + {"custom_llm_provider": "openai", "api_base": "http://localhost:8000/v1"}, + [tool], + ) + assert request["tools"][0]["function"]["parameters"] == self._anyof_tool()["function"]["parameters"] + + def test_non_openai_provider_keeps_union(self): + request = self._transform( + self.config, "some-oss-model", {"custom_llm_provider": "groq", "api_base": None}, [self._anyof_tool()] + ) + assert request["tools"][0]["function"]["parameters"] == self._anyof_tool()["function"]["parameters"] + + def test_caller_tool_dict_is_not_mutated(self): + tool = self._anyof_tool() + self._transform(self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool]) + assert tool == self._anyof_tool() + + def test_clean_object_schema_passes_through_as_same_object(self): + tool = { + "type": "function", + "function": { + "name": "lookup", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + }, + } + request = self._transform( + self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool] + ) + assert request["tools"][0] is tool + + @pytest.mark.asyncio + async def test_async_transform_request_flattens_for_hosted_openai(self): + request = await self.config.async_transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tools": [self._anyof_tool()]}, + litellm_params={"custom_llm_provider": "openai", "api_base": None}, + headers={}, + ) + parameters = request["tools"][0]["function"]["parameters"] + assert "anyOf" not in parameters + assert set(parameters["properties"]) == {"id", "enabled", "schedule"} diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index 447175b09a6..315b6948bd8 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -828,6 +828,24 @@ class MockPassThroughGuardrail(CustomGuardrail): return inputs +class MockRecordingGuardrail(MockPassThroughGuardrail): + """Pass-through guardrail that records every apply_guardrail inputs payload""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.seen_inputs: List[GenericGuardrailAPIInputs] = [] + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + self.seen_inputs.append(inputs) + return inputs + + class TestOpenAIResponsesHandlerStreamingOutputProcessing: """Test streaming output processing functionality""" @@ -1104,6 +1122,80 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing: output_text = result[-1]["response"]["output"][0]["content"][0]["text"] assert output_text == original_text + @pytest.mark.asyncio + async def test_failed_stream_scans_delta_text(self): + """A stream ending in response.failed has text only in delta events; the + fallback scan must assemble and scan it instead of skipping on an empty string.""" + handler = OpenAIResponsesHandler() + guardrail = MockRecordingGuardrail(guardrail_name="test") + + responses_so_far = [ + {"type": "response.created", "response": {"id": "resp_123"}}, + {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_123"}}, + { + "type": "response.output_text.delta", + "item_id": "msg_123", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + }, + { + "type": "response.output_text.delta", + "item_id": "msg_123", + "output_index": 0, + "content_index": 0, + "delta": " world", + }, + {"type": "response.failed", "response": {"id": "resp_123", "status": "failed"}}, + ] + + result = await handler.process_output_streaming_response( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail, + litellm_logging_obj=None, + ) + + assert result == responses_so_far + assert [inputs.get("texts") for inputs in guardrail.seen_inputs] == [["Hello world"]] + + def test_get_streaming_string_so_far_prefers_done_text_over_deltas(self): + """The done event repeats the whole part, so deltas must not be double counted; + a part with no done event yet still contributes its joined deltas.""" + handler = OpenAIResponsesHandler() + + events = [ + { + "type": "response.output_text.delta", + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + }, + { + "type": "response.output_text.delta", + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": " world", + }, + { + "type": "response.output_text.done", + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "text": "Hello world", + }, + { + "type": "response.output_text.delta", + "item_id": "msg_2", + "output_index": 1, + "content_index": 0, + "delta": "; unfinished", + }, + ] + + assert handler.get_streaming_string_so_far(events) == "Hello world; unfinished" + class TestGetStructuredMessages: """Test the get_structured_messages method for Responses API handler.""" @@ -1229,3 +1321,219 @@ class TestOpenAIResponsesHandlerToolInjection: names = [t.get("name") for t in result["tools"]] assert "get_weather" in names assert "injected_tool" in names + + +class TestBuildBlockSseChunks: + """build_block_sse_chunks turns a streaming ModifyResponseException into 200 SSE events""" + + def _exc(self, original_response=None): + from litellm.exceptions import ModifyResponseException + + return ModifyResponseException( + message="Blocked by policy.", + model="gpt-5.4-mini", + request_data={}, + guardrail_name="test", + original_response=original_response, + ) + + def _payloads(self, chunks): + import json + + return [json.loads(chunk.decode().removeprefix("data: ").strip()) for chunk in chunks] + + def test_standalone_block_emits_complete_synthetic_stream(self): + handler = OpenAIResponsesHandler() + payloads = self._payloads(handler.build_block_sse_chunks(self._exc(), stream_started=False)) + types = [payload["type"] for payload in payloads] + assert types[0] == "response.created" + assert types[-1] == "response.completed" + completed = payloads[-1]["response"] + assert completed["id"].startswith("resp_") + assert completed["model"] == "gpt-5.4-mini" + assert completed["output"][0]["content"][0]["text"] == "Blocked by policy." + + def test_continuation_appends_item_at_next_output_index_with_real_usage(self): + handler = OpenAIResponsesHandler() + yielded = [ + {"type": "response.created", "response": {"id": "resp_live", "model": "gpt-5.4-mini-2026-01-01"}}, + {"type": "response.output_item.added", "output_index": 2, "item": {"id": "msg_orig"}}, + ] + original = yielded + [ + { + "type": "response.completed", + "response": { + "id": "resp_live", + "model": "gpt-5.4-mini-2026-01-01", + "output": [], + "usage": {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28}, + }, + } + ] + payloads = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=original), stream_started=True, responses_so_far=yielded + ) + ) + types = [payload["type"] for payload in payloads] + assert "response.created" not in types + assert types[0] == "response.output_item.done" + assert payloads[0]["output_index"] == 2 + assert payloads[0]["item"]["id"] == "msg_orig" + assert payloads[0]["item"]["status"] == "completed" + assert types[1] == "response.output_item.added" + assert payloads[1]["output_index"] == 3 + completed = payloads[-1]["response"] + assert completed["id"] == "resp_live" + assert completed["model"] == "gpt-5.4-mini-2026-01-01" + assert completed["output"][0]["content"][0]["text"] == "Blocked by policy." + assert completed["usage"] == {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28} + + def test_continuation_reads_usage_from_typed_completed_event(self): + from litellm.types.llms.openai import ( + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + handler = OpenAIResponsesHandler() + original = [ + ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=ResponsesAPIResponse.model_validate( + { + "id": "resp_live", + "created_at": 1, + "model": "gpt-5.4-mini", + "output": [], + "usage": {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28}, + } + ), + ) + ] + payloads = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=original), stream_started=True, responses_so_far=[] + ) + ) + completed = payloads[-1]["response"] + assert completed["usage"]["input_tokens"] == 7 + assert completed["usage"]["output_tokens"] == 21 + assert completed["usage"]["total_tokens"] == 28 + + def test_continuation_closes_open_item_given_pydantic_events_with_enum_types(self): + from litellm.types.llms.openai import ( + BaseLiteLLMOpenAIResponseObject, + ContentPartAddedEvent, + OutputItemAddedEvent, + OutputTextDeltaEvent, + ResponsesAPIStreamEvents, + ) + + handler = OpenAIResponsesHandler() + open_item = GenericResponseOutputItem.model_validate( + {"type": "message", "id": "msg_live", "status": "in_progress", "role": "assistant", "content": []} + ) + yielded = [ + OutputItemAddedEvent( + type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, output_index=0, item=open_item + ), + ContentPartAddedEvent( + type=ResponsesAPIStreamEvents.CONTENT_PART_ADDED, + item_id="msg_live", + output_index=0, + content_index=0, + part=BaseLiteLLMOpenAIResponseObject.model_validate( + {"type": "output_text", "text": "", "annotations": []} + ), + ), + OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id="msg_live", + output_index=0, + content_index=0, + delta="partial ", + ), + OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id="msg_live", + output_index=0, + content_index=0, + delta="text", + ), + ] + payloads = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=yielded), stream_started=True, responses_so_far=yielded + ) + ) + types = [payload["type"] for payload in payloads] + assert types[:3] == [ + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + ] + assert payloads[0]["text"] == "partial text" + assert payloads[2]["item"]["id"] == "msg_live" + assert payloads[2]["item"]["status"] == "completed" + assert payloads[2]["item"]["content"][0]["text"] == "partial text" + assert types[3] == "response.output_item.added" + assert payloads[3]["output_index"] == 1 + + def test_continuation_closes_open_function_call_as_incomplete(self): + handler = OpenAIResponsesHandler() + yielded = [ + {"type": "response.created", "response": {"id": "resp_live", "model": "gpt-5.4-mini"}}, + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_live", + "type": "function_call", + "status": "in_progress", + "call_id": "call_1", + "name": "run_payment", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_live", + "output_index": 0, + "delta": '{"amount": 100}', + }, + ] + payloads = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=yielded), stream_started=True, responses_so_far=yielded + ) + ) + types = [payload["type"] for payload in payloads] + assert types[0] == "response.output_item.done" + closed = payloads[0]["item"] + assert closed["id"] == "fc_live" + assert closed["type"] == "function_call" + assert closed["status"] == "incomplete" + assert closed["name"] == "run_payment" + assert "content" not in closed + assert types[1] == "response.output_item.added" + assert payloads[1]["output_index"] == 1 + assert types[-1] == "response.completed" + + def test_continuation_without_open_item_emits_no_closing_events(self): + handler = OpenAIResponsesHandler() + yielded = [ + {"type": "response.created", "response": {"id": "resp_live", "model": "gpt-5.4-mini"}}, + {"type": "response.in_progress", "response": {"id": "resp_live"}}, + ] + payloads = self._payloads( + handler.build_block_sse_chunks( + self._exc(original_response=yielded), stream_started=True, responses_so_far=yielded + ) + ) + types = [payload["type"] for payload in payloads] + assert types[0] == "response.output_item.added" + assert types[-1] == "response.completed" + dones = [payload for payload in payloads if payload["type"] == "response.output_item.done"] + assert len(dones) == 1 + assert dones[0]["item"]["content"][0]["text"] == "Blocked by policy." diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index 1a90db7c1fe..b0ffd1845fe 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -220,6 +220,111 @@ class TestOpenAIResponsesAPIConfig: assert result["input"] == input_clean + def test_transform_drops_foreign_tool_call_item_ids(self): + """Replayed tool call items whose ids are not OpenAI-shaped (e.g. + Anthropic toolu_/srvtoolu_ ids after a router fallback) must be sent + without an id: OpenAI 400s foreign ids ("Expected an ID that begins + with 'fc'") but accepts the items with no id at all. Genuine fc_/ctc_ + ids and non-tool-call items pass through untouched.""" + replayed_input = [ + {"role": "user", "content": [{"type": "input_text", "text": "hi"}]}, + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": '{"city": "SF"}', + }, + {"type": "function_call_output", "call_id": "toolu_01Foreign", "output": "sunny"}, + { + "type": "custom_tool_call", + "id": "srvtoolu_01Foreign", + "call_id": "srvtoolu_01Foreign", + "name": "apply_patch", + "input": "patch", + }, + { + "type": "function_call", + "id": "fc_genuine", + "call_id": "call_genuine", + "name": "get_weather", + "arguments": "{}", + }, + {"type": "message", "id": "msg_1", "role": "assistant", "content": []}, + ] + + result = self.config.transform_responses_api_request( + model=self.model, + input=replayed_input, + response_api_optional_request_params={}, + litellm_params={}, + headers={}, + ) + + assert "id" not in result["input"][1] + assert result["input"][1]["call_id"] == "toolu_01Foreign" + assert "id" not in result["input"][3] + assert result["input"][3]["call_id"] == "srvtoolu_01Foreign" + assert result["input"][4]["id"] == "fc_genuine" + assert result["input"][5]["id"] == "msg_1" + assert replayed_input[1]["id"] == "toolu_01Foreign" + assert replayed_input[3]["id"] == "srvtoolu_01Foreign" + + def test_transform_keeps_foreign_tool_call_item_ids_for_other_providers(self): + """Providers reusing this config that do not enforce OpenAI's id + shapes must keep replayed ids untouched.""" + from litellm.types.utils import LlmProviders + + class _OpenRouterLikeConfig(OpenAIResponsesAPIConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.OPENROUTER + + replayed_input = [ + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": "{}", + } + ] + + result = _OpenRouterLikeConfig().transform_responses_api_request( + model="openrouter/some-model", + input=replayed_input, + response_api_optional_request_params={}, + litellm_params={}, + headers={}, + ) + + assert result["input"][0]["id"] == "toolu_01Foreign" + + def test_transform_compact_drops_foreign_tool_call_item_ids(self): + """The compact request path replays input the same way, so it must + apply the same id drop.""" + replayed_input = [ + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": "{}", + } + ] + + _url, data = self.config.transform_compact_response_api_request( + model=self.model, + input=replayed_input, + response_api_optional_request_params={}, + api_base="https://api.openai.com/v1/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "id" not in data["input"][0] + assert data["input"][0]["call_id"] == "toolu_01Foreign" + def test_transform_streaming_response(self): """Test streaming response transformation""" # Test with a text delta event diff --git a/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search.py b/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search.py index 8a9ae4dae6d..62b4d003b45 100644 --- a/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search.py +++ b/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search.py @@ -2,6 +2,7 @@ Tests for Parallel AI Search API integration (v1 endpoint). """ +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -30,13 +31,41 @@ MOCK_V1_RESPONSE = { } -def _mock_response(): +def _mock_response(payload=None): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = MOCK_V1_RESPONSE + mock_response.json.return_value = payload if payload is not None else MOCK_V1_RESPONSE return mock_response +@pytest.fixture +def httpx_transport(monkeypatch): + monkeypatch.setattr( # test-quality-ok: respx needs HTTPX enabled to fake the provider HTTP boundary. + litellm, + "disable_aiohttp_transport", + True, + ) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.fixture +def bundled_cost_map(monkeypatch): + """Price lookups against the bundled cost map. + + litellm caches model-info lookups, so swapping ``model_cost`` only takes + effect once those caches are invalidated -- on the way in and back out. + """ + from litellm.utils import _invalidate_model_cost_lowercase_map + + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + _invalidate_model_cost_lowercase_map() + yield + monkeypatch.undo() + _invalidate_model_cost_lowercase_map() + + class TestParallelAISearch: @pytest.fixture(autouse=True) def _set_api_key(self, monkeypatch): @@ -135,9 +164,7 @@ class TestParallelAISearch: json_data = mock_post.call_args.kwargs.get("json") assert json_data["mode"] == "basic" - @pytest.mark.parametrize( - "processor,expected_mode", [("base", "basic"), ("pro", "advanced")] - ) + @pytest.mark.parametrize("processor,expected_mode", [("base", "basic"), ("pro", "advanced")]) @pytest.mark.asyncio async def test_legacy_processor_maps_to_mode(self, processor, expected_mode): with patch( @@ -222,9 +249,7 @@ class TestParallelAISearch: "arxiv.org", "nature.com", ] - assert advanced_settings["source_policy"]["exclude_domains"] == [ - "reddit.com" - ] + assert advanced_settings["source_policy"]["exclude_domains"] == ["reddit.com"] assert advanced_settings["excerpt_settings"]["max_chars_per_result"] == 1500 assert "max_results" not in json_data @@ -306,10 +331,7 @@ class TestParallelAISearch: ) call_args = mock_post.call_args - assert ( - call_args.kwargs["url"] - == "https://proxy.internal.example.com/v1/search" - ) + assert call_args.kwargs["url"] == "https://proxy.internal.example.com/v1/search" @pytest.mark.asyncio async def test_caller_api_base_without_key_is_refused(self, monkeypatch): @@ -338,3 +360,147 @@ class TestParallelAISearch: query="AI developments", search_provider="parallel_ai", ) + + @pytest.mark.asyncio + async def test_flat_source_and_fetch_params_nest_under_advanced_settings(self, respx_mock, httpx_transport): + route = respx_mock.post("https://api.parallel.ai/v1/search").respond(json=MOCK_V1_RESPONSE) + + await litellm.asearch( + query="AI developments", + search_provider="parallel_ai", + objective="find peer-reviewed AI research", + include_domains=["arxiv.org"], + after_date="2026-01-01", + location="gb", + fetch_policy={"max_age_seconds": 600, "disable_cache_fallback": True}, + client_model="claude-fable-5", + ) + + json_data = json.loads(route.calls[0].request.content) + assert json_data["objective"] == "find peer-reviewed AI research" + assert json_data["client_model"] == "claude-fable-5" + + advanced_settings = json_data["advanced_settings"] + assert advanced_settings["location"] == "gb" + assert advanced_settings["fetch_policy"] == { + "max_age_seconds": 600, + "disable_cache_fallback": True, + } + assert advanced_settings["source_policy"]["include_domains"] == ["arxiv.org"] + assert advanced_settings["source_policy"]["after_date"] == "2026-01-01" + + assert "include_domains" not in json_data + assert "after_date" not in json_data + assert "location" not in json_data + assert "fetch_policy" not in json_data + + @pytest.mark.asyncio + async def test_response_preserves_raw_parallel_fields(self, respx_mock, httpx_transport): + respx_mock.post("https://api.parallel.ai/v1/search").respond(json=MOCK_V1_RESPONSE) + + response = await litellm.asearch( + query="AI developments", + search_provider="parallel_ai", + ) + + dumped = response.model_dump() + assert dumped["search_id"] == "search_abc123" + assert dumped["session_id"] == "session_xyz" + assert dumped["parallel_usage"] == [{"name": "search_advanced", "count": 1}] + + first = response.results[0].model_dump() + assert first["excerpts"] == ["First excerpt.", "Second excerpt."] + + @pytest.mark.asyncio + async def test_response_normalizes_null_result_fields(self, respx_mock, httpx_transport): + response_payload = { + **MOCK_V1_RESPONSE, + "results": [{"url": None, "title": None, "publish_date": None, "excerpts": None}], + } + respx_mock.post("https://api.parallel.ai/v1/search").respond(json=response_payload) + + response = await litellm.asearch( + query="AI developments", + search_provider="parallel_ai", + ) + + assert len(response.results) == 1 + result = response.results[0] + assert result.url == "" + assert result.title == "" + assert result.snippet == "" + assert result.date is None + assert result.model_dump()["excerpts"] == () + + @pytest.mark.parametrize( + "mode,usage,max_results,expected_cost", + [ + ("turbo", [{"name": "sku_search", "count": 1}], None, 0.001), + ("fast", [{"name": "sku_search", "count": 1}], None, 0.001), + ("basic", [{"name": "sku_search", "count": 1}], None, 0.005), + ("advanced", [{"name": "sku_search", "count": 1}], None, 0.005), + ( + "basic", + [ + {"name": "sku_search", "count": 1}, + {"name": "sku_search_additional_results", "count": 2}, + ], + 20, + 0.007, + ), + ("basic", None, 20, 0.015), + ], + ) + @pytest.mark.asyncio + async def test_search_cost_uses_mode_and_provider_usage( + self, mode, usage, max_results, expected_cost, bundled_cost_map, respx_mock, httpx_transport + ): + response_payload = {**MOCK_V1_RESPONSE, "usage": usage} + respx_mock.post("https://api.parallel.ai/v1/search").respond(json=response_payload) + + response = await litellm.asearch( + query="AI developments", + search_provider="parallel_ai", + mode=mode, + max_results=max_results, + ) + + assert response._hidden_params["response_cost"] == pytest.approx(expected_cost) + + @pytest.mark.asyncio + async def test_search_cost_treats_keyword_queries_as_one_request( + self, bundled_cost_map, respx_mock, httpx_transport + ): + response_payload = { + **MOCK_V1_RESPONSE, + "usage": [{"name": "sku_search", "count": 1}], + } + respx_mock.post("https://api.parallel.ai/v1/search").respond(json=response_payload) + + response = await litellm.asearch( + query=["AI developments", "machine learning trends"], + search_provider="parallel_ai", + mode="basic", + ) + + assert response._hidden_params["response_cost"] == pytest.approx(0.005) + + @pytest.mark.asyncio + async def test_caller_cannot_supply_provider_usage(self, bundled_cost_map, respx_mock, httpx_transport): + """`_parallel_ai_usage` prices the request, so a caller must not be able to set it. + + The provider reports no usage here, which is the case where a caller-supplied + value would otherwise survive into the cost calculation. + """ + response_payload = {k: v for k, v in MOCK_V1_RESPONSE.items() if k != "usage"} + route = respx_mock.post("https://api.parallel.ai/v1/search").respond(json=response_payload) + + response = await litellm.asearch( + query="AI developments", + search_provider="parallel_ai", + mode="basic", + _parallel_ai_usage=[{"name": "sku_search", "count": 0}], + ) + + assert response._hidden_params["response_cost"] == pytest.approx(0.005) + assert "_parallel_ai_usage" not in json.loads(route.calls[0].request.content) diff --git a/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search_gateway.py b/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search_gateway.py new file mode 100644 index 00000000000..72c69fc622c --- /dev/null +++ b/tests/test_litellm/llms/parallel_ai/test_parallel_ai_search_gateway.py @@ -0,0 +1,191 @@ +"""Gateway coverage for Parallel AI Search.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Final +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi.testclient import TestClient + +import litellm +from litellm import Router +from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, +) +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.proxy import proxy_server +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.utils import LlmProviders + +PARALLEL_SEARCH_URL: Final = "https://api.parallel.ai/v1/search" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(proxy_server.app, raise_server_exceptions=False) + + +@pytest.fixture +def auth_as() -> Iterator[None]: + async def _authorized_request() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="hashed-sk-test", + user_id="parallel-test-user", + ) + + previous: Final = proxy_server.app.dependency_overrides.get(user_api_key_auth) + proxy_server.app.dependency_overrides[user_api_key_auth] = _authorized_request + try: + yield + finally: + if previous is None: + proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) + else: + proxy_server.app.dependency_overrides[user_api_key_auth] = previous + + +def _parallel_search_body() -> dict[str, object]: + return { + "search_id": "search_parallel_gateway", + "results": [ + { + "url": "https://example.com/parallel", + "title": "Parallel result", + "publish_date": "2026-08-13", + "excerpts": ["First excerpt", "Second excerpt"], + } + ], + "usage": [{"name": "sku_search", "count": 1}], + } + + +def _parallel_router(mode: str = "turbo") -> Router: + return Router( + model_list=[], + search_tools=[ + { + "search_tool_name": "parallel-search", + "litellm_params": { + "search_provider": "parallel_ai", + "api_key": "parallel-search-key", + "mode": mode, + }, + } + ], + num_retries=0, + ) + + +def _mock_async_post( + monkeypatch, + *, + url: str, + response_body: dict[str, object], +) -> AsyncMock: + response = httpx.Response( + status_code=200, + json=response_body, + request=httpx.Request("POST", url), + ) + mock_post = AsyncMock(return_value=response) + monkeypatch.setattr(AsyncHTTPHandler, "post", mock_post) + return mock_post + + +def test_parallel_search_gateway_route(client, auth_as, monkeypatch): + """The named search route selects its configured Parallel Search tool. + + The tool-level `mode` must survive the router hop, so the upstream request + is sent as `turbo` rather than falling back to the adapter default. + """ + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + monkeypatch.setattr(proxy_server, "llm_router", _parallel_router()) + mock_post = _mock_async_post( + monkeypatch, + url=PARALLEL_SEARCH_URL, + response_body=_parallel_search_body(), + ) + + response = client.post( + "/v1/search/parallel-search", + json={"query": "Parallel AI news", "max_results": 3}, + ) + + assert response.status_code == 200, response.text + assert response.json()["results"] == [ + { + "title": "Parallel result", + "url": "https://example.com/parallel", + "snippet": "First excerpt ... Second excerpt", + "date": "2026-08-13", + "last_updated": None, + "excerpts": ["First excerpt", "Second excerpt"], + } + ] + + request_kwargs = mock_post.await_args.kwargs + assert request_kwargs["url"] == PARALLEL_SEARCH_URL + assert request_kwargs["headers"]["x-api-key"] == "parallel-search-key" + assert request_kwargs["json"] == { + "objective": "Parallel AI news", + "search_queries": ["Parallel AI news"], + "mode": "turbo", + "advanced_settings": {"max_results": 3}, + } + + +@pytest.mark.asyncio +async def test_web_search_interception_executes_parallel_search(monkeypatch): + """An intercepted web-search call uses the configured Parallel Search tool.""" + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + monkeypatch.setattr(proxy_server, "llm_router", _parallel_router(mode="fast")) + mock_post = _mock_async_post( + monkeypatch, + url=PARALLEL_SEARCH_URL, + response_body=_parallel_search_body(), + ) + logger = WebSearchInterceptionLogger( + enabled_providers=[LlmProviders.OPENAI], + search_tool_name="parallel-search", + ) + + plan = await logger.async_build_responses_agentic_loop_plan( + tools={ + "tool_calls": [ + { + "id": "fc_parallel", + "call_id": "fc_parallel", + "type": "function_call", + "name": "litellm_web_search", + "arguments": '{"query":"Parallel AI news"}', + "input": {"query": "Parallel AI news"}, + } + ] + }, + model="gpt-5", + messages=[{"role": "user", "content": "Research Parallel"}], + response=None, + optional_params={"tools": [{"type": "function", "name": "litellm_web_search"}]}, + logging_obj=None, + stream=False, + kwargs={"custom_llm_provider": "openai"}, + ) + + assert plan.run_agentic_loop is True + assert plan.request_patch is not None + assert plan.request_patch.messages[-1] == { + "type": "function_call_output", + "call_id": "fc_parallel", + "output": ( + "Title: Parallel result\nURL: https://example.com/parallel\nSnippet: First excerpt ... Second excerpt" + ), + } + + request_kwargs = mock_post.await_args.kwargs + assert request_kwargs["url"] == PARALLEL_SEARCH_URL + assert request_kwargs["headers"]["x-api-key"] == "parallel-search-key" + assert request_kwargs["json"]["mode"] == "fast" diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index 552ca98441f..9419f88a981 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -727,3 +727,28 @@ def test_sanitize_strips_effort_for_haiku_45(): data = {"output_config": {"effort": "high"}} sanitize_vertex_anthropic_output_params(data, "vertex_ai/claude-opus-4-6") assert data["output_config"] == {"effort": "high"} + + +def test_vertex_ai_fable_5_1_response_format_uses_native_output_format(local_model_cost_map): + """Regression: Fable 5.1 rejects forced tool use, so the vertex map entry + advertises native structured output and ``response_format`` must map to + ``output_format`` instead of the tool-based path's forced tool_choice.""" + config = VertexAIAnthropicConfig() + response_format = { + "type": "json_schema", + "json_schema": { + "name": "test_schema", + "schema": {"type": "object", "properties": {"result": {"type": "string"}}}, + }, + } + + result_params = config.map_openai_params( + non_default_params={"response_format": response_format}, + optional_params={}, + model="claude-fable-5-1", + drop_params=False, + ) + + assert "output_format" in result_params + assert "tool_choice" not in result_params + assert "tools" not in result_params diff --git a/tests/test_litellm/ocr/test_function_trace.py b/tests/test_litellm/ocr/test_function_trace.py new file mode 100644 index 00000000000..9d6759a5848 --- /dev/null +++ b/tests/test_litellm/ocr/test_function_trace.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import Awaitable +from functools import partial +from types import FunctionType +from typing import Final, Protocol, cast + +import pytest +from pydantic import StrictInt, StrictStr, TypeAdapter +from typing_extensions import ReadOnly, TypedDict + +import litellm +from litellm.llms.mistral.ocr.transformation import MistralOCRConfig +from litellm.rust_bridge import get_native_bridge +from litellm.rust_bridge import ocr as rust_ocr_bridge +from tests.sdk_function_trace import ( + FunctionTraceEvent, + TraceScenario, + TraceStep, + assert_function_trace_parity, +) +from tests.sdk_function_trace.mock_provider import MockProviderResponse, mock_provider +from tests.sdk_function_trace.profiler import profile_python + +MODEL: Final = "mistral-ocr-latest" +DOCUMENT: Final[dict[str, str]] = { + "type": "document_url", + "document_url": "https://example.com/document.pdf", +} +OPTIONAL_PARAMS: Final[dict[str, object]] = {"pages": [0], "unsupported": True} +RESPONSE_DATA: Final[dict[str, object]] = { + "pages": [{"index": 0, "markdown": "hello"}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1}, +} +PROVIDER_RESPONSE: Final = MockProviderResponse( + status_code=200, + headers=(("content-type", "application/json"),), + body=json.dumps(RESPONSE_DATA).encode(), +) + + +class _TraceEventPayload(TypedDict): + function: ReadOnly[StrictStr] + depth: ReadOnly[StrictInt] + + +class _TraceResponsePayload(TypedDict): + response: ReadOnly[object] + trace: ReadOnly[list[_TraceEventPayload]] + + +_TRACE_RESPONSE: Final = TypeAdapter(_TraceResponsePayload) + + +class _NativeTraceOcr(Protocol): + def __call__( + self, + *, + model: str, + document: dict[str, str], + api_key: str, + api_base: str, + optional_params: dict[str, object], + trace: bool, + ) -> object: ... + + +def _invoke_python() -> object: + previous_enabled: Final = rust_ocr_bridge.rust_ocr_enabled() + rust_ocr_bridge.use_litellm_rust(False) + try: + with mock_provider(PROVIDER_RESPONSE) as api_base: + return litellm.ocr( + model=f"mistral/{MODEL}", + document=DOCUMENT, + api_key="test-key", + api_base=api_base, + pages=[0], + unsupported=True, + ) + finally: + rust_ocr_bridge.use_litellm_rust(previous_enabled) + + +def _invoke_rust(*, asynchronous: bool = False) -> tuple[FunctionTraceEvent, ...]: + bridge: Final = get_native_bridge() + if bridge is None: + raise AssertionError("The native Rust bridge is required for function-trace parity") + trace_ocr: Final = cast(_NativeTraceOcr, bridge.aocr if asynchronous else bridge.ocr) + with mock_provider(PROVIDER_RESPONSE) as api_base: + invoke: Final = partial( + trace_ocr, + model=f"mistral/{MODEL}", + document=DOCUMENT, + api_key="test-key", + api_base=api_base, + optional_params=OPTIONAL_PARAMS, + trace=True, + ) + + async def invoke_async() -> object: + return await cast(Awaitable[object], invoke()) + + raw_result: Final = asyncio.run(invoke_async()) if asynchronous else invoke() + result: Final = _TRACE_RESPONSE.validate_python(raw_result) + return tuple( + FunctionTraceEvent( + function=event["function"], + depth=event["depth"], + ) + for event in result["trace"] + ) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +def test_mistral_ocr_transformation_function_trace_parity(asynchronous: bool) -> None: + assert_function_trace_parity( + TraceScenario( + steps=( + TraceStep(cast(FunctionType, MistralOCRConfig.get_supported_ocr_params), depth=0), + TraceStep(cast(FunctionType, MistralOCRConfig.map_ocr_params), depth=0), + TraceStep(cast(FunctionType, MistralOCRConfig.get_supported_ocr_params), depth=1), + TraceStep(cast(FunctionType, MistralOCRConfig.transform_ocr_request), depth=0), + TraceStep(cast(FunctionType, MistralOCRConfig.transform_ocr_response), depth=0), + ), + invoke_python=_invoke_python, + invoke_rust=partial(_invoke_rust, asynchronous=asynchronous), + ) + ) + + +class First: + @staticmethod + def run() -> None: + return None + + +class Second: + @staticmethod + def run() -> None: + return None + + +def test_profiler_matches_code_objects_and_keeps_repeated_calls() -> None: + with profile_python((First.run,)) as profiler: + Second.run() + First.run() + First.run() + + assert profiler.events == [ + FunctionTraceEvent(function="run", depth=0), + FunctionTraceEvent(function="run", depth=0), + ] + + +def test_profiler_records_selected_function_nesting_depth() -> None: + class Nested: + @staticmethod + def run() -> None: + First.run() + + with profile_python((Nested.run, First.run)) as profiler: + Nested.run() + + assert profiler.events == [ + FunctionTraceEvent(function="run", depth=0), + FunctionTraceEvent(function="run", depth=1), + ] + + +def test_profiler_restores_previous_profiler_after_failure() -> None: + previous: Final = sys.getprofile() + + with pytest.raises(RuntimeError, match="stop"): + with profile_python((First.run,)): + raise RuntimeError("stop") + + assert sys.getprofile() is previous diff --git a/tests/test_litellm/ocr/test_rust_bridge.py b/tests/test_litellm/ocr/test_rust_bridge.py index b489cb0a280..565c92ec483 100644 --- a/tests/test_litellm/ocr/test_rust_bridge.py +++ b/tests/test_litellm/ocr/test_rust_bridge.py @@ -10,6 +10,9 @@ import pytest import litellm from litellm.llms.base_llm.ocr.transformation import OCRResponse +from litellm.rust_bridge import bindings, configuration +from litellm.rust_bridge.bindings import UNSET, Unset +from litellm.rust_bridge.ocr import RustAocr, RustOcr # `litellm/__init__.py` does `from .ocr.main import *`, which binds the `ocr` # function onto `litellm.ocr` and shadows the submodule, so import the modules @@ -33,6 +36,16 @@ FAKE_OCR_RESPONSE: dict[str, object] = { } +def _use_test_rust( + enabled: bool = True, + *, + ocr: RustOcr | None | Unset = UNSET, + aocr: RustAocr | None | Unset = UNSET, +) -> None: + rust_bridge.set_rust_ocr(ocr=ocr, aocr=aocr) + litellm.use_litellm_rust(enabled) + + class CapturedException(Exception): pass @@ -214,10 +227,12 @@ def build_prepared_request( @pytest.fixture(autouse=True) def _reset_rust_flag(): """Keep the global toggle isolated between tests.""" - rust_bridge.use_litellm_rust(False, ocr=None, aocr=None) + rust_bridge.set_rust_ocr(ocr=None, aocr=None) + configuration.reset_rust_configuration() rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL yield - rust_bridge.use_litellm_rust(False, ocr=None, aocr=None) + rust_bridge.set_rust_ocr(ocr=None, aocr=None) + configuration.reset_rust_configuration() rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL @@ -225,7 +240,8 @@ def _reset_rust_flag(): def fake_bridge(): """Enable the Rust path with an injected recording bridge (no native wheel).""" bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + rust_bridge.set_rust_ocr(ocr=bridge) + litellm.use_litellm_rust(True) return bridge @@ -233,7 +249,8 @@ def fake_bridge(): def fake_async_bridge(): """Enable the async Rust path with an injected recording bridge.""" bridge = RecordingAsyncBridge() - litellm.use_litellm_rust(True, aocr=bridge) + rust_bridge.set_rust_ocr(aocr=bridge) + litellm.use_litellm_rust(True) return bridge @@ -245,14 +262,16 @@ def test_use_litellm_rust_toggles_flag(): assert rust_bridge.rust_ocr_enabled() is False -def test_env_var_enables_rust_ocr(monkeypatch): - monkeypatch.setenv("LITELLM_USE_RUST_OCR", "1") - assert rust_bridge._env_enables_rust_ocr() is True +def test_explicit_false_overrides_the_process_switch(): + litellm.use_litellm_rust(True) + prepared = build_prepared_request(litellm_params={"rust": False}) + + assert ocr_main._rust_ocr_enabled(prepared) is False def test_load_rust_ocr_returns_injected_impl(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) assert rust_bridge.load_rust_ocr() is bridge @@ -296,40 +315,21 @@ def test_native_bridge_available_reflects_loader(monkeypatch): def test_load_rust_aocr_returns_injected_impl(): bridge = RecordingAsyncBridge() - litellm.use_litellm_rust(True, aocr=bridge) + _use_test_rust(True, aocr=bridge) assert rust_bridge.load_rust_aocr() is bridge -def test_toggle_without_ocr_arg_preserves_injected_impl(): - """Regression: routine enable/disable calls must not clobber a prior injection. - - Earlier, ``use_litellm_rust()`` unconditionally assigned the keyword default - of ``None`` to ``_rust_ocr_impl``, silently dropping a custom bridge whenever - a caller toggled the flag without re-passing ``ocr=``. - """ - bridge = RecordingBridge() - async_bridge = RecordingAsyncBridge() - litellm.use_litellm_rust(True, ocr=bridge, aocr=async_bridge) - - litellm.use_litellm_rust(False) - assert rust_bridge.load_rust_ocr() is bridge - assert rust_bridge.load_rust_aocr() is async_bridge - litellm.use_litellm_rust(True) - assert rust_bridge.load_rust_ocr() is bridge - assert rust_bridge.load_rust_aocr() is async_bridge - - def test_explicit_ocr_none_clears_injected_impl(monkeypatch): monkeypatch.setattr( - importlib.import_module("litellm.rust_bridge"), + bindings, "get_native_bridge", lambda: None, ) bridge = RecordingBridge() async_bridge = RecordingAsyncBridge() - litellm.use_litellm_rust(True, ocr=bridge, aocr=async_bridge) + rust_bridge.set_rust_ocr(ocr=bridge, aocr=async_bridge) - litellm.use_litellm_rust(True, ocr=None, aocr=None) + rust_bridge.set_rust_ocr(ocr=None, aocr=None) assert rust_bridge.load_rust_ocr() is None assert rust_bridge.load_rust_aocr() is None @@ -338,7 +338,7 @@ def test_load_rust_ocr_none_when_extension_absent(monkeypatch): """With no injected impl and no compiled wheel, the loader returns None so the caller degrades to the Python path instead of raising ImportError.""" monkeypatch.setattr( - importlib.import_module("litellm.rust_bridge"), + bindings, "get_native_bridge", lambda: None, ) @@ -355,7 +355,7 @@ def test_load_rust_ocr_uses_compiled_extension(monkeypatch): fake_module.ocr = lambda **kwargs: dict(FAKE_OCR_RESPONSE) # type: ignore[attr-defined] fake_module.aocr = lambda **kwargs: dict(FAKE_OCR_RESPONSE) # type: ignore[attr-defined] monkeypatch.setattr( - importlib.import_module("litellm.rust_bridge"), + bindings, "get_native_bridge", lambda: fake_module, ) @@ -386,7 +386,7 @@ def test_ocr_provider_error_uses_resolved_request_url(): def test_bridge_wrapper_forwards_prepared_args_and_wraps_response(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) response = rust_bridge.ocr( model="mistral-ocr-latest", document=DOCUMENT, @@ -419,7 +419,7 @@ def test_bridge_wrapper_forwards_prepared_args_and_wraps_response(): async def test_bridge_wrapper_forwards_prepared_async_args_and_wraps_response(): bridge = RecordingAsyncBridge() - litellm.use_litellm_rust(True, aocr=bridge) + _use_test_rust(True, aocr=bridge) response = await rust_bridge.aocr( model="mistral-ocr-maas", document=DOCUMENT, @@ -447,7 +447,7 @@ async def test_bridge_wrapper_forwards_prepared_async_args_and_wraps_response(): def test_run_rust_ocr_prepares_request_and_wraps_response(): bridge = RecordingBridge() logging_obj = RecordingLogging() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) response = ocr_main._run_rust_ocr( prepared_request=build_prepared_request( @@ -462,6 +462,11 @@ def test_run_rust_ocr_prepares_request_and_wraps_response(): assert isinstance(response, OCRResponse) assert response.pages[0].markdown == "hello world" + assert response._hidden_params["core_engine"] == "rust" + assert response._hidden_params["additional_headers"] == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } assert bridge.calls[0] == { "model": "mistral-ocr-latest", "document": DOCUMENT, @@ -479,13 +484,11 @@ def test_run_rust_ocr_prepares_request_and_wraps_response(): def test_run_rust_ocr_resolves_key_via_secret_manager_when_missing(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) ocr_main._run_rust_ocr( prepared_request=build_prepared_request(api_key=None, timeout=None), - resolve_api_key=lambda name: ( - "sk-from-vault" if name == "MISTRAL_API_KEY" else None - ), + resolve_api_key=lambda name: "sk-from-vault" if name == "MISTRAL_API_KEY" else None, ) assert bridge.calls[0]["api_key"] == "sk-from-vault" @@ -493,7 +496,7 @@ def test_run_rust_ocr_resolves_key_via_secret_manager_when_missing(): def test_run_rust_ocr_prefers_explicit_key_over_resolver(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) def _resolver(name: str) -> str | None: raise AssertionError(f"resolver should not be called for {name}") @@ -512,7 +515,7 @@ def test_run_rust_ocr_prefers_explicit_key_over_resolver(): def test_run_rust_ocr_uses_provider_api_key_env_var(): bridge = RecordingBridge() resolver_calls = [] - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) def _resolver(name): resolver_calls.append(name) @@ -534,7 +537,7 @@ def test_run_rust_ocr_uses_provider_api_key_env_var(): def test_prepare_rust_ocr_call_forwards_vertex_routing_metadata(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) ocr_main._run_rust_ocr( prepared_request=build_prepared_request( @@ -560,7 +563,7 @@ def test_prepare_rust_ocr_call_forwards_vertex_routing_metadata(): def test_prepare_rust_ocr_call_resolves_vertex_routing_metadata_from_secret_manager(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) def _resolver(name: str) -> str | None: return { @@ -583,7 +586,7 @@ def test_prepare_rust_ocr_call_resolves_vertex_routing_metadata_from_secret_mana def test_prepare_rust_ocr_call_resolves_azure_ai_api_base_from_secret_manager(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) ocr_main._run_rust_ocr( prepared_request=build_prepared_request( @@ -592,9 +595,7 @@ def test_prepare_rust_ocr_call_resolves_azure_ai_api_base_from_secret_manager(): api_base=None, timeout=None, ), - resolve_api_key=lambda name: ( - "https://azure.example.com" if name == "AZURE_AI_API_BASE" else None - ), + resolve_api_key=lambda name: "https://azure.example.com" if name == "AZURE_AI_API_BASE" else None, ) assert bridge.calls[0]["api_base"] == "https://azure.example.com" @@ -602,7 +603,7 @@ def test_prepare_rust_ocr_call_resolves_azure_ai_api_base_from_secret_manager(): def test_prepare_rust_ocr_call_resolves_document_intelligence_endpoint(): bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) ocr_main._run_rust_ocr( prepared_request=build_prepared_request( @@ -612,9 +613,7 @@ def test_prepare_rust_ocr_call_resolves_document_intelligence_endpoint(): timeout=None, ), resolve_api_key=lambda name: ( - "https://document-intelligence.example.com" - if name == "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT" - else None + "https://document-intelligence.example.com" if name == "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT" else None ), ) @@ -624,7 +623,7 @@ def test_prepare_rust_ocr_call_resolves_document_intelligence_endpoint(): def test_run_rust_ocr_runs_pre_call_logging(): logging_obj = RecordingLogging() bridge = RecordingBridge() - litellm.use_litellm_rust(True, ocr=bridge) + _use_test_rust(True, ocr=bridge) ocr_main._run_rust_ocr( prepared_request=build_prepared_request( @@ -661,6 +660,7 @@ def test_ocr_routes_to_rust_when_enabled(fake_bridge): assert isinstance(response, OCRResponse) assert response.pages[0].markdown == "hello world" + assert response._hidden_params["core_engine"] == "rust" assert len(fake_bridge.calls) == 1 call = fake_bridge.calls[0] assert call["model"] == "mistral-ocr-latest" @@ -727,7 +727,7 @@ def test_ocr_exception_type_uses_resolved_provider_context( return CapturedException("wrapped") monkeypatch.setattr(ocr_main.litellm, "exception_type", fake_exception_type) - litellm.use_litellm_rust(True, ocr=RaisingBridge()) + _use_test_rust(True, ocr=RaisingBridge()) with pytest.raises(CapturedException): litellm.ocr(model=MODEL, document=DOCUMENT, api_key="sk-test") @@ -772,7 +772,7 @@ async def test_aocr_exception_type_uses_resolved_provider_context( return CapturedException("wrapped") monkeypatch.setattr(ocr_main.litellm, "exception_type", fake_exception_type) - litellm.use_litellm_rust(True, aocr=RaisingAsyncBridge()) + _use_test_rust(True, aocr=RaisingAsyncBridge()) with pytest.raises(CapturedException): await litellm.aocr(model=MODEL, document=DOCUMENT, api_key="sk-test") @@ -800,7 +800,7 @@ def test_ocr_passes_default_request_timeout_to_rust(fake_bridge): def test_ocr_does_not_route_to_rust_when_disabled(): """With the flag off, the bridge must not be consulted even if an impl exists.""" bridge = RecordingBridge() - litellm.use_litellm_rust(False, ocr=bridge) + _use_test_rust(False, ocr=bridge) assert rust_bridge.rust_ocr_enabled() is False # The impl stays available for injection, but the disabled flag gates usage, @@ -826,6 +826,28 @@ def test_ocr_falls_back_to_python_when_bridge_unavailable(monkeypatch): assert captured.get("called") is True # Python path was used assert isinstance(response, OCRResponse) + assert response._hidden_params["core_engine"] == "python" + assert response._hidden_params["additional_headers"] == {"x-litellm-core": "python"} + + +def test_ocr_unsupported_provider_skips_rust(monkeypatch): + bridge = RecordingBridge() + _use_test_rust(True, ocr=bridge) + + def fake_handler_ocr(**kwargs): + return OCRResponse(pages=[], model="parse-v3", object="ocr") + + monkeypatch.setattr(ocr_main.base_llm_http_handler, "ocr", fake_handler_ocr) + + response = litellm.ocr( + model="reducto/parse-v3", + document={"type": "document_url", "document_url": "reducto://document-id"}, + api_key="test-key", + ) + + assert isinstance(response, OCRResponse) + assert response._hidden_params["core_engine"] == "python" + assert bridge.calls == [] def test_ocr_non_string_header_uses_python_path(monkeypatch): @@ -864,9 +886,6 @@ def test_ocr_provider_configs_expose_api_key_env_vars(): assert BaseOCRConfig().get_api_key_env_var() is None assert MistralOCRConfig().get_api_key_env_var() == "MISTRAL_API_KEY" assert AzureAIOCRConfig().get_api_key_env_var() == "AZURE_AI_API_KEY" - assert ( - AzureDocumentIntelligenceOCRConfig().get_api_key_env_var() - == "AZURE_DOCUMENT_INTELLIGENCE_API_KEY" - ) + assert AzureDocumentIntelligenceOCRConfig().get_api_key_env_var() == "AZURE_DOCUMENT_INTELLIGENCE_API_KEY" assert VertexAIOCRConfig().get_api_key_env_var() == "VERTEX_AI_API_KEY" assert VertexAIDeepSeekOCRConfig().get_api_key_env_var() == "VERTEX_AI_API_KEY" diff --git a/tests/test_litellm/ocr/test_sdk_parity.py b/tests/test_litellm/ocr/test_sdk_parity.py index 3f7fb84958b..8aa5713a63c 100644 --- a/tests/test_litellm/ocr/test_sdk_parity.py +++ b/tests/test_litellm/ocr/test_sdk_parity.py @@ -296,13 +296,20 @@ class _RustAocrSpy: @contextmanager def _restore_rust_ocr_state() -> Generator[None]: - enabled: Final = rust_ocr_bridge._rust_ocr_enabled # pyright: ignore[reportPrivateUsage] # restore test state - ocr_impl: Final = rust_ocr_bridge._rust_ocr_impl # pyright: ignore[reportPrivateUsage] # restore test state - aocr_impl: Final = rust_ocr_bridge._rust_aocr_impl # pyright: ignore[reportPrivateUsage] # restore test state + from litellm.rust_bridge import configuration + + config: Final = configuration._CONFIGURATION # pyright: ignore[reportPrivateUsage] # restore test state + sync_binding: Final = rust_ocr_bridge._OCR # pyright: ignore[reportPrivateUsage] # restore test state + async_binding: Final = rust_ocr_bridge._AOCR # pyright: ignore[reportPrivateUsage] # restore test state + enabled: Final = config.override + ocr_impl: Final = sync_binding._override # pyright: ignore[reportPrivateUsage] # preserve unset binding + aocr_impl: Final = async_binding._override # pyright: ignore[reportPrivateUsage] # preserve unset binding try: yield finally: - rust_ocr_bridge.use_litellm_rust(enabled, ocr=ocr_impl, aocr=aocr_impl) + config.override = enabled + sync_binding._override = ocr_impl # pyright: ignore[reportPrivateUsage] # restore exact binding state + async_binding._override = aocr_impl # pyright: ignore[reportPrivateUsage] # restore exact binding state def _native_spies() -> tuple[_RustOcrSpy, _RustAocrSpy]: @@ -343,7 +350,8 @@ def test_recorded_ocr_sdk_parity( event_loop: Final = asyncio.new_event_loop() try: with _restore_rust_ocr_state(), replay_server() as provider: - rust_ocr_bridge.use_litellm_rust(False, ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.set_rust_ocr(ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.use_litellm_rust(False) python: Final = run_in_process( provider, ocr_fixture.provider_responses, @@ -352,7 +360,7 @@ def test_recorded_ocr_sdk_parity( assert sync_spy.calls == 0 assert async_spy.calls == 0 - rust_ocr_bridge.use_litellm_rust(True, ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.use_litellm_rust(True) rust: Final = run_in_process( provider, ocr_fixture.provider_responses, @@ -380,7 +388,8 @@ def test_invalid_ocr_sdk_parity(case: InvalidOcrCase, route: SDKRoute) -> None: event_loop: Final = asyncio.new_event_loop() try: with _restore_rust_ocr_state(), replay_server() as provider: - rust_ocr_bridge.use_litellm_rust(False, ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.set_rust_ocr(ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.use_litellm_rust(False) python: Final = run_in_process( provider, (), @@ -389,7 +398,7 @@ def test_invalid_ocr_sdk_parity(case: InvalidOcrCase, route: SDKRoute) -> None: assert sync_spy.calls == 0 assert async_spy.calls == 0 - rust_ocr_bridge.use_litellm_rust(True, ocr=sync_spy, aocr=async_spy) + rust_ocr_bridge.use_litellm_rust(True) rust: Final = run_in_process( provider, (), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index ef5631218f3..0480bbc40a7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -214,6 +214,46 @@ class TestExecuteWithMcpClient: assert server.scopes == ["read", "write"] assert server.has_client_credentials is True + async def test_preview_forwards_per_server_timeout_to_client_factory(self, monkeypatch): + """The request's per-server timeout must reach the temporary MCPServer model: + the client factory reads ``server.timeout`` for both the per-request timeout + and the preview's whole-walk listing deadline.""" + captured: dict = {} + + def fake_build_stdio_env(server, raw_headers): + return None + + async def fake_create_client(*args, **kwargs): + captured["server"] = kwargs.get("server") + return object() + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_build_stdio_env", + fake_build_stdio_env, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + raising=False, + ) + + async def ok_operation(client): + return {"status": "ok"} + + payload = NewMCPServerRequest( + server_name="slow-catalog-server", + url="https://example.com", + timeout=120.5, + ) + + result = await rest_endpoints._execute_with_mcp_client(payload, ok_operation) + + assert result["status"] == "ok" + assert captured["server"].timeout == 120.5 + @pytest.mark.asyncio async def test_m2m_drops_incoming_oauth2_headers(self, monkeypatch): """For M2M OAuth servers the incoming Authorization header (which carries @@ -524,6 +564,131 @@ class TestTestToolsList: assert captured["oauth2_headers"] is None assert oauth_call_counter["count"] == 0 + async def test_preview_tools_list_times_out_on_slow_pagination(self, monkeypatch): + """A preview whose upstream paginates past the listing deadline returns a + timeout error instead of holding the request open.""" + monkeypatch.setattr(rest_endpoints, "MCP_CLIENT_TIMEOUT", 0.05, raising=False) + monkeypatch.setattr(rest_endpoints, "MCP_TOOL_LISTING_TIMEOUT", 0.05, raising=False) + + class SlowClient: + async def list_tools(self, raise_on_error=False): + await asyncio.sleep(1) + return [] + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + return await operation(SlowClient()) + + monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False) + + from litellm.proxy._types import LitellmUserRoles + + request = _build_request() + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.api_key, + credentials={"auth_value": "secret-key"}, + ) + + result = await rest_endpoints.test_tools_list( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result["status"] == "error" + assert result["error"] is True + assert "Timed out listing tools" in result["message"] + + async def test_preview_tools_list_succeeds_within_deadline(self, monkeypatch): + """The preview timeout scope passes a fast listing through untouched.""" + from mcp.types import Tool as MCPTool + + class QuickClient: + async def list_tools(self, raise_on_error=False): + return [MCPTool(name="quick_tool", description="q", inputSchema={})] + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + return await operation(QuickClient()) + + monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False) + + from litellm.proxy._types import LitellmUserRoles + + request = _build_request() + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.api_key, + credentials={"auth_value": "secret-key"}, + ) + + result = await rest_endpoints.test_tools_list( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result["error"] is None + assert result["message"] == "Successfully retrieved tools" + assert [tool["name"] for tool in result["tools"]] == ["quick_tool"] + + async def test_preview_tools_list_honors_per_server_timeout(self, monkeypatch): + """A per-server timeout above the global default extends the preview deadline.""" + monkeypatch.setattr(rest_endpoints, "MCP_CLIENT_TIMEOUT", 0.05, raising=False) + monkeypatch.setattr(rest_endpoints, "MCP_TOOL_LISTING_TIMEOUT", 0.05, raising=False) + + from mcp.types import Tool as MCPTool + + class SlowConfiguredClient: + timeout = 1.0 + + async def list_tools(self, raise_on_error=False): + await asyncio.sleep(0.2) + return [MCPTool(name="slow_tool", description="s", inputSchema={})] + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + return await operation(SlowConfiguredClient()) + + monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False) + + from litellm.proxy._types import LitellmUserRoles + + request = _build_request() + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.api_key, + credentials={"auth_value": "secret-key"}, + ) + + result = await rest_endpoints.test_tools_list( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result["error"] is None + assert [tool["name"] for tool in result["tools"]] == ["slow_tool"] + async def test_extracts_oauth2_headers(self, monkeypatch): """Ensure oauth2 auth type pulls oauth headers and omits MCP auth header.""" @@ -786,9 +951,7 @@ class TestListToolsRestAPI: they do for a gateway session, never to the bare session key.""" from litellm.constants import UI_SESSION_TOKEN_TEAM_ID - session_auth = UserAPIKeyAuth( - team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user" - ) + session_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user") admitted_auth = UserAPIKeyAuth(user_id="grant-user", org_id="admitted-org") async def fake_reload(user_id): @@ -868,9 +1031,7 @@ class TestListToolsRestAPI: from litellm.constants import UI_SESSION_TOKEN_TEAM_ID from litellm.proxy._types import LiteLLM_ObjectPermissionTable - session_auth = UserAPIKeyAuth( - team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user" - ) + session_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user") scoped_auth = UserAPIKeyAuth( object_permission=LiteLLM_ObjectPermissionTable( object_permission_id="toolset-scope", @@ -952,6 +1113,123 @@ class TestListToolsRestAPI: assert scope_inputs == [session_auth] assert reload_calls == [] + async def test_single_server_response_includes_paginated_upstream_tools( + self, + monkeypatch, + ): + """The REST tools/list path should include tools beyond the upstream first page.""" + import litellm.experimental_mcp_client.client as mcp_client_module + from mcp.types import ListToolsResult, PaginatedRequestParams + from mcp.types import Tool as MCPTool + + from litellm.proxy._experimental.mcp_server.server import MCPServer + from litellm.types.mcp import MCPTransport + + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return ["server-1"] + + stub_server = MCPServer( + server_id="server-1", + name="stub", + server_name="stub", + alias="stub", + url="https://example.com/mcp", + transport=MCPTransport.http, + mcp_info={"server_name": "stub"}, + ) + stub_server.available_on_public_internet = True + + mock_transport_ctx = AsyncMock() + mock_transport_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock())) + mock_transport_ctx.__aexit__ = AsyncMock(return_value=None) + monkeypatch.setattr( + mcp_client_module, + "streamable_http_client", + MagicMock(return_value=mock_transport_ctx), + raising=False, + ) + + mock_session_ctx = AsyncMock() + mock_session_instance = AsyncMock() + mock_session_instance.initialize = AsyncMock(return_value=None) + mock_session_instance.list_tools.side_effect = [ + ListToolsResult( + tools=[ + MCPTool( + name="first_page_tool", + description="First page tool", + inputSchema={}, + ) + ], + nextCursor="page-2", + ), + ListToolsResult( + tools=[ + MCPTool( + name="second_page_tool", + description="Second page tool", + inputSchema={}, + ) + ] + ), + ] + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) + mock_session_ctx.__aexit__ = AsyncMock(return_value=None) + monkeypatch.setattr( + mcp_client_module, + "ClientSession", + MagicMock(return_value=mock_session_ctx), + raising=False, + ) + + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "filter_server_ids_by_ip_with_info", + lambda server_ids, client_ip: (server_ids, 0), + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda server_id: stub_server if server_id == "server-1" else None, + raising=False, + ) + + request = _build_request(path="/mcp-rest/tools/list", method="GET") + result = await rest_endpoints.list_tool_rest_api( + request, + server_id="server-1", + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert set(result.keys()) == {"tools", "error", "message"} + assert [tool.name for tool in result["tools"]] == [ + "first_page_tool", + "second_page_tool", + ] + assert result["error"] is None + assert result["message"] == "Successfully retrieved tools" + + assert mock_session_instance.list_tools.call_count == 2 + second_call_params = mock_session_instance.list_tools.call_args_list[1].kwargs["params"] + assert isinstance(second_call_params, PaginatedRequestParams) + assert second_call_params.cursor == "page-2" + async def test_include_disabled_tools_is_admin_only(self, monkeypatch): """include_disabled_tools skips the allowlist filter only for PROXY_ADMIN; a non-admin passing it stays filtered so the REST endpoint can't be used @@ -1153,7 +1431,11 @@ class TestListToolsRestAPI: async def test_aggregate_list_absorbs_one_server_auth_failure(self, monkeypatch): """The multi-server aggregate listing degrades a server whose upstream rejects auth to an empty contribution and still returns the healthy - server's tools with a 200, rather than surfacing a 401.""" + server's tools with a 200, rather than surfacing a 401. The absorbed + server must still show up as a classified per-server outcome so a REST + caller can tell "needs upstream auth" apart from "has no tools".""" + from pydantic import TypeAdapter + from litellm.proxy._experimental.mcp_server.exceptions import ( MCPUpstreamAuthError, ) @@ -1219,6 +1501,11 @@ class TestListToolsRestAPI: assert result["tools"] == ["good-tool"] assert result["error"] is None + wire_body = json.loads(TypeAdapter(dict).dump_json(result)) + assert wire_body["server_outcomes"] == { + "good": {"status": "ok", "tool_count": 1}, + "bad": {"status": "auth_required", "http_status": 401}, + } async def test_name_resolution_finds_server_by_uuid(self, monkeypatch): """When server_id is a name string, it should be resolved to its UUID @@ -3021,9 +3308,7 @@ class TestRestListToolsetFiltering: mock_manager = MagicMock() mock_manager.expand_tool_permissions = MagicMock(side_effect=lambda perms: perms or {}) - mock_manager.resolve_toolset_tool_permissions = AsyncMock( - return_value={"server-a": ["lookup_status"]} - ) + mock_manager.resolve_toolset_tool_permissions = AsyncMock(return_value={"server-a": ["lookup_status"]}) monkeypatch.setattr( rest_endpoints.global_mcp_server_manager, diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_streaming_model_restamp.py b/tests/test_litellm/proxy/anthropic_endpoints/test_streaming_model_restamp.py new file mode 100644 index 00000000000..b7bc670c7f8 --- /dev/null +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_streaming_model_restamp.py @@ -0,0 +1,288 @@ +""" +Tests for restamping the public model on Anthropic Messages streaming chunks. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.proxy.anthropic_endpoints.streaming_model_restamp import ( + AnthropicStreamModelRestamper, + restamp_anthropic_stream_chunk_model, +) +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing + + +def _message_start_frame(model: str, line_end: str = "\n") -> bytes: + payload = { + "type": "message_start", + "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": model, "content": []}, + } + return f"event: message_start{line_end}data: {json.dumps(payload)}{line_end}{line_end}".encode() + + +def _proxy_logging_obj_streaming(frames: list[bytes]) -> MagicMock: + async def _iterator_hook(**_kwargs): + for frame in frames: + yield frame + + proxy_logging_obj = MagicMock() + proxy_logging_obj.async_post_call_streaming_iterator_hook = _iterator_hook + proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["response"]) + return proxy_logging_obj + + +def _model_from_frame(frame: bytes | str) -> str: + text = frame.decode("utf-8") if isinstance(frame, bytes) else frame + data_line = next(line for line in text.split("\n") if line.startswith("data:")) + return json.loads(data_line[len("data:") :])["message"]["model"] + + +def test_restamps_sse_bytes_frame(): + restamped = restamp_anthropic_stream_chunk_model( + _message_start_frame("claude-haiku-4-5-20251001"), "claude-auto-1" + ) + + assert isinstance(restamped, bytes) + assert _model_from_frame(restamped) == "claude-auto-1" + assert b"event: message_start" in restamped + + +def test_restamps_event_dict(): + chunk = {"type": "message_start", "message": {"id": "msg_1", "model": "claude-sonnet-4-6"}} + + restamped = restamp_anthropic_stream_chunk_model(chunk, "claude-auto-2") + + assert restamped == {"type": "message_start", "message": {"id": "msg_1", "model": "claude-auto-2"}} + assert chunk["message"]["model"] == "claude-sonnet-4-6" + + +@pytest.mark.parametrize( + "chunk", + [ + b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n', + {"type": "content_block_delta", "delta": {"text": "hi"}}, + {"type": "message_start", "message": "not-a-dict"}, + b"event: message_start\ndata: not-json\n\n", + b"data: [DONE]\n\n", + ], +) +def test_leaves_chunks_without_a_model_untouched(chunk): + assert restamp_anthropic_stream_chunk_model(chunk, "claude-auto-1") == chunk + + +@pytest.mark.asyncio +async def test_sse_generator_publishes_requested_model_on_message_start(): + """The message_start event reports the requested model, not the provider's.""" + delta_frame = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n' + proxy_logging_obj = _proxy_logging_obj_streaming([_message_start_frame("claude-haiku-4-5-20251001"), delta_frame]) + + chunks = [ + chunk + async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator( + response=MagicMock(), + user_api_key_dict=MagicMock(), + request_data={"model": "claude-auto-1"}, + proxy_logging_obj=proxy_logging_obj, + restamp_model="claude-auto-1", + ) + ] + + assert _model_from_frame(chunks[0]) == "claude-auto-1" + assert chunks[1] == delta_frame + + +@pytest.mark.asyncio +async def test_sse_generator_keeps_provider_model_when_restamping_is_off(): + proxy_logging_obj = _proxy_logging_obj_streaming([_message_start_frame("claude-haiku-4-5-20251001")]) + + chunks = [ + chunk + async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator( + response=MagicMock(), + user_api_key_dict=MagicMock(), + request_data={"model": "claude-auto-1"}, + proxy_logging_obj=proxy_logging_obj, + ) + ] + + assert _model_from_frame(chunks[0]) == "claude-haiku-4-5-20251001" + + +def test_restamps_message_start_split_across_transport_chunks(): + frame = _message_start_frame("claude-haiku-4-5-20251001") + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + held = restamper.process(frame[:25]) + emitted = restamper.process(frame[25:]) + + assert held == b"" + assert isinstance(emitted, bytes) + assert _model_from_frame(emitted) == "claude-auto-1" + + +def test_emits_coalesced_frames_with_only_message_start_rewritten(): + delta = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n' + combined = _message_start_frame("claude-haiku-4-5-20251001") + delta + + emitted = restamper_output = AnthropicStreamModelRestamper("claude-auto-1").process(combined) + + assert isinstance(restamper_output, bytes) + assert _model_from_frame(emitted) == "claude-auto-1" + assert emitted.endswith(delta) + + +def test_ping_frames_keep_the_restamper_armed(): + ping = b'event: ping\ndata: {"type": "ping"}\n\n' + frame = _message_start_frame("claude-haiku-4-5-20251001") + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + assert restamper.process(ping) == ping + reassembled = restamper.process(frame[:10]) + reassembled += restamper.process(frame[10:]) + + assert _model_from_frame(reassembled) == "claude-auto-1" + + +def test_first_non_ping_event_disarms_the_restamper(): + delta = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n' + late_message_start = _message_start_frame("claude-haiku-4-5-20251001") + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + assert restamper.process(delta) == delta + assert restamper.process(late_message_start) == late_message_start + + +def test_oversized_unterminated_chunk_flushes_unmodified(): + blob = b"data: " + b"x" * 70000 + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + assert restamper.process(blob) == blob + frame = _message_start_frame("claude-haiku-4-5-20251001") + assert restamper.process(frame) == frame + + +def test_dict_message_start_disarms_after_restamp(): + restamper = AnthropicStreamModelRestamper("claude-auto-1") + first = restamper.process({"type": "message_start", "message": {"id": "msg_1", "model": "claude-sonnet-4-6"}}) + second = {"type": "message_start", "message": {"id": "msg_2", "model": "claude-sonnet-4-6"}} + + assert first == {"type": "message_start", "message": {"id": "msg_1", "model": "claude-auto-1"}} + assert restamper.process(second) == second + + +@pytest.mark.asyncio +async def test_sse_generator_restamps_message_start_split_across_chunks(): + frame = _message_start_frame("claude-haiku-4-5-20251001") + proxy_logging_obj = _proxy_logging_obj_streaming([frame[:30], frame[30:]]) + + chunks = [ + chunk + async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator( + response=MagicMock(), + user_api_key_dict=MagicMock(), + request_data={"model": "claude-auto-1"}, + proxy_logging_obj=proxy_logging_obj, + restamp_model="claude-auto-1", + ) + ] + + joined = b"".join(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") for chunk in chunks) + assert _model_from_frame(joined) == "claude-auto-1" + + +def test_restamps_crlf_terminated_message_start_frame(): + frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n") + delta = b'event: content_block_delta\r\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\r\n\r\n' + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + emitted = restamper.process(frame) + + assert isinstance(emitted, bytes) + assert _model_from_frame(emitted) == "claude-auto-1" + assert emitted.endswith(b"\r\n\r\n") + assert restamper.process(delta) == delta + + +def test_restamps_cr_terminated_message_start_frame(): + frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r") + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + emitted = restamper.process(frame) + + assert isinstance(emitted, bytes) + assert b'"model":"claude-auto-1"' in emitted + assert emitted.endswith(b"\r\r") + + +def test_restamps_crlf_message_start_split_across_transport_chunks(): + frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n") + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + held = restamper.process(frame[:25]) + emitted = restamper.process(frame[25:]) + + assert held == b"" + assert isinstance(emitted, bytes) + assert _model_from_frame(emitted) == "claude-auto-1" + + +def test_flush_returns_restamped_held_tail(): + unterminated = _message_start_frame("claude-haiku-4-5-20251001")[:-2] + restamper = AnthropicStreamModelRestamper("claude-auto-1") + + assert restamper.process(unterminated) == b"" + flushed = restamper.flush() + + assert b'"model":"claude-auto-1"' in flushed + assert restamper.flush() == b"" + + +def test_flush_disarms_the_restamper(): + restamper = AnthropicStreamModelRestamper("claude-auto-1") + frame = _message_start_frame("claude-haiku-4-5-20251001") + + assert restamper.flush() == b"" + assert restamper.process(frame) == frame + + +@pytest.mark.asyncio +async def test_sse_generator_flushes_held_tail_at_end_of_stream(): + unterminated = _message_start_frame("claude-haiku-4-5-20251001")[:-2] + proxy_logging_obj = _proxy_logging_obj_streaming([unterminated]) + + chunks = [ + chunk + async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator( + response=MagicMock(), + user_api_key_dict=MagicMock(), + request_data={"model": "claude-auto-1"}, + proxy_logging_obj=proxy_logging_obj, + restamp_model="claude-auto-1", + ) + ] + + joined = b"".join(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") for chunk in chunks) + assert b'"model":"claude-auto-1"' in joined + + +@pytest.mark.asyncio +async def test_sse_generator_restamps_crlf_stream(): + frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n") + delta = b'event: content_block_delta\r\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\r\n\r\n' + proxy_logging_obj = _proxy_logging_obj_streaming([frame, delta]) + + chunks = [ + chunk + async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator( + response=MagicMock(), + user_api_key_dict=MagicMock(), + request_data={"model": "claude-auto-1"}, + proxy_logging_obj=proxy_logging_obj, + restamp_model="claude-auto-1", + ) + ] + + assert _model_from_frame(chunks[0]) == "claude-auto-1" + assert chunks[1] == delta diff --git a/tests/test_litellm/proxy/client/cli/test_claude_settings.py b/tests/test_litellm/proxy/client/cli/test_claude_settings.py index 898f9ab1ed7..e5f2a9d95bd 100644 --- a/tests/test_litellm/proxy/client/cli/test_claude_settings.py +++ b/tests/test_litellm/proxy/client/cli/test_claude_settings.py @@ -26,6 +26,64 @@ def _owners(*backup_paths): CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings" AUTH_MODULE = "litellm.proxy.client.cli.commands.auth" +WINDOWS_LITE_EXE = "C:\\Users\\u\\AppData\\Local\\Programs\\Python\\Python313\\Scripts\\lite.EXE" + +CMD_METACHARACTERS = frozenset("&|<>^()") +CMD_PERCENT_GUARD = "%%cd:~,%" + + +def _through_cmd_exe(command): + """The line cmd.exe hands to CreateProcess after reading the apiKeyHelper. + + A `"` toggles cmd's quote state and the metacharacters only act outside it. cmd expands + `%VAR%` even inside quotes, so every `%` has to arrive as the `%%cd:~,%` guard: the first + `%` has no variable name and stays literal, and `%cd:~,%` is a zero length substring of `cd`. + """ + assert not any(CMD_METACHARACTERS & set(run) for run in command.split('"')[::2]), command + assert command.count("%") == 3 * command.count(CMD_PERCENT_GUARD), command + return command.replace(CMD_PERCENT_GUARD, "%") + + +def _through_c_runtime(command_line): + """argv as the Microsoft C runtime builds it for the `lite` executable. + + Outside quotes whitespace ends an argument. A `"` toggles quoting, and inside quotes `""` + is a literal quote. Backslashes are literal unless they run up to a `"`, where each pair + is one backslash and an odd one left over makes the quote literal. + """ + argv = [] + current = None + quoted = False + i = 0 + while i < len(command_line): + ch = command_line[i] + if ch in " \t" and not quoted: + if current is not None: + argv.append(current) + current = None + i += 1 + continue + if current is None: + current = "" + if ch == "\\": + run = len(command_line[i:]) - len(command_line[i:].lstrip("\\")) + before_quote = command_line[i + run : i + run + 1] == '"' + current += "\\" * (run // 2 if before_quote else run) + if before_quote and run % 2: + current += '"' + i += 1 + i += run + elif ch == '"': + if quoted and command_line[i + 1 : i + 2] == '"': + current += '"' + i += 1 + else: + quoted = not quoted + i += 1 + else: + current += ch + i += 1 + return argv if current is None else [*argv, current] @pytest.fixture @@ -199,6 +257,36 @@ class TestApiKeyHelperIsActuallyInvocable: assert "Not authenticated for this server" in result.output + def _windows_argv(self, lite_exe, base_url): + with patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value=lite_exe): + helper = resolve_api_key_helper(base_url, platform="win32") + return _through_c_runtime(_through_cmd_exe(helper)) + + @pytest.mark.parametrize( + ("lite_exe", "base_url"), + [ + (WINDOWS_LITE_EXE, "http://localhost:4000"), + ("C:\\Program Files\\LiteLLM\\lite.EXE", "https://gateway.example.com/?a=1&b=2"), + ("C:\\Users\\u\\Scripts\\lite.EXE", "https://gateway.example.com/team%20a/%7Eproxy"), + ('C:\\odd "dir"\\lite.EXE', "http://localhost:4000/x\\"), + ], + ) + def test_the_windows_command_survives_cmd_exe_and_the_c_runtime(self, lite_exe, base_url): + assert self._windows_argv(lite_exe, base_url) == [lite_exe, "--base-url", base_url, "auth", "print-token"] + + def test_the_windows_command_carries_the_base_url_through_cmd_quoting(self): + stale = CliTokenRecord( + base_url="http://other-proxy.example.com", + key="sk-stale", + timestamp=time.time(), + ) + argv = self._windows_argv(WINDOWS_LITE_EXE, "http://localhost:4000") + with patch(f"{AUTH_MODULE}.load_cli_token", return_value=stale): + result = CliRunner().invoke(cli, argv[1:]) + + assert argv[0] == WINDOWS_LITE_EXE + assert "Not authenticated for this server" in result.output + class TestConflictingOwnersOfTheSettingsFile: """Both `lite up` and `lite autoroute up` restore a backup when they stop. diff --git a/tests/test_litellm/proxy/client/cli/test_up_commands.py b/tests/test_litellm/proxy/client/cli/test_up_commands.py index 053c90c36b4..c78bdfa75b1 100644 --- a/tests/test_litellm/proxy/client/cli/test_up_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_up_commands.py @@ -225,6 +225,32 @@ class TestResolveApiKeyHelper: with pytest.raises(ClaudeSettingsError, match="Could not find `lite`"): resolve_api_key_helper("http://localhost:4000") + def test_windows_quotes_for_cmd_exe_instead_of_posix_sh(self, monkeypatch): + """cmd.exe takes a single quote literally, so a POSIX-quoted backslashed path is unrunnable.""" + lite_exe = "C:\\Users\\u\\AppData\\Local\\Programs\\Python\\Python313\\Scripts\\lite.EXE" + monkeypatch.setattr(shutil, "which", lambda name: lite_exe) + + helper = resolve_api_key_helper("https://gateway.example.com", platform="win32") + + assert helper == f'"{lite_exe}" "--base-url" "https://gateway.example.com" "auth" "print-token"' + + def test_windows_keeps_a_spaced_path_and_a_metacharacter_url_as_single_tokens(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "C:\\Program Files\\LiteLLM\\lite.EXE") + + helper = resolve_api_key_helper("https://gateway.example.com/?a=1&b=2", platform="win32") + + assert helper == ( + '"C:\\Program Files\\LiteLLM\\lite.EXE" "--base-url" "https://gateway.example.com/?a=1&b=2" ' + '"auth" "print-token"' + ) + + def test_non_windows_platforms_keep_posix_quoting(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/lite") + + helper = resolve_api_key_helper("http://example.com/path; rm -rf /", platform="darwin") + + assert helper == "/usr/local/bin/lite --base-url 'http://example.com/path; rm -rf /' auth print-token" + def _make_ctx(base_url): return click.Context(click.Command("test"), obj={"base_url": base_url}) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py new file mode 100644 index 00000000000..fd2e86ccde8 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py @@ -0,0 +1,614 @@ +import json +import os +from copy import deepcopy +from unittest.mock import AsyncMock + +import httpx +import pytest +from httpx import Request, Response + +import litellm +from litellm.exceptions import GuardrailRaisedException +from litellm.proxy.guardrails.guardrail_hooks.alice.alice import ( + GUARDRAIL_NAME, + AliceGuardrail, + AliceGuardrailMissingSecrets, + _json_safe, +) +from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2 + + +def _guardrail(**overrides: object) -> AliceGuardrail: + params: dict[str, object] = {"api_key": "test-key", "guardrail_name": "alice", "event_hook": "pre_call"} + params.update(overrides) + return AliceGuardrail(**params) + + +def _verdict(payload: dict[str, object], status_code: int = 200) -> Response: + return Response( + status_code=status_code, + json=payload, + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + + +def test_alice_guardrail_config(monkeypatch: pytest.MonkeyPatch): + """Should register through init_guardrails_v2 like any other provider.""" + monkeypatch.setattr(litellm, "guardrail_name_config_map", {}) + monkeypatch.setenv("ALICE_API_KEY", "test-key") + + init_guardrails_v2( + all_guardrails=[ + { + "guardrail_name": "alice", + "litellm_params": {"guardrail": "alice", "mode": "pre_call", "default_on": True}, + } + ], + config_file_path="", + ) + + registered = [cb for cb in litellm.callbacks if isinstance(cb, AliceGuardrail)] + assert len(registered) == 1 + assert registered[0].guardrail_name == "alice" + + +class TestAliceGuardrailInitialization: + def setup_method(self): + for key in ("ALICE_API_KEY", "ALICE_API_BASE"): + os.environ.pop(key, None) + + def test_missing_api_key_raises(self): + with pytest.raises(AliceGuardrailMissingSecrets, match="API key"): + AliceGuardrail(guardrail_name="alice", event_hook="pre_call") + + def test_reads_credentials_from_environment(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ALICE_API_KEY", "env-key") + monkeypatch.setenv("ALICE_API_BASE", "https://env.alice.test") + + guardrail = AliceGuardrail(guardrail_name="alice", event_hook="pre_call") + + assert guardrail.alice_api_key == "env-key" + assert guardrail.api_base == "https://env.alice.test/v2/evaluate/litellm" + + def test_defaults_the_api_base(self): + assert _guardrail().api_base == "https://api.alice.io/v2/evaluate/litellm" + + def test_trailing_slash_does_not_double_up(self): + assert _guardrail(api_base="https://api.alice.io/").api_base == ("https://api.alice.io/v2/evaluate/litellm") + + +class TestAliceForwarding: + """The hook's arguments cross the wire as they were received — nothing selected, nothing + renamed — except the caller's raw credentials, which are stripped before request_data is + serialized (see TestAliceCredentialStripping).""" + + @pytest.mark.asyncio + async def test_forwards_the_hook_arguments_verbatim(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + inputs = {"texts": ["hello"], "structured_messages": [{"role": "user", "content": "hello"}]} + request_data = {"model": "gpt-4o", "metadata": {"user_api_key_alias": "payments-bot"}} + # Snapshot before the call: @log_guardrail_information writes its own entry into + # request_data["metadata"] afterwards, so the original is no longer what was sent. + sent_inputs = deepcopy(inputs) + sent_request_data = deepcopy(request_data) + + await guardrail.apply_guardrail(inputs=inputs, request_data=request_data, input_type="request") + + body = guardrail.async_handler.post.call_args.kwargs["json"] + assert body["input_type"] == "request" + assert body["inputs"] == sent_inputs + assert body["request_data"] == sent_request_data + + @pytest.mark.asyncio + async def test_sends_the_credential(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + + await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data={}, input_type="request") + + assert guardrail.async_handler.post.call_args.kwargs["headers"]["af-api-key"] == "test-key" + + @pytest.mark.asyncio + async def test_marks_a_completion_as_a_response(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + + await guardrail.apply_guardrail(inputs={"texts": ["answer"]}, request_data={}, input_type="response") + + assert guardrail.async_handler.post.call_args.kwargs["json"]["input_type"] == "response" + + @pytest.mark.asyncio + async def test_nothing_selectable_reaches_no_evaluation(self): + """No texts, images, tools, tool_calls, or structured_messages: genuinely nothing to send.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock() + + result = await guardrail.apply_guardrail(inputs={"texts": []}, request_data={}, input_type="request") + + assert result == {"texts": []} + guardrail.async_handler.post.assert_not_called() + + @pytest.mark.asyncio + async def test_tool_calls_only_still_reaches_alice(self): + """A batch with empty texts but populated tool_calls is still a selection decision Alice + should make, not the plugin — see the class docstring.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + inputs = {"texts": [], "tool_calls": [{"id": "call_1", "function": {"name": "get_weather"}}]} + + await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request") + + guardrail.async_handler.post.assert_called_once() + assert guardrail.async_handler.post.call_args.kwargs["json"]["inputs"]["tool_calls"] == inputs["tool_calls"] + + @pytest.mark.asyncio + async def test_images_only_still_reaches_alice(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + + await guardrail.apply_guardrail( + inputs={"texts": [], "images": ["data:image/png;base64,abc"]}, request_data={}, input_type="request" + ) + + guardrail.async_handler.post.assert_called_once() + + @pytest.mark.asyncio + async def test_structured_messages_only_still_reaches_alice(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + + await guardrail.apply_guardrail( + inputs={"texts": [], "structured_messages": [{"role": "user", "content": []}]}, + request_data={}, + input_type="request", + ) + + guardrail.async_handler.post.assert_called_once() + + @pytest.mark.asyncio + async def test_makes_exactly_one_attempt(self): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + + await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data={}, input_type="request") + + assert guardrail.async_handler.post.call_count == 1 + + +class TestAliceCredentialStripping: + """request_data's raw-credential keys never leave the process.""" + + @pytest.mark.asyncio + async def test_secret_fields_and_api_key_are_stripped(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + request_data = { + "model": "gpt-4o", + "api_key": "sk-forwarded-provider-secret", + "secret_fields": {"raw_headers": {"authorization": "Bearer caller-virtual-key"}}, + "metadata": {"user_api_key_alias": "payments-bot"}, + } + + await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request") + + sent_request_data = guardrail.async_handler.post.call_args.kwargs["json"]["request_data"] + assert "secret_fields" not in sent_request_data + assert "api_key" not in sent_request_data + assert sent_request_data == {"model": "gpt-4o", "metadata": {"user_api_key_alias": "payments-bot"}} + + @pytest.mark.asyncio + async def test_nested_credentials_are_stripped_at_every_depth(self): + """Shaped after a real captured Claude Code payload: the caller's Authorization/x-api-key + lives under several independent nesting paths, none of which are the root.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + request_data = { + "model": "claude-3-5-sonnet", + "secret_fields": {"raw_headers": {"authorization": "Bearer caller-virtual-key"}}, + "provider_specific_header": {"extra_headers": {"authorization": "sk-ant-oat01-nested-oauth"}}, + "proxy_server_request": { + "url": "/v1/messages", + "headers": {"authorization": "Bearer inbound-caller-secret", "x-request-id": "req-1"}, + "body": { + "model": "claude-3-5-sonnet", + "metadata": {"headers": {"authorization": "Bearer body-metadata-secret"}}, + }, + }, + "metadata": { + "user_api_key_alias": "payments-bot", + "headers": {"authorization": "Bearer metadata-secret"}, + "requester_metadata": {"headers": {"authorization": "Bearer requester-metadata-secret"}}, + }, + "litellm_metadata": {"headers": {"authorization": "Bearer litellm-metadata-secret"}}, + } + + await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request") + + posted_body = guardrail.async_handler.post.call_args.kwargs["json"] + serialized = json.dumps(posted_body) + assert "authorization" not in serialized.lower() + assert "caller-virtual-key" not in serialized + assert "nested-oauth" not in serialized + assert "inbound-caller-secret" not in serialized + assert "body-metadata-secret" not in serialized + assert "metadata-secret" not in serialized + assert "requester-metadata-secret" not in serialized + assert "litellm-metadata-secret" not in serialized + + sent_request_data = posted_body["request_data"] + assert sent_request_data["model"] == "claude-3-5-sonnet" + assert sent_request_data["proxy_server_request"]["url"] == "/v1/messages" + assert "headers" not in sent_request_data["proxy_server_request"] + assert sent_request_data["proxy_server_request"]["body"]["model"] == "claude-3-5-sonnet" + assert "headers" not in sent_request_data["proxy_server_request"]["body"]["metadata"] + assert sent_request_data["metadata"]["user_api_key_alias"] == "payments-bot" + assert "headers" not in sent_request_data["metadata"] + assert "requester_metadata" in sent_request_data["metadata"] + assert "headers" not in sent_request_data["metadata"]["requester_metadata"] + assert "headers" not in sent_request_data["litellm_metadata"] + assert "secret_fields" not in sent_request_data + assert "provider_specific_header" not in sent_request_data + + @pytest.mark.asyncio + async def test_the_original_request_data_is_not_mutated(self): + """Stripping must only affect the outbound copy — api_key still has to reach the + provider, and secret_fields still has to reach the rest of the request pipeline.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + request_data = {"api_key": "sk-forwarded-provider-secret", "secret_fields": {"raw_headers": {}}} + + await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request") + + assert request_data["api_key"] == "sk-forwarded-provider-secret" + assert request_data["secret_fields"] == {"raw_headers": {}} + + +class TestAliceVerdicts: + @pytest.mark.asyncio + async def test_allow_leaves_the_inputs_untouched(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []})) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + @pytest.mark.asyncio + async def test_block_surfaces_the_policy_message(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict( + { + "verdict": "BLOCK", + "categories": ["self_harm"], + "correlation_id": "c1", + "message": "Blocked by your organization's policy", + } + ) + ) + + with pytest.raises(GuardrailRaisedException) as error: + await guardrail.apply_guardrail(inputs={"texts": ["bad"]}, request_data={}, input_type="request") + + assert "Blocked by your organization's policy" in str(error.value) + + @pytest.mark.asyncio + async def test_block_without_a_message_still_blocks(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "BLOCK", "categories": []})) + + with pytest.raises(GuardrailRaisedException): + await guardrail.apply_guardrail(inputs={"texts": ["bad"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_mask_substitutes_by_position(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict( + { + "verdict": "MASK", + "categories": ["pii"], + "replacements": [{"index": 1, "text": "my ssn is ***"}], + } + ) + ) + + result = await guardrail.apply_guardrail( + inputs={"texts": ["untouched", "my ssn is 123-45-6789"]}, + request_data={}, + input_type="request", + ) + + assert result["texts"] == ["untouched", "my ssn is ***"] + + @pytest.mark.asyncio + async def test_mask_that_lands_nowhere_blocks(self): + """A mask that wrote nothing would let the text through under a verdict that said not to.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict({"verdict": "MASK", "categories": [], "replacements": [{"index": 9, "text": "***"}]}) + ) + + with pytest.raises(GuardrailRaisedException): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_mask_with_no_replacements_blocks(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "MASK", "categories": []})) + + with pytest.raises(GuardrailRaisedException): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_mask_with_one_invalid_replacement_blocks_entirely(self): + """A mixed valid/invalid replacement list must not let the valid half through: that + would leave the content named by the invalid entry unmasked while looking like success.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict( + { + "verdict": "MASK", + "categories": ["pii"], + "replacements": [{"index": 0, "text": "***"}, {"index": 9, "text": "***"}], + } + ) + ) + + with pytest.raises(GuardrailRaisedException): + await guardrail.apply_guardrail( + inputs={"texts": ["my ssn is 123-45-6789"]}, request_data={}, input_type="request" + ) + + @pytest.mark.asyncio + async def test_mask_leaves_structured_messages_identical(self): + """A new structured_messages object makes the translation layer skip the texts write-back.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict({"verdict": "MASK", "categories": [], "replacements": [{"index": 0, "text": "***"}]}) + ) + messages = [{"role": "user", "content": "secret"}] + + result = await guardrail.apply_guardrail( + inputs={"texts": ["secret"], "structured_messages": messages}, + request_data={}, + input_type="request", + ) + + assert result["structured_messages"] is messages + + @pytest.mark.asyncio + async def test_detect_allows_and_leaves_the_text_alone(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=_verdict({"verdict": "DETECT", "categories": ["profanity"], "correlation_id": "c1"}) + ) + + result = await guardrail.apply_guardrail(inputs={"texts": ["mild"]}, request_data={}, input_type="request") + + assert result["texts"] == ["mild"] + + +class TestAliceUnreachable: + @pytest.mark.parametrize( + "failure", + [ + pytest.param({"side_effect": httpx.ConnectError("refused")}, id="connect-error"), + pytest.param({"return_value": _verdict({"verdict": "MAYBE"})}, id="unrecognized-verdict"), + pytest.param({"return_value": _verdict({})}, id="no-verdict"), + ], + ) + @pytest.mark.asyncio + async def test_fails_closed_by_default(self, failure: dict): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock(**failure) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_fails_open_when_configured(self): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + +class TestAliceTransportFailures: + """Every path out of the HTTP call, since each decides whether traffic flows unscreened.""" + + @pytest.mark.asyncio + async def test_a_timeout_is_unreachable(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + side_effect=litellm.exceptions.Timeout(message="slow", model="gpt-4o", llm_provider="openai") + ) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_upstream_5xx_is_unreachable(self, status: int): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock( + side_effect=httpx.HTTPStatusError( + "server error", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + response=_verdict({}, status_code=status), + ) + ) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + @pytest.mark.asyncio + async def test_a_500_fails_closed_by_default(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + side_effect=httpx.HTTPStatusError( + "server error", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + response=_verdict({}, status_code=500), + ) + ) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_a_4xx_is_not_treated_as_unreachable(self): + """A rejected credential is our misconfiguration, not an outage — it must not fail open.""" + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock( + side_effect=httpx.HTTPStatusError( + "unauthorized", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + response=_verdict({}, status_code=401), + ) + ) + + with pytest.raises(httpx.HTTPStatusError): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_a_non_object_body_fails_closed_by_default(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + json=["not", "an", "object"], + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_a_non_object_body_fails_open_when_configured(self): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + json=["not", "an", "object"], + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + @pytest.mark.asyncio + async def test_malformed_json_fails_closed_by_default(self): + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + content=b"not json", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_malformed_json_fails_open_when_configured(self): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + content=b"not json", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + @pytest.mark.asyncio + async def test_an_undecodable_body_fails_closed_by_default(self): + """UnicodeDecodeError is a sibling of JSONDecodeError under ValueError, not a subclass.""" + guardrail = _guardrail() + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + content=b"\xff\xfe not utf-8", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + with pytest.raises(GuardrailRaisedException, match="unavailable"): + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + @pytest.mark.asyncio + async def test_an_undecodable_body_fails_open_when_configured(self): + guardrail = _guardrail(unreachable_fallback="fail_open") + guardrail.async_handler.post = AsyncMock( + return_value=Response( + status_code=200, + content=b"\xff\xfe not utf-8", + request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"), + ) + ) + + result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert result["texts"] == ["hello"] + + +class TestAliceSerialization: + """`request_data` carries live objects, so it cannot be posted as it stands.""" + + def test_drops_what_cannot_serialize_and_keeps_the_rest(self): + class Span: + pass + + result = _json_safe({"model": "x", "metadata": {"span": Span(), "user": "u1"}, "n": 1}) + + assert result == {"model": "x", "metadata": {"span": None, "user": "u1"}, "n": 1} + + def test_survives_a_cycle(self): + data: dict = {"a": 1} + data["self"] = data + + assert _json_safe(data) == {"a": 1, "self": None} + + def test_drops_a_model_that_will_not_dump(self): + class Stubborn: + def model_dump(self, mode: str = "python") -> dict: + raise RuntimeError("cannot serialise") + + assert _json_safe({"m": Stubborn()}) == {"m": None} + + def test_drops_a_bare_unserialisable_value(self): + class Span: + pass + + assert _json_safe(Span()) is None + + def test_dumps_pydantic_models(self): + from pydantic import BaseModel + + class Model(BaseModel): + name: str + + assert _json_safe({"m": Model(name="x")}) == {"m": {"name": "x"}} + + +def test_config_model_is_exposed_for_the_ui(): + config_model = AliceGuardrail.get_config_model() + + assert config_model is not None + assert config_model.ui_friendly_name() == "Alice" + + +def test_guardrail_name_constant(): + assert GUARDRAIL_NAME == "alice" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index bcda1b8b61d..953e3de1519 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -5524,7 +5524,9 @@ async def test_streaming_end_of_stream_block_emits_error_frame_instead_of_trunca """Regression for PR #38722: a topicPolicy DENY caught by the end-of-stream scan used to raise after SSE headers were flushed, so the client saw a silently truncated stream. The unified hook must emit the chat in-stream - error frame instead.""" + error frame instead. The finish chunk is withheld while the end-of-stream + scan runs, so on a block it is dropped rather than relayed before the + frame.""" from litellm.llms import load_guardrail_translation_mappings from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail import ( unified_guardrail as unified_module, @@ -5582,8 +5584,9 @@ async def test_streaming_end_of_stream_block_emits_error_frame_instead_of_trunca finally: unified_module.endpoint_guardrail_translation_mappings = None - assert len(out) == 3 + assert len(out) == 2 assert isinstance(out[0], ModelResponseStream) + assert out[0].choices[0].finish_reason is None frame = out[-1] assert isinstance(frame, bytes) payload = json.loads(frame.decode()[len("data: ") :]) @@ -5592,6 +5595,156 @@ async def test_streaming_end_of_stream_block_emits_error_frame_instead_of_trunca assert payload["error"]["provider_specific_fields"]["guardrailIdentifier"] == "test-guardrail" +def _responses_stream_events() -> list: + from litellm.types.llms.openai import ( + OutputTextDeltaEvent, + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + deltas = [ + OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id="msg_lit6457", + output_index=0, + content_index=0, + delta=part, + ) + for part in ("Hello", " world") + ] + completed = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=ResponsesAPIResponse( + id="resp_lit6457", + created_at=1234567890, + model="gpt-4o", + object="response", + status="completed", + output=[ + { + "type": "message", + "id": "msg_lit6457", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello world"}], + } + ], + ), + ) + return [*deltas, completed] + + +@pytest.mark.asyncio +async def test_responses_api_stream_scans_output_and_replays_buffered_events(): + """Streamed /v1/responses events must be scanned via the unified translation + layer, not fed to stream_chunk_builder (which raises APIError on them).""" + guardrail = BedrockGuardrail( + guardrail_name="bedrock-responses-stream", + guardrailIdentifier="test-id", + guardrailVersion="DRAFT", + event_hook=GuardrailEventHooks.post_call, + default_on=True, + ) + stream_events = _responses_stream_events() + order = [] + yielded = [] + + async def record_scan(*args, **kwargs): + order.append("scan") + return {"action": "NONE", "assessments": [], "outputs": []} + + async def mock_stream(): + for event in stream_events: + yield event + + with patch.object(guardrail, "make_bedrock_api_request", AsyncMock(side_effect=record_scan)): + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", request_route="/v1/responses"), + response=mock_stream(), + request_data={"model": "gpt-4o", "input": "hi"}, + ): + order.append("chunk") + yielded.append(chunk) + + assert order == ["scan", "chunk", "chunk", "chunk"] + assert len(yielded) == len(stream_events) + assert all(emitted is original for emitted, original in zip(yielded, stream_events)) + + +def _responses_failed_stream_events() -> list: + from litellm.types.llms.openai import ( + OutputTextDeltaEvent, + ResponseFailedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + deltas = [ + OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id="msg_lit6457_failed", + output_index=0, + content_index=0, + delta=part, + ) + for part in ("Hello", " world") + ] + failed = ResponseFailedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_FAILED, + response=ResponsesAPIResponse( + id="resp_lit6457_failed", + created_at=1234567890, + model="gpt-4o", + object="response", + status="failed", + output=[], + ), + ) + return [*deltas, failed] + + +@pytest.mark.asyncio +async def test_responses_api_failed_stream_scans_delta_text_before_replay(): + """A responses stream that dies mid-generation carries its text only in delta + events; the end-of-stream scan must still see that text instead of skipping + on an empty assembled string and replaying the buffer unmoderated.""" + guardrail = BedrockGuardrail( + guardrail_name="bedrock-responses-failed-stream", + guardrailIdentifier="test-id", + guardrailVersion="DRAFT", + event_hook=GuardrailEventHooks.post_call, + default_on=True, + ) + stream_events = _responses_failed_stream_events() + order = [] + scan_payloads = [] + yielded = [] + + async def record_scan(*args, **kwargs): + order.append("scan") + scan_payloads.append(str(args) + str(kwargs)) + return {"action": "NONE", "assessments": [], "outputs": []} + + async def mock_stream(): + for event in stream_events: + yield event + + with patch.object(guardrail, "make_bedrock_api_request", AsyncMock(side_effect=record_scan)): + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", request_route="/v1/responses"), + response=mock_stream(), + request_data={"model": "gpt-4o", "input": "hi"}, + ): + order.append("chunk") + yielded.append(chunk) + + assert order == ["scan", "chunk", "chunk", "chunk"] + assert "Hello world" in scan_payloads[0] + assert len(yielded) == len(stream_events) + assert all(emitted is original for emitted, original in zip(yielded, stream_events)) + + @pytest.mark.asyncio async def test_apply_guardrail_debug_log_masks_signed_request_headers(): import logging diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_openai_streaming_block.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_openai_streaming_block.py new file mode 100644 index 00000000000..42bdf41bc88 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_openai_streaming_block.py @@ -0,0 +1,327 @@ +""" +Regression tests for blocking an OpenAI-format streaming response from the +unified guardrail post-call streaming iterator hook. + +When a guardrail's ``apply_guardrail`` raises ``ModifyResponseException`` +while (or at the end of) a chat completions or Responses API stream is being +relayed, the hook must emit a well-formed SSE termination sequence carrying +the block message - NOT a bare ``data: {"error": ...}`` blob that surfaces as +an HTTP 500 error frame and truncates the stream. +""" + +import json +from typing import Any, AsyncGenerator, Dict, Literal, Optional, Tuple, Union + +import pytest + +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( + UnifiedLLMGuardrails, +) +from litellm.types.utils import ( + Delta, + GenericGuardrailAPIInputs, + ModelResponseStream, + StreamingChoices, +) + +BLOCK_MESSAGE = "This response was replaced by policy." + +JsonPayload = Dict[str, object] +StreamChunk = Union[ModelResponseStream, JsonPayload, bytes] + + +class _BlockingGuardrail(CustomGuardrail): + """Mock guardrail that always blocks response scans by raising ModifyResponseException.""" + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + raise ModifyResponseException( + message=BLOCK_MESSAGE, + model="gpt-5.4-mini", + request_data=request_data, + guardrail_name=self.guardrail_name, + ) + + +class _PassingGuardrail(CustomGuardrail): + """Mock guardrail that always lets response scans through unchanged.""" + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + return inputs + + +def _chat_chunk(delta: Delta, finish_reason: Optional[str] = None) -> ModelResponseStream: + return ModelResponseStream( + id="chatcmpl-live", + created=1724900000, + model="gpt-5.4-mini", + choices=[StreamingChoices(index=0, delta=delta, finish_reason=finish_reason)], + ) + + +async def _chat_stream(end: bool) -> AsyncGenerator[ModelResponseStream, None]: + yield _chat_chunk(Delta(role="assistant", content="This ")) + for text in ["is ", "the ", "original ", "answer."]: + yield _chat_chunk(Delta(content=text)) + if end: + yield _chat_chunk(Delta(), finish_reason="stop") + + +async def _responses_stream(end: bool) -> AsyncGenerator[JsonPayload, None]: + original_text = "This is the original answer." + response_envelope = {"id": "resp_live", "model": "gpt-5.4-mini", "status": "in_progress", "output": []} + yield {"type": "response.created", "response": response_envelope} + yield {"type": "response.in_progress", "response": response_envelope} + yield { + "type": "response.output_item.added", + "output_index": 0, + "item": {"id": "msg_orig", "type": "message", "role": "assistant", "content": []}, + } + yield { + "type": "response.content_part.added", + "item_id": "msg_orig", + "output_index": 0, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + } + for delta in ["This ", "is ", "the ", "original ", "answer."]: + yield { + "type": "response.output_text.delta", + "item_id": "msg_orig", + "output_index": 0, + "content_index": 0, + "delta": delta, + } + yield { + "type": "response.output_text.done", + "item_id": "msg_orig", + "output_index": 0, + "content_index": 0, + "text": original_text, + } + if end: + yield { + "type": "response.completed", + "response": { + "id": "resp_live", + "model": "gpt-5.4-mini", + "status": "completed", + "output": [ + { + "id": "msg_orig", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": original_text, "annotations": []}], + } + ], + "usage": {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28}, + }, + } + + +async def _run_hook( + route: str, + stream: AsyncGenerator[Union[ModelResponseStream, JsonPayload], None], + sampling_rate: int = 1, + end_of_stream_only: bool = False, + buffer_until_moderated: bool = False, + blocks: bool = True, +) -> Tuple[StreamChunk, ...]: + guardrail = ( + _BlockingGuardrail(guardrail_name="test-blocking-guardrail", event_hook="post_call") + if blocks + else _PassingGuardrail(guardrail_name="test-passing-guardrail", event_hook="post_call") + ) + guardrail.streaming_sampling_rate = sampling_rate + guardrail.streaming_end_of_stream_only = end_of_stream_only + guardrail.streaming_buffer_until_moderated = buffer_until_moderated + + unified_guardrail = UnifiedLLMGuardrails() + user_api_key_dict = UserAPIKeyAuth(api_key="test", request_route=route) + request_data = { + "messages": [{"role": "user", "content": "hi"}], + "guardrail_to_apply": guardrail, + "metadata": {"guardrails": [guardrail.guardrail_name]}, + } + + return tuple( + [ + chunk + async for chunk in unified_guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=stream, + request_data=request_data, + ) + ] + ) + + +def _sse_payloads(collected: Tuple[StreamChunk, ...]) -> Tuple[JsonPayload, ...]: + return tuple( + json.loads(line[len("data:") :].strip()) + for chunk in collected + if isinstance(chunk, bytes) + for block in chunk.decode().split("\n\n") + for line in block.strip().split("\n") + if line.startswith("data:") + ) + + +def _assert_no_error_frame(collected: Tuple[StreamChunk, ...]) -> None: + raw = "".join(chunk.decode() for chunk in collected if isinstance(chunk, bytes)) + assert '"error"' not in raw, f"unexpected error blob in stream: {raw!r}" + + +@pytest.mark.asyncio +async def test_chat_pre_stream_block_emits_standalone_completion(): + """Block on the first chunk: a standalone completion opens with a role delta + and ends with finish_reason content_filter.""" + collected = await _run_hook("/v1/chat/completions", _chat_stream(end=False)) + _assert_no_error_frame(collected) + payloads = _sse_payloads(collected) + assert payloads, "no block SSE chunks were emitted" + assert payloads[0]["choices"][0]["delta"] == {"role": "assistant", "content": BLOCK_MESSAGE} + assert payloads[-1]["choices"][0]["finish_reason"] == "content_filter" + + +@pytest.mark.asyncio +async def test_chat_mid_stream_block_continues_the_completion(): + """Regression for the LIT-6496 500 error frame: after chunks were already + forwarded, the block continues the same completion id and terminates with + finish_reason content_filter instead of raising into an error blob.""" + collected = await _run_hook("/v1/chat/completions", _chat_stream(end=False), sampling_rate=5) + _assert_no_error_frame(collected) + forwarded = [chunk for chunk in collected if isinstance(chunk, ModelResponseStream)] + assert forwarded, "original chunks should have streamed before the block" + payloads = _sse_payloads(collected) + assert payloads, "no block SSE chunks were emitted" + assert all(payload["id"] == "chatcmpl-live" for payload in payloads), ( + "block chunks must continue the in-progress completion, not start a new one" + ) + assert payloads[0]["choices"][0]["delta"] == {"content": BLOCK_MESSAGE} + assert payloads[-1]["choices"][0]["finish_reason"] == "content_filter" + + +@pytest.mark.asyncio +async def test_chat_end_of_stream_block_terminates_cleanly(): + """Regression for bugbot's finish-ordering finding: in end_of_stream_only + mode the original finish chunk must be withheld until moderation decides, + so a block's content_filter finish is the only stream terminator a client + ever sees - never policy text trailing after finish_reason stop.""" + collected = await _run_hook("/v1/chat/completions", _chat_stream(end=True), end_of_stream_only=True) + _assert_no_error_frame(collected) + forwarded = [chunk for chunk in collected if isinstance(chunk, ModelResponseStream)] + assert forwarded, "content chunks still stream to the client before end-of-stream moderation" + assert all(choice.finish_reason is None for chunk in forwarded for choice in chunk.choices), ( + "the original finish chunk must be withheld until moderation decides" + ) + payloads = _sse_payloads(collected) + assert BLOCK_MESSAGE in json.dumps(payloads) + assert payloads[-1]["choices"][0]["finish_reason"] == "content_filter" + + +@pytest.mark.asyncio +async def test_chat_end_of_stream_pass_releases_withheld_finish_chunk(): + """When end-of-stream moderation passes, the withheld finish chunk is + released so a clean stream still terminates normally.""" + collected = await _run_hook( + "/v1/chat/completions", _chat_stream(end=True), end_of_stream_only=True, blocks=False + ) + assert not [chunk for chunk in collected if isinstance(chunk, bytes)], ( + "a clean stream must carry no synthetic block frames" + ) + forwarded = [chunk for chunk in collected if isinstance(chunk, ModelResponseStream)] + finish_reasons = [choice.finish_reason for chunk in forwarded for choice in chunk.choices] + assert finish_reasons[-1] == "stop", "the withheld finish chunk must be released after moderation passes" + assert all(reason is None for reason in finish_reasons[:-1]) + + +@pytest.mark.asyncio +async def test_responses_buffered_block_emits_full_event_sequence(): + """Buffered moderation blocks before anything streams: a complete synthetic + Responses stream from response.created through response.completed carrying + the block message, with the original content never released.""" + collected = await _run_hook("/v1/responses", _responses_stream(end=True), buffer_until_moderated=True) + _assert_no_error_frame(collected) + assert not [chunk for chunk in collected if isinstance(chunk, dict)], ( + "buffered original chunks must never be released after a block" + ) + payloads = _sse_payloads(collected) + event_types = [payload["type"] for payload in payloads] + assert event_types[0] == "response.created" + assert "response.output_text.delta" in event_types + assert event_types[-1] == "response.completed" + completed = payloads[-1]["response"] + assert completed["status"] == "completed" + assert completed["output"][0]["content"][0]["text"] == BLOCK_MESSAGE + assert completed["usage"] == {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28} + assert "original answer" not in json.dumps(payloads) + + +@pytest.mark.asyncio +async def test_responses_mid_stream_block_continues_the_response(): + """Regression for the LIT-6496 500 error frame and bugbot's unclosed-item + finding: after events were already forwarded, the block first closes the + output item still open on the wire, then appends the replacement item under + the same response id, and closes with response.completed - never a second + response.created and never a completed response with an item left open.""" + collected = await _run_hook("/v1/responses", _responses_stream(end=False)) + _assert_no_error_frame(collected) + forwarded = [chunk for chunk in collected if isinstance(chunk, dict)] + forwarded_types = [chunk["type"] for chunk in forwarded] + assert "response.created" in forwarded_types, "original events should have streamed before the block" + payloads = _sse_payloads(collected) + assert payloads, "no block SSE chunks were emitted" + block_types = [payload["type"] for payload in payloads] + assert "response.created" not in block_types, "a mid-stream block must not restart the response" + assert block_types[-1] == "response.completed" + + all_events = forwarded + list(payloads) + opened = sorted(event["output_index"] for event in all_events if event["type"] == "response.output_item.added") + closed = sorted(event["output_index"] for event in all_events if event["type"] == "response.output_item.done") + assert opened == closed, "every output item opened on the stream must be closed before response.completed" + original_done_position = block_types.index("response.output_item.done") + block_item_position = block_types.index("response.output_item.added") + assert original_done_position < block_item_position, ( + "the in-progress original item must be closed before the block item is appended" + ) + assert payloads[original_done_position]["item"]["id"] == "msg_orig" + assert payloads[block_item_position]["output_index"] == 1, ( + "the block item must continue after the original output item" + ) + completed = payloads[-1]["response"] + assert completed["id"] == "resp_live" + assert completed["output"][0]["content"][0]["text"] == BLOCK_MESSAGE + + +@pytest.mark.asyncio +async def test_responses_end_of_stream_block_reports_original_usage(): + collected = await _run_hook("/v1/responses", _responses_stream(end=True), end_of_stream_only=True) + _assert_no_error_frame(collected) + forwarded_types = [chunk["type"] for chunk in collected if isinstance(chunk, dict)] + assert "response.completed" not in forwarded_types, ( + "the original terminal event must be withheld and replaced by the block sequence" + ) + payloads = _sse_payloads(collected) + completed = payloads[-1]["response"] + assert payloads[-1]["type"] == "response.completed" + assert completed["id"] == "resp_live" + assert completed["output"][0]["content"][0]["text"] == BLOCK_MESSAGE + assert completed["usage"] == {"input_tokens": 7, "output_tokens": 21, "total_tokens": 28} diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index 0b32558a00a..8cad1c634a9 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -1844,7 +1844,8 @@ class TestStreamingHttpErrorFrames: out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) - assert out[:2] == chunks + assert out[0] == chunks[0] + assert chunks[1] not in out frame = out[-1] assert isinstance(frame, bytes) text = frame.decode() diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 957f9fde645..1697b77b99a 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -1,7 +1,8 @@ import logging import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping, Sequence from itertools import chain +from types import MappingProxyType from typing import Final from unittest.mock import AsyncMock, MagicMock, call @@ -38,6 +39,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import ( get_groups, get_users, get_service_provider_config, + merge_placeholder, patch_group, patch_team_membership, patch_user, @@ -52,6 +54,7 @@ from litellm.types.proxy.management_endpoints.scim_v2 import ( SCIMMember, SCIMPatchOp, SCIMPatchOperation, + SCIMPlaceholderMergeResult, SCIMServiceProviderConfig, SCIMUser, SCIMUserEmail, @@ -778,13 +781,17 @@ async def test_handle_existing_user_by_email_without_teams_preserves_memberships "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", AsyncMock(return_value=None), ) - mock_team_member_add = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", - AsyncMock(), + mock_team_member_add = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", + AsyncMock(), + ) ) - mock_team_member_delete = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper - "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", - AsyncMock(), + mock_team_member_delete = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", + AsyncMock(), + ) ) new_user_request = NewUserRequest( @@ -1645,6 +1652,25 @@ async def test_update_group_e2e(mocker): ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once_with(updated_team) +def _rows_by_exact_id( + user_row: Callable[[Mapping[str, str]], LiteLLM_UserTable | MagicMock | None], +) -> Callable[..., tuple[LiteLLM_UserTable | MagicMock, ...]]: + """``find_many`` stand-in for the classifier's cross-field read on a table where a + member value only ever matches as an exact ``user_id``.""" + + def rows(where: Mapping[str, object], take: int | None = None) -> tuple[LiteLLM_UserTable | MagicMock, ...]: + clauses: Final = where["OR"] + assert isinstance(clauses, list) + found: Final = tuple(user_row(clause) for clause in clauses if "user_id" in clause) + return tuple(row for row in found if row is not None) + + return rows + + +def _user_row_for(where: Mapping[str, str]) -> LiteLLM_UserTable: + return LiteLLM_UserTable(user_id=where["user_id"]) + + @pytest.mark.asyncio async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch): """ @@ -1696,9 +1722,8 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch): return mock_user return None # new-user-1 and new-user-2 don't exist - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup)) # Mock dependencies mocker.patch( @@ -1782,9 +1807,8 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch): return mock_user return None # new-user-3 and new-user-4 don't exist - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup)) # Mock dependencies mocker.patch( @@ -1853,9 +1877,8 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(mocker return mock_user return None # new-user-1 and new-user-2 don't exist - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup)) # Mock user creation created_user_1 = NewUserResponse(user_id="new-user-1", key="test-key-1") @@ -1943,9 +1966,8 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(mocker, mon return mock_user return None # new-user-1 doesn't exist - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup)) # Mock user creation created_user = NewUserResponse(user_id="new-user-1", key="test-key-1") @@ -2013,9 +2035,8 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa return mock_user return None # new-user-1 doesn't exist - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup)) # Mock dependencies mocker.patch( @@ -3121,8 +3142,7 @@ async def test_process_group_patch_operations_add_retains_existing_members(mocke mock_prisma_client.db = mocker.MagicMock() mock_prisma_client.db.litellm_usertable = mocker.MagicMock() # new-user already exists in the DB - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock(user_id="new-user")) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=(mocker.MagicMock(user_id="new-user"),)) _, final_members, _ = await _process_group_patch_operations( patch_ops=patch_ops, @@ -3415,8 +3435,7 @@ async def test_patch_group_add_applies_delta_and_keeps_concurrent_add(mocker): ) mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team) mock_prisma_client.db.litellm_usertable = mocker.MagicMock() - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock()) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(_user_row_for)) mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", @@ -3509,8 +3528,7 @@ async def test_patch_group_replace_stays_absolute_against_concurrent_roster(mock ) mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team) mock_prisma_client.db.litellm_usertable = mocker.MagicMock() - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock()) - mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(_user_row_for)) mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", @@ -3640,8 +3658,7 @@ async def test_process_group_patch_add_filtered_path_without_value(mocker): prisma_client = mocker.MagicMock() prisma_client.db = mocker.MagicMock() prisma_client.db.litellm_usertable = mocker.MagicMock() - prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id="user-3")) - prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=(LiteLLM_UserTable(user_id="user-3"),)) _, final_members, _ = await _process_group_patch_operations( patch_ops=patch_ops, @@ -3733,12 +3750,14 @@ def _member_resolution_prisma( starts folding it, fails here instead of passing. A caller that must know which accounts match rather than merely how many - passes take=None, so an unbounded read returns every match. + passes take=None, so an unbounded read returns every match. The row keyed by + the value comes last, the order a bounded read is least prepared for, since + the database promises no order at all. """ clauses: Final = where["OR"] assert isinstance(clauses, list) fields: Final = tuple(next(iter(clause)) for clause in clauses) - assert fields == ("sso_user_id", "user_email"), fields + assert fields in (("user_id", "sso_user_id", "user_email"), ("sso_user_id", "user_email")), fields def comparison(clause: Mapping[str, object]) -> tuple[str, bool]: """The needle and whether production asked for a case-insensitive compare, @@ -3749,8 +3768,9 @@ def _member_resolution_prisma( assert isinstance(criterion, dict), criterion return criterion["equals"], criterion.get("mode") == "insensitive" - sso_needle, sso_insensitive = comparison(clauses[0]) - email_needle, email_insensitive = comparison(clauses[1]) + by_field: Final = dict(zip(fields, (comparison(clause) for clause in clauses))) + sso_needle, sso_insensitive = by_field["sso_user_id"] + email_needle, email_insensitive = by_field["user_email"] def same(stored: str, needle: str, insensitive: bool) -> bool: return stored.casefold() == needle.casefold() if insensitive else stored == needle @@ -3768,6 +3788,11 @@ def _member_resolution_prisma( if same(email, email_needle, email_insensitive) for user_id in user_ids ), + ( + user_id + for user_id in users + if "user_id" in by_field and same(user_id, by_field["user_id"][0], by_field["user_id"][1]) + ), ) ) found: Final = tuple(dict.fromkeys(matched)) @@ -4452,9 +4477,11 @@ async def test_create_group_applies_default_team_params( "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", AsyncMock(return_value=_member_resolution_prisma(mocker, users=set(), teams=set())), ) - new_team_mock = mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group - "litellm.proxy.management_endpoints.scim.scim_v2.new_team", - AsyncMock(return_value=mocker.MagicMock()), + new_team_mock = ( + mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=mocker.MagicMock()), + ) ) mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group", @@ -4611,9 +4638,15 @@ async def test_resolve_group_member_ids_dedupes_repeated_member(mocker, scim_ups def _identity_lookup(value: str) -> object: - """The single cross-field lookup the classifier is expected to issue.""" + """The single cross-field lookup the classifier is expected to issue per member.""" return call( - where={"OR": [{"sso_user_id": value}, {"user_email": {"equals": value, "mode": "insensitive"}}]}, + where={ + "OR": [ + {"user_id": value}, + {"sso_user_id": value}, + {"user_email": {"equals": value, "mode": "insensitive"}}, + ] + }, take=2, ) @@ -4903,9 +4936,7 @@ async def test_process_group_patch_remove_by_the_id_the_directory_added_with( @pytest.mark.asyncio -async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id( - mocker, scim_upsert_user_enabled -): +async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id(mocker, scim_upsert_user_enabled): """An earlier release put unmatched ids on the roster verbatim, so a remove has to keep clearing the id as written even once it also resolves.""" patch_ops = SCIMPatchOp( @@ -4916,7 +4947,10 @@ async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_liter team_id="parent-group", team_alias="Parent Group", members=[], - members_with_roles=[Member(user_id="legacy@example.com", role="user"), Member(user_id="keep-user", role="user")], + members_with_roles=[ + Member(user_id="legacy@example.com", role="user"), + Member(user_id="keep-user", role="user"), + ], ) _, final_members, _ = await _process_group_patch_operations( @@ -5081,11 +5115,8 @@ async def test_process_group_patch_remove_refuses_when_two_members_share_the_id( assert "more than one member of this group" in str(exc_info.value.detail) - @pytest.mark.asyncio -async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else( - mocker, scim_upsert_user_enabled -): +async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else(mocker, scim_upsert_user_enabled): """The canonical user id stays authoritative, including when the same account also holds that value as its email, which is how a SCIM-provisioned account is keyed.""" prisma_client = _member_resolution_prisma( @@ -5147,10 +5178,79 @@ async def test_resolve_group_member_ids_refuses_a_user_id_that_names_another_acc assert exc_info.value.status_code == 400 assert "member-id" in str(exc_info.value.detail) create_user_mock.assert_not_called() - assert any( - record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records + assert any(record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records) + + +@pytest.mark.asyncio +async def test_resolve_group_member_ids_reads_the_exact_id_when_two_other_accounts_fill_the_lookup( + mocker, scim_upsert_user_enabled +): + """A value that is one account's id and two other accounts' identities fills the + bounded lookup with the other two. The account keyed by the value must still be + found, or the id would lose its precedence and a non-canonical type would skip + a member that names a real user.""" + prisma_client = _member_resolution_prisma( + mocker, + users={"shared"}, + teams=set(), + sso_user_id_to_user_id={"shared": "by-sso"}, + email_to_user_id={"shared": "by-email"}, + ) + create_user_mock = mocker.patch( # test-quality-ok: user creation is module-level, not injectable into the resolver + "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", + AsyncMock(return_value=None), ) + with pytest.raises(HTTPException) as exc_info: + await _resolve_group_member_ids( + members=[SCIMMember(value="shared", type="direct")], + created_via="scim_group_membership", + prisma_client=prisma_client, + ) + + assert exc_info.value.status_code == 400 + assert "shared" in str(exc_info.value.detail) + create_user_mock.assert_not_called() + assert prisma_client.db.litellm_usertable.find_many.await_args_list == [_identity_lookup("shared")] + prisma_client.db.litellm_usertable.find_unique.assert_awaited_once_with(where={"user_id": "shared"}) + + +@pytest.mark.asyncio +async def test_resolve_group_member_ids_reads_the_user_table_once_per_member(mocker, scim_upsert_user_enabled): + """Every member costs one read of the user table, however it resolves: by its exact + id (which still outranks a non-canonical type), by identity, as a SCIM team, or not + at all. Looking the exact id up on its own before the identity read doubled the + reads of a push, and the identity read is a scan.""" + prisma_client = _member_resolution_prisma( + mocker, + users={"by-id"}, + teams={"by-team"}, + email_to_user_id={"by-email@example.com": "email-user"}, + ) + mocker.patch( # test-quality-ok: user creation is module-level, not injectable into the resolver + "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", + AsyncMock(return_value=NewUserResponse(user_id="nobody", key="key")), + ) + + result = await _resolve_group_member_ids( + members=[ + SCIMMember(value="by-id", type="direct"), + SCIMMember(value="by-email@example.com"), + SCIMMember(value="by-team"), + SCIMMember(value="nobody"), + ], + created_via="scim_group_membership", + prisma_client=prisma_client, + ) + + assert result.all_member_ids == ["by-id", "email-user", "nobody"] + prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() + assert prisma_client.db.litellm_usertable.find_many.await_args_list == [ + _identity_lookup("by-id"), + _identity_lookup("by-email@example.com"), + _identity_lookup("by-team"), + _identity_lookup("nobody"), + ] @pytest.mark.asyncio @@ -5536,10 +5636,7 @@ async def test_resolve_group_member_ids_admits_member_created_concurrently(mocke the member is still admitted: the id resolves to a real user row, so failing or dropping it would be wrong either way.""" prisma_client = _member_resolution_prisma(mocker, users=set(), teams=set()) - prisma_client.db.litellm_usertable.find_unique = AsyncMock( - side_effect=[None, LiteLLM_UserTable(user_id="raced-user")] - ) - prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id="raced-user")) mocker.patch( "litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists", AsyncMock(return_value=None), @@ -5619,3 +5716,196 @@ async def test_patch_group_404s_when_team_deleted_mid_request(mocker): assert exc_info.value.code == "404" assert f"Group not found with ID: {group_id}" in exc_info.value.message + + +_SHADOW_MEMBER_VALUE: Final = "00u1shadow" +_SHADOWED_ACCOUNT: Final = "real-1" +_SHADOWED_GROUP: Final = "grp-eng" + + +def _shadowed_tenant_rows() -> tuple[LiteLLM_UserTable, ...]: + """A placeholder keyed by the raw member value, and the real account that value names by SSO id.""" + return ( + LiteLLM_UserTable(user_id=_SHADOW_MEMBER_VALUE, user_email=_SHADOW_MEMBER_VALUE, teams=[_SHADOWED_GROUP]), + LiteLLM_UserTable(user_id=_SHADOWED_ACCOUNT, user_email="alice@example.com", sso_user_id=_SHADOW_MEMBER_VALUE), + ) + + +def _shadow_tenant_prisma( + mocker: MockerFixture, + *, + rows: Sequence[LiteLLM_UserTable], + keys_owned_by: Mapping[str, int] = MappingProxyType({}), +) -> MagicMock: + """Prisma fake whose user rows are live: deleting one removes it from every later lookup.""" + users: Final[dict[str, LiteLLM_UserTable]] = {row.user_id: row for row in rows} + team: Final = LiteLLM_TeamTable( + team_id=_SHADOWED_GROUP, + members=[_SHADOW_MEMBER_VALUE], + members_with_roles=[Member(user_id=_SHADOW_MEMBER_VALUE, role="user")], + metadata={SCIM_MANAGED_TEAM_METADATA_KEY: True}, + ) + + async def find_unique(where: Mapping[str, str]) -> LiteLLM_UserTable | None: + return users.get(where["user_id"]) + + def clause_matches(row: LiteLLM_UserTable, clause: Mapping[str, object]) -> bool: + if "user_id" in clause: + return row.user_id == clause["user_id"] + if "sso_user_id" in clause: + return row.sso_user_id == clause["sso_user_id"] + email_filter: Final = clause["user_email"] + assert isinstance(email_filter, dict) + return (row.user_email or "").casefold() == str(email_filter["equals"]).casefold() + + async def identity_rows(where: Mapping[str, object], take: int | None = None) -> tuple[LiteLLM_UserTable, ...]: + clauses: Final = where["OR"] + assert isinstance(clauses, list) + matched: Final = tuple(row for row in users.values() if any(clause_matches(row, clause) for clause in clauses)) + return matched[:take] if take else matched + + async def delete(where: Mapping[str, str]) -> LiteLLM_UserTable | None: + return users.pop(where["user_id"], None) + + async def keys_for(where: Mapping[str, object]) -> tuple[MagicMock, ...]: + return tuple(mocker.MagicMock() for _ in range(keys_owned_by.get(str(where["user_id"]), 0))) + + async def team_lookup(where: Mapping[str, str]) -> LiteLLM_TeamTable | None: + return team if where["team_id"] == team.team_id else None + + prisma_client = mocker.MagicMock() + prisma_client.db = mocker.MagicMock() + prisma_client.db.litellm_usertable = mocker.MagicMock() + prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=find_unique) + prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=identity_rows) + prisma_client.db.litellm_usertable.delete = AsyncMock(side_effect=delete) + prisma_client.db.litellm_teamtable = mocker.MagicMock() + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=team_lookup) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=team) + prisma_client.db.litellm_verificationtoken = mocker.MagicMock() + prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(side_effect=keys_for) + prisma_client.db.litellm_invitationlink = mocker.MagicMock(delete_many=AsyncMock(return_value=0)) + prisma_client.db.litellm_organizationmembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0)) + prisma_client.db.litellm_teammembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0)) + return prisma_client + + +@pytest.fixture +def shadowed_tenant(mocker, monkeypatch, scim_upsert_user_enabled) -> MagicMock: + from litellm.proxy import proxy_server + + prisma_client: Final = _shadow_tenant_prisma(mocker, rows=_shadowed_tenant_rows()) + monkeypatch.setattr(proxy_server, "prisma_client", prisma_client) + return prisma_client + + +async def _push_shadow_member(prisma_client: MagicMock): + return await _resolve_group_member_ids( + members=[SCIMMember(value=_SHADOW_MEMBER_VALUE)], + created_via="scim_group_membership", + prisma_client=prisma_client, + ) + + +@pytest.mark.asyncio +async def test_merge_placeholder_hands_the_group_to_the_shadowed_account(mocker, shadowed_tenant): + """Every group push of the shadowing value is refused until the placeholder is folded into + the real account; after the merge the same push resolves to that account.""" + team_member_add_mock = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock() + ) + ) + team_member_delete_mock = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock() + ) + ) + + with pytest.raises(HTTPException) as before: + await _push_shadow_member(shadowed_tenant) + assert before.value.status_code == 400 + + result: Final = await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE) + + assert result == SCIMPlaceholderMergeResult( + placeholder_user_id=_SHADOW_MEMBER_VALUE, + merged_into_user_id=_SHADOWED_ACCOUNT, + team_ids=(_SHADOWED_GROUP,), + ) + added: Final = team_member_add_mock.call_args.kwargs["data"] + assert (added.team_id, added.member.user_id) == (_SHADOWED_GROUP, _SHADOWED_ACCOUNT) + dropped: Final = team_member_delete_mock.call_args.kwargs["data"] + assert (dropped.team_id, dropped.user_id) == (_SHADOWED_GROUP, _SHADOW_MEMBER_VALUE) + shadowed_tenant.db.litellm_teammembership.delete_many.assert_awaited_once_with( + where={"user_id": _SHADOW_MEMBER_VALUE} + ) + shadowed_tenant.db.litellm_usertable.delete.assert_awaited_once_with(where={"user_id": _SHADOW_MEMBER_VALUE}) + + after: Final = await _push_shadow_member(shadowed_tenant) + assert after.all_member_ids == [_SHADOWED_ACCOUNT] + assert after.created_users == [] + + +@pytest.mark.asyncio +async def test_merge_placeholder_keeps_the_placeholder_when_the_roster_write_fails(mocker, shadowed_tenant): + """If the real account cannot join the team, the placeholder stays on it, or the membership is gone + from both accounts.""" + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", + AsyncMock(side_effect=Exception("database connection lost")), + ) + team_member_delete_mock = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock() + ) + ) + + with pytest.raises(ProxyException): + await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE) + + team_member_delete_mock.assert_not_awaited() + shadowed_tenant.db.litellm_usertable.delete.assert_not_awaited() + assert await shadowed_tenant.db.litellm_usertable.find_unique(where={"user_id": _SHADOW_MEMBER_VALUE}) is not None + + +@pytest.mark.parametrize( + ("rows", "keys_owned_by", "merged", "reason"), + [ + pytest.param(_shadowed_tenant_rows(), {}, _SHADOWED_ACCOUNT, "SSO identity of its own", id="real-account"), + pytest.param( + _shadowed_tenant_rows(), {_SHADOW_MEMBER_VALUE: 2}, _SHADOW_MEMBER_VALUE, "2 virtual keys", id="owns-keys" + ), + pytest.param(_shadowed_tenant_rows()[:1], {}, _SHADOW_MEMBER_VALUE, "shadows no account", id="names-nobody"), + pytest.param( + (*_shadowed_tenant_rows(), LiteLLM_UserTable(user_id="real-2", user_email=_SHADOW_MEMBER_VALUE.upper())), + {}, + _SHADOW_MEMBER_VALUE, + "names 2 accounts (real-1, real-2)", + id="names-two-accounts", + ), + ], +) +@pytest.mark.asyncio +async def test_merge_placeholder_refuses_rows_that_are_not_a_lone_placeholder( + mocker, monkeypatch, scim_upsert_user_enabled, rows, keys_owned_by, merged, reason +): + """Only a row with no SSO identity and no keys whose id names exactly one other account is folded; + anything else could move memberships to the wrong person, so nothing is written.""" + from litellm.proxy import proxy_server + + prisma_client: Final = _shadow_tenant_prisma(mocker, rows=rows, keys_owned_by=keys_owned_by) + monkeypatch.setattr(proxy_server, "prisma_client", prisma_client) + team_member_add_mock = ( + mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint + "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock() + ) + ) + + with pytest.raises(ProxyException) as exc_info: + await merge_placeholder(user_id=merged) + + assert int(exc_info.value.code) == 409 + assert reason in str(exc_info.value.message) + team_member_add_mock.assert_not_awaited() + prisma_client.db.litellm_usertable.delete.assert_not_awaited() diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index a81c6b4c656..7e2e680743f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -17489,3 +17489,49 @@ async def test_check_project_key_limits_still_rejects_real_model_outside_project assert exc_info.value.status_code == 400 assert "Model 'gpt-5.4-mini' not in project's allowed models" in exc_info.value.detail["error"] + + +def test_generate_key_request_blank_team_id_is_personal(): + """The UI Team-field clear submits team_id=""; it must count as no team (LIT-3925).""" + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _is_team_key, + ) + + cleared = GenerateKeyRequest(team_id="") + assert cleared.team_id is None + assert _is_team_key(data=cleared) is False + assert RegenerateKeyRequest(team_id="").team_id is None + assert GenerateKeyRequest(team_id="team-1").team_id == "team-1" + + +def test_key_generation_check_blank_team_id_uses_personal_permissions(monkeypatch): + """key_generation_check with team_id="" must take the personal-key path instead + of failing the team lookup with "Unable to find team object" (LIT-3925).""" + from litellm.proxy._types import KeyManagementRoutes + from litellm.proxy.management_endpoints.key_management_endpoints import ( + key_generation_check, + ) + + monkeypatch.setattr( + litellm, + "key_generation_settings", + { + "team_key_generation": {"allowed_team_member_roles": ["admin"]}, + "personal_key_generation": {"allowed_user_roles": ["proxy_admin", "internal_user"]}, + }, + ) + + assert ( + key_generation_check( + team_table=None, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-alice", + user_id="alice", + ), + data=GenerateKeyRequest(key_alias="personal", team_id=""), + route=KeyManagementRoutes.KEY_GENERATE, + ) + is True + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 393953ccf68..4661cc17dbc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -18,6 +18,7 @@ from litellm.proxy._types import ( ReconcileOutcome, UserAPIKeyAuth, ) +from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.management_endpoints.model_management_endpoints import ( ModelManagementAuthChecks, _get_team_deployments, @@ -263,6 +264,131 @@ class TestModelManagementAuthChecks: ) assert "403" in str(exc_info.value) + def test_can_user_attach_credential_admin_success(self): + result = ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + user_api_key_dict=self.admin_user, + ) + assert result is True + + def test_can_user_attach_credential_without_credential_allows_any_role(self): + result = ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model"), + user_api_key_dict=self.team_admin_user, + ) + assert result is True + + def test_can_user_attach_credential_team_admin_fails(self): + with pytest.raises(Exception, match="Only a proxy admin can attach a stored credential") as exc_info: + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + user_api_key_dict=self.team_admin_user, + ) + assert exc_info.value.code == "403" + + def test_can_user_attach_credential_unchanged_existing_allows_any_role(self): + result = ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + user_api_key_dict=self.team_admin_user, + existing_litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + ) + assert result is True + + def test_can_user_attach_credential_unchanged_encrypted_existing_allows_any_role(self, monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234") + encrypted_name = encrypt_value_helper(value="shared-credential") + assert encrypted_name != "shared-credential" + result = ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + user_api_key_dict=self.team_admin_user, + existing_litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name=encrypted_name), + ) + assert result is True + + @pytest.mark.asyncio + async def test_add_new_model_rejects_credential_attach_for_non_admin(self): + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import ( + add_new_model, + ) + + mock_prisma = MagicMock() + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch( # test-quality-ok: prior auth check needs a live DB; only the credential check is under test + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + ): + with pytest.raises(ProxyException) as exc_info: + await add_new_model( + model_params=Deployment( + model_name="credential-model", + litellm_params=LiteLLM_Params( + model="openai/gpt-4o", litellm_credential_name="shared-credential" + ), + model_info={"id": "credential-create-test"}, + ), + user_api_key_dict=self.team_admin_user, + ) + assert exc_info.value.code == "403" + mock_prisma.db.litellm_proxymodeltable.create.assert_not_called() + + @pytest.mark.asyncio + async def test_patch_model_rejects_credential_attach_for_non_admin(self): + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import ( + patch_model, + ) + from litellm.types.router import updateLiteLLMParams + + model_id = "credential-patch-test" + db_model = Deployment( + model_name="credential-model", + litellm_params=LiteLLM_Params(model="openai/gpt-4o"), + model_info={"id": model_id}, + ) + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server.llm_router", MagicMock()), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch( # test-quality-ok: stubs the DB row fetch; only the credential check is under test + "litellm.proxy.management_endpoints.model_management_endpoints.get_db_model", + new=AsyncMock(return_value=db_model), + ), + patch( # test-quality-ok: prior auth check needs a live DB; only the credential check is under test + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + patch( # test-quality-ok: asserts the DB write is never reached on rejection + "litellm.proxy.management_endpoints.model_management_endpoints._update_team_model_in_db", + new=AsyncMock(), + ) as mock_update, + ): + with pytest.raises(ProxyException) as exc_info: + await patch_model( + model_id=model_id, + patch_data=updateDeployment( + litellm_params=updateLiteLLMParams( + model="openai/gpt-4o", litellm_credential_name="shared-credential" + ) + ), + user_api_key_dict=self.team_admin_user, + ) + assert exc_info.value.code == "403" + mock_update.assert_not_awaited() + + def test_can_user_attach_credential_internal_user_fails(self): + with pytest.raises(Exception, match="Only a proxy admin can attach a stored credential") as exc_info: + ModelManagementAuthChecks.can_user_attach_credential( + litellm_params=LiteLLM_Params(model="test_model", litellm_credential_name="shared-credential"), + user_api_key_dict=self.normal_user, + ) + assert exc_info.value.code == "403" + class MockModelTable: def __init__(self, model_aliases: Dict[str, str], include: Optional[dict] = None): diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index f5ae0fe5977..d3f17c73499 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +from collections.abc import Callable from contextlib import ExitStack, contextmanager from io import BytesIO from types import SimpleNamespace @@ -29,6 +30,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( websocket_passthrough_request, ) from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import ProxyException, UserAPIKeyAuth from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, @@ -5464,7 +5466,10 @@ def test_the_marker_check_distinguishes_the_two_route_kinds(): assert request_dispatched_to_pass_through_endpoint(builtin) is False -async def _drive_passthrough_request_and_capture_logging(user_api_key_dict: UserAPIKeyAuth) -> tuple[int, object]: +async def _drive_passthrough_request_and_capture_logging( + user_api_key_dict: UserAPIKeyAuth, + on_pre_call: Callable[[LiteLLMLoggingObj | None], None] | None = None, +) -> tuple[int, LiteLLMLoggingObj | None]: import litellm from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider @@ -5487,10 +5492,12 @@ async def _drive_passthrough_request_and_capture_logging(user_api_key_dict: User mock_request.query_params = QueryParams({}) mock_request.body = AsyncMock(return_value=b'{"model": "gemini-2.0-flash"}') - captured_data: dict = {} + captured_data: dict = {} # mutable-ok: the pre-call hook records the request data into it async def capture_pre_call_hook(user_api_key_dict, data, call_type): captured_data.update(data) + if on_pre_call is not None: + on_pre_call(data.get("litellm_logging_obj")) return data mock_proxy_logging = MagicMock() @@ -5623,3 +5630,103 @@ async def test_resolve_team_callback_wiring_fails_open_on_operational_error(): assert wiring.success_callbacks is None assert wiring.failure_callbacks is None assert wiring.logging_kwargs is None + + +@pytest.mark.asyncio +async def test_pass_through_request_leaves_guardrail_readable_metadata(): + """A pre-call guardrail reads the request headers off the passthrough logging + params without raising.""" + from litellm.proxy.guardrails.guardrail_hooks.hiddenlayer.hiddenlayer import ( + _logged_request_headers, + ) + + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_id="test-team", + team_metadata={ + "logging": [ + { + "callback_name": "langfuse", + "callback_type": "success_and_failure", + "callback_vars": { + "langfuse_public_key": "pk_test", + "langfuse_secret_key": "sk_test", + }, + } + ] + }, + ) + + observed: dict[str, dict[str, str] | BaseException] = {} # mutable-ok: the pre-call hook records into it + + def read_headers_the_way_a_guardrail_does(logging_obj: LiteLLMLoggingObj | None) -> None: + assert logging_obj is not None + try: + observed["headers"] = _logged_request_headers(logging_obj) + except Exception as exc: # noqa: BLE001 - the regression is that this used to raise + observed["headers"] = exc + + status_code, logging_obj = await _drive_passthrough_request_and_capture_logging( + user_api_key_dict, on_pre_call=read_headers_the_way_a_guardrail_does + ) + + assert "headers" in observed, "the pre-call hook never ran, so nothing was observed" + assert observed["headers"] == {}, f"guardrail header read failed: {observed['headers']!r}" + assert status_code == 200 + assert logging_obj is not None + assert logging_obj.dynamic_success_callbacks, "team success callbacks must stay wired" + assert logging_obj.standard_callback_dynamic_params.get("langfuse_public_key") == "pk_test" + + +@pytest.mark.asyncio +async def test_pass_through_request_leaves_cost_router_logger_working(): + """The cost router's logger reads the deployment id off the passthrough logging + params without raising. least_busy shares the read but swallows the exception, + so this is the strategy where the break is observable.""" + from litellm._logging import verbose_logger + from litellm.caching.caching import DualCache + from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler + + handler = LowestCostLoggingHandler(router_cache=DualCache()) + + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_id="test-team", + team_metadata={ + "logging": [ + { + "callback_name": "langfuse", + "callback_type": "success_and_failure", + "callback_vars": { + "langfuse_public_key": "pk_test", + "langfuse_secret_key": "sk_test", + }, + } + ] + }, + ) + + status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(user_api_key_dict) + assert status_code == 200 + assert logging_obj is not None + + raised: list[logging.LogRecord] = [] # mutable-ok: logging.Handler records into it + + class _RecordTracebacks(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + if record.exc_info is not None: + raised.append(record) + + recorder = _RecordTracebacks() + verbose_logger.addHandler(recorder) + try: + await handler.async_log_success_event( + kwargs=logging_obj.model_call_details, + response_obj=None, + start_time=None, + end_time=None, + ) + finally: + verbose_logger.removeHandler(recorder) + + assert not raised, f"cost router logger raised on the passthrough logging params: {raised[0].exc_info}" diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py index 35b5c72f92e..1e1436fcef8 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py @@ -9,12 +9,13 @@ Pins (PR2): from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, MagicMock +import json import pytest import litellm from litellm.proxy import proxy_server +from litellm.router_utils import pattern_match_deployments from .conftest import normalize # type: ignore[import-not-found] @@ -99,6 +100,7 @@ def test_token_counter_missing_input_returns_400( @pytest.fixture def patched_supported_params(monkeypatch): + monkeypatch.setattr(proxy_server, "llm_router", None) monkeypatch.setattr( litellm, "get_llm_provider", @@ -124,12 +126,104 @@ def test_supported_openai_params_happy_path(client, auth_as, patched_supported_p } +def test_supported_openai_params_resolves_router_alias(client, auth_as, monkeypatch): + """A router alias absent from the cost map resolves through the deployment's underlying model.""" + router = litellm.Router( + model_list=[ + { + "model_name": "claude-opus-4-6-cached", + "litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"}, + } + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + + with auth_as(): + response = client.get("/utils/supported_openai_params", params={"model": "claude-opus-4-6-cached"}) + + assert response.status_code == 200 + expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic") + assert response.json() == {"supported_openai_params": expected} + assert "max_tokens" in response.json()["supported_openai_params"] + + +def test_supported_openai_params_declared_prefix_alias_resolves_through_router(client, auth_as, monkeypatch): + """Regression: an alias whose name starts with an authenticating provider's prefix skipped + router resolution and answered with that provider's params instead of the deployment's.""" + router = litellm.Router( + model_list=[ + { + "model_name": "github_copilot/gpt-4o", + "litellm_params": {"model": "anthropic/claude-opus-4-6", "api_key": "sk-test"}, + } + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + + with auth_as(): + response = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"}) + + assert response.status_code == 200 + expected = litellm.get_supported_openai_params(model="claude-opus-4-6", custom_llm_provider="anthropic") + assert response.json() == {"supported_openai_params": expected} + + +def test_supported_openai_params_never_runs_oauth_for_authenticating_providers(client, auth_as, monkeypatch, tmp_path): + """Regression: github_copilot/chatgpt names answer from their declaration; resolving them + through ``get_llm_provider`` would run the provider's OAuth device flow and block the event loop.""" + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("fake-access-token") + (tmp_path / "api-key.json").write_text( + json.dumps( + { + "token": "fake-api-key", + "expires_at": 4102444800, + "endpoints": {"api": "https://api.githubcopilot.com"}, + } + ) + ) + router = litellm.Router( + model_list=[ + { + "model_name": "copilot-alias", + "litellm_params": {"model": "github_copilot/gpt-4o"}, + }, + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*"}, + }, + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + + resolution_attempts: list[str] = [] + + def _oauth_tripwire(model, *args, **kwargs): + resolution_attempts.append(model) + raise AssertionError("get_llm_provider would run the OAuth device flow") + + monkeypatch.setattr(litellm, "get_llm_provider", _oauth_tripwire) + monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _oauth_tripwire) + expected = litellm.get_supported_openai_params(model="gpt-4o", custom_llm_provider="github_copilot") + + with auth_as(): + via_alias = client.get("/utils/supported_openai_params", params={"model": "copilot-alias"}) + via_direct_name = client.get("/utils/supported_openai_params", params={"model": "github_copilot/gpt-4o"}) + + assert via_alias.status_code == 200 + assert via_alias.json() == {"supported_openai_params": expected} + assert via_direct_name.status_code == 200 + assert via_direct_name.json() == {"supported_openai_params": expected} + assert resolution_attempts == [] + + def test_supported_openai_params_invalid_model(client, auth_as, monkeypatch): """Pins ``GET /utils/supported_openai_params`` (error: unknown model).""" def _raise(model): raise Exception("unknown") + monkeypatch.setattr(proxy_server, "llm_router", None) monkeypatch.setattr(litellm, "get_llm_provider", _raise) with auth_as(): response = client.get("/utils/supported_openai_params", params={"model": "??"}) diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py index 95067929ac1..b8fb6170d34 100644 --- a/tests/test_litellm/proxy/test_budget_reservation.py +++ b/tests/test_litellm/proxy/test_budget_reservation.py @@ -819,6 +819,94 @@ async def test_should_cap_known_estimate_to_remaining_budget( ) == pytest.approx(0.9) +@pytest.mark.asyncio +async def test_fail_closed_rejects_known_estimate_exceeding_remaining_budget( + spend_counter_state, +): + """LIT-5922: with strict enforcement on, a request whose known estimate does + not fit the remaining budget must be rejected before dispatch instead of + having its reservation shrunk to the headroom and admitted, and the counter + must be restored to the pre-request spend.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-budget-known-estimate-fail-closed", + spend=0.9, + max_budget=1.0, + ) + counter_cache.in_memory_cache.set_cache( + key="spend:key:key-budget-known-estimate-fail-closed", + value=0.9, + ) + + with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute + "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", + return_value=0.6, + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await reserve_budget_for_request( + request_body=_request_body(), + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + fail_closed_budget_enforcement=True, + ) + + assert exc_info.value.current_cost == pytest.approx(0.9) + assert exc_info.value.max_budget == pytest.approx(1.0) + assert "Current cost: 0.9, Estimated request cost: 0.6, Max budget: 1.0" in str(exc_info.value) + assert counter_cache.in_memory_cache.get_cache( + key="spend:key:key-budget-known-estimate-fail-closed" + ) == pytest.approx(0.9) + + +@pytest.mark.asyncio +async def test_fail_closed_tolerates_float_noise_when_estimate_exactly_fits( + spend_counter_state, +): + """0.1 + 0.2 lands a hair above 0.3 in floating point. Strict enforcement + must treat that as fitting the budget, not reject it.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-budget-fail-closed-float-noise", + spend=0.1, + max_budget=0.3, + ) + counter_cache.in_memory_cache.set_cache( + key="spend:key:key-budget-fail-closed-float-noise", + value=0.1, + ) + + with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute + "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", + return_value=0.2, + ): + reservation = await reserve_budget_for_request( + request_body=_request_body(), + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + fail_closed_budget_enforcement=True, + ) + + assert reservation is not None + assert reservation["reserved_cost"] == pytest.approx(0.2) + assert counter_cache.in_memory_cache.get_cache( + key="spend:key:key-budget-fail-closed-float-noise" + ) == pytest.approx(0.3) + + @pytest.mark.asyncio async def test_should_clamp_reservation_to_default_when_output_cap_missing( spend_counter_state, diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 5e2dd358d75..3e70dee23b7 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -1737,8 +1737,7 @@ class TestRunServerDbSetup: mock_atexit_register, mock_subprocess_run, ): - """Which resolver and which migration mode run_server hands setup_database, - across the db push flag, the v2/legacy flag pair and USE_V2_MIGRATION_RESOLVER.""" + """Test that use_prisma_db_push flag correctly controls PrismaManager.setup_database use_migrate parameter""" from litellm.proxy.proxy_cli import run_server # Mock subprocess.run to simulate prisma being available @@ -1788,7 +1787,7 @@ class TestRunServerDbSetup: # use_prisma_db_push should be False (default), so use_migrate should be True run_server.main(["--local", "--skip_server_startup"], standalone_mode=False) mock_setup_database.assert_called_with( - use_migrate=True, use_v2_resolver=True + use_migrate=True, use_v2_resolver=False ) # Reset mocks @@ -1803,38 +1802,9 @@ class TestRunServerDbSetup: standalone_mode=False, ) mock_setup_database.assert_called_with( - use_migrate=False, use_v2_resolver=True + use_migrate=False, use_v2_resolver=False ) - for argv, env_value, expected_v2 in ( - ([], None, True), - (["--use_v2_migration_resolver"], None, True), - (["--use_legacy_migration_resolver"], None, False), - ([], "false", False), - ([], "true", True), - (["--use_v2_migration_resolver"], "false", True), - (["--use_legacy_migration_resolver"], "true", False), - ): - mock_setup_database.reset_mock() - mock_should_update_schema.reset_mock() - mock_should_update_schema.return_value = True - - resolver_env = ( - {"USE_V2_MIGRATION_RESOLVER": env_value} - if env_value is not None - else {} - ) - os.environ.pop("USE_V2_MIGRATION_RESOLVER", None) - with patch.dict(os.environ, resolver_env): - run_server.main( - ["--local", "--skip_server_startup", *argv], - standalone_mode=False, - ) - assert mock_setup_database.call_args.kwargs == { - "use_migrate": True, - "use_v2_resolver": expected_v2, - }, f"argv={argv} env={env_value}" - @patch("subprocess.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") @@ -1899,7 +1869,7 @@ class TestRunServerDbSetup: ) assert exc_info.value.code == 1 mock_setup_database.assert_called_once_with( - use_migrate=True, use_v2_resolver=True + use_migrate=True, use_v2_resolver=False ) @patch("subprocess.run") @@ -2011,6 +1981,7 @@ class TestRunServerDbSetup: use_migrate=True, use_v2_resolver=True ) + # --- Module-level helpers for worker startup hook tests --- _dummy_hook_called = False @@ -2481,6 +2452,96 @@ class TestReadReplicaConnectionParams: assert "DATABASE_URL_READ_REPLICA" not in captured +class TestMaxIdleConnectionLifetimeDefault: + """The proxy defaults `max_idle_connection_lifetime` below common infra idle + timeouts so stale pooled connections are recycled instead of failing requests.""" + + def _config(self, tmp_path, general_settings): + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump({"model_list": [], "general_settings": general_settings})) + return str(config_path) + + def test_default_applied_to_database_and_direct_url(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {}), + direct_url="postgresql://t:t@localhost:5432/t", + ) + + for env_var in ("DATABASE_URL", "DIRECT_URL"): + query = urlparse.parse_qs(urlparse.urlparse(captured[env_var]).query) + assert query["max_idle_connection_lifetime"] == ["60"], env_var + + def test_url_pinned_value_wins_over_default(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {}), + database_url="postgresql://t:t@localhost:5432/t?max_idle_connection_lifetime=300", + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query) + assert query["max_idle_connection_lifetime"] == ["300"] + + def test_url_pinned_value_wins_over_config_key(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}), + database_url="postgresql://t:t@localhost:5432/t?max_idle_connection_lifetime=300", + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query) + assert query["max_idle_connection_lifetime"] == ["300"] + + def test_config_key_overrides_default(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}), + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query) + assert query["max_idle_connection_lifetime"] == ["45"] + + def test_extra_connection_params_override_default(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config( + tmp_path, + {"database_extra_connection_params": {"max_idle_connection_lifetime": 120}}, + ), + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL"]).query) + assert query["max_idle_connection_lifetime"] == ["120"] + + def test_read_replica_gets_the_default(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {}), + read_replica_url="postgresql://t:t@reader:5432/t", + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query) + assert query["max_idle_connection_lifetime"] == ["60"] + + def test_replica_pinned_value_wins(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}), + read_replica_url="postgresql://t:t@reader:5432/t?max_idle_connection_lifetime=200", + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query) + assert query["max_idle_connection_lifetime"] == ["200"] + + def test_config_key_reaches_the_read_replica(self, tmp_path): + captured = _run_server_and_capture_urls( + self._config(tmp_path, {"database_max_idle_connection_lifetime": 45}), + read_replica_url="postgresql://t:t@reader:5432/t", + ) + + query = urlparse.parse_qs(urlparse.urlparse(captured["DATABASE_URL_READ_REPLICA"]).query) + assert query["max_idle_connection_lifetime"] == ["45"] + + def test_idle_lifetime_params_prefers_configured_value(self): + from litellm.proxy.db.db_url_settings import idle_lifetime_params + + assert dict(idle_lifetime_params(45)) == {"max_idle_connection_lifetime": 45} + assert dict(idle_lifetime_params(None)) == {"max_idle_connection_lifetime": 60} + + class TestTokenAuthCliFlags: """`--azure_postgresql_auth` has to reach the URL assembly the same way the env var does.""" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 43280258153..91fca8f1e27 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -10445,6 +10445,75 @@ async def test_update_config_general_settings_emits_audit_log(monkeypatch): assert before["some_api_key"] != "sk-stored-secret" +@pytest.mark.asyncio +async def test_update_config_field_rejects_out_of_range_alerting_args(monkeypatch): + """Out-of-range alerting_args must be rejected at save time. If they land in the + DB, SlackAlertingArgs raises during the config reload and alerting breaks.""" + from unittest.mock import MagicMock + + from fastapi import HTTPException + + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.proxy_server import update_config_general_settings + + monkeypatch.setattr(proxy_server_module, "prisma_client", MagicMock()) + + admin = UserAPIKeyAuth( + api_key="hashed-admin", + user_id="admin-1", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + with pytest.raises(HTTPException) as exc_info: + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="alerting_args", + field_value={ + "daily_spend_per_user_threshold": -5.0, + "user_spend_check_interval": 20, + }, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + + assert exc_info.value.status_code == 400 + error_msg = exc_info.value.detail["error"] + assert "daily_spend_per_user_threshold" in error_msg + assert "user_spend_check_interval" in error_msg + + +@pytest.mark.asyncio +async def test_update_config_field_accepts_valid_alerting_args(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.proxy_server import update_config_general_settings + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + monkeypatch.setattr(litellm, "store_audit_logs", False) + + admin = UserAPIKeyAuth( + api_key="hashed-admin", + user_id="admin-1", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="alerting_args", + field_value={ + "daily_spend_per_user_threshold": 5.0, + "user_spend_check_interval": 60, + }, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + + written = json.loads(fake.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"]) + assert written["alerting_args"]["daily_spend_per_user_threshold"] == 5.0 + + @pytest.mark.asyncio async def test_update_config_general_settings_applies_ssrf_globals(monkeypatch): import litellm.proxy.proxy_server as proxy_server_module diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index aba79fe11bf..b2b8eb5da80 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -648,6 +648,72 @@ class TestLiteLLMCompletionResponsesConfig: assert responses_api_response.status == "incomplete" + def test_tool_call_only_response_emits_no_null_text_message_item(self): + """A tool-calls-only turn (message content None, e.g. from Anthropic) + must not emit a message output item whose output_text has text null. + OpenAI rejects such an item on replay with + "Invalid type for 'input[..].content[..].text': expected a string, but + got null instead." Native OpenAI tool-only turns carry no message item.""" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="claude-sonnet-4-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="tool_calls", + index=0, + message=Message( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="toolu_01OnlyToolCall", + type="function", + function=Function(name="get_weather", arguments='{"city": "SF"}'), + ) + ], + ), + ) + ], + ) + + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="what's the weather in SF?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + output_types = [item.type for item in responses_api_response.output] + assert "message" not in output_types + assert "function_call" in output_types + + def test_content_bearing_response_still_emits_message_item(self): + """Turns with real text content must keep their message output item.""" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="claude-sonnet-4-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message(content="It is sunny.", role="assistant"), + ) + ], + ) + + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="what's the weather in SF?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + message_items = [item for item in responses_api_response.output if item.type == "message"] + assert len(message_items) == 1 + assert message_items[0].content[0].text == "It is sunny." + def test_transform_chat_completion_response_preserves_hidden_params(self): """Test that _hidden_params from chat completion response are preserved in responses API response""" # Setup @@ -3343,6 +3409,7 @@ class TestEnsureOutputItemContentPartAdded: iterator._pending_tool_events = [] iterator._tool_output_index_by_call_id = {} iterator._tool_args_by_call_id = {} + iterator._tool_item_id_by_call_id = {} iterator._tool_call_id_by_index = {} iterator._ambiguous_tool_call_indexes = set() iterator._next_tool_output_index = 1 diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 01148f627f1..4a03913f55a 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -132,7 +132,7 @@ def test_tool_call_delta_is_emitted_as_responses_events(): evt2 = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk) assert evt2 is not None assert evt2.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA - assert evt2.item_id == "call_1" + assert evt2.item_id == "fc_call_1" assert evt2.output_index == 1 # The delta will be a chunk of the arguments, not the full arguments assert len(evt2.delta) <= 10 # Chunks are max 10 characters @@ -197,7 +197,7 @@ def test_tool_calls_present_only_in_final_response_are_emitted_before_completed( # The last event should be FUNCTION_CALL_ARGUMENTS_DONE assert evt.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE - assert evt.item_id == "call_2" + assert evt.item_id == "fc_call_2" assert evt.output_index == 1 assert evt.arguments == '{"y":2}' @@ -291,7 +291,7 @@ def test_tool_call_arguments_are_chunked_to_match_openai_behavior(): # Verify each delta is at most 10 characters for evt in delta_events: assert len(evt.delta) <= 10 - assert evt.item_id == "call_test" + assert evt.item_id == "fc_call_test" assert evt.output_index == 1 assert hasattr(evt, "__dict__") and "sequence_number" in evt.__dict__ @@ -349,7 +349,8 @@ def test_tool_call_delta_without_id_uses_index_mapping(): if evt.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED ] assert len(output_item_added_events) == 1 - assert output_item_added_events[0].item.id == "call_abc123" + assert output_item_added_events[0].item.id == "fc_call_abc123" + assert output_item_added_events[0].item.call_id == "call_abc123" def test_parallel_tool_calls_without_ids_use_index_mapping(): @@ -404,8 +405,8 @@ def test_parallel_tool_calls_without_ids_use_index_mapping(): arguments_by_call_id.setdefault(evt.item_id, "") arguments_by_call_id[evt.item_id] += evt.delta - assert arguments_by_call_id["call_a"] == '{"x":1}' - assert arguments_by_call_id["call_b"] == '{"y":2}' + assert arguments_by_call_id["fc_call_a"] == '{"x":1}' + assert arguments_by_call_id["fc_call_b"] == '{"y":2}' def test_reused_index_with_new_call_id_marks_fallback_ambiguous(): @@ -461,10 +462,10 @@ def test_reused_index_with_new_call_id_marks_fallback_ambiguous(): arguments_by_call_id.setdefault(evt.item_id, "") arguments_by_call_id[evt.item_id] += evt.delta - assert arguments_by_call_id["call_a"] == '{"a":' - assert arguments_by_call_id["call_b"] == '{"b":' - assert arguments_by_call_id["call_a"] != '{"a":1}' - assert arguments_by_call_id["call_b"] != '{"b":1}' + assert arguments_by_call_id["fc_call_a"] == '{"a":' + assert arguments_by_call_id["fc_call_b"] == '{"b":' + assert arguments_by_call_id["fc_call_a"] != '{"a":1}' + assert arguments_by_call_id["fc_call_b"] != '{"b":1}' @pytest.mark.asyncio @@ -557,3 +558,58 @@ def test_object_tool_call_arguments_stream_as_valid_json(): ) assert json.loads(streamed_arguments) == {"command": "ls", "flags": ["-l"]} + + +def test_streamed_anthropic_tool_call_events_correlate_on_normalized_item_id(): + iterator = LiteLLMCompletionStreamingIterator( + model="test-model", + litellm_custom_stream_wrapper=AsyncMock(), + request_input="Test input", + responses_api_request={}, + ) + + response = ModelResponse( + id="resp-anthropic", + created=123, + model="test-model", + object="chat.completion", + choices=[ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_01AbCdEf", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"Paris"}'}, + "index": 0, + } + ], + }, + } + ], + ) + iterator.litellm_model_response = response + + events = [] + while True: + evt = iterator.common_done_event_logic(sync_mode=True) + events.append(evt) + if evt.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: + break + + added = [e for e in events if e.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED] + deltas = [e for e in events if e.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA] + dones = [e for e in events if e.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE] + item_dones = [e for e in events if e.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE] + + assert len(added) == 1 and len(dones) == 1 and len(item_dones) == 1 and deltas + assert added[0].item.id == "fc_toolu_01AbCdEf" + assert added[0].item.call_id == "toolu_01AbCdEf" + assert item_dones[0].item.id == "fc_toolu_01AbCdEf" + assert item_dones[0].item.call_id == "toolu_01AbCdEf" + for evt in deltas + dones: + assert evt.item_id == added[0].item.id diff --git a/tests/test_litellm/responses/test_custom_tool_call.py b/tests/test_litellm/responses/test_custom_tool_call.py index c605ef24934..5122c1c1d67 100644 --- a/tests/test_litellm/responses/test_custom_tool_call.py +++ b/tests/test_litellm/responses/test_custom_tool_call.py @@ -20,6 +20,7 @@ from litellm.responses.litellm_completion_transformation.transformation import ( from litellm.responses.litellm_completion_transformation.custom_tools import ( extract_custom_tool_names, is_custom_tool_call, + openai_shaped_tool_call_item_id, unwrap_custom_tool_arguments, build_tool_call_item_kwargs, convert_custom_tool_to_function_tool, @@ -129,6 +130,41 @@ class TestCustomToolUtilities: assert kwargs["arguments"] == raw assert "input" not in kwargs + def test_openai_shaped_tool_call_item_id_prefixes_foreign_ids(self): + """Anthropic-style tool ids must be normalized to OpenAI's item id + shapes (fc/ctc prefixes) so replaying the item to OpenAI does not 400 + with "Expected an ID that begins with 'fc'".""" + assert openai_shaped_tool_call_item_id("function_call", "toolu_01Abc") == "fc_toolu_01Abc" + assert openai_shaped_tool_call_item_id("function_call", "srvtoolu_01Xyz") == "fc_srvtoolu_01Xyz" + assert openai_shaped_tool_call_item_id("custom_tool_call", "toolu_01Abc") == "ctc_toolu_01Abc" + assert openai_shaped_tool_call_item_id("function_call", "fc_already") == "fc_already" + assert openai_shaped_tool_call_item_id("custom_tool_call", "ctc_already") == "ctc_already" + assert openai_shaped_tool_call_item_id("function_call", "") == "" + assert openai_shaped_tool_call_item_id("message", "toolu_01Abc") == "toolu_01Abc" + + def test_build_tool_call_item_kwargs_normalizes_item_id_keeps_call_id(self): + """The streaming item id gets the OpenAI shape while call_id stays raw + so tool_result pairing (which keys off call_id) keeps working.""" + function_kwargs = build_tool_call_item_kwargs( + call_id="toolu_01Abc", + name="get_weather", + arguments_or_input="{}", + status="completed", + custom_tool_names=set(), + ) + assert function_kwargs["id"] == "fc_toolu_01Abc" + assert function_kwargs["call_id"] == "toolu_01Abc" + + custom_kwargs = build_tool_call_item_kwargs( + call_id="toolu_01Def", + name="apply_patch", + arguments_or_input=json.dumps({"content": "patch"}), + status="completed", + custom_tool_names={"apply_patch"}, + ) + assert custom_kwargs["id"] == "ctc_toolu_01Def" + assert custom_kwargs["call_id"] == "toolu_01Def" + def test_unwrap_custom_tool_arguments_oversized_returns_raw(self): """Arguments larger than the safety cap are returned unchanged to avoid OOM on JSON parsing a pathologically large string.""" @@ -293,6 +329,52 @@ class TestTransformationCustomTools: assert item.name == "regular_tool" assert item.arguments == json.dumps({"param": "value"}) + def test_transform_anthropic_tool_call_ids_get_openai_item_id_shape(self): + """Anthropic tool ids (toolu_/srvtoolu_) surfacing through the bridge + must be emitted with fc/ctc-prefixed item ids so a Responses client can + replay them to OpenAI verbatim, while call_id stays raw for pairing.""" + from litellm.types.utils import ModelResponse, Choices, Message, ChatCompletionMessageToolCall, Function + + client_call = ChatCompletionMessageToolCall( + id="toolu_01ClientCall", + type="function", + function=Function(name="get_weather", arguments=json.dumps({"city": "SF"})), + ) + server_call = ChatCompletionMessageToolCall( + id="srvtoolu_01ServerCall", + type="function", + function=Function(name="web_search", arguments=json.dumps({"query": "zig"})), + ) + custom_call = ChatCompletionMessageToolCall( + id="toolu_01CustomCall", + type="function", + function=Function(name="apply_patch", arguments=json.dumps({"content": "patch content"})), + ) + + message = Message(role="assistant", content=None, tool_calls=[client_call, server_call, custom_call]) + choices = [Choices(index=0, message=message, finish_reason="tool_calls")] + response = ModelResponse( + id="test_response", choices=choices, created=1234567890, model="claude-sonnet-4-5", object="chat.completion" + ) + responses_api_request = { + "tools": [{"type": "custom", "name": "apply_patch"}, {"type": "function", "name": "get_weather"}] + } + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools( + response, responses_api_request=responses_api_request + ) + + assert [item.id for item in result] == [ + "fc_toolu_01ClientCall", + "fc_srvtoolu_01ServerCall", + "ctc_toolu_01CustomCall", + ] + assert [item.call_id for item in result] == [ + "toolu_01ClientCall", + "srvtoolu_01ServerCall", + "toolu_01CustomCall", + ] + def test_transform_mixed_tool_calls(self): """Test transformation with both custom and regular tool calls.""" from litellm.types.utils import ModelResponse, Choices, Message, ChatCompletionMessageToolCall, Function diff --git a/tests/test_litellm/responses/test_rust_bridge_websocket.py b/tests/test_litellm/responses/test_rust_bridge_websocket.py index c9a5b988be6..744c5172689 100644 --- a/tests/test_litellm/responses/test_rust_bridge_websocket.py +++ b/tests/test_litellm/responses/test_rust_bridge_websocket.py @@ -1,29 +1,32 @@ from __future__ import annotations +from collections.abc import Mapping + import pytest +from litellm.exceptions import APIError from litellm.llms.custom_httpx.llm_http_handler import _rust_responses_websocket_enabled -from litellm.rust_bridge import responses_websocket +from litellm.rust_bridge import bindings, configuration, responses_websocket, streaming from litellm.types.router import GenericLiteLLMParams class _FakeNativeConnection: def __init__(self) -> None: - self.sent: list[str] = [] + self.sent: list[dict[str, object]] = [] self.closed = False - async def send_text(self, text: str) -> None: - self.sent.append(text) + async def send_event(self, event: Mapping[str, object]) -> None: + self.sent.append(dict(event)) - async def recv_text(self) -> str: - return "response.completed" + async def recv_event(self) -> Mapping[str, object]: + return {"type": "response.completed"} async def close(self) -> None: self.closed = True class _ClosedNativeConnection: - async def recv_text(self) -> None: + async def recv_event(self) -> None: return None @@ -31,23 +34,99 @@ class _FakeNativeBridge: @classmethod async def connect( cls, - *, - url: str, - headers: dict[str, str], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, timeout_seconds: float | None, ) -> _FakeNativeConnection: return _FakeNativeConnection() +class _Declined(Exception): + pass + + +class _Upstream(Exception): + pass + + +class _NativeErrors: + RustBridgeDeclined = _Declined + RustUpstreamError = _Upstream + + +class _DecliningNativeBridge: + @classmethod + async def connect( + cls, + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> _FakeNativeConnection: + raise _Declined("provider unsupported") + + +class _FailingNativeBridge: + @classmethod + async def connect( + cls, + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> _FakeNativeConnection: + raise _Upstream(503, "request may have executed") + + +@pytest.fixture(autouse=True) +def reset_responses_websocket(): + streaming.set_rust_streaming(capability=None) + responses_websocket.set_rust_responses_websocket(connection=None) + configuration.reset_rust_configuration() + yield + streaming.set_rust_streaming(capability=None) + responses_websocket.set_rust_responses_websocket(connection=None) + configuration.reset_rust_configuration() + + def test_rust_websocket_bridge_is_disabled_without_flag() -> None: assert not _rust_responses_websocket_enabled("openai", GenericLiteLLMParams()) assert not _rust_responses_websocket_enabled("anthropic", GenericLiteLLMParams(rust=True)) + assert not _rust_responses_websocket_enabled("openai", GenericLiteLLMParams(rust=True)) + + +def test_injected_typed_capability_enables_the_gate() -> None: + streaming.set_rust_streaming( + capability=lambda api, provider, transport: (api, provider, transport) == ("responses", "openai", "websocket") + ) + assert _rust_responses_websocket_enabled("openai", GenericLiteLLMParams(rust=True)) +def test_explicit_false_overrides_process_enable() -> None: + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) + configuration.use_litellm_rust(True) + + assert not _rust_responses_websocket_enabled("openai", GenericLiteLLMParams(rust=False)) + + +def test_process_enable_applies_without_request_override() -> None: + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) + configuration.use_litellm_rust(True) + + assert _rust_responses_websocket_enabled("openai", GenericLiteLLMParams()) + + @pytest.mark.asyncio async def test_adapter_raises_clean_close_when_rust_connection_ends() -> None: - adapter = responses_websocket._ConnectionAdapter(_ClosedNativeConnection()) + adapter = responses_websocket._ConnectionAdapter( + _ClosedNativeConnection(), + responses_websocket.BridgeErrorContext(route="responses websocket", provider="openai", model=""), + ) with pytest.raises(responses_websocket.ConnectionClosedOK): await adapter.recv() @@ -55,32 +134,74 @@ async def test_adapter_raises_clean_close_when_rust_connection_ends() -> None: @pytest.mark.asyncio async def test_bridge_unavailable_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(responses_websocket, "_STATE", responses_websocket._RustResponsesWebSocketState()) - monkeypatch.setattr(responses_websocket, "get_native_bridge", lambda: None) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) - assert ( - await responses_websocket.connect( - url="wss://example.test/responses", - headers={}, - timeout=None, - ) - is None + result = await responses_websocket.connect( + provider="openai", + api_key=None, + api_base="https://example.test", + headers={}, + timeout=None, ) + assert result.value is None + assert result.source is responses_websocket.CoreEngine.PYTHON @pytest.mark.asyncio async def test_enabled_bridge_connects_and_adapts_socket( monkeypatch: pytest.MonkeyPatch, ) -> None: + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) responses_websocket.set_rust_responses_websocket(connection=_FakeNativeBridge) - connection = await responses_websocket.connect( - url="wss://example.test/responses", + result = await responses_websocket.connect( + provider="openai", + api_key="key", + api_base="https://example.test", headers={"Authorization": "Bearer key"}, timeout=1.0, ) + assert result.source is responses_websocket.CoreEngine.RUST + connection = result.value assert connection is not None - await connection.send("response.create") - assert await connection.recv() == "response.completed" + assert connection.core_engine is responses_websocket.CoreEngine.RUST + await connection.send('{"type":"response.create","model":"gpt-5"}') + assert await connection.recv() == '{"type":"response.completed"}' await connection.close() + + +@pytest.mark.asyncio +async def test_declined_connect_falls_back_before_dispatch(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _NativeErrors()) + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) + responses_websocket.set_rust_responses_websocket(connection=_DecliningNativeBridge) + + result = await responses_websocket.connect( + provider="openai", + api_key="key", + api_base="https://example.test", + headers={}, + timeout=None, + ) + + assert result.value is None + assert result.source is responses_websocket.CoreEngine.PYTHON + + +@pytest.mark.asyncio +async def test_upstream_connect_failure_never_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _NativeErrors()) + streaming.set_rust_streaming(capability=lambda api, provider, transport: True) + responses_websocket.set_rust_responses_websocket(connection=_FailingNativeBridge) + + with pytest.raises(APIError, match="request may have executed") as caught: + await responses_websocket.connect( + provider="openai", + api_key="key", + api_base="https://example.test", + headers={}, + timeout=None, + ) + assert caught.value.headers["x-litellm-core"] == "rust" diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_deployment_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_deployment_affinity_check.py index b3a2bdda53c..60433921de6 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_deployment_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_deployment_affinity_check.py @@ -598,6 +598,45 @@ async def test_async_filter_deployments_falls_back_when_cached_deployment_is_unh assert filtered == healthy_deployments +@pytest.mark.asyncio +async def test_async_filter_deployments_does_not_pin_when_target_order_is_set(): + user_key = "user-key-order-fallback" + stable_model_map_key = "claude-sonnet-4-5@20250929" + cache = AsyncMock() + cache.async_get_cache = AsyncMock(return_value={"model_id": "deployment-1"}) + callback = DeploymentAffinityCheck( + cache=cache, + ttl_seconds=123, + enable_user_key_affinity=True, + enable_responses_api_affinity=False, + ) + healthy_deployments = [ + { + "model_name": stable_model_map_key, + "litellm_params": {"model": f"vertex_ai/{stable_model_map_key}"}, + "model_info": {"id": "deployment-1"}, + }, + { + "model_name": stable_model_map_key, + "litellm_params": { + "model": f"bedrock/global.anthropic.{stable_model_map_key}-v1:0" + }, + "model_info": {"id": "deployment-2"}, + }, + ] + + filtered = await callback.async_filter_deployments( + model="some-router-model-group", + healthy_deployments=healthy_deployments, + messages=None, + request_kwargs={"_target_order": 2, "metadata": {"user_api_key_hash": user_key}}, + parent_otel_span=None, + ) + + assert filtered == healthy_deployments + cache.async_get_cache.assert_not_called() + + @pytest.mark.asyncio async def test_async_user_key_affinity_ttl_expiry_allows_reroute(): """ diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index f54a1cfa284..79ae00e155c 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -150,6 +150,25 @@ async def test_async_filter_deployments_narrows_prompt_above_model_minimum(): assert filtered == [deployments[1]] +@pytest.mark.asyncio +async def test_async_filter_deployments_does_not_pin_when_target_order_is_set(): + cache = DualCache() + check = PromptCachingDeploymentCheck(cache=cache) + deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6") + messages = _messages(word_count=5000) + + await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=messages, tools=None) + + filtered = await check.async_filter_deployments( + model=MODEL_GROUP_ALIAS, + healthy_deployments=deployments, + messages=messages, + request_kwargs={"_target_order": 2}, + ) + + assert filtered == deployments + + @pytest.mark.asyncio async def test_async_filter_deployments_narrows_for_group_whose_model_minimum_is_lower(): """ diff --git a/tests/test_litellm/router_utils/test_fallback_event_handlers.py b/tests/test_litellm/router_utils/test_fallback_event_handlers.py index 8336926c050..894b2d9e74f 100644 --- a/tests/test_litellm/router_utils/test_fallback_event_handlers.py +++ b/tests/test_litellm/router_utils/test_fallback_event_handlers.py @@ -11,7 +11,10 @@ from litellm.router_utils.fallback_event_handlers import ( AttemptedFallbackTargets, _trigger_cooldown_for_failed_deployment, fallback_attempt_key, + clear_pre_routing_selection, get_fallback_model_group, + get_pre_routing_selection, + record_pre_routing_selection, run_async_fallback, ) @@ -1090,3 +1093,119 @@ async def test_run_async_fallback_preserves_original_model_group_on_nested_fallb metadata = router.received_kwargs["metadata"] assert metadata["attempted_fallbacks"] == 2 assert metadata["original_model_group"] == "primary-model" + + +class TestPreRoutingSelectionCarriesToFallbacks: + """#38832: a complexity/auto router picks a tier behind the router name, but fallback + lookup kept using the router name, so the tier's configured chain never ran.""" + + def test_selection_is_recorded_in_the_metadata_bucket(self): + kwargs = {"model": "smart-router", "metadata": {}} + record_pre_routing_selection(kwargs, "tier1") + assert kwargs["metadata"]["pre_routing_selected_model"] == "tier1" + assert get_pre_routing_selection(kwargs) == "tier1" + + def test_selection_is_recorded_in_the_litellm_metadata_bucket(self): + kwargs = {"model": "smart-router", "litellm_metadata": {}} + record_pre_routing_selection(kwargs, "tier2") + assert get_pre_routing_selection(kwargs) == "tier2" + + def test_a_bucket_survives_the_kwargs_copy_that_fallbacks_run_on(self): + """The bucket is shared by reference, which is the whole reason this works.""" + outer = {"model": "smart-router", "metadata": {}} + inner = {**outer} + record_pre_routing_selection(inner, "tier1") + assert get_pre_routing_selection(outer) == "tier1" + + def test_no_selection_reads_as_none(self): + assert get_pre_routing_selection({"model": "smart-router", "metadata": {}}) is None + assert get_pre_routing_selection({"model": "smart-router"}) is None + + def test_missing_kwargs_is_a_no_op(self): + """A caller with no kwargs must not raise, and must not leak the selection anywhere.""" + record_pre_routing_selection(None, "tier1") + + assert get_pre_routing_selection({}) is None + + def test_a_non_dict_bucket_is_ignored(self): + kwargs = {"model": "smart-router", "metadata": "not-a-dict"} + record_pre_routing_selection(kwargs, "tier1") + assert get_pre_routing_selection(kwargs) is None + + def test_fallbacks_resolve_against_the_selected_tier(self): + """The lookup the router performs, keyed on the tier rather than the router name.""" + fallbacks = [{"tier1": ["backup-a", "backup-b"]}, {"tier2": ["backup-c"]}] + assert get_fallback_model_group(fallbacks=fallbacks, model_group="tier1")[0] == ["backup-a", "backup-b"] + assert get_fallback_model_group(fallbacks=fallbacks, model_group="smart-router")[0] is None + + +class TestPreRoutingSelectionIsPerHop: + """#38832 review: the buckets also carry whatever the caller sent, and a fallback hop + inherits the previous hop's tier, so a hop must start without a selection.""" + + def test_a_caller_supplied_selection_is_dropped(self): + kwargs = {"model": "plain", "metadata": {"pre_routing_selected_model": "tier1"}} + + clear_pre_routing_selection(kwargs) + + assert get_pre_routing_selection(kwargs) is None + assert "pre_routing_selected_model" not in kwargs["metadata"] + + def test_both_buckets_are_cleared(self): + kwargs = { + "metadata": {"pre_routing_selected_model": "tier1"}, + "litellm_metadata": {"pre_routing_selected_model": "tier2"}, + } + + clear_pre_routing_selection(kwargs) + + assert get_pre_routing_selection(kwargs) is None + + def test_the_rest_of_the_bucket_is_left_alone(self): + kwargs = {"metadata": {"pre_routing_selected_model": "tier1", "tags": ["a"]}} + + clear_pre_routing_selection(kwargs) + + assert kwargs["metadata"] == {"tags": ["a"]} + + def test_clearing_is_a_no_op_without_a_usable_bucket(self): + kwargs = {"model": "plain", "metadata": "not-a-dict"} + + clear_pre_routing_selection(None) + clear_pre_routing_selection(kwargs) + + assert kwargs == {"model": "plain", "metadata": "not-a-dict"} + + def test_a_selection_recorded_after_clearing_is_kept(self): + """Clearing runs before routing, so the hook's own write must survive it.""" + kwargs = {"model": "smart-router", "metadata": {"pre_routing_selected_model": "stale"}} + + clear_pre_routing_selection(kwargs) + record_pre_routing_selection(kwargs, "tier1") + + assert get_pre_routing_selection(kwargs) == "tier1" + + +class TestOrderedFallbackLookupGroups: + def test_tier_first_then_requested_group_deduped(self): + from litellm.router_utils.fallback_event_handlers import ( + PRE_ROUTING_SELECTED_MODEL_KEY, + fallback_lookup_groups, + ) + + kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier1"}} + assert fallback_lookup_groups(kwargs, "smart-router") == ("tier1", "smart-router") + assert fallback_lookup_groups(kwargs, "tier1") == ("tier1",) + assert fallback_lookup_groups({}, "smart-router") == ("smart-router",) + assert fallback_lookup_groups({}, None) == () + + def test_first_resolving_group_wins_and_generic_idx_survives_a_miss(self): + from litellm.router_utils.fallback_event_handlers import ( + get_fallback_model_group_for_lookup_groups, + ) + + fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}, {"*": ["backup-c"]}] + assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier1", "smart-router")) == (["backup-a"], None) + assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "smart-router")) == (["backup-b"], None) + assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "no-such")) == (["backup-c"], 2) + assert get_fallback_model_group_for_lookup_groups([{"tier1": ["backup-a"]}], ("no", "nope")) == (None, None) diff --git a/tests/test_litellm/router_utils/test_pattern_match_deployments.py b/tests/test_litellm/router_utils/test_pattern_match_deployments.py new file mode 100644 index 00000000000..795d448ef5f --- /dev/null +++ b/tests/test_litellm/router_utils/test_pattern_match_deployments.py @@ -0,0 +1,78 @@ +"""Behavior pins for ``litellm/router_utils/pattern_match_deployments.py``.""" + +from __future__ import annotations + +from litellm.router_utils import pattern_match_deployments +from litellm.router_utils.pattern_match_deployments import PatternMatchRouter + + +def _wildcard_deployment(model_name: str) -> dict: + return {"model_name": model_name, "litellm_params": {"model": model_name}} + + +def _matched_models(matches: list[dict] | None) -> list[str]: + return [deployment["litellm_params"]["model"] for deployment in matches or []] + + +def test_get_pattern_never_resolves_declared_authenticating_providers(monkeypatch): + """Regression: resolving a github_copilot/chatgpt name through ``get_llm_provider`` runs the + provider's OAuth device flow; the auth layer walks every wildcard router on every request, so + a single metadata lookup for an unserved name would block the proxy's event loop.""" + resolution_attempts: list[str] = [] + + def _oauth_tripwire(model, *args, **kwargs): + resolution_attempts.append(model) + raise AssertionError("get_llm_provider would run the OAuth device flow") + + monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _oauth_tripwire) + + unmatched_router = PatternMatchRouter() + unmatched_router.add_pattern("anthropic/*", _wildcard_deployment("anthropic/*")) + assert unmatched_router.get_pattern("github_copilot/gpt-4o") is None + + matched_router = PatternMatchRouter() + matched_router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*")) + assert _matched_models(matched_router.get_pattern("github_copilot/gpt-4o")) == ["github_copilot/gpt-4o"] + assert _matched_models(matched_router.get_pattern("gpt-4o", custom_llm_provider="github_copilot")) == [ + "github_copilot/gpt-4o" + ] + + assert resolution_attempts == [] + + +def test_get_pattern_bare_provider_name_never_matches_that_providers_wildcard(monkeypatch): + """Regression: a bare ``github_copilot`` adopted itself as its provider and retried as + ``github_copilot/github_copilot``, false-matching the wildcard for a name no deployment serves.""" + + def _unknown_provider(model, *args, **kwargs): + raise ValueError(f"unknown provider for {model}") + + monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _unknown_provider) + router = PatternMatchRouter() + router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*")) + assert router.get_pattern("github_copilot") is None + + +def test_get_pattern_missing_model_returns_none(monkeypatch): + """Regression: a request without a model reaches the auth layer's pattern walk as ``None``; the + declared-provider guard raised ``TypeError`` where the old inline resolve swallowed every + resolver error, so the proxy's missing-model 400 became a crash.""" + + def _unknown_provider(model, *args, **kwargs): + raise ValueError(f"unknown provider for {model}") + + monkeypatch.setattr(pattern_match_deployments, "get_llm_provider", _unknown_provider) + router = PatternMatchRouter() + router.add_pattern("openai/*", _wildcard_deployment("openai/*")) + assert router.get_pattern(None) is None + + +def test_get_pattern_still_resolves_unqualified_names(monkeypatch): + monkeypatch.setattr( + pattern_match_deployments, + "get_llm_provider", + lambda model, **kwargs: (model, "openai", None, None), + ) + router = PatternMatchRouter() + router.add_pattern("openai/*", _wildcard_deployment("openai/*")) + assert _matched_models(router.get_pattern("gpt-4o")) == ["openai/gpt-4o"] diff --git a/tests/test_litellm/rust_bridge/native_route_wheel_test.py b/tests/test_litellm/rust_bridge/native_route_wheel_test.py new file mode 100644 index 00000000000..8f51c33565e --- /dev/null +++ b/tests/test_litellm/rust_bridge/native_route_wheel_test.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import threading +import zipfile +from collections.abc import Awaitable, Callable +from http.client import HTTPMessage +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from socket import socket as Socket +from typing import Final + +REQUEST_STARTED: Final = threading.Event() +REQUEST_CANCELLED: Final = threading.Event() + +ANTHROPIC_RESPONSE: Final = ( + b'{"id":"msg_native","type":"message","role":"assistant",' + b'"model":"claude-sonnet-4-5","content":[{"type":"text","text":"native-message"}],' + b'"stop_reason":"end_turn","stop_sequence":null,' + b'"usage":{"input_tokens":2,"output_tokens":3}}' +) + + +class NativeRouteHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + content_length: Final = int(self.headers.get("content-length", "0")) + body: Final = json.loads(self.rfile.read(content_length)) + route: Final = self.headers.get("x-test-route") + outcome: Final = self.headers.get("x-test-outcome") + assert_native_request(route, outcome, self.path, self.headers, body) + if outcome == "hang": + REQUEST_STARTED.set() + self.connection.settimeout(5) + if connection_was_cancelled(self.connection): + REQUEST_CANCELLED.set() + return + + status: Final = 429 if outcome == "429" else 200 + response_body: Final = native_response(status, route) + + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(response_body))) + self.send_header("connection", "close") + self.end_headers() + self.wfile.write(response_body) + + def log_message(self, _message_format: str, *_args: object) -> None: + pass + + +def connection_was_cancelled(connection: Socket) -> bool: + try: + return connection.recv(1) == b"" + except TimeoutError: + return False + except OSError: + return True + + +def assert_native_request( + route: str | None, + outcome: str | None, + path: str, + headers: HTTPMessage, + body: object, +) -> None: + if route not in {"ocr", "transcription", "messages", "chat_completions"}: + raise AssertionError(f"unexpected route marker: {route!r}") + if outcome not in {"success", "429", "hang"}: + raise AssertionError(f"unexpected outcome marker: {outcome!r}") + if not isinstance(body, dict): + raise TypeError(f"{route} sent {type(body).__name__}, expected a JSON object") + if route == "ocr": + assert path == "/v1/ocr" + assert headers.get("authorization") == "Bearer sk-native" + assert body["model"] == "mistral-ocr-latest" + assert body["document"]["document_url"] == "https://example.com/document.pdf" + assert body["include_image_base64"] is True + return + if route == "transcription": + assert path == "/model/mistral.voxtral-mini-3b-2507/converse" + assert headers.get("authorization", "").startswith("AWS4-HMAC-SHA256 ") + assert headers.get("x-amz-date") + assert body["messages"][0]["content"][0]["audio"]["source"]["bytes"] == "AQI=" + assert "The audio language is en" in body["messages"][0]["content"][1]["text"] + return + assert path == "/v1/messages" + assert headers.get("x-api-key") == "sk-native" + assert body["model"] == "claude-sonnet-4-5" + if route == "messages": + assert body["max_tokens"] == 16 + assert body["messages"][0]["content"] == "hello-from-messages" + return + assert body["max_tokens"] == 17 + assert body["messages"][0]["content"] == [{"type": "text", "text": "hello-from-chat"}] + + +def native_response(status: int, route: str | None) -> bytes: + if status == 429: + return b'{"error":"native-rate-limit"}' + if route == "ocr": + return b'{"pages":[{"index":0,"markdown":"native-ocr"}]}' + if route == "transcription": + return b'{"output":{"message":{"content":[{"text":"native-transcription"}]}}}' + return ANTHROPIC_RESPONSE + + +def load_native(native_path: Path) -> object: + module_spec: Final = importlib.util.spec_from_file_location("litellm.rust_bridge._native", native_path) + if module_spec is None or module_spec.loader is None: + raise RuntimeError("cannot create native extension import specification") + native_module: Final = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(native_module) + return native_module + + +def route_kwargs(route: str, api_base: str, outcome: str) -> dict[str, object]: + common: Final = { + "api_base": api_base, + "extra_headers": {"x-test-outcome": outcome, "x-test-route": route}, + "timeout_seconds": 3.0, + } + if route == "ocr": + return common | { + "model": "mistral-ocr-latest", + "document": {"type": "document_url", "document_url": "https://example.com/document.pdf"}, + "api_key": "sk-native", + "custom_llm_provider": "mistral", + "optional_params": {"include_image_base64": True}, + } + if route == "transcription": + return common | { + "model": "mistral.voxtral-mini-3b-2507", + "audio": {"data": "AQI=", "format": "wav", "filename": "audio.wav"}, + "custom_llm_provider": "bedrock", + "optional_params": { + "aws_access_key_id": "native-access-key", + "aws_secret_access_key": "native-secret-key", + "aws_region_name": "us-east-1", + "language": "en", + }, + } + if route == "messages": + return common | { + "model": "claude-sonnet-4-5", + "body": { + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello-from-messages"}], + }, + "api_key": "sk-native", + "custom_llm_provider": "anthropic", + } + if route == "chat_completions": + return common | { + "model": "anthropic/claude-sonnet-4-5", + "messages": [{"role": "user", "content": "hello-from-chat"}], + "optional_params": {"max_tokens": 17}, + "api_key": "sk-native", + } + raise AssertionError(f"unknown route: {route}") + + +def messages_wrapper_kwargs(api_base: str, outcome: str) -> dict[str, object]: + native_kwargs: Final = route_kwargs("messages", api_base, outcome) + return { + "model": native_kwargs["model"], + "body": native_kwargs["body"], + "api_key": native_kwargs["api_key"], + "api_base": native_kwargs["api_base"], + "custom_llm_provider": native_kwargs["custom_llm_provider"], + "extra_headers": native_kwargs["extra_headers"], + "timeout": native_kwargs["timeout_seconds"], + } + + +def assert_success(route: str, response: object) -> None: + if not isinstance(response, dict): + raise TypeError(f"{route} returned {type(response).__name__}, expected dict") + actual: Final = success_value(route, response) + expected: Final = ( + "native-ocr" if route == "ocr" else "native-transcription" if route == "transcription" else "native-message" + ) + if actual != expected: + raise AssertionError(f"{route} returned {actual!r}, expected {expected!r}") + + +def success_value(route: str, response: dict[object, object]) -> object: + if route == "ocr": + return response["pages"][0]["markdown"] + if route == "transcription": + return response["text"] + if route == "messages": + return response["content"][0]["text"] + return response["choices"][0]["message"]["content"] + + +def assert_rate_limit(native: object, route: str, error: BaseException) -> None: + if route in ("chat_completions", "messages"): + upstream_error: Final = native.RustUpstreamError + if not isinstance(error, upstream_error) or error.args[0] != 429: + raise AssertionError(f"{route} returned the wrong 429 error: {error!r}") + return + if not isinstance(error, RuntimeError) or "429" not in str(error): + raise AssertionError(f"{route} returned the wrong 429 error: {error!r}") + + +def exercise_sync(native: object, api_base: str) -> None: + for route in ("ocr", "transcription", "messages", "chat_completions"): + function: Final = getattr(native, route) + assert_success(route, function(**route_kwargs(route, api_base, "success"))) + try: + function(**route_kwargs(route, api_base, "429")) + except (RuntimeError, native.RustUpstreamError) as error: + assert_rate_limit(native, route, error) + else: + raise AssertionError(f"{route} accepted a 429 response") + + +async def exercise_async(native: object, api_base: str) -> None: + for route in ("ocr", "transcription", "messages", "chat_completions"): + function: Final = getattr(native, f"a{route}") + assert_success(route, await function(**route_kwargs(route, api_base, "success"))) + try: + await function(**route_kwargs(route, api_base, "429")) + except (RuntimeError, native.RustUpstreamError) as error: + assert_rate_limit(native, route, error) + else: + raise AssertionError(f"a{route} accepted a 429 response") + + +async def exercise_async_concurrency(native: object, api_base: str) -> None: + responses: Final = await asyncio.wait_for( + asyncio.gather( + *( + native.amessages(**route_kwargs("messages", api_base, "success")) + for _ in range(32) + ) + ), + timeout=15, + ) + for response in responses: + assert_success("messages", response) + + +def streaming_route_kwargs(route: str) -> dict[str, object]: + if route == "chat_completions": + return { + "request": { + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + }, + "provider": "anthropic", + } + if route == "messages": + return { + "request": { + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 16, + "stream": True, + }, + "provider": "anthropic", + } + if route == "responses": + return { + "request": {"model": "gpt-5", "input": "hello", "stream": True}, + "provider": "openai", + } + raise AssertionError(f"unknown streaming route: {route}") + + +def assert_streaming_decline(native: object, operation: Callable[[], object]) -> None: + try: + operation() + except native.RustBridgeDeclined: + return + raise AssertionError("disabled native streaming route did not decline") + + +async def assert_async_streaming_decline( + native: object, + operation: Callable[[], Awaitable[object]], +) -> None: + try: + await operation() + except native.RustBridgeDeclined: + return + raise AssertionError("disabled async native streaming route did not decline") + + +def exercise_disabled_streaming_surface(native: object) -> None: + expected: Final = ( + "ChatCompletionsEventStream", + "MessagesEventStream", + "ResponsesEventStream", + "ResponsesWebSocketSession", + "chat_completions_stream", + "achat_completions_stream", + "messages_stream", + "amessages_stream", + "responses_stream", + "aresponses_stream", + ) + for name in expected: + if not hasattr(native, name): + raise AssertionError(f"packaged native bridge is missing {name}") + + for route in ("chat_completions", "messages", "responses"): + function: Final = getattr(native, f"{route}_stream") + kwargs: Final = streaming_route_kwargs(route) + assert_streaming_decline( + native, + lambda function=function, kwargs=kwargs: function(**kwargs), + ) + + async def exercise_async_surface() -> None: + for route in ("chat_completions", "messages", "responses"): + function: Final = getattr(native, f"a{route}_stream") + kwargs: Final = streaming_route_kwargs(route) + await assert_async_streaming_decline( + native, + lambda function=function, kwargs=kwargs: function(**kwargs), + ) + await assert_async_streaming_decline( + native, + lambda: native.ResponsesWebSocketSession.connect(provider="openai"), + ) + + asyncio.run(exercise_async_surface()) + + +def exercise_routes(native_path: Path, api_base: str) -> object: + native: Final = load_native(native_path) + exercise_sync(native, api_base) + asyncio.run(exercise_async(native, api_base)) + asyncio.run(exercise_async_concurrency(native, api_base)) + exercise_disabled_streaming_surface(native) + return native + + +def assert_packaged_native_loaded(wheel_root: Path) -> None: + from litellm.rust_bridge import get_native_bridge + + native: Final = get_native_bridge() + if native is None: + raise AssertionError("packaged native bridge was not loaded") + native_file: Final = getattr(native, "__file__", None) + if not isinstance(native_file, str): + raise AssertionError("packaged native bridge has no module path") + if wheel_root.resolve() not in Path(native_file).resolve().parents: + raise AssertionError(f"native bridge loaded outside the wheel: {native_file}") + + +async def exercise_packaged_messages(api_base: str) -> None: + from litellm.exceptions import APIError + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + from litellm.rust_bridge import messages as messages_bridge + from litellm.types.router import GenericLiteLLMParams + + response: Final = messages_bridge.messages(**messages_wrapper_kwargs(api_base, "success")) + assert_success("messages", response) + + responses: Final = await asyncio.wait_for( + asyncio.gather( + *( + messages_bridge.amessages(**messages_wrapper_kwargs(api_base, "success")) + for _ in range(32) + ) + ), + timeout=15, + ) + for concurrent_response in responses: + assert_success("messages", concurrent_response) + + declined_kwargs: Final = messages_wrapper_kwargs(api_base, "success") + declined_kwargs["custom_llm_provider"] = "openai" + if await messages_bridge.amessages(**declined_kwargs) is not None: + raise AssertionError("unsupported Messages request did not decline") + + params: Final = GenericLiteLLMParams(api_key="sk-native", rust=True) + success_kwargs: Final = route_kwargs("messages", api_base, "success") + gate_response: Final = await BaseLLMHTTPHandler._maybe_rust_anthropic_messages( + custom_llm_provider="anthropic", + litellm_params=params, + has_agentic_hook=False, + model=str(success_kwargs["model"]), + api_key="sk-native", + api_base=api_base, + headers=dict(success_kwargs["extra_headers"]), + request_body=dict(success_kwargs["body"]), + timeout=3.0, + ) + assert_success("messages", gate_response) + + rate_limit_kwargs: Final = route_kwargs("messages", api_base, "429") + try: + await BaseLLMHTTPHandler._maybe_rust_anthropic_messages( + custom_llm_provider="anthropic", + litellm_params=params, + has_agentic_hook=False, + model=str(rate_limit_kwargs["model"]), + api_key="sk-native", + api_base=api_base, + headers=dict(rate_limit_kwargs["extra_headers"]), + request_body=dict(rate_limit_kwargs["body"]), + timeout=3.0, + ) + except APIError as error: + if error.status_code != 429 or "native-rate-limit" not in str(error): + raise AssertionError(f"Messages gate returned the wrong upstream error: {error!r}") from error + else: + raise AssertionError("Messages gate retried or swallowed the native upstream error") + + +def exercise_packaged_python_bridge(wheel_root: Path, api_base: str) -> int: + assert_packaged_native_loaded(wheel_root) + asyncio.run(exercise_packaged_messages(api_base)) + return 0 + + +def exercise_signal(native: object, api_base: str) -> int: + try: + native.messages( + **route_kwargs("messages", api_base, "hang"), + ) + except KeyboardInterrupt: + sys.stdout.write("KeyboardInterrupt\n") + sys.stdout.flush() + sys.stdin.read(1) + return 0 + raise AssertionError("sync native route ignored SIGINT") + + +def verify_sigint(native_path: Path, api_base: str) -> None: + REQUEST_STARTED.clear() + REQUEST_CANCELLED.clear() + process: Final = subprocess.Popen( + (sys.executable, __file__, "child", str(native_path), api_base), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + if not REQUEST_STARTED.wait(30): + process.kill() + stdout, stderr = process.communicate(timeout=5) + raise AssertionError( + f"native route matrix did not reach the hanging upstream\nstdout:\n{stdout}\nstderr:\n{stderr}" + ) + os.kill(process.pid, signal.SIGINT) + if not REQUEST_CANCELLED.wait(5): + raise AssertionError("interrupted native route did not cancel its upstream future") + if process.poll() is not None: + raise AssertionError("signal child exited before cancellation was observed") + stdout, stderr = process.communicate(input="\n", timeout=5) + if process.returncode != 0 or stdout != "KeyboardInterrupt\n": + raise AssertionError( + f"signal child exited with status {process.returncode}\nstdout:\n{stdout}\nstderr:\n{stderr}" + ) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def verify_packaged_python_bridge(wheel: Path, wheel_root: Path, api_base: str) -> None: + uv: Final = shutil.which("uv") + if uv is None: + raise AssertionError("uv is required to test the packaged Python bridge") + environment: Final = {key: value for key, value in os.environ.items() if key != "ANTHROPIC_API_KEY"} | { + "PYTHONPATH": str(wheel_root) + } + result: Final = subprocess.run( + ( + uv, + "run", + "--isolated", + "--with", + str(wheel.resolve()), + "python", + __file__, + "bridge-child", + str(wheel_root), + api_base, + ), + cwd=wheel_root, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise AssertionError( + f"packaged Python bridge failed with status {result.returncode}" + f"\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + +def verify_wheel(wheel: Path) -> int: + with tempfile.TemporaryDirectory() as temporary_directory, zipfile.ZipFile(wheel) as archive: + wheel_root: Final = Path(temporary_directory) + for member in archive.infolist(): + target: Final = wheel_root / member.filename + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + native_members: Final = tuple( + member + for member in archive.infolist() + if member.filename.startswith("litellm/rust_bridge/_native.") and member.filename.endswith(".so") + ) + if len(native_members) != 1: + raise AssertionError(f"expected one native extension, found {len(native_members)}") + native_path: Final = wheel_root / native_members[0].filename + + server: Final = ThreadingHTTPServer(("127.0.0.1", 0), NativeRouteHandler) + server_thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + api_base: Final = f"http://127.0.0.1:{server.server_address[1]}" + try: + verify_sigint(native_path, api_base) + verify_packaged_python_bridge(wheel, wheel_root, api_base) + finally: + server.shutdown() + server.server_close() + server_thread.join(timeout=5) + return 0 + + +def main() -> int: + if len(sys.argv) == 2: + return verify_wheel(Path(sys.argv[1])) + if len(sys.argv) == 4 and sys.argv[1] == "child": + native: Final = exercise_routes(Path(sys.argv[2]), sys.argv[3]) + return exercise_signal(native, sys.argv[3]) + if len(sys.argv) == 4 and sys.argv[1] == "bridge-child": + return exercise_packaged_python_bridge(Path(sys.argv[2]), sys.argv[3]) + sys.stderr.write(f"usage: {Path(sys.argv[0]).name} WHEEL\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_litellm/rust_bridge/test_bindings.py b/tests/test_litellm/rust_bridge/test_bindings.py new file mode 100644 index 00000000000..6ce48b3bdf0 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_bindings.py @@ -0,0 +1,21 @@ +from types import SimpleNamespace + +from litellm.rust_bridge import bindings + + +def test_binding_distinguishes_disable_from_reset(monkeypatch) -> None: + native = SimpleNamespace(route=lambda: "native") + monkeypatch.setattr(bindings, "get_native_bridge", lambda: native) + binding: bindings.NativeBinding[object] = bindings.NativeBinding("route") + + assert binding.load() is native.route + + binding.override(None) + assert binding.load() is None + + replacement = object() + binding.override(replacement) + assert binding.load() is replacement + + binding.reset() + assert binding.load() is native.route diff --git a/tests/test_litellm/rust_bridge/test_chat_completions.py b/tests/test_litellm/rust_bridge/test_chat_completions.py index 47cb66932b7..fd114c0b9a3 100644 --- a/tests/test_litellm/rust_bridge/test_chat_completions.py +++ b/tests/test_litellm/rust_bridge/test_chat_completions.py @@ -10,6 +10,8 @@ from __future__ import annotations import pytest import litellm +from litellm.exceptions import APIError +from litellm.rust_bridge import bindings, configuration from litellm.rust_bridge import chat_completions as bridge from litellm.types.utils import ModelResponse @@ -53,7 +55,7 @@ class _FakeNative: def _fake_native_bridge(monkeypatch): """Expose the bridge's exception classes without the compiled extension.""" - monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative()) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _FakeNative()) def _hide_native_bridge(monkeypatch): @@ -62,31 +64,21 @@ def _hide_native_bridge(monkeypatch): There is no injection seam for "the .so is absent", so the loader itself is replaced; every other case here uses `set_rust_chat_completions`. """ - monkeypatch.setattr(bridge, "get_native_bridge", lambda: None) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) @pytest.fixture(autouse=True) def reset_bridge(): """Every test starts with no injected callables, and leaves none behind.""" bridge.set_rust_chat_completions( - chat_completions=None, achat_completions=None, decline=None + chat_completions=None, + achat_completions=None, + decline=lambda **_kwargs: None, ) + configuration.reset_rust_configuration() yield - bridge.set_rust_chat_completions( - chat_completions=None, achat_completions=None, decline=None - ) - - -class _RecordingDecline: - """A stand-in for the native gate that records what it was asked.""" - - def __init__(self, reason: str | None = None): - self.reason = reason - self.calls: list[dict] = [] - - def __call__(self, **kwargs): - self.calls.append(kwargs) - return self.reason + bridge.set_rust_chat_completions(chat_completions=None, achat_completions=None, decline=None) + configuration.reset_rust_configuration() class _RecordingCall: @@ -107,7 +99,7 @@ class _RecordingAsyncCall(_RecordingCall): return _RecordingCall.__call__(self, **kwargs) -def _accepts(**overrides) -> bool: +def _should_attempt(**overrides) -> bool: kwargs = { "model": "claude-sonnet-4-5", "messages": MESSAGES, @@ -117,40 +109,40 @@ def _accepts(**overrides) -> bool: "stream": None, } kwargs.update(overrides) + kwargs.pop("asynchronous", None) return bridge.rust_chat_completions_accepts(**kwargs) -class TestGate: +class TestEligibility: def test_declines_when_the_deployment_did_not_opt_in(self, monkeypatch): monkeypatch.delenv("LITELLM_RUST", raising=False) - gate = _RecordingDecline() - bridge.set_rust_chat_completions(decline=gate) - assert _accepts(litellm_params={}) is False - assert _accepts(litellm_params=None) is False - assert _accepts(litellm_params={"rust": False}) is False - assert gate.calls == [], "the gate must not be consulted before opt-in" + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + assert _should_attempt(litellm_params={}) is False + assert _should_attempt(litellm_params=None) is False + assert _should_attempt(litellm_params={"rust": False}) is False - def test_accepts_when_the_deployment_opted_in_and_the_core_agrees(self, monkeypatch): + def test_attempts_when_the_deployment_opted_in(self, monkeypatch): monkeypatch.delenv("LITELLM_RUST", raising=False) - gate = _RecordingDecline() - bridge.set_rust_chat_completions(decline=gate) - assert _accepts() is True - assert gate.calls[0]["model"] == "claude-sonnet-4-5" - assert gate.calls[0]["custom_llm_provider"] == "anthropic" + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + assert _should_attempt() is True def test_the_env_var_opts_in_without_a_per_model_flag(self, monkeypatch): monkeypatch.setenv("LITELLM_RUST", "true") - bridge.set_rust_chat_completions(decline=_RecordingDecline()) - assert _accepts(litellm_params={}) is True + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + assert _should_attempt(litellm_params={}) is True + + def test_explicit_false_overrides_the_process_switch(self): + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + configuration.use_litellm_rust(True) + + assert _should_attempt(litellm_params={"rust": False}) is False def test_declines_streaming_and_providers_off_the_path(self, monkeypatch): monkeypatch.delenv("LITELLM_RUST", raising=False) - gate = _RecordingDecline() - bridge.set_rust_chat_completions(decline=gate) - assert _accepts(stream=True) is False - assert _accepts(custom_llm_provider="openai") is False - assert _accepts(custom_llm_provider=None) is False - assert gate.calls == [] + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + assert _should_attempt(stream=True) is False + assert _should_attempt(custom_llm_provider="openai") is False + assert _should_attempt(custom_llm_provider=None) is False def test_declines_an_anthropic_request_carrying_a_litellm_metadata_user_id(self, monkeypatch): """`AnthropicConfig.transform_request` copies a valid `user_id` into the Messages body. @@ -160,24 +152,21 @@ class TestGate: to Anthropic with the abuse-detection attribution silently missing. """ monkeypatch.delenv("LITELLM_RUST", raising=False) - gate = _RecordingDecline() - bridge.set_rust_chat_completions(decline=gate) - assert _accepts(litellm_params={"rust": True, "metadata": {"user_id": "u-123"}}) is False - assert gate.calls == [], "the core must not be consulted for a request it cannot see the key of" + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) + assert _should_attempt(litellm_params={"rust": True, "metadata": {"user_id": "u-123"}}) is False # Bedrock's Converse transform reads no `user_id`, and an Anthropic request # whose metadata carries none is one Python would not attribute either. assert ( - _accepts( + _should_attempt( custom_llm_provider="bedrock", - model="bedrock/us-east-1/anthropic.claude-v2", litellm_params={"rust": True, "metadata": {"user_id": "u-123"}}, ) is True ) - assert _accepts(litellm_params={"rust": True, "metadata": {"trace_id": "t-1"}}) is True - assert _accepts(litellm_params={"rust": True, "metadata": {"user_id": None}}) is True - assert _accepts(litellm_params={"rust": True, "metadata": None}) is True + assert _should_attempt(litellm_params={"rust": True, "metadata": {"trace_id": "t-1"}}) is True + assert _should_attempt(litellm_params={"rust": True, "metadata": {"user_id": None}}) is True + assert _should_attempt(litellm_params={"rust": True, "metadata": None}) is True def test_declines_a_bedrock_request_while_the_proxy_owns_request_metadata(self, monkeypatch): """`AmazonConverseConfig` resolves proxy-owned `requestMetadata` onto the @@ -186,39 +175,49 @@ class TestGate: who armed `bedrock_request_metadata_fields` keeps the Python path. """ monkeypatch.delenv("LITELLM_RUST", raising=False) - gate = _RecordingDecline() - bridge.set_rust_chat_completions(decline=gate) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall()) bedrock = { "custom_llm_provider": "bedrock", - "model": "bedrock/us-east-1/anthropic.claude-v2", } monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", ["user_api_key_team_id"]) - assert _accepts(**bedrock) is False - assert gate.calls == [], "the core must not be consulted for a field it cannot write" - assert _accepts() is True, "arming Bedrock attribution must not decline Anthropic" + assert _should_attempt(**bedrock) is False + assert _should_attempt() is True, "arming Bedrock attribution must not decline Anthropic" monkeypatch.setattr(litellm, "bedrock_request_metadata_fields", None) - assert _accepts(**bedrock) is True, "the decline follows the operator's opt-in alone" - - def test_declines_when_the_core_declines(self, monkeypatch): - monkeypatch.delenv("LITELLM_RUST", raising=False) - bridge.set_rust_chat_completions(decline=_RecordingDecline("streaming")) - assert _accepts() is False + assert _should_attempt(**bedrock) is True, "the decline follows the operator's opt-in alone" def test_declines_when_the_bridge_is_unavailable(self, monkeypatch): monkeypatch.delenv("LITELLM_RUST", raising=False) + bridge.set_rust_chat_completions(decline=None) _hide_native_bridge(monkeypatch) - assert _accepts() is False + assert _should_attempt() is False - def test_declines_when_the_gate_itself_raises(self, monkeypatch): - monkeypatch.delenv("LITELLM_RUST", raising=False) + def test_checks_the_native_capability_gate(self, monkeypatch): + _hide_native_bridge(monkeypatch) + bridge.set_rust_chat_completions(decline=None) + assert _should_attempt() is False + bridge.set_rust_chat_completions(decline=lambda **_kwargs: None) + assert _should_attempt() is True - def exploding(**_kwargs): - raise RuntimeError("boom") + def test_native_unsupported_capability_declines(self, monkeypatch): + _fake_native_bridge(monkeypatch) - bridge.set_rust_chat_completions(decline=exploding) - assert _accepts() is False + def decline(**_kwargs): + raise _FakeDeclined("unsupported request") + + bridge.set_rust_chat_completions(decline=decline) + assert _should_attempt() is False + + def test_native_capability_failure_does_not_fall_back(self, monkeypatch): + _fake_native_bridge(monkeypatch) + + def fail(**_kwargs): + raise _FakeUpstream(502, "capability check failed") + + bridge.set_rust_chat_completions(decline=fail) + with pytest.raises(APIError, match="capability check failed"): + _should_attempt() def _call_kwargs(model_response: ModelResponse) -> dict: @@ -236,14 +235,26 @@ def _call_kwargs(model_response: ModelResponse) -> dict: } +def _sync_call_kwargs(model_response: ModelResponse) -> dict: + return {**_call_kwargs(model_response), "python_fallback": lambda: "python"} + + +async def _async_python_fallback() -> object: + return "python" + + +def _async_call_kwargs(model_response: ModelResponse) -> dict: + return {**_call_kwargs(model_response), "python_fallback": _async_python_fallback} + + class TestSyncCall: - def test_builds_a_model_response_and_stamps_the_rust_header(self): + def test_builds_a_model_response(self): native = _RecordingCall() bridge.set_rust_chat_completions(chat_completions=native) model_response = ModelResponse() original_id = model_response.id - result = bridge.chat_completions(**_call_kwargs(model_response)) + result = bridge.chat_completions_or_fallback(**_sync_call_kwargs(model_response)) assert result is not None assert result.choices[0].message.content == "hello from rust" @@ -252,52 +263,62 @@ class TestSyncCall: assert result.usage.prompt_tokens == 11 assert result.usage.completion_tokens == 4 assert result.usage.total_tokens == 15 - assert result._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} - assert result.id == original_id, ( - "the rust path must keep the chatcmpl id litellm already minted" - ) + assert result.id == original_id, "the rust path must keep the chatcmpl id litellm already minted" + assert result._hidden_params["core_engine"] == "rust" + assert result._hidden_params["additional_headers"] == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } def test_passes_the_timeout_through_as_seconds(self): native = _RecordingCall() bridge.set_rust_chat_completions(chat_completions=native) - bridge.chat_completions(**_call_kwargs(ModelResponse())) + bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) assert native.calls[0]["timeout_seconds"] == 30.0 def test_falls_back_when_the_bridge_is_unavailable(self, monkeypatch): _hide_native_bridge(monkeypatch) - assert bridge.chat_completions(**_call_kwargs(ModelResponse())) is None + assert bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) == "python" def test_falls_back_when_the_core_declines_before_calling_the_provider(self, monkeypatch): _fake_native_bridge(monkeypatch) - bridge.set_rust_chat_completions( - chat_completions=_RecordingCall(error=_FakeDeclined("streaming")) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=_FakeDeclined("streaming"))) + assert bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) == "python" + + def test_model_response_fallback_is_returned_unchanged(self, monkeypatch): + _fake_native_bridge(monkeypatch) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=_FakeDeclined("unsupported"))) + fallback_response = ModelResponse() + + result = bridge.chat_completions_or_fallback( + **_call_kwargs(ModelResponse()), + python_fallback=lambda: fallback_response, ) - assert bridge.chat_completions(**_call_kwargs(ModelResponse())) is None + + assert result is fallback_response + assert result._hidden_params["core_engine"] == "python" + assert result._hidden_params["additional_headers"] == {"x-litellm-core": "python"} class TestAsyncCall: @pytest.mark.asyncio async def test_builds_a_model_response(self): bridge.set_rust_chat_completions(achat_completions=_RecordingAsyncCall()) - result = await bridge.achat_completions(**_call_kwargs(ModelResponse())) + result = await bridge.achat_completions_or_fallback(**_async_call_kwargs(ModelResponse())) assert result is not None assert result.choices[0].message.content == "hello from rust" - assert result._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} + assert result._hidden_params["core_engine"] == "rust" @pytest.mark.asyncio async def test_falls_back_when_the_bridge_is_unavailable(self, monkeypatch): _hide_native_bridge(monkeypatch) - assert await bridge.achat_completions(**_call_kwargs(ModelResponse())) is None + assert await bridge.achat_completions_or_fallback(**_async_call_kwargs(ModelResponse())) == "python" @pytest.mark.asyncio - async def test_falls_back_when_the_core_declines_before_calling_the_provider( - self, monkeypatch - ): + async def test_falls_back_when_the_core_declines_before_calling_the_provider(self, monkeypatch): _fake_native_bridge(monkeypatch) - bridge.set_rust_chat_completions( - achat_completions=_RecordingAsyncCall(error=_FakeDeclined("streaming")) - ) - assert await bridge.achat_completions(**_call_kwargs(ModelResponse())) is None + bridge.set_rust_chat_completions(achat_completions=_RecordingAsyncCall(error=_FakeDeclined("streaming"))) + assert await bridge.achat_completions_or_fallback(**_async_call_kwargs(ModelResponse())) == "python" class TestAsyncFallbackWrapper: @@ -310,25 +331,19 @@ class TestAsyncFallbackWrapper: ran.append(True) return "python" - result = await bridge.achat_completions_or_fallback( - **_call_kwargs(ModelResponse()), python_fallback=fallback - ) + result = await bridge.achat_completions_or_fallback(**_call_kwargs(ModelResponse()), python_fallback=fallback) assert result.choices[0].message.content == "hello from rust" assert ran == [] @pytest.mark.asyncio async def test_runs_the_fallback_when_the_core_declines(self, monkeypatch): _fake_native_bridge(monkeypatch) - bridge.set_rust_chat_completions( - achat_completions=_RecordingAsyncCall(error=_FakeDeclined("streaming")) - ) + bridge.set_rust_chat_completions(achat_completions=_RecordingAsyncCall(error=_FakeDeclined("streaming"))) async def fallback(): return "python" - result = await bridge.achat_completions_or_fallback( - **_call_kwargs(ModelResponse()), python_fallback=fallback - ) + result = await bridge.achat_completions_or_fallback(**_call_kwargs(ModelResponse()), python_fallback=fallback) assert result == "python" @pytest.mark.asyncio @@ -338,9 +353,7 @@ class TestAsyncFallbackWrapper: async def fallback(): return "python" - result = await bridge.achat_completions_or_fallback( - **_call_kwargs(ModelResponse()), python_fallback=fallback - ) + result = await bridge.achat_completions_or_fallback(**_call_kwargs(ModelResponse()), python_fallback=fallback) assert result == "python" @@ -353,46 +366,36 @@ class TestFailureClassification: _fake_native_bridge(monkeypatch) def test_a_decline_falls_back_because_nothing_was_sent(self): - bridge.set_rust_chat_completions( - chat_completions=_RecordingCall(error=_FakeDeclined("streaming")) - ) - assert bridge.chat_completions(**_call_kwargs(ModelResponse())) is None + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=_FakeDeclined("streaming"))) + assert bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) == "python" def test_an_upstream_failure_is_surfaced_with_its_status(self): from litellm.exceptions import APIError - bridge.set_rust_chat_completions( - chat_completions=_RecordingCall(error=_FakeUpstream(429, "429: rate limited")) - ) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=_FakeUpstream(429, "429: rate limited"))) with pytest.raises(APIError) as raised: - bridge.chat_completions(**_call_kwargs(ModelResponse())) + bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) assert raised.value.status_code == 429 assert "rate limited" in str(raised.value) def test_a_transport_failure_with_no_response_surfaces_as_a_500(self): from litellm.exceptions import APIError - bridge.set_rust_chat_completions( - chat_completions=_RecordingCall(error=_FakeUpstream(0, "connection reset")) - ) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=_FakeUpstream(0, "connection reset"))) with pytest.raises(APIError) as raised: - bridge.chat_completions(**_call_kwargs(ModelResponse())) + bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) assert raised.value.status_code == 500 def test_an_unrecognized_error_is_not_swallowed(self): - bridge.set_rust_chat_completions( - chat_completions=_RecordingCall(error=RuntimeError("something else")) - ) + bridge.set_rust_chat_completions(chat_completions=_RecordingCall(error=RuntimeError("something else"))) with pytest.raises(RuntimeError): - bridge.chat_completions(**_call_kwargs(ModelResponse())) + bridge.chat_completions_or_fallback(**_sync_call_kwargs(ModelResponse())) @pytest.mark.asyncio async def test_the_async_wrapper_does_not_fall_back_on_an_upstream_failure(self): from litellm.exceptions import APIError - bridge.set_rust_chat_completions( - achat_completions=_RecordingAsyncCall(error=_FakeUpstream(500, "500: boom")) - ) + bridge.set_rust_chat_completions(achat_completions=_RecordingAsyncCall(error=_FakeUpstream(500, "500: boom"))) ran = [] async def fallback(): @@ -400,9 +403,7 @@ class TestFailureClassification: return "python" with pytest.raises(APIError): - await bridge.achat_completions_or_fallback( - **_call_kwargs(ModelResponse()), python_fallback=fallback - ) + await bridge.achat_completions_or_fallback(**_call_kwargs(ModelResponse()), python_fallback=fallback) assert ran == [], "a request the provider already served must not be re-issued" @pytest.mark.asyncio @@ -414,7 +415,5 @@ class TestFailureClassification: async def fallback(): return "python" - result = await bridge.achat_completions_or_fallback( - **_call_kwargs(ModelResponse()), python_fallback=fallback - ) + result = await bridge.achat_completions_or_fallback(**_call_kwargs(ModelResponse()), python_fallback=fallback) assert result == "python" diff --git a/tests/test_litellm/rust_bridge/test_configuration.py b/tests/test_litellm/rust_bridge/test_configuration.py new file mode 100644 index 00000000000..e7ce9a625ad --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_configuration.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Generator +from typing import Final + +import pytest + +from litellm.rust_bridge import configuration +from litellm.rust_bridge import ocr as rust_ocr + + +class _OcrBridge: + def __call__( + self, + model: str, + document: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + optional_params: dict[str, object], + timeout_seconds: float | None, + ) -> dict[str, object]: + return {} + + +@pytest.fixture(autouse=True) +def _isolated_configuration( # pyright: ignore[reportUnusedFunction] # pytest discovers fixtures dynamically + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + configuration.reset_rust_configuration() + monkeypatch.delenv("LITELLM_RUST", raising=False) + monkeypatch.delenv("LITELLM_USE_RUST_OCR", raising=False) + rust_ocr.set_rust_ocr(ocr=None, aocr=None) + yield + configuration.reset_rust_configuration() + rust_ocr.set_rust_ocr(ocr=None, aocr=None) + + +@pytest.mark.parametrize( + ("request_override", "process", "environment", "legacy_ocr", "release_default", "expected"), + ( + (False, True, True, True, True, False), + (True, False, False, False, False, True), + (None, False, True, True, True, False), + (None, True, False, False, False, True), + (None, None, False, True, True, False), + (None, None, True, False, False, True), + (None, None, None, False, True, False), + (None, None, None, True, False, True), + (None, None, None, None, False, False), + (None, None, None, None, True, True), + ), +) +def test_resolution_precedence( + request_override: bool | None, + process: bool | None, + environment: bool | None, + legacy_ocr: bool | None, + release_default: bool, + expected: bool, +) -> None: + assert ( + configuration.resolve_rust_enabled( + request_override=request_override, + process_override=process, + environment_override=environment, + legacy_ocr_override=legacy_ocr, + release_default=release_default, + ) + is expected + ) + + +def test_release_default_remains_disabled() -> None: + assert configuration.DEFAULT_RUST_ENABLED is False + assert configuration.rust_enabled() is False + + +def test_process_override_wins_over_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "0") + configuration.use_litellm_rust(True) + + assert configuration.rust_enabled() is True + assert configuration.rust_enabled(request_override=False) is False + + +def test_global_environment_accepts_explicit_false(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "off") + + assert configuration.rust_enabled() is False + + +def test_invalid_environment_value_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "sometimes") + + with pytest.raises(ValueError, match="LITELLM_RUST must be one of"): + configuration.rust_enabled() + + +def test_explicit_override_precedes_invalid_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "sometimes") + + assert configuration.rust_enabled(request_override=False) is False + configuration.use_litellm_rust(True) + assert configuration.rust_enabled() is True + + +def test_legacy_ocr_environment_is_deprecated_and_ocr_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_USE_RUST_OCR", "1") + + with pytest.warns(DeprecationWarning, match="LITELLM_USE_RUST_OCR is deprecated"): + assert configuration.rust_ocr_enabled() is True + assert configuration.rust_enabled() is False + + +def test_global_environment_precedes_legacy_ocr_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_RUST", "0") + monkeypatch.setenv("LITELLM_USE_RUST_OCR", "1") + + assert configuration.rust_ocr_enabled() is False + + +def test_deprecated_public_injection_delegates_to_internal_binding() -> None: + bridge: Final = _OcrBridge() + + with pytest.warns(DeprecationWarning, match="Injecting Rust bridge implementations"): + configuration.use_litellm_rust(True, ocr=bridge) + + assert rust_ocr.load_rust_ocr() is bridge + + +@pytest.mark.parametrize(("value", "expected"), (("1", "True"), ("0", "False"))) +def test_environment_controls_startup(value: str, expected: str) -> None: + environment: Final = {**os.environ, "LITELLM_RUST": value} + result: Final = subprocess.run( + ( + sys.executable, + "-c", + "from litellm.rust_bridge.configuration import rust_enabled; print(rust_enabled())", + ), + check=True, + capture_output=True, + text=True, + env=environment, + ) + + assert result.stdout.strip() == expected diff --git a/tests/test_litellm/rust_bridge/test_runtime.py b/tests/test_litellm/rust_bridge/test_runtime.py new file mode 100644 index 00000000000..27f0bd60c3a --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_runtime.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest + +from litellm.exceptions import APIError +from litellm.rust_bridge import bindings, runtime + + +class RustBridgeDeclined(Exception): + pass + + +class RustUpstreamError(Exception): + pass + + +@pytest.fixture(autouse=True) +def native_exceptions(monkeypatch: pytest.MonkeyPatch) -> None: + native = SimpleNamespace( + RustBridgeDeclined=RustBridgeDeclined, + RustUpstreamError=RustUpstreamError, + ) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: native) + + +def context() -> runtime.BridgeErrorContext: + return runtime.BridgeErrorContext(route="messages", provider="anthropic", model="model") + + +def test_invoke_tags_native_decline_before_running_fallback() -> None: + calls: list[str] = [] + + def decline() -> object: + calls.append("rust") + raise RustBridgeDeclined("unsupported") + + value = runtime.invoke( + native_call=decline, + fallback=lambda: calls.append("python") or "fallback", + adapt=str, + mode=runtime.FallbackMode.PYTHON, + context=context(), + ) + + assert value.value == "fallback" + assert value.source is runtime.CoreEngine.PYTHON + assert calls == ["rust", "python"] + + +def test_invoke_translates_upstream_without_fallback() -> None: + def fail() -> object: + raise RustUpstreamError(429, "rate limited") + + with pytest.raises(APIError, match="rate limited") as caught: + runtime.invoke( + native_call=fail, + fallback=lambda: pytest.fail("fallback must not run"), + adapt=str, + mode=runtime.FallbackMode.PYTHON, + context=context(), + ) + + assert caught.value.status_code == 429 + assert caught.value.headers == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } + + +@pytest.mark.asyncio +async def test_ainvoke_handles_native_success() -> None: + async def native() -> int: + return 3 + + async def fallback() -> str: + pytest.fail("fallback must not run") + + result = await runtime.ainvoke( + native_call=native, + fallback=fallback, + adapt=str, + mode=runtime.FallbackMode.PYTHON, + context=context(), + ) + assert result.value == "3" + assert result.source is runtime.CoreEngine.RUST + + +def test_execution_hidden_params_overwrites_reserved_provider_headers() -> None: + hidden_params = runtime.execution_hidden_params( + { + "provider": "anthropic", + "additional_headers": { + "request-id": "req-1", + "X-LiteLLM-Core": "spoofed", + "x-litellm-rust": "spoofed", + }, + }, + runtime.CoreEngine.PYTHON, + ) + + assert hidden_params == { + "provider": "anthropic", + "core_engine": "python", + "additional_headers": { + "request-id": "req-1", + "x-litellm-core": "python", + }, + } + + +def test_required_mode_rejects_unavailable_bridge() -> None: + with pytest.raises(RuntimeError, match="is unavailable"): + runtime.invoke( + native_call=None, + fallback=lambda: pytest.fail("fallback must not run"), + adapt=str, + mode=runtime.FallbackMode.RUST_REQUIRED, + context=context(), + ) + + +@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async")) +@pytest.mark.asyncio +async def test_upstream_error_adapter_preserves_response_without_fallback(asynchronous: bool) -> None: + request = httpx.Request("POST", "https://example.com/ocr") + + def provider_error(status: int, message: str) -> Exception: + return httpx.HTTPStatusError( + message, + request=request, + response=httpx.Response(status, request=request), + ) + + error_context = runtime.BridgeErrorContext( + route="ocr", provider="mistral", model="model", upstream_error=provider_error + ) + + def fail() -> str: + raise RustUpstreamError(429, '{"message":"rate limited"}') + + async def afail() -> str: + return fail() + + def fallback() -> str: + pytest.fail("provider failure must not execute Python fallback") + + async def afallback() -> str: + return fallback() + + async def invoke() -> None: + if asynchronous: + await runtime.ainvoke( + native_call=afail, + fallback=afallback, + adapt=runtime.identity, + mode=runtime.FallbackMode.PYTHON, + context=error_context, + ) + else: + runtime.invoke( + native_call=fail, + fallback=fallback, + adapt=runtime.identity, + mode=runtime.FallbackMode.PYTHON, + context=error_context, + ) + + with pytest.raises(httpx.HTTPStatusError) as caught: + await invoke() + + assert str(caught.value) == '{"message":"rate limited"}' + assert caught.value.response.status_code == 429 + assert caught.value.request is request + assert isinstance(caught.value.__cause__, RustUpstreamError) + assert caught.value.headers == {"x-litellm-core": "rust", "x-litellm-rust": "true"} diff --git a/tests/test_litellm/rust_bridge/test_streaming.py b/tests/test_litellm/rust_bridge/test_streaming.py new file mode 100644 index 00000000000..585187fa5e9 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_streaming.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from typing import Final +from unittest.mock import MagicMock + +import pytest + +from litellm.exceptions import APIError +from litellm.rust_bridge import bindings, streaming +from litellm.rust_bridge.runtime import BridgeErrorContext + + +class _FakeEventStream: + def __init__(self, events: tuple[Mapping[str, object], ...]) -> None: + self.metadata: Final = { + "status_code": 200, + "provider": "anthropic", + "transport": "http", + "response_headers": [{"name": "x-test", "value": "ready"}], + } + self._events: Final = iter(events) + self.closed = False + + def next_event(self) -> Mapping[str, object] | None: + if self.closed: + return None + return next(self._events, None) + + async def anext_event(self) -> Mapping[str, object] | None: + return self.next_event() + + def close(self) -> None: + self.closed = True + + async def aclose(self) -> None: + self.close() + + +class _RecordingOpen: + def __init__(self, events: tuple[Mapping[str, object], ...]) -> None: + self._events: Final = events + self.calls = 0 + + def __call__( + self, + request: Mapping[str, object], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> _FakeEventStream: + self.calls += 1 + return _FakeEventStream(self._events) + + +class _RecordingAsyncOpen: + def __init__(self, events: tuple[Mapping[str, object], ...]) -> None: + self._events: Final = events + self.calls = 0 + + async def __call__( + self, + request: Mapping[str, object], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> _FakeEventStream: + self.calls += 1 + return _FakeEventStream(self._events) + + +class _Declined(Exception): + pass + + +class _Upstream(Exception): + pass + + +class _NativeErrors: + RustBridgeDeclined = _Declined + RustUpstreamError = _Upstream + + +class _FailingOpen: + def __init__(self, error: Exception) -> None: + self._error: Final = error + + def __call__( + self, + request: Mapping[str, object], + provider: str, + credentials: Mapping[str, str] | None, + api_base: str | None, + extra_headers: Mapping[str, str] | None, + timeout_seconds: float | None, + ) -> _FakeEventStream: + raise self._error + + +class _FailingEventStream(_FakeEventStream): + def next_event(self) -> Mapping[str, object] | None: + raise _Upstream(502, "stream interrupted") + + +@pytest.fixture(autouse=True) +def reset_bridge() -> Iterator[None]: + streaming.set_rust_streaming( + capability=None, + chat=None, + achat=None, + messages=None, + amessages=None, + responses=None, + aresponses=None, + ) + yield + streaming.set_rust_streaming( + capability=None, + chat=None, + achat=None, + messages=None, + amessages=None, + responses=None, + aresponses=None, + ) + + +def _chat_event(text: str) -> Mapping[str, object]: + return { + "text": text, + "tool_use": None, + "is_finished": False, + "finish_reason": "", + "usage": None, + } + + +def _context() -> BridgeErrorContext: + return BridgeErrorContext(route="chat completions stream", provider="anthropic", model="claude") + + +def test_no_native_capability_keeps_every_provider_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + opener: Final = _RecordingOpen((_chat_event("unused"),)) + monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) + streaming.set_rust_streaming(chat=opener) + + result: Final = streaming.open_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials={"api_key": "test"}, + api_base=None, + extra_headers=None, + timeout=None, + ) + + assert result is None + assert opener.calls == 0 + + +def test_disabled_capability_never_calls_native() -> None: + opener: Final = _RecordingOpen((_chat_event("unused"),)) + streaming.set_rust_streaming(capability=lambda api, provider, transport: False, chat=opener) + + result: Final = streaming.open_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials=None, + api_base=None, + extra_headers=None, + timeout=None, + ) + + assert result is None + assert opener.calls == 0 + + +def test_declined_open_failure_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _NativeErrors()) + streaming.set_rust_streaming( + capability=lambda api, provider, transport: True, + chat=_FailingOpen(_Declined("unsupported request")), + ) + + result: Final = streaming.open_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials=None, + api_base=None, + extra_headers=None, + timeout=None, + ) + + assert result is None + + +def test_upstream_open_failure_never_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _NativeErrors()) + streaming.set_rust_streaming( + capability=lambda api, provider, transport: True, + chat=_FailingOpen(_Upstream(503, "connection closed after request")), + ) + + with pytest.raises(APIError, match="connection closed after request") as caught: + streaming.open_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials=None, + api_base=None, + extra_headers=None, + timeout=None, + ) + assert caught.value.headers["x-litellm-core"] == "rust" + + +def test_midstream_failure_preserves_rust_provenance(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bindings, "get_native_bridge", lambda: _NativeErrors()) + events: Final = streaming.TypedEventStreamAdapter(_FailingEventStream(()), _context()) + + with pytest.raises(APIError, match="stream interrupted") as caught: + next(events) + assert caught.value.headers["x-litellm-core"] == "rust" + + +def test_sync_typed_events_preserve_shape_metadata_and_close() -> None: + opener: Final = _RecordingOpen((_chat_event("one"), _chat_event("two"))) + streaming.set_rust_streaming(capability=lambda api, provider, transport: True, chat=opener) + result: Final = streaming.open_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials=None, + api_base=None, + extra_headers=None, + timeout=1.0, + ) + + assert result is not None + assert tuple(event["text"] for event in result) == ("one", "two") + assert result.metadata["provider"] == "anthropic" + result.close() + assert opener.calls == 1 + + +def test_chat_events_flow_through_custom_stream_wrapper() -> None: + from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper + from litellm.types.utils import ModelResponseStream + + native: Final = _FakeEventStream((_chat_event("hello"),)) + events: Final = streaming.TypedEventStreamAdapter(native, _context()) + wrapper: Final = CustomStreamWrapper( + completion_stream=events, + model="claude", + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + ) + + chunk: Final = next(wrapper) + assert isinstance(chunk, ModelResponseStream) + assert chunk.choices[0].delta.content == "hello" + + +@pytest.mark.asyncio +async def test_async_typed_events_and_cancellation() -> None: + opener: Final = _RecordingAsyncOpen((_chat_event("one"), _chat_event("two"))) + streaming.set_rust_streaming(capability=lambda api, provider, transport: True, achat=opener) + result: Final = await streaming.aopen_stream( + api="chat_completions", + provider="anthropic", + request={"model": "claude", "messages": []}, + credentials=None, + api_base=None, + extra_headers=None, + timeout=None, + ) + + assert result is not None + assert tuple(event["text"] for event in [event async for event in result]) == ("one", "two") + await result.aclose() + + +def test_messages_events_are_wrapped_in_existing_sse_bytes() -> None: + events: Final = streaming.TypedEventStreamAdapter(_FakeEventStream(({"type": "message_stop"},)), _context()) + messages: Final = streaming.MessagesSseStreamAdapter(events) + + assert tuple(messages) == (b'data: {"type":"message_stop"}\n\n',) + + +def test_responses_events_are_validated_into_existing_sdk_objects() -> None: + from litellm.types.llms.openai import OutputTextDeltaEvent + + native: Final = _FakeEventStream( + ( + { + "type": "response.output_text.delta", + "item_id": "item_1", + "output_index": 0, + "content_index": 0, + "delta": "hello", + }, + ) + ) + responses: Final = streaming.ResponsesSdkEventStreamAdapter(streaming.TypedEventStreamAdapter(native, _context())) + + event: Final = next(iter(responses)) + assert isinstance(event, OutputTextDeltaEvent) + assert event.delta == "hello" + + +@pytest.mark.asyncio +async def test_rejects_mixed_sync_and_async_consumption() -> None: + events: Final = streaming.TypedEventStreamAdapter(_FakeEventStream((_chat_event("one"),)), _context()) + assert tuple(events) == (_chat_event("one"),) + + with pytest.raises(RuntimeError, match="cannot mix"): + async for _ in events: + pass diff --git a/tests/test_litellm/rust_bridge/test_verify_linux_native_wheel.py b/tests/test_litellm/rust_bridge/test_verify_linux_native_wheel.py new file mode 100644 index 00000000000..e449d4392d8 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_verify_linux_native_wheel.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import zipfile +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from types import MappingProxyType, ModuleType +from typing import Final, Protocol, cast + +import pytest + + +class _CommandRunner(Protocol): + def __call__( + self, + command: tuple[str, ...], + *, + check: bool, + capture_output: bool, + text: bool, + ) -> subprocess.CompletedProcess[str]: ... + + +class _VerifierModule(Protocol): + main: Callable[ + [ + Sequence[str] | None, + Mapping[str, str] | None, + Callable[[Path], ModuleType | None], + _CommandRunner, + ], + int, + ] + + +_REPO_ROOT: Final = Path(__file__).resolve().parents[3] +_MODULE_PATH: Final = _REPO_ROOT / ".github" / "scripts" / "verify_linux_native_wheel.py" +_SPEC: Final = importlib.util.spec_from_file_location("verify_linux_native_wheel", _MODULE_PATH) +assert _SPEC is not None and _SPEC.loader is not None +_LOADED_VERIFIER: Final = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = _LOADED_VERIFIER +_SPEC.loader.exec_module(_LOADED_VERIFIER) +verifier: Final = cast(_VerifierModule, _LOADED_VERIFIER) + +_EXPECTED_TAG: Final = "cp310-abi3-linux_x86_64" +_NATIVE_MEMBER: Final = "litellm/rust_bridge/_native.abi3.so" +_DIST_INFO: Final = "litellm-1.100.0.dist-info" + + +def _write_wheel( + tmp_path: Path, + *, + filename_tag: str, + metadata_tags: tuple[str, ...] | None = (_EXPECTED_TAG,), + dist_info: str = _DIST_INFO, + duplicate_wheel: bool = False, +) -> Path: + wheel: Final = tmp_path / f"litellm-1.100.0-{filename_tag}.whl" + with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(_NATIVE_MEMBER, b"synthetic native extension") + archive.writestr( + f"{dist_info}/METADATA", + "Metadata-Version: 2.1\nName: litellm\nVersion: 1.100.0\n", + ) + archive.writestr( + f"{dist_info}/RECORD", + f"{_NATIVE_MEMBER},,\n{dist_info}/WHEEL,,\n", + ) + if metadata_tags is not None: + wheel_metadata: Final = ( + "Wheel-Version: 1.0\nGenerator: regression-test\nRoot-Is-Purelib: false\n" + + "".join(f"Tag: {tag}\n" for tag in metadata_tags) + ) + archive.writestr(f"{dist_info}/WHEEL", wheel_metadata) + if duplicate_wheel: + archive.writestr(f"{dist_info}/WHEEL", wheel_metadata) + return wheel + + +def _fake_subprocess_run( + command: tuple[str, ...], + *, + check: bool, + capture_output: bool, + text: bool, +) -> subprocess.CompletedProcess[str]: + assert check and capture_output and text + if command == ("rustc", "--version"): + return subprocess.CompletedProcess(command, 0, stdout="rustc 1.98.0 (regression-test)\n", stderr="") + if "--sections" in command: + return subprocess.CompletedProcess(command, 0, stdout="[ 1] .text PROGBITS\n", stderr="") + if "--dyn-syms" in command: + return subprocess.CompletedProcess(command, 0, stdout="PyInit__native\n", stderr="") + raise AssertionError(f"unexpected subprocess command: {command}") + + +class _NativeModuleWithPanicHook(ModuleType): + def _panic_for_test(self) -> None: + return None + + +def _run_verifier( + wheel: Path, + *, + exposes_panic: bool = False, +) -> int: + native_module: Final = ( + _NativeModuleWithPanicHook("litellm.rust_bridge._native") + if exposes_panic + else ModuleType("litellm.rust_bridge._native") + ) + + def _fake_load_native_module(_: Path) -> ModuleType: + return native_module + + environment: Final = MappingProxyType({"GITHUB_STEP_SUMMARY": str(wheel.parent / "summary.md")}) + return verifier.main( + (str(_MODULE_PATH), str(wheel)), + environment, + _fake_load_native_module, + _fake_subprocess_run, + ) + + +def test_accepts_expected_release_wheel_tags(tmp_path: Path) -> None: + wheel: Final = _write_wheel(tmp_path, filename_tag=_EXPECTED_TAG) + + assert _run_verifier(wheel) == 0 + + +def test_rejects_cp312_version_specific_wheel(tmp_path: Path) -> None: + tag: Final = "cp312-cp312-linux_x86_64" + wheel: Final = _write_wheel(tmp_path, filename_tag=tag, metadata_tags=(tag,)) + + assert _run_verifier(wheel) == 1 + + +def test_rejects_non_linux_platform_tag(tmp_path: Path) -> None: + tag: Final = "cp310-abi3-win_amd64" + wheel: Final = _write_wheel(tmp_path, filename_tag=tag, metadata_tags=(tag,)) + + assert _run_verifier(wheel) == 1 + + +@pytest.mark.parametrize( + "metadata_tags", + (None, ("cp312-cp312-linux_x86_64",)), + ids=("missing", "mismatched"), +) +def test_rejects_missing_or_mismatched_wheel_metadata_tag( + tmp_path: Path, + metadata_tags: tuple[str, ...] | None, +) -> None: + wheel: Final = _write_wheel(tmp_path, filename_tag=_EXPECTED_TAG, metadata_tags=metadata_tags) + + assert _run_verifier(wheel) == 1 + + +def test_rejects_wheel_metadata_from_wrong_dist_info_directory( + tmp_path: Path, +) -> None: + wheel: Final = _write_wheel( + tmp_path, + filename_tag=_EXPECTED_TAG, + dist_info="decoy-1.0.0.dist-info", + ) + + assert _run_verifier(wheel) == 1 + + +def test_rejects_duplicate_wheel_metadata_tags(tmp_path: Path) -> None: + wheel: Final = _write_wheel( + tmp_path, + filename_tag=_EXPECTED_TAG, + metadata_tags=(_EXPECTED_TAG, _EXPECTED_TAG), + ) + + assert _run_verifier(wheel) == 1 + + +def test_rejects_duplicate_wheel_metadata_file(tmp_path: Path) -> None: + with pytest.warns(UserWarning, match="Duplicate name"): + wheel: Final = _write_wheel( + tmp_path, + filename_tag=_EXPECTED_TAG, + duplicate_wheel=True, + ) + + assert _run_verifier(wheel) == 1 + + +def test_rejects_production_module_exposing_panic_hook(tmp_path: Path) -> None: + wheel: Final = _write_wheel(tmp_path, filename_tag=_EXPECTED_TAG) + + assert _run_verifier(wheel, exposes_panic=True) == 1 diff --git a/tests/test_litellm/test_audio_transcription_rust_bridge.py b/tests/test_litellm/test_audio_transcription_rust_bridge.py index bbeb6c38f78..f9fdfe84c25 100644 --- a/tests/test_litellm/test_audio_transcription_rust_bridge.py +++ b/tests/test_litellm/test_audio_transcription_rust_bridge.py @@ -6,6 +6,7 @@ import litellm from litellm.llms.bedrock.audio_transcription import BedrockAudioTranscriptionRustDispatch rust_bridge = importlib.import_module("litellm.rust_bridge.transcription") +rust_bridge_bindings = importlib.import_module("litellm.rust_bridge.bindings") class SyncBridge: @@ -77,7 +78,7 @@ async def test_enabled_async_bridge() -> None: def test_loader_returns_none_without_native_extension(monkeypatch: pytest.MonkeyPatch) -> None: rust_bridge.configure_rust_transcription(transcription=None, atranscription=None) - monkeypatch.setattr("litellm.rust_bridge.get_native_bridge", lambda: None) + monkeypatch.setattr(rust_bridge_bindings, "get_native_bridge", lambda: None) assert rust_bridge.load_rust_transcription() is None assert rust_bridge.load_rust_atranscription() is None @@ -132,6 +133,11 @@ def test_bedrock_transcription_uses_rust_only_path() -> None: rust_bridge.configure_rust_transcription(transcription=None, atranscription=None) assert response.text == "rust" + assert response._hidden_params["core_engine"] == "rust" + assert response._hidden_params["additional_headers"] == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } @pytest.mark.asyncio @@ -149,3 +155,8 @@ async def test_bedrock_atranscription_uses_rust_only_path() -> None: rust_bridge.configure_rust_transcription(transcription=None, atranscription=None) assert response.text == "rust" + assert response._hidden_params["core_engine"] == "rust" + assert response._hidden_params["additional_headers"] == { + "x-litellm-core": "rust", + "x-litellm-rust": "true", + } diff --git a/tests/test_litellm/test_circleci_rust_toolchain.py b/tests/test_litellm/test_circleci_rust_toolchain.py index c35ced51e16..800ca21b95d 100644 --- a/tests/test_litellm/test_circleci_rust_toolchain.py +++ b/tests/test_litellm/test_circleci_rust_toolchain.py @@ -17,28 +17,27 @@ Two invariants are pinned here: Windows job, so the check accepts either. A new job that syncs without one falls back to the unpinned path, which is exactly the regression a static check catches at PR time and a green CI run does not. - 2. `install_rust` itself pins what it downloads: an explicit rustup version in - the URL, a verified SHA-256, and an exact toolchain version rather than a - channel name. - -The Windows job predates `install_rust` and provisions its toolchain inline, so -invariant 2 is scoped to `install_rust`; invariant 1 covers both. + 2. Both installers pin what they download: an explicit rustup version, a + verified SHA-256, and the exact toolchain in `rust-toolchain.toml`. """ from __future__ import annotations import re from pathlib import Path +from typing import Final import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[2] CONFIG = REPO_ROOT / ".circleci" / "config.yml" +TOOLCHAIN: Final = REPO_ROOT / "rust-toolchain.toml" BUILDS_WORKSPACE = re.compile(r"\buv\s+(?:sync|build)\b") RUSTUP_ARCHIVE_URL = re.compile(r"https://static\.rust-lang\.org/rustup/archive/\d+\.\d+\.\d+/") -EXACT_TOOLCHAIN = re.compile(r"--default-toolchain\s+\"?\d+\.\d+\.\d+\"?") +EXACT_TOOLCHAIN = re.compile(r"--default-toolchain\s+\"?(\d+\.\d+\.\d+)\"?") +TOOLCHAIN_CHANNEL: Final = re.compile(r'^channel = "(\d+\.\d+\.\d+)"$', re.MULTILINE) def _config() -> dict[str, object]: @@ -57,6 +56,12 @@ def _step_text(step: object) -> str: return "" +def _pinned_toolchain() -> str: + match: Final = TOOLCHAIN_CHANNEL.search(TOOLCHAIN.read_text()) + assert match is not None, "rust-toolchain.toml must pin an exact channel" + return match.group(1) + + def _without_comments(text: str) -> str: return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) @@ -142,7 +147,17 @@ def test_install_rust_verifies_the_installer_checksum(install_rust_command: str) def test_install_rust_pins_an_exact_toolchain_version(install_rust_command: str) -> None: - assert EXACT_TOOLCHAIN.search(install_rust_command), ( - "install_rust must pin an exact toolchain version (e.g. 1.97.1); a channel name like " + match: Final = EXACT_TOOLCHAIN.search(install_rust_command) + assert match is not None, ( + "install_rust must pin an exact toolchain version (e.g. 1.98.0); a channel name like " "stable/beta/nightly makes the compiler drift with whatever upstream published that day" ) + assert match.group(1) == _pinned_toolchain() + + +def test_windows_installer_matches_the_repo_toolchain() -> None: + windows_steps: Final = _step_lists()["job using_litellm_on_windows"] + windows_command: Final = "\n".join(_step_text(step) for step in windows_steps) + match: Final = EXACT_TOOLCHAIN.search(windows_command) + assert match is not None + assert match.group(1) == _pinned_toolchain() diff --git a/tests/test_litellm/test_claude_fable_5_config.py b/tests/test_litellm/test_claude_fable_5_config.py index 99c59ffa58e..3ecf94602d9 100644 --- a/tests/test_litellm/test_claude_fable_5_config.py +++ b/tests/test_litellm/test_claude_fable_5_config.py @@ -1,5 +1,5 @@ """ -Validate Claude Fable 5 model configuration entries. +Validate Claude Fable 5 and Claude Fable 5.1 model configuration entries. Fable 5 is a new tier above Opus ($10/$50 per MTok) with the same adaptive-only API surface as Opus 4.7/4.8. The cost-map entries below are what make the model @@ -210,6 +210,156 @@ def test_adaptive_thinking_detected_for_fable_5(local_model_cost_map, model): assert AnthropicModelInfo._is_adaptive_thinking_model(model, "anthropic") is True +FABLE_5_1_VARIANTS = ( + "claude-fable-5-1", + "anthropic.claude-fable-5-1", + "global.anthropic.claude-fable-5-1", + "us.anthropic.claude-fable-5-1", + "eu.anthropic.claude-fable-5-1", + "vertex_ai/claude-fable-5-1", + "vertex_ai/claude-fable-5-1@default", + "azure_ai/claude-fable-5-1", +) + + +def test_fable_5_1_model_pricing_and_capabilities(): + model_data = _load_root_cost_map() + + expected_models = [ + ("claude-fable-5-1", "anthropic"), + ("anthropic.claude-fable-5-1", "bedrock_converse"), + ("vertex_ai/claude-fable-5-1", "vertex_ai-anthropic_models"), + ("azure_ai/claude-fable-5-1", "azure_ai"), + ] + + for model_name, provider in expected_models: + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + + assert info["litellm_provider"] == provider + assert info["mode"] == "chat" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + assert info["max_tokens"] == 128000 + + assert info["input_cost_per_token"] == 1e-05 + assert info["output_cost_per_token"] == 5e-05 + assert info["cache_creation_input_token_cost"] == 1.25e-05 + assert info["cache_creation_input_token_cost_above_1hr"] == 2e-05 + + assert "input_cost_per_token_above_200k_tokens" not in info + assert "output_cost_per_token_above_200k_tokens" not in info + + assert info["supports_assistant_prefill"] is False + assert info["supports_forced_tool_use"] is False + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_xhigh_reasoning_effort"] is True + assert info["supports_max_reasoning_effort"] is True + assert info["prompt_cache_min_tokens"] == 512 + + +@pytest.mark.parametrize( + "cost_map", + [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], + ids=["root", "bundled_backup"], +) +def test_fable_5_1_cache_reads_cost_a_quarter_of_fable_5(cost_map): + """Fable 5.1 prices cache hits at 0.025x base input instead of the usual + 0.1x, so copying Fable 5's cache-read price overcharges every cache hit 4x.""" + for model_name in FABLE_5_1_VARIANTS: + info = cost_map[model_name] + geo_premium = model_name.startswith(("us.", "eu.")) + expected = 2.75e-07 if geo_premium else 2.5e-07 + assert info["cache_read_input_token_cost"] == expected, model_name + assert info["cache_read_input_token_cost"] == pytest.approx( + info["input_cost_per_token"] * 0.025 + ), model_name + + +def test_fable_5_1_bedrock_regional_model_pricing(): + model_data = _load_root_cost_map() + + expected_models = { + "global.anthropic.claude-fable-5-1": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "cache_creation_input_token_cost": 1.25e-05, + "cache_read_input_token_cost": 2.5e-07, + }, + "us.anthropic.claude-fable-5-1": { + "input_cost_per_token": 1.1e-05, + "output_cost_per_token": 5.5e-05, + "cache_creation_input_token_cost": 1.375e-05, + "cache_read_input_token_cost": 2.75e-07, + }, + "eu.anthropic.claude-fable-5-1": { + "input_cost_per_token": 1.1e-05, + "output_cost_per_token": 5.5e-05, + "cache_creation_input_token_cost": 1.375e-05, + "cache_read_input_token_cost": 2.75e-07, + }, + } + + for model_name, expected in expected_models.items(): + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + assert info["litellm_provider"] == "bedrock_converse" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + assert info["bedrock_output_config_effort_ceiling"] == "xhigh" + for key, value in expected.items(): + assert info[key] == value + + +def test_fable_5_1_geo_multiplier_without_fast_mode(): + """Fable 5.1 has no fast mode, so a ``fast`` key here would misprice + ``speed='fast'`` requests.""" + model_data = _load_root_cost_map() + assert model_data["claude-fable-5-1"]["provider_specific_entry"] == {"us": 1.1} + + +def test_fable_5_1_present_in_bundled_backup(): + backup = GetModelCostMap.load_local_model_cost_map() + root = _load_root_cost_map() + for model_name in FABLE_5_1_VARIANTS: + assert model_name in backup, f"Missing from backup cost map: {model_name}" + assert backup[model_name] == root[model_name], model_name + + +def test_fable_5_1_registered_for_bedrock_converse(): + assert "anthropic.claude-fable-5-1" in BEDROCK_CONVERSE_MODELS + + +def test_fable_5_1_provider_resolves_via_model_info(local_model_cost_map): + info = litellm.get_model_info(model="claude-fable-5-1") + assert info["litellm_provider"] == "anthropic" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + + +@pytest.mark.parametrize( + "model", + [ + "claude-fable-5-1", + "anthropic/claude-fable-5-1", + "anthropic.claude-fable-5-1", + "bedrock/us.anthropic.claude-fable-5-1", + "bedrock/invoke/eu.anthropic.claude-fable-5-1", + "bedrock/global.anthropic.claude-fable-5-1", + "vertex_ai/claude-fable-5-1", + "azure_ai/claude-fable-5-1", + ], +) +def test_adaptive_thinking_detected_for_fable_5_1(local_model_cost_map, model): + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + assert AnthropicModelInfo._is_adaptive_thinking_model(model, "anthropic") is True + + @pytest.mark.parametrize( "cost_map", [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], diff --git a/tests/test_litellm/test_dockerfile_apk_repository.py b/tests/test_litellm/test_dockerfile_apk_repository.py new file mode 100644 index 00000000000..cbd772defbf --- /dev/null +++ b/tests/test_litellm/test_dockerfile_apk_repository.py @@ -0,0 +1,52 @@ +""" +Static checks on the root Dockerfile's apk repository configuration. + +The base image (cgr.dev/chainguard/wolfi-base) only configures the +authenticated Chainguard apk repo (https://apk.cgr.dev/chainguard) in +/etc/apk/repositories, which requires a Chainguard enterprise subscription. +Anyone pulling the published litellm image and running `apk add` inside it +hits SSL/auth failures with no fallback repo configured, so nothing can be +installed. See https://github.com/BerriAI/litellm/issues/33518 +""" + +import os +import re + +import pytest + +DOCKERFILE_PATH = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "Dockerfile", +) + + +def _runtime_stage(dockerfile_text: str) -> str: + """Return the contents of the final `FROM ... AS runtime` build stage.""" + match = re.search(r"^FROM .*\bAS runtime\b(.*)\Z", dockerfile_text, re.MULTILINE | re.DOTALL) + assert match, "Dockerfile has no `FROM ... AS runtime` stage" + return match.group(1) + + +@pytest.mark.skipif( + not os.path.exists(DOCKERFILE_PATH), + reason="Dockerfile not present in this checkout", +) +def test_runtime_stage_adds_public_wolfi_repo(): + """The runtime stage must add the public Wolfi apk repo so `apk add` + works for users without a Chainguard enterprise subscription.""" + with open(DOCKERFILE_PATH, "r", encoding="utf-8") as f: + contents = f.read() + + runtime_stage = _runtime_stage(contents) + + assert re.search( + r"echo\s+[\"']?https://packages\.wolfi\.dev/os[\"']?\s*>>\s*/etc/apk/repositories", + runtime_stage, + ), ( + "Runtime stage must append the public Wolfi apk repo " + '(RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories) ' + "so `apk add` works without Chainguard enterprise credentials. " + "See https://github.com/BerriAI/litellm/issues/33518" + ) diff --git a/tests/test_litellm/test_dockerfile_bedrock_realtime_extra.py b/tests/test_litellm/test_dockerfile_bedrock_realtime_extra.py new file mode 100644 index 00000000000..44572aed08e --- /dev/null +++ b/tests/test_litellm/test_dockerfile_bedrock_realtime_extra.py @@ -0,0 +1,56 @@ +""" +Static checks that every proxy Docker image installs the `bedrock-realtime` extra. + +Bedrock Nova Sonic speech-to-speech (`/v1/realtime`) needs `aws-sdk-bedrock-runtime`, +which only ships in the `bedrock-realtime` extra. An image whose `uv sync` stages +omit the extra fails every Nova Sonic realtime session with +"Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime". +""" + +import os +import re +from typing import Final + +import pytest + +REPO_ROOT: Final = os.path.join(os.path.dirname(__file__), "..", "..") + +PROXY_DOCKERFILES: Final = ( + "Dockerfile", + os.path.join("docker", "Dockerfile.non_root"), + os.path.join("docker", "Dockerfile.database"), + os.path.join("gateway", "Dockerfile"), +) + +CONTINUED_LINE_RE: Final = re.compile(r"(?:\\\n|[^\n])+") +UV_SYNC_BOUNDARY_RE: Final = re.compile(r"(?=uv sync)") + + +def _uv_sync_invocations(dockerfile_text: str) -> tuple[str, ...]: + """Return each `uv sync ...` command, split apart when one RUN holds several (if/else branches).""" + return tuple( + part + for line in CONTINUED_LINE_RE.finditer(dockerfile_text) + for part in UV_SYNC_BOUNDARY_RE.split(line.group(0)) + if part.startswith("uv sync") + ) + + +@pytest.mark.parametrize("relative_path", PROXY_DOCKERFILES) +def test_every_uv_sync_installs_bedrock_realtime_extra(relative_path: str): + dockerfile_path: Final = os.path.join(REPO_ROOT, relative_path) + if not os.path.exists(dockerfile_path): + pytest.skip(f"{relative_path} not present in this checkout") + + with open(dockerfile_path, "r", encoding="utf-8") as f: + contents: Final = f.read() + + invocations: Final = _uv_sync_invocations(contents) + assert invocations, f"{relative_path} has no `uv sync` invocation" + + missing: Final = tuple(invocation for invocation in invocations if "--extra bedrock-realtime" not in invocation) + assert not missing, ( + f"{relative_path}: {len(missing)} of {len(invocations)} `uv sync` invocations omit " + "`--extra bedrock-realtime`, so aws-sdk-bedrock-runtime is absent and Bedrock Nova Sonic " + "/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'" + ) diff --git a/tests/test_litellm/test_register_model_custom_pricing.py b/tests/test_litellm/test_register_model_custom_pricing.py index 39f498b4e58..452a15334ef 100644 --- a/tests/test_litellm/test_register_model_custom_pricing.py +++ b/tests/test_litellm/test_register_model_custom_pricing.py @@ -793,3 +793,203 @@ def test_embedding_direct_sdk_custom_pricing_still_registers_shared_key(): finally: litellm.model_cost.pop(model_key, None) _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 + inserted by reference into the merged result. + """ + from litellm.utils import _update_dictionary + + existing_nested = {"hours_utc": "01:00-02:00"} + existing = {"off_peak_pricing": existing_nested} + incoming_nested = {"windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}]} + incoming = {"off_peak_pricing": incoming_nested} + + merged = _update_dictionary(existing, incoming) + + assert merged["off_peak_pricing"] == { + "hours_utc": "01:00-02:00", + "windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}], + } + assert existing_nested == {"hours_utc": "01:00-02:00"} + assert merged["off_peak_pricing"] is not incoming_nested + + fresh = _update_dictionary({}, incoming) + assert fresh["off_peak_pricing"] == incoming_nested + assert fresh["off_peak_pricing"] is not incoming_nested + + +def test_router_deployments_sharing_backend_keep_their_own_off_peak_pricing(): + """Two deployments of the same backend model with different + ``off_peak_pricing`` blocks must each keep their own schedule under their + unique model id, and neither block may leak onto the shared backend keys. + + Before the fix, ``register_model`` inserted the first deployment's block by + reference into the built-in ``gpt-4o-mini`` entry, and the second + deployment's registration merged its keys into that same object, corrupting + the first deployment's schedule and polluting the built-in entry. + """ + from litellm import Router + + active_block = { + "windows": [{"hours_utc": "16:00-19:00", "weekdays": [2]}], + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1e-06, + } + inactive_block = { + "hours_utc": "05:00-06:00", + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1e-06, + } + shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"] + deployment_ids = ["offpeak-alias-dep-1", "offpeak-alias-dep-2"] + original_entries = _snapshot_model_cost_entries(shared_keys) + + router = Router( + model_list=[ + { + "model_name": "offpeak-active-weekday", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key-for-registration", + }, + "model_info": { + "id": deployment_ids[0], + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + "off_peak_pricing": dict(active_block), + }, + }, + { + "model_name": "offpeak-inactive-hours", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key-for-registration", + }, + "model_info": { + "id": deployment_ids[1], + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + "off_peak_pricing": dict(inactive_block), + }, + }, + ] + ) + + try: + registered_first = litellm.model_cost[deployment_ids[0]]["off_peak_pricing"] + registered_second = litellm.model_cost[deployment_ids[1]]["off_peak_pricing"] + assert registered_first == active_block + assert registered_second == inactive_block + for shared_key in shared_keys: + shared_entry = litellm.model_cost.get(shared_key) or {} + assert not shared_entry.get("off_peak_pricing") + finally: + for deployment_id in deployment_ids: + litellm.model_cost.pop(deployment_id, None) + _restore_model_cost_entries(original_entries) + del router + + +def test_router_off_peak_only_deployment_inherits_builtin_base_rates(): + """A deployment that sets only ``off_peak_pricing`` on its model_info must + still be costed from its deployment-scoped entry: the base token rates are + inherited from the backend model's built-in cost map entry, since the + shared backend key deliberately never carries the off-peak block. + """ + from litellm import Router + + block = { + "hours_utc": "00:00-00:00", + "input_cost_per_token": 5e-05, + "output_cost_per_token": 1e-04, + } + shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"] + deployment_id = "offpeak-only-dep-1" + original_entries = _snapshot_model_cost_entries(shared_keys + [deployment_id]) + builtin_info = litellm.get_model_info(model="openai/gpt-4o-mini") + + router = Router( + model_list=[ + { + "model_name": "offpeak-only", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key-for-registration", + }, + "model_info": {"id": deployment_id, "off_peak_pricing": dict(block)}, + } + ] + ) + + try: + entry = litellm.model_cost[deployment_id] + assert entry["off_peak_pricing"] == block + assert entry["input_cost_per_token"] is not None + assert entry["input_cost_per_token"] == builtin_info["input_cost_per_token"] + assert entry["output_cost_per_token"] == builtin_info["output_cost_per_token"] + for shared_key in shared_keys: + shared_entry = litellm.model_cost.get(shared_key) or {} + assert not shared_entry.get("off_peak_pricing") + finally: + _restore_model_cost_entries(original_entries) + del router + + +def test_use_custom_pricing_for_model_sees_off_peak_only_model_info(): + from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model + + block = {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-05} + assert use_custom_pricing_for_model({"metadata": {"model_info": {"off_peak_pricing": block}}}) is True + assert use_custom_pricing_for_model({"metadata": {"model_info": {"off_peak_pricing": None}}}) is False + assert use_custom_pricing_for_model({"metadata": {"model_info": {"id": "some-id"}}}) is False + + +def test_completion_cost_applies_off_peak_only_deployment_pricing(): + """End to end through the cost calculator: with ``custom_pricing`` set and + a ``router_model_id`` whose entry carries only an always-on off-peak block, + the request bills at the block's rates rather than the shared backend rate. + """ + from litellm import Router + from litellm.types.utils import ModelResponse, Usage + + block = { + "hours_utc": "00:00-00:00", + "input_cost_per_token": 5e-05, + "output_cost_per_token": 1e-04, + } + shared_keys = ["gpt-4o-mini", "openai/gpt-4o-mini"] + deployment_id = "offpeak-only-dep-2" + original_entries = _snapshot_model_cost_entries(shared_keys + [deployment_id]) + + router = Router( + model_list=[ + { + "model_name": "offpeak-only", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key-for-registration", + }, + "model_info": {"id": deployment_id, "off_peak_pricing": dict(block)}, + } + ] + ) + + try: + response = ModelResponse( + model="gpt-4o-mini", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + cost = litellm.completion_cost( + completion_response=response, + model="openai/gpt-4o-mini", + custom_llm_provider="openai", + custom_pricing=True, + router_model_id=deployment_id, + ) + assert cost == pytest.approx(100 * 5e-05 + 50 * 1e-04) + finally: + _restore_model_cost_entries(original_entries) + del router diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 44c1cdbff06..84f6344be35 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -11595,3 +11595,138 @@ class TestTierParamsTheTargetAccepts: accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}, {}) assert accepted == {"reasoning_effort": "max"} + + +class TestPreRoutingTierDrivesFallbacks: + """#38832: a complexity/auto router picks a tier behind the router name, but fallback + lookup stayed on the router name, so the tier's configured chain never ran and a + provider failure on the tier's first hop was returned to the client.""" + + class _TierRouter(litellm.Router): + async def async_pre_routing_hook( + self, model, request_kwargs, messages=None, input=None, specific_deployment=False + ): + from litellm.types.router import PreRoutingHookResponse + + if model == "smart-router": + return PreRoutingHookResponse(model="tier1", messages=messages) + return None + + @classmethod + def _router(cls, fallbacks) -> "litellm.Router": + return cls._TierRouter( + model_list=[ + { + "model_name": "smart-router", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + }, + { + "model_name": "tier1", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-x", + "mock_response": "litellm.RateLimitError", + }, + }, + { + "model_name": "backup-a", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-x", + "mock_response": "from backup-a", + }, + }, + { + "model_name": "backup-b", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-x", + "mock_response": "from backup-b", + }, + }, + { + "model_name": "failing-backup", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-x", + "mock_response": "litellm.RateLimitError", + }, + }, + { + "model_name": "plain", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-x", + "mock_response": "litellm.RateLimitError", + }, + }, + ], + fallbacks=fallbacks, + num_retries=0, + ) + + @pytest.mark.asyncio + async def test_the_selected_tier_fallback_chain_runs(self): + router = self._router([{"tier1": ["backup-a"]}]) + + response = await router.acompletion( + model="smart-router", messages=[{"role": "user", "content": "hi"}] + ) + + assert response.choices[0].message.content == "from backup-a" + + @pytest.mark.asyncio + async def test_a_chain_keyed_on_the_router_name_is_not_used(self): + """The router name has no chain of its own, so nothing should rescue this call.""" + router = self._router([{"tier2": ["backup-a"]}]) + + with pytest.raises(litellm.RateLimitError): + await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + + @pytest.mark.asyncio + async def test_a_chain_keyed_on_the_router_name_rescues_when_no_tier_chain_exists(self): + """The documented contract: configs keyed on the requested name keep working behind auto-routers.""" + router = self._router([{"smart-router": ["backup-a"]}]) + + response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + + assert response.choices[0].message.content == "from backup-a" + + @pytest.mark.asyncio + async def test_the_tier_chain_wins_over_the_router_name_chain(self): + router = self._router([{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}]) + + response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + + assert response.choices[0].message.content == "from backup-a" + + @pytest.mark.asyncio + async def test_a_request_without_a_pre_routing_hook_still_uses_its_own_group(self): + router = self._router([{"tier1": ["backup-a"]}]) + + response = await router.acompletion( + model="tier1", messages=[{"role": "user", "content": "hi"}] + ) + + assert response.choices[0].message.content == "from backup-a" + + @pytest.mark.asyncio + async def test_a_caller_cannot_pick_the_chain_by_sending_the_selection(self): + """The metadata bucket carries caller-supplied keys, so only the hook may set the tier.""" + router = self._router([{"tier1": ["backup-a"]}]) + + with pytest.raises(litellm.RateLimitError): + await router.acompletion( + model="plain", + messages=[{"role": "user", "content": "hi"}], + metadata={"pre_routing_selected_model": "tier1"}, + ) + + @pytest.mark.asyncio + async def test_each_fallback_hop_resolves_its_own_chain(self): + """The second hop must key off the group it is running, not the tier that failed.""" + router = self._router([{"tier1": ["failing-backup"]}, {"failing-backup": ["backup-b"]}]) + + response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + + assert response.choices[0].message.content == "from backup-b" diff --git a/tests/test_litellm/test_router_model_cost_isolation.py b/tests/test_litellm/test_router_model_cost_isolation.py index b580b03574e..30b265905f3 100644 --- a/tests/test_litellm/test_router_model_cost_isolation.py +++ b/tests/test_litellm/test_router_model_cost_isolation.py @@ -538,6 +538,157 @@ def test_inherit_builtin_cache_pricing_noop_for_unknown_backend(): assert model_info == {"input_cost_per_token": 0.000003} +def test_inherit_builtin_base_rates_for_off_peak_fills_missing_rates(): + """Direct unit test of the helper: an entry carrying only an + off_peak_pricing block inherits the backend model's built-in base token + rates, so cost lookup via the deployment id can bill standard rates + outside the windows. + """ + backend_model = "gpt-4o-mini" + builtin_info = litellm.get_model_info(model=backend_model, custom_llm_provider="openai") + off_peak_block = { + "hours_utc": "00:00-00:00", + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1e-06, + } + model_info = {"off_peak_pricing": off_peak_block} + + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model=backend_model, + custom_llm_provider="openai", + ) + + assert model_info["input_cost_per_token"] == builtin_info["input_cost_per_token"] + assert model_info["output_cost_per_token"] == builtin_info["output_cost_per_token"] + assert model_info["off_peak_pricing"] == off_peak_block + + +def test_inherit_builtin_base_rates_for_off_peak_carries_threshold_rates(): + """A backend with above-threshold pricing hands the whole rate structure to + the deployment entry, so peak-hour billing of large prompts through that + entry matches the shared backend entry instead of flattening to the base + rate. + """ + backend_model = "gemini/gemini-2.5-pro" + builtin_info = litellm.get_model_info(model=backend_model) + assert builtin_info["input_cost_per_token_above_200k_tokens"] is not None + + model_info = { + "off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}, + } + + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model=backend_model, + custom_llm_provider="gemini", + ) + + assert model_info["input_cost_per_token"] == builtin_info["input_cost_per_token"] + assert ( + model_info["input_cost_per_token_above_200k_tokens"] + == builtin_info["input_cost_per_token_above_200k_tokens"] + ) + assert ( + model_info["output_cost_per_token_above_200k_tokens"] + == builtin_info["output_cost_per_token_above_200k_tokens"] + ) + + +def test_inherit_builtin_base_rates_for_off_peak_carries_companion_billing_fields(): + """Billing rules that are not literal cost rates, like the web search + billing unit, must ride along, or grounding and regional uplifts would + bill differently through the deployment entry than through the shared + backend entry. + """ + backend_model = "gemini-3-pro-image" + raw_entry = litellm.model_cost[backend_model] + assert raw_entry.get("web_search_billing_unit") is not None + + model_info = { + "off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}, + } + + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model=backend_model, + custom_llm_provider=None, + ) + + assert model_info["web_search_billing_unit"] == raw_entry["web_search_billing_unit"] + assert model_info["input_cost_per_token"] == raw_entry["input_cost_per_token"] + + +def test_inherit_builtin_base_rates_for_off_peak_tiered_only_backend_stores_no_zero(): + """A tiered-only backend has no flat token rates; get_model_info synthesizes + zeros for them, and storing those would mark the deployment explicitly + priced free. The tier table itself must carry over as an isolated copy so + mutating the deployment entry never touches the shared cost map. + """ + backend_model = "dashscope/qwen-flash" + raw_tiers = litellm.model_cost[backend_model]["tiered_pricing"] + + model_info = { + "off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}, + } + + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model=backend_model, + custom_llm_provider="dashscope", + ) + + assert model_info.get("input_cost_per_token") != 0 + assert model_info.get("output_cost_per_token") != 0 + assert model_info["tiered_pricing"] == raw_tiers + assert model_info["tiered_pricing"] is not raw_tiers + assert model_info["tiered_pricing"][0] is not raw_tiers[0] + + original_first_tier = copy.deepcopy(raw_tiers[0]) + model_info["tiered_pricing"][0]["input_cost_per_token"] = 123.0 + assert raw_tiers[0] == original_first_tier + + +def test_inherit_builtin_base_rates_for_off_peak_leaves_explicit_rates_alone(): + """An entry that sets its own base rate beside the block already counts as + a full custom pricing entry; the helper must not mix builtin rates into it. + """ + model_info = { + "off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}, + "input_cost_per_token": 3e-06, + } + + Router._inherit_builtin_base_rates_for_off_peak( + model_info=model_info, + backend_model="gpt-4o-mini", + custom_llm_provider="openai", + ) + + assert model_info["input_cost_per_token"] == 3e-06 + assert "output_cost_per_token" not in model_info + + +def test_inherit_builtin_base_rates_for_off_peak_noop_without_block_or_backend(): + """Nothing happens without an off_peak_pricing block, and an unmapped + backend model leaves the entry unchanged rather than raising. + """ + plain_info = {"id": "dep-1"} + Router._inherit_builtin_base_rates_for_off_peak( + model_info=plain_info, + backend_model="gpt-4o-mini", + custom_llm_provider="openai", + ) + assert plain_info == {"id": "dep-1"} + + off_peak_info = {"off_peak_pricing": {"hours_utc": "00:00-00:00", "input_cost_per_token": 5e-07}} + Router._inherit_builtin_base_rates_for_off_peak( + model_info=off_peak_info, + backend_model="this-backend-model-does-not-exist-x9y8z7", + custom_llm_provider=None, + ) + assert "input_cost_per_token" not in off_peak_info + + def test_custom_pricing_field_denylist_covers_all_builtin_pricing_fields(): """The shared-backend-key stripping in Router relies on CustomPricingLiteLLMParams enumerating every per-deployment pricing field. @@ -2130,3 +2281,83 @@ def test_every_declaring_deployment_is_named(caplog): assert "azure-ptu-east" in warnings[0] assert "azure-ptu-west" in warnings[0] assert "plain-gpt-4o" not in warnings[0] + + +def _simulate_price_data_reload_with_provider_sets(monkeypatch, fetched_catalog): + """Like `_simulate_price_data_reload`, plus the provider model-set refresh the proxy's + `_swap_in_model_cost_map` does before replaying, so bare names in the new catalog resolve.""" + monkeypatch.setattr(litellm, "model_cost", fetched_catalog) + _invalidate_model_cost_lowercase_map() + litellm.add_known_models(model_cost_map=fetched_catalog) + reapply_runtime_model_cost_registrations() + + +def test_a_config_deployment_dropped_by_a_stale_cost_map_comes_back_on_reload(monkeypatch): + """ + Booting on the bundled backup, a bare model that only the remote catalog knows + cannot be provider-resolved, so the proxy router (ignore_invalid_deployments) drops + it. Once a reload brings in a catalog that knows the model, the deployment must be + served again with its access groups, and exactly once however many reloads follow. + """ + backend = "lit-5766-only-in-remote-catalog" + try: + router = Router( + model_list=[ + { + "model_name": "new-model", + "litellm_params": {"model": backend, "api_key": "k"}, + "model_info": {"id": "new-id", "access_groups": ["team-models"]}, + }, + { + "model_name": "control-model", + "litellm_params": {"model": "hosted_vllm/control-backend", "api_key": "k"}, + "model_info": {"id": "control-id", "access_groups": ["team-models"]}, + }, + ], + ignore_invalid_deployments=True, + ) + assert router.get_model_names() == ["control-model"] + assert router.get_model_access_groups(model_name="new-model") == {} + + fresh_catalog = {**litellm.model_cost, backend: {"litellm_provider": "openai", "mode": "chat"}} + _simulate_price_data_reload_with_provider_sets(monkeypatch, fresh_catalog) + _simulate_price_data_reload_with_provider_sets(monkeypatch, fresh_catalog) + + assert sorted(router.get_model_names()) == ["control-model", "new-model"] + assert router.get_model_access_groups(model_name="new-model") == {"team-models": ["new-model"]} + assert [d["model_info"]["id"] for d in router.model_list] == ["control-id", "new-id"] + assert "new-id" in litellm.model_cost + finally: + litellm.open_ai_chat_completion_models.discard(backend) + litellm.models_by_provider["openai"].discard(backend) + + +def test_a_config_deployment_dropped_for_a_permanent_reason_is_not_retried_on_reload(monkeypatch): + """ + Only provider-resolution drops can be healed by a fresh catalog. A deployment that + fails after its provider resolved (here a pass-through vertex entry with no project) + has already touched router state, so replaying it on every reload would leak into + `deployment_names` each time. + """ + router = Router( + model_list=[ + { + "model_name": "vertex-passthrough", + "litellm_params": {"model": "vertex_ai/gemini-2.5-flash", "use_in_pass_through": True}, + "model_info": {"id": "vertex-id"}, + }, + { + "model_name": "control-model", + "litellm_params": {"model": "hosted_vllm/control-backend", "api_key": "k"}, + "model_info": {"id": "control-id"}, + }, + ], + ignore_invalid_deployments=True, + ) + assert router.get_model_names() == ["control-model"] + names_after_boot = list(router.deployment_names) + + _simulate_price_data_reload_with_provider_sets(monkeypatch, dict(litellm.model_cost)) + + assert router.get_model_names() == ["control-model"] + assert router.deployment_names == names_after_boot diff --git a/tests/test_litellm/test_router_order_fallback.py b/tests/test_litellm/test_router_order_fallback.py index 7743cb005d0..fde870e5abe 100644 --- a/tests/test_litellm/test_router_order_fallback.py +++ b/tests/test_litellm/test_router_order_fallback.py @@ -6,12 +6,19 @@ should be tried first, and higher order deployments should be used as fallbacks when lower order deployments fail. """ -from typing import Optional +import json +from typing import Final, Optional +import httpx import pytest +from openai import AsyncOpenAI +import litellm from litellm import Router -from litellm.utils import _get_order_filtered_deployments +from litellm.integrations.custom_logger import CustomLogger +from litellm.router_utils.prompt_caching_cache import PromptCachingCache +from litellm.types.router import RouterRateLimitError +from litellm.utils import _get_deployment_order, _get_order_filtered_deployments # --------------------------------------------------------------------------- # Unit tests for _get_order_filtered_deployments @@ -49,13 +56,22 @@ class TestGetOrderFilteredDeployments: assert len(result) == 1 assert result[0]["model_info"]["id"] == "b" - def test_target_order_no_match_returns_all(self): + def test_target_order_no_match_returns_empty(self): deps = [ self._make_deployment(1, "a"), self._make_deployment(2, "b"), ] result = _get_order_filtered_deployments(deps, target_order=99) - assert len(result) == 2 + assert result == [] + + def test_target_order_no_match_does_not_reselect_lower_order(self): + deps = [ + self._make_deployment(1, "a"), + self._make_deployment(2, "b"), + ] + remaining_after_pre_call = [deps[0]] + result = _get_order_filtered_deployments(remaining_after_pre_call, target_order=2) + assert result == [] def test_no_order_set_returns_all(self): deps = [ @@ -406,35 +422,239 @@ async def test_router_order_fallback_with_hidden_model_group_alias(): assert response._hidden_params["model_id"] == "2" +@pytest.mark.asyncio +async def test_router_order_fallback_does_not_reselect_order_1_when_order_2_is_filtered_out(): + class _DropOrder2(CustomLogger): + async def async_filter_deployments( + self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None + ): + return [d for d in healthy_deployments if _get_deployment_order(d) != 2] + + drop_order_2: Final = _DropOrder2() + router = Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "key", + "mock_response": "litellm.RateLimitError", + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "key", + "mock_response": "success from order 2", + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + num_retries=0, + ) + litellm.callbacks.append(drop_order_2) + try: + with pytest.raises(RouterRateLimitError, match="No deployments available") as exc_info: + await router.acompletion( + model="test-model", + messages=[{"role": "user", "content": "hi"}], + ) + assert "success from order 2" not in str(exc_info.value) + finally: + litellm.callbacks.remove(drop_order_2) + + +@pytest.mark.asyncio +async def test_router_order_fallback_ignores_prompt_cache_pin_on_target_order(): + messages = [{"role": "user", "content": "word " * 5000}] + router = Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "bad", + "mock_response": Exception("azure peak load"), + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "good", + "mock_response": "success from order 2", + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + num_retries=0, + optional_pre_call_checks=["prompt_caching"], + ) + await PromptCachingCache(cache=router.cache).async_add_model_id( + model_id="1", + messages=messages, + tools=None, + ) + response = await router.acompletion(model="test-model", messages=messages) + assert response._hidden_params["model_id"] == "2" + + +@pytest.mark.asyncio +async def test_router_order_fallback_retries_keep_target_order(): + seen_target_orders: Final = [] + + class _RecordTargetOrder(CustomLogger): + async def async_filter_deployments( + self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None + ): + seen_target_orders.append((request_kwargs or {}).get("_target_order")) + return healthy_deployments + + recorder: Final = _RecordTargetOrder() + router = Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "bad", + "mock_response": Exception("fail order 1"), + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "test-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "bad", + "mock_response": Exception("fail order 2"), + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + num_retries=1, + ) + litellm.callbacks.append(recorder) + try: + with pytest.raises(Exception, match="fail order 2"): + await router.acompletion( + model="test-model", + messages=[{"role": "user", "content": "hi"}], + ) + finally: + litellm.callbacks.remove(recorder) + assert seen_target_orders.count(2) >= 2 + + +@pytest.mark.asyncio +async def test_generic_api_call_strips_target_order_from_provider_kwargs(): + captured: Final = {} + + async def _fake_provider(**provider_kwargs): + captured.update(provider_kwargs) + return "ok" + + router = Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": {"model": "gpt-4o", "api_key": "key", "order": 2}, + "model_info": {"id": "2"}, + }, + ], + ) + response = await router._ageneric_api_call_with_fallbacks_helper( + model="test-model", + original_generic_function=_fake_provider, + _target_order=2, + messages=[{"role": "user", "content": "hi"}], + ) + assert response == "ok" + assert captured["model"] == "gpt-4o" + assert "_target_order" not in captured + + +@pytest.mark.asyncio +async def test_text_completion_order_fallback_hop_does_not_send_target_order_upstream(): + upstream_bodies: Final[list[dict]] = [] + + def _upstream(request: httpx.Request) -> httpx.Response: + upstream_bodies.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "id": "cmpl-1", + "object": "text_completion", + "created": 0, + "model": "gpt-3.5-turbo-instruct", + "choices": [{"text": "ok from order 2", "index": 0, "logprobs": None, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + + upstream_client: Final = AsyncOpenAI( + api_key="key", + base_url="http://upstream.test", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_upstream)), + ) + router = Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": { + "model": "text-completion-openai/gpt-3.5-turbo-instruct", + "api_key": "key", + "mock_response": Exception("fail order 1"), + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "test-model", + "litellm_params": { + "model": "text-completion-openai/gpt-3.5-turbo-instruct", + "api_key": "key", + "api_base": "http://upstream.test", + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + num_retries=0, + ) + try: + response = await router.atext_completion(model="test-model", prompt="hi", client=upstream_client) + finally: + await upstream_client.close() + + assert response._hidden_params["model_id"] == "2" + assert upstream_bodies + assert all("_target_order" not in body for body in upstream_bodies) + + def test_check_non_standard_fallback_format(): from litellm.router_utils.fallback_event_handlers import ( _check_non_standard_fallback_format, ) # Standard formats - assert ( - _check_non_standard_fallback_format([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) - == False - ) + assert _check_non_standard_fallback_format([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) == False assert _check_non_standard_fallback_format([{"model": ["qwen-backup"]}]) == False - assert ( - _check_non_standard_fallback_format( - [{"model": ["qwen-backup"], "region": ["us-east-1"]}] - ) - == False - ) + assert _check_non_standard_fallback_format([{"model": ["qwen-backup"], "region": ["us-east-1"]}]) == False # Non-standard formats assert _check_non_standard_fallback_format([{"model": "qwen-backup"}]) == True assert ( - _check_non_standard_fallback_format( - [{"model": "qwen-backup", "messages": [{"role": "user", "content": "hi"}]}] - ) - == True - ) - assert ( - _check_non_standard_fallback_format( - [{"model": ["qwen-backup"], "api_key": "some-key"}] - ) + _check_non_standard_fallback_format([{"model": "qwen-backup", "messages": [{"role": "user", "content": "hi"}]}]) == True ) + assert _check_non_standard_fallback_format([{"model": ["qwen-backup"], "api_key": "some-key"}]) == True diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 627f341fe2f..521e91daded 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1005,6 +1005,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "gemini_native_audio": {"type": "boolean"}, "gemini_audio_only_live": {"type": "boolean"}, "supports_embedding_image_input": {"type": "boolean"}, + "supports_forced_tool_use": {"type": "boolean"}, "supports_function_calling": {"type": "boolean"}, "supports_image_input": {"type": "boolean"}, "supports_nova_canvas_image_edit": {"type": "boolean"}, diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 3d2e97d55a5..52cb9628252 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22367 + "limit": 22364 }, "LIT002": { "limit": 26777 diff --git a/ui/litellm-dashboard/public/assets/logos/alice.svg b/ui/litellm-dashboard/public/assets/logos/alice.svg new file mode 100644 index 00000000000..f18f887b98c --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/alice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx index 06099a9fc22..4d18ec2ef5f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx @@ -74,6 +74,42 @@ describe("AgentsTable", () => { expect(onDeleteClick).toHaveBeenCalledWith("agent-9", "Doomed Agent"); }); + it("filters agents by name or by agent card description", async () => { + const user = userEvent.setup(); + render( + , + ); + + const search = screen.getByPlaceholderText("Search agent names or descriptions..."); + await user.type(search, "billing"); + expect(screen.getByText("Billing Router")).toBeInTheDocument(); + expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); + + await user.clear(search); + await user.type(search, "support tickets"); + expect(screen.getByText("Second Agent")).toBeInTheDocument(); + expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); + }); + + it("shows the no-match empty state when the search matches nothing", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz"); + expect(screen.queryByText("Test Agent")).not.toBeInTheDocument(); + expect(screen.getByText("No matching agents")).toBeInTheDocument(); + }); + it("hides the actions column entirely for non-admins", () => { const agent = makeAgent({ agent_id: "agent-2" }); render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx index 67c7ed74180..35ed6b66425 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx @@ -1,13 +1,15 @@ "use client"; import { SortingState } from "@tanstack/react-table"; -import { Bot, CircleCheck } from "lucide-react"; +import { Bot, CircleCheck, Search as SearchIcon, X } from "lucide-react"; import React, { useMemo, useState } from "react"; import { Agent } from "@/components/agents/types"; import { DataTable } from "@/components/shared/DataTable"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { filterBySearchTerm } from "@/utils/searchUtils"; import { getAgentsTableColumns } from "./AgentsTableColumns"; @@ -24,14 +26,18 @@ interface AgentsTableProps { const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; -function EmptyState() { +function EmptyState({ isFiltered }: { isFiltered: boolean }) { return (
-
No agents yet
-
Add an agent to make it available in your organization.
+
{isFiltered ? "No matching agents" : "No agents yet"}
+
+ {isFiltered + ? "Adjust the search to see more agents." + : "Add an agent to make it available in your organization."} +
); } @@ -47,6 +53,11 @@ const AgentsTable: React.FC = ({ onDeleteClick, }) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [searchTerm, setSearchTerm] = useState(""); + const filteredAgents = useMemo( + () => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]), + [agents, searchTerm], + ); const columns = useMemo( () => getAgentsTableColumns({ isAdmin, onAgentClick, onDeleteClick }), @@ -55,7 +66,7 @@ const AgentsTable: React.FC = ({ return ( agent.agent_id || String(index)} sortingMode="client" @@ -63,10 +74,27 @@ const AgentsTable: React.FC = ({ onSortingChange={setSorting} isLoading={isLoading} loadingMessage="Loading agents…" - noDataMessage={} + noDataMessage={ 0} />} size="compact" toolbar={() => ( -
+
+ + + + + setSearchTerm(e.target.value)} + /> + {searchTerm && ( + + setSearchTerm("")}> + + + + )} + ({ createAgentCall: vi.fn(), @@ -309,8 +310,7 @@ describe("AddAgentForm submit payload", () => { await user.type(await screen.findByLabelText("Allowed Models"), "gpt-4o,"); await user.keyboard("{Escape}"); - await user.click(screen.getByLabelText("Allowed Agents (Sub-Agents)")); - await user.click(await screen.findByTitle("Sub Agent One")); + await chooseSelectOption(user, screen.getByLabelText("Allowed Agents (Sub-Agents)"), "Sub Agent One"); await user.keyboard("{Escape}"); await user.click(screen.getByText(/Configure which models, agents, and MCP tools/)); await user.click(screen.getByRole("button", { name: /^Next/ })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx index bc920e55abd..17a297d203d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.integration.test.tsx @@ -4,6 +4,7 @@ import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import BudgetModal from "./budget_modal"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() })); @@ -63,8 +64,7 @@ describe("BudgetModal", () => { await openOptionalSettings(user); fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.567" } }); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("monthly")); + await chooseSelectOption(user, screen.getByRole("combobox"), "monthly"); await create(user); @@ -80,8 +80,7 @@ describe("BudgetModal", () => { await openOptionalSettings(user); fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.567" } }); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("monthly")); + await chooseSelectOption(user, screen.getByRole("combobox"), "monthly"); await user.click(screen.getByText("Optional Settings")); await waitFor(() => expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument()); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx index 3fa96b54f1d..fe0ecfc10dd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.integration.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { components } from "@/lib/http/schema"; import EditBudgetModal from "./edit_budget_modal"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; const { updateMock } = vi.hoisted(() => ({ updateMock: vi.fn() })); @@ -73,8 +74,7 @@ describe("EditBudgetModal", () => { await user.clear(screen.getByLabelText("Max Budget (USD)")); fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "42.567" } }); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("monthly")); + await chooseSelectOption(user, screen.getByRole("combobox"), "monthly"); await save(user); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx index 76287e6e724..563c684237a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/../tests/test-utils"; import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector"; import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields"; +import { chooseSelectOption } from "../../../../../../tests/test-utils"; describe("CoordinationRedisTypeSelector", () => { it("labels the control and shows the current selection", () => { @@ -36,8 +37,7 @@ describe("CoordinationRedisTypeSelector", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Cluster")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Cluster"); expect(onTypeChange).toHaveBeenCalledTimes(1); expect(onTypeChange.mock.calls[0][0]).toBe("cluster"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx index 64e03985f57..a1de608d0bb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx @@ -97,6 +97,7 @@ import { useStopShadowEval, type ShadowEvalJob, } from "./useShadowEval"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; const job = (overrides: Partial = {}): ShadowEvalJob => ({ job_id: "job-1", @@ -437,8 +438,7 @@ describe("ShadowEvalSection", () => { await user.click(within(keyList).getByText("prod-alpha")); await user.click(keyInput); await user.click(within(keyList).getByText("staging-beta")); - await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers")); - await user.click(await screen.findByText("gpt-auto")); + await chooseSelectOption(user, screen.getByPlaceholderText("Select up to 4 auto-routers"), "gpt-auto"); expect(screen.getByText("Start shadow eval")).toBeDisabled(); @@ -470,8 +470,7 @@ describe("ShadowEvalSection", () => { await user.click(screen.getByPlaceholderText("Search teams by alias")); const teamList = await screen.findByTestId("paginated-multi-select-list"); await user.click(within(teamList).getByText("engineering")); - await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers")); - await user.click(await screen.findByText("gpt-auto")); + await chooseSelectOption(user, screen.getByPlaceholderText("Select up to 4 auto-routers"), "gpt-auto"); await user.click(screen.getByPlaceholderText("Select a judge model")); await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ })); await user.click(screen.getByText("Start shadow eval")); @@ -502,8 +501,7 @@ describe("ShadowEvalSection", () => { await user.click(screen.getByPlaceholderText("Search keys by alias")); const keyList = await screen.findByTestId("paginated-multi-select-list"); await user.click(within(keyList).getByText("prod-alpha")); - await user.click(screen.getByPlaceholderText("Select up to 4 auto-routers")); - await user.click(await screen.findByText("gpt-auto")); + await chooseSelectOption(user, screen.getByPlaceholderText("Select up to 4 auto-routers"), "gpt-auto"); await user.click(screen.getByPlaceholderText("Select a judge model")); await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts index 03cfeed42ff..7785a8e44ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts @@ -312,4 +312,10 @@ export const GUARDRAIL_PRESETS: Record = { mode: "pre_call", defaultOn: false, }, + alice: { + provider: "Alice", + guardrailNameSuggestion: "Alice", + mode: "pre_call", + defaultOn: false, + }, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts index 13909e48185..1e486639840 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts @@ -27,6 +27,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record = { deepkeep: "deepkeep.svg", repelloai: "repelloai.png", straiker: "straiker.svg", + alice: "alice.svg", }; describe("guardrail_garden_data logos", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts index 744af89a357..931b3a111d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts @@ -464,6 +464,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [ tags: ["Agentic", "Prompt Injection", "Tool Misuse", "MCP", "Skills"], providerKey: "Straiker", }, + { + id: "alice", + name: "Alice", + description: + "Policy-based guardrails for prompts and model responses, evaluated per application so one proxy can enforce a different policy set per team or product.", + category: "partner", + logo: guardrailLogoMap["Alice"], + tags: ["Content Moderation", "Prompt Injection", "PII", "Policy"], + providerKey: "Alice", + }, ]; export const ALL_CARDS = [...LITELLM_CONTENT_FILTER_CARDS, ...PARTNER_GUARDRAIL_CARDS]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx index 0a0f70b6bc2..dda56a06ab1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/cva.config"; import AddGuardrailForm from "./add_guardrail_form"; import { Logo } from "@/components/molecules/logo/Logo"; import { GUARDRAIL_PRESETS } from "./guardrail_garden_configs"; @@ -40,7 +41,7 @@ const GuardrailDetailView: React.FC = ({ card, onBack, const tabs = [{ key: "overview", label: "Overview" }, ...(card.eval ? [{ key: "eval", label: "Eval Results" }] : [])]; return ( -
+
{/* Back link */}
= ({ card, onBack,
{/* ── Header block (Vertex-style) ── */} -
+
-

{card.name}

+

{card.name}

-

{card.description}

+

{card.description}

{/* Action buttons — outlined style like Vertex */}
@@ -66,21 +67,18 @@ const GuardrailDetailView: React.FC = ({ card, onBack,
{/* ── Tab bar ──────────────────────────────────── */} -
-
+
+
{tabs.map((tab) => (
setActiveTab(tab.key)} - style={{ - padding: "12px 20px", - fontSize: 14, - color: activeTab === tab.key ? "#1a73e8" : "#5f6368", - borderBottom: activeTab === tab.key ? "3px solid #1a73e8" : "3px solid transparent", - cursor: "pointer", - fontWeight: activeTab === tab.key ? 500 : 400, - marginBottom: -1, - }} + className={cn( + "-mb-px cursor-pointer border-b-[3px] px-5 py-3 text-sm", + activeTab === tab.key + ? "border-info font-medium text-info" + : "border-transparent font-normal text-muted-foreground", + )} > {tab.label}
@@ -90,31 +88,27 @@ const GuardrailDetailView: React.FC = ({ card, onBack, {/* ── Tab content ──────────────────────────────── */} {activeTab === "overview" && ( -
+
{/* Left column — overview + details table */} -
-

Overview

-

{card.description}

+
+

Overview

+

{card.description}

-

Guardrail Details

-

Details are as follows

+

Guardrail Details

+

Details are as follows

- +
- - - + + + {detailRows.map((row, i) => ( - - - + + + ))} @@ -122,37 +116,30 @@ const GuardrailDetailView: React.FC = ({ card, onBack, {/* Right column — metadata sidebar like Vertex */} -
+
{/* Guardrail ID */} -
-
Guardrail ID
-
litellm/{card.id}
+
+
Guardrail ID
+
litellm/{card.id}
{/* Type */} -
-
Type
-
+
+
Type
+
{card.category === "litellm" ? "Content Filter" : "Partner"}
{/* Tags — pill style like Vertex */} {card.tags.length > 0 && ( -
-
Tags
-
+
+
Tags
+
{card.tags.map((tag) => ( {tag} @@ -166,19 +153,19 @@ const GuardrailDetailView: React.FC = ({ card, onBack, {activeTab === "eval" && (
-

Eval Results

-
- Property - - {card.name} -
Property{card.name}
{row.property}{row.value}
{row.property}{row.value}
+

Eval Results

+
- - - + + + {evalRows.map((row, i) => ( - - - + + + ))} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index 83038b8e0e7..c1f2ddcf51c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -1,5 +1,6 @@ import aimSecurityLogo from "../../../../../public/assets/logos/aim_security.jpeg"; import aktoLogo from "../../../../../public/assets/logos/akto.svg"; +import aliceLogo from "../../../../../public/assets/logos/alice.svg"; import aporiaLogo from "../../../../../public/assets/logos/aporia.png"; import bedrockLogo from "../../../../../public/assets/logos/bedrock.svg"; import catoNetworksLogo from "../../../../../public/assets/logos/cato_networks.svg"; @@ -83,6 +84,7 @@ export const guardrail_provider_map: Record = { Deepkeep: "deepkeep", QostodianNexus: "qostodian_nexus", Repelloai: "repelloai", + Alice: "alice", }; // Function to populate provider map from API response - updates the original map @@ -204,6 +206,7 @@ export const guardrailLogoMap = { "Qostodian Nexus": qohashLogo.src, "RepelloAI Argus": repelloAiLogo.src, Straiker: straikerLogo.src, + Alice: aliceLogo.src, } satisfies Record; export const getGuardrailLogo = (displayName: string): string | undefined => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx index 26e66c8338c..81c55e7982a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { ToolTestPanel } from "./ToolTestPanel"; import { InputSchema, MCPTool } from "@/components/mcp_tools/types"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; const buildTool = (schema: InputSchema | string): MCPTool => ({ name: "demo-tool", @@ -229,8 +230,7 @@ describe("ToolTestPanel argument payload", () => { const onSubmit = await submitPanel( { type: "object", properties: { active: { type: "boolean", default: false } } }, async (user) => { - await user.click(screen.getByLabelText("active")); - await user.click(await screen.findByText("True")); + await chooseSelectOption(user, screen.getByLabelText("active"), "True"); }, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx index 8c683f230e0..f460b77c2c7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx @@ -140,6 +140,7 @@ const renderPanel = (canModify = true) => accessToken="token" userRole="Admin" userID="u-admin" + isViewOnly={false} teams={null} createScope={canModify ? "unscoped-ok" : "forbidden"} />, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx index db5120cebce..5b53217f9c1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx @@ -21,12 +21,20 @@ interface AutoRoutersPanelProps { accessToken: string; userRole: string; userID: string | null; + isViewOnly: boolean; teams: Team[] | null; /** Owned by the page, which knows how this caller must scope what they create. */ createScope: ModelWriteScope; } -export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) { +export function AutoRoutersPanel({ + accessToken, + userRole, + userID, + isViewOnly, + teams, + createScope, +}: AutoRoutersPanelProps) { const canCreate = createScope !== "forbidden"; const { data: deployments, isLoading } = useAutoRouters(); const invalidateAutoRouters = useInvalidateAutoRouters(); @@ -39,8 +47,8 @@ export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createS const [isDeleting, setIsDeleting] = useState(false); const routers = useMemo( - () => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams), - [deployments, userRole, userID, teams], + () => toAutoRouterRows(deployments ?? [], { userRole, userID, isViewOnly }, teams), + [deployments, userRole, userID, isViewOnly, teams], ); const handleCreated = () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts index 9944653b638..23585f6c110 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts @@ -5,8 +5,9 @@ import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows"; // Existing cases assert resource classification, so they run as a proxy admin: the actor // gate is then a pass-through and canEdit/canDelete still reflect the row itself. -const ADMIN = { userRole: "Admin", userID: "u-admin" }; -const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" }; +const ADMIN = { userRole: "Admin", userID: "u-admin", isViewOnly: false }; +const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin", isViewOnly: false }; +const VIEW_ONLY_ADMIN = { userRole: "Admin", userID: "u-viewer", isViewOnly: true }; const complexityDeployment = { model_name: "tri-tier-router", @@ -224,7 +225,7 @@ describe("autoRouterRows actor gating", () => { { team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] }, ] as never; - const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) => + const rowIn = (actor: { userRole: string; userID: string; isViewOnly: boolean }, teamId: string | null) => toAutoRouterRow( { ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } }, 0, @@ -259,4 +260,12 @@ describe("autoRouterRows actor gating", () => { expect(row.canEdit).toBe(true); expect(row.canDelete).toBe(true); }); + + // A proxy_admin_viewer session reads "Admin" through the masquerade, but PATCH and + // DELETE both 403 it, so its rows must not offer the affordances. + it("hides write affordances from a view-only admin session", () => { + const row = rowIn(VIEW_ONLY_ADMIN, null); + expect(row.canEdit).toBe(false); + expect(row.canDelete).toBe(false); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index a504d75bb63..84a05113177 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -38,8 +38,17 @@ vi.mock("./useModelDashboardData", () => ({ useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }), })); -const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false }; -const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false }; +const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false, isViewOnly: false }; +const NON_ADMIN = { + accessToken: "at", + token: "t", + userRole: "Internal User", + userId: "u1", + premiumUser: false, + isViewOnly: false, +}; +// A proxy_admin_viewer session: effectiveSessionRole masquerades the role as "Admin". +const VIEW_ONLY_ADMIN = { ...ADMIN, isViewOnly: true }; const renderPage = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); @@ -99,6 +108,22 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); }); + // POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one. + it("hides the Add Model tab for a view-only admin session", () => { + mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); + renderPage(); + expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); + }); + + // Read parity: the Auto-Routers list stays reachable for a view-only admin; only the + // create affordance inside it is withheld, which AutoRoutersTabPanel decides. + it("keeps the Auto-Routers tab for a view-only admin session", () => { + mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); + renderPage(); + expect(screen.getByRole("tab", { name: /Auto-Routers/ })).toBeInTheDocument(); + }); + // Auto-routers are excluded from the All Models table, so this tab is their home: the only // place in the product to list, create, edit or delete one. describe("Auto-Routers tab", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 9ae7dc12f81..34c9d87004e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -80,7 +80,7 @@ const renderPanel = (key: string) => { }; export default function ModelsAndEndpointsPage() { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized(); const { data: teams } = useTeams(); const { data: uiSettings } = useUISettings(); const queryClient = useQueryClient(); @@ -92,7 +92,7 @@ export default function ModelsAndEndpointsPage() { const isInternalUser = userRole && internalUserRoles.includes(userRole); const canCreate = canCreateModels( - { userRole, userID }, + { userRole, userID, isViewOnly }, { teams: teams ?? null, disabledForInternalUsers: @@ -182,6 +182,7 @@ export default function ModelsAndEndpointsPage() { accessToken={accessToken} userID={userID} userRole={userRole} + isViewOnly={isViewOnly} onModelUpdate={invalidateModels} modelAccessGroups={availableModelAccessGroups} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index 19e1e3aa8bd..efba26734ff 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -87,7 +87,6 @@ const alwaysMounted = { api_key: undefined, api_base: undefined, custom_llm_provider: "openai", - litellm_credential_name: null, model: "gpt-4o", }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx new file mode 100644 index 00000000000..12f0b95bf13 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx @@ -0,0 +1,39 @@ +/* @vitest-environment jsdom */ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import AutoRoutersTabPanel from "./AutoRoutersTabPanel"; + +const panelProps = vi.fn(); +vi.mock("../components/AutoRouters/AutoRoutersPanel", () => ({ + AutoRoutersPanel: (props: Record) => { + panelProps(props); + return
; + }, +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: {} } }), +})); + +const SESSION = { accessToken: "at", userRole: "Admin", userId: "u1", isViewOnly: false }; + +const lastProps = () => panelProps.mock.calls.at(-1)?.[0] as { createScope: string }; + +describe("AutoRoutersTabPanel", () => { + it("grants an unscoped create to a real proxy admin", () => { + mockUseAuthorized.mockReturnValue(SESSION); + render(); + expect(lastProps().createScope).toBe("unscoped-ok"); + }); + + // The masqueraded "Admin" a proxy_admin_viewer session carries: POST /model/new 403s it, + // so the panel must not be told it may create. + it("withholds the create affordance from a view-only admin session", () => { + mockUseAuthorized.mockReturnValue({ ...SESSION, isViewOnly: true }); + render(); + expect(lastProps().createScope).toBe("forbidden"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx index 5f7d56e8e33..69b442da09b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx @@ -15,13 +15,13 @@ import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel"; * Viewer roles reach the list without write affordances. */ export default function AutoRoutersTabPanel() { - const { accessToken, userRole, userId: userID } = useAuthorized(); + const { accessToken, userRole, userId: userID, isViewOnly } = useAuthorized(); const { data: teams } = useTeams(); const { data: uiSettings } = useUISettings(); const isInternalUser = userRole != null && internalUserRoles.includes(userRole); const scope = modelCreationScope( - { userRole, userID }, + { userRole, userID, isViewOnly }, { teams: teams ?? null, disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true, @@ -33,6 +33,7 @@ export default function AutoRoutersTabPanel() { accessToken={accessToken} userRole={userRole ?? ""} userID={userID ?? null} + isViewOnly={isViewOnly} teams={teams ?? null} createScope={scope} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx index c8779be06c4..f23145a917a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.test.tsx @@ -5,6 +5,7 @@ import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event" import { renderWithProviders } from "@/../tests/test-utils"; import * as networking from "@/components/networking"; import PolicyTestPanel from "./policy_test_panel"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; vi.mock("@/components/networking"); @@ -24,8 +25,7 @@ const setup = () => { }; const pickOption = async (user: ReturnType, label: string, option: string) => { - await user.click(screen.getByLabelText(label)); - await user.click(await screen.findByTitle(option)); + await chooseSelectOption(user, screen.getByLabelText(label), option); }; const simulate = async (user: ReturnType) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx index d6a4aaea2e1..3b0ee3a3ce2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { deletePromptCall, getPromptsList } from "@/components/networking"; import PromptsPanel from "./index"; +import { chooseSelectOption } from "../../../../../tests/test-utils"; vi.mock("@/components/networking", () => ({ getPromptsList: vi.fn(), @@ -118,8 +119,7 @@ describe("PromptsPanel toolbar", () => { expect(screen.getByText("All Environments")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Production")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Production"); await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", "production")); }); @@ -131,12 +131,10 @@ describe("PromptsPanel toolbar", () => { renderPanel("Admin"); await screen.findByText("table-loaded"); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Production")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Production"); await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("Production")); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("All Environments")); + await chooseSelectOption(user, screen.getByRole("combobox"), "All Environments"); await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("All Environments")); await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", undefined)); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index 397e2ab10fa..906565750b1 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -203,12 +203,12 @@ describe("ModelHubTable", () => { }); describe("hub tabs", () => { - const renderHub = async () => { + const renderHub = async (agents: object[] = []) => { vi.mocked(networking.modelHubCall).mockResolvedValue({ data: [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }], }); vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false }); - vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] }); + vi.mocked(networking.getAgentsList).mockResolvedValue({ agents }); vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} }); mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false }); @@ -230,6 +230,37 @@ describe("ModelHubTable", () => { expect(await screen.findByPlaceholderText("Search model names...")).toHaveValue("opus"); }); + it("filters the Agent Hub table by name or description and shows the no-match state", async () => { + const { user } = await renderHub([ + { + agent_id: "a1", + agent_card_params: { name: "Billing Router", description: "routes billing questions" }, + litellm_params: { is_public: false }, + }, + { + agent_id: "a2", + agent_card_params: { name: "Support Bot", description: "handles support tickets" }, + litellm_params: { is_public: false }, + }, + ]); + const agentCount = (expected: string) => + screen.getByText((_, el) => el?.tagName === "P" && el.textContent === expected); + + await user.click(screen.getByRole("tab", { name: "Agent Hub" })); + expect(await screen.findByText("Billing Router")).toBeInTheDocument(); + + const search = screen.getByPlaceholderText("Search agent names or descriptions..."); + await user.type(search, "support tickets"); + expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); + expect(screen.getByText("Support Bot")).toBeInTheDocument(); + expect(agentCount("Showing 1 of 2 agents")).toBeInTheDocument(); + + await user.clear(search); + await user.type(search, "zzzz"); + expect(screen.getByText("No matching agents")).toBeInTheDocument(); + expect(agentCount("Showing 0 of 2 agents")).toBeInTheDocument(); + }); + it("renders the hub strip as underlined tabs rather than a segmented pill", async () => { await renderHub(); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 299104b271b..475e3dcd70b 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -23,13 +23,15 @@ import { import PublicModelHub from "@/components/public_model_hub"; import { copyToClipboard } from "@/utils/dataUtils"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; +import { filterBySearchTerm } from "@/utils/searchUtils"; import { SortingState } from "@tanstack/react-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Copy, Inbox } from "lucide-react"; +import { Copy, Inbox, Search as SearchIcon, X } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; @@ -80,6 +82,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const [agentLoading, setAgentLoading] = useState(true); const [selectedAgent, setSelectedAgent] = useState(null); const [isAgentModalVisible, setIsAgentModalVisible] = useState(false); + const [agentSearchTerm, setAgentSearchTerm] = useState(""); // MCP Hub state const [mcpHubData, setMcpHubData] = useState(null); const [mcpLoading, setMcpLoading] = useState(true); @@ -385,6 +388,10 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]); const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]); + const filteredAgentData = useMemo( + () => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]), + [agentHubData, agentSearchTerm], + ); const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]); // If this is a public page, use the dedicated PublicModelHub component @@ -505,9 +512,34 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
)} +
+

Search Agents:

+ + + + + setAgentSearchTerm(e.target.value)} + /> + {agentSearchTerm && ( + + setAgentSearchTerm("")} + > + + + + )} + +
+ {/* Agent Table */} agent.agent_id || agent.name || String(index)} sortingMode="client" @@ -516,7 +548,14 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, isLoading={agentLoading} loadingMessage="Loading agents…" noDataMessage={ - + } size="compact" /> @@ -524,7 +563,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,

- Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""} + Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents

diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx index 9375673ba10..d3b9a55a7d5 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, MockedFunction, vi } from "vitest"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils"; import { Team } from "../key_team_helpers/key_list"; import { TeamsResponse, useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams"; import { TeamsTable } from "./TeamsTable"; @@ -243,8 +243,7 @@ describe("row actions", () => { await user.click(await screen.findByText("Edit team")); expect(onEditTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); - await user.click(screen.getByTestId("team-actions-team-1")); - await user.click(await screen.findByText("Delete team")); + await chooseSelectOption(user, screen.getByTestId("team-actions-team-1"), "Delete team", "menuitem"); expect(onDeleteTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); }); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index d09ccbf8826..b06448912d0 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; @@ -327,8 +327,7 @@ it("sorts by the backend max_budget field when 'Budget descending' is chosen fro const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("sort-trigger-spend")); - await user.click(await screen.findByText("Budget descending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Budget descending", "menuitem"); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith( @@ -343,8 +342,7 @@ it("emphasizes the active field in the Spend / Budget header so the sorted colum const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("sort-trigger-spend")); - await user.click(await screen.findByText("Budget descending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Budget descending", "menuitem"); await waitFor(() => { expect(screen.getByText("Budget", { selector: "[data-sort-field='max_budget']" })).toHaveClass("font-semibold"); @@ -356,8 +354,7 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByTestId("sort-trigger-spend")); - await user.click(await screen.findByText("Spend ascending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Spend ascending", "menuitem"); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "spend", sortOrder: "asc" })); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index ad0f749b189..92951b68fd5 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -82,7 +82,7 @@ const AddModelForm: React.FC = ({ // Using a unique ID to force the ConnectionErrorDisplay to remount and run a fresh test const [connectionTestId, setConnectionTestId] = useState(""); - const { accessToken, userRole, premiumUser, userId } = useAuthorized(); + const { accessToken, userRole, premiumUser, userId, isViewOnly } = useAuthorized(); const { data: providerMetadata, isLoading: isProviderMetadataLoading, @@ -157,7 +157,10 @@ const AddModelForm: React.FC = ({ 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({ userRole, userID: userId }, { teams, disabledForInternalUsers: false }); + const createScope = modelCreationScope( + { userRole, userID: userId, isViewOnly }, + { teams, disabledForInternalUsers: false }, + ); const requiresTeamScope = createScope === "team-required"; return ( diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index d8a955b719c..ba380662403 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -101,18 +101,24 @@ vi.mock("./build_complexity_router_config", async (importOriginal) => { }); // A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is -// whether team_id is registered, validated and forwarded, so a plain control stands in. +// whether team_id is registered, validated and forwarded, so a plain control stands in. The +// clear button mirrors the real dropdown's x, which emits null rather than a string. vi.mock("../common_components/team_dropdown", () => ({ - default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => ( - + default: ({ value, onChange }: { value?: string; onChange?: (next: string | null) => void }) => ( + <> + + + ), })); @@ -354,6 +360,25 @@ describe("AddAutoRouterTab", () => { expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled(); }); + // The shared dropdown emits null on clear while this form's schema wants a string, so the + // form maps null back to "": the user sees the pick-a-team message, not a zod type error. + it("treats a team picked and then cleared like no team at all", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router"); + await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1"); + await user.click(screen.getByTestId("team-dropdown-clear")); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument(); + expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled(); + }); + it("defaults a new router to session affinity off, matching the backend field default", async () => { const user = userEvent.setup(); vi.mocked(getMissingTiersError).mockReturnValue(null); diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 318adcce369..03b7432e4ec 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -548,7 +548,9 @@ const AddAutoRouterTab: React.FC = ({ "Select the team this auto router belongs to. Only keys for this team will be able to call it.", )} > - {({ id, value, onChange }) => } + {({ id, value, onChange }) => ( + onChange(next ?? "")} /> + )} )} diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx index 7ef09d34924..9d792480c9f 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { prepareModelAddRequest } from "./handle_add_model_submit"; +vi.mock("../networking", () => ({ + modelCreateCall: vi.fn(), +})); + describe("prepareModelAddRequest", () => { it("returns deployment data for the most basic form", async () => { const formValues = { @@ -73,4 +77,28 @@ describe("prepareModelAddRequest", () => { expect(deployment.litellmParamsObj.litellm_credential_name).toBe("selected-credential"); expect(deployment.litellmParamsObj.timeout).toBe(5); }); + + it("keeps litellm_credential_name from LiteLLM Params JSON when no credential is selected", async () => { + const formValues = { + model_mappings: [ + { + public_name: "Public Model", + litellm_model: "litellm/public", + }, + ], + model_name: "custom-model-name", + litellm_extra_params: JSON.stringify({ + litellm_credential_name: "from-json", + timeout: 5, + }), + litellm_credential_name: null, + }; + + const deployments = await prepareModelAddRequest({ ...formValues }, "token", null); + + expect(deployments).toHaveLength(1); + const [deployment] = deployments!; + expect(deployment.litellmParamsObj.litellm_credential_name).toBe("from-json"); + expect(deployment.litellmParamsObj.timeout).toBe(5); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index bb2f78fa84e..41133958c0a 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -91,6 +91,9 @@ export const prepareModelAddRequest = async (formValues: Record, ac if (value === "") { continue; } + if (key === "litellm_credential_name" && value == null) { + continue; + } // Skip the custom_pricing and pricing_model fields as they're only used for UI control if (key === "custom_pricing" || key === "pricing_model" || key === "cache_control") { continue; @@ -124,13 +127,13 @@ export const prepareModelAddRequest = async (formValues: Record, ac if (value && value != undefined) { try { litellmExtraParams = JSON.parse(value); - if ("litellm_credential_name" in litellmExtraParams) { - delete litellmExtraParams.litellm_credential_name; - } } catch (error) { toast.fromError("Failed to parse LiteLLM Extra Params: " + error); throw new Error("Failed to parse litellm_extra_params: " + error); } + if ("litellm_credential_name" in litellmExtraParams && formValues.litellm_credential_name) { + delete litellmExtraParams.litellm_credential_name; + } for (const [key, value] of Object.entries(litellmExtraParams)) { litellmParamsObj[key] = value; } diff --git a/ui/litellm-dashboard/src/components/add_pass_through.integration.test.tsx b/ui/litellm-dashboard/src/components/add_pass_through.integration.test.tsx index df4dbff4111..daaacd7f0e2 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.integration.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -import { fireEvent, renderWithProviders, screen, waitFor } from "../../tests/test-utils"; +import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "../../tests/test-utils"; import AddPassThroughEndpoint from "./add_pass_through"; const createPassThroughEndpoint = vi.fn(); @@ -153,8 +153,7 @@ describe("add_pass_through submit payload", () => { await openModal(user); await fillRequiredFields(user); - await user.click(screen.getByLabelText(/HTTP Methods/)); - await user.click(await screen.findByTitle("POST")); + await chooseSelectOption(user, screen.getByLabelText(/HTTP Methods/), "POST"); await submit(user); diff --git a/ui/litellm-dashboard/src/components/alerting/alerting_settings.tsx b/ui/litellm-dashboard/src/components/alerting/alerting_settings.tsx index 98e876801ac..cff33546fbc 100644 --- a/ui/litellm-dashboard/src/components/alerting/alerting_settings.tsx +++ b/ui/litellm-dashboard/src/components/alerting/alerting_settings.tsx @@ -5,6 +5,7 @@ import React, { useState, useEffect } from "react"; import { alertingSettingsCall, updateConfigFieldSetting } from "../networking"; import DynamicForm from "./dynamic_form"; +import { extractProxyErrorMessage } from "@/lib/http/client"; import { toast } from "@/lib/toast"; interface alertingSettingsItem { field_name: string; @@ -43,7 +44,7 @@ const AlertingSettings: React.FC = ({ accessToken, premiu setAlertingSettings(updatedSettings); }; - const handleSubmit = (formValues: Record) => { + const handleSubmit = async (formValues: Record) => { if (!accessToken) { return; } @@ -64,18 +65,18 @@ const AlertingSettings: React.FC = ({ accessToken, premiu const mergedFormValues = { ...formValues, ...initialFormValues }; const { slack_alerting, ...alertingArgs } = mergedFormValues; try { - updateConfigFieldSetting(accessToken, "alerting_args", alertingArgs); + await updateConfigFieldSetting(accessToken, "alerting_args", alertingArgs); if (typeof slack_alerting === "boolean") { if (slack_alerting == true) { - updateConfigFieldSetting(accessToken, "alerting", ["slack"]); + await updateConfigFieldSetting(accessToken, "alerting", ["slack"]); } else { - updateConfigFieldSetting(accessToken, "alerting", []); + await updateConfigFieldSetting(accessToken, "alerting", []); } } // update value in state toast.success("Wait 10s for proxy to update."); } catch (error) { - // do something + toast.error(extractProxyErrorMessage(error)); } }; diff --git a/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx b/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx index f9b9765f59e..7764052173c 100644 --- a/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/alerting/dynamic_form.integration.test.tsx @@ -38,6 +38,14 @@ const SETTINGS: Setting[] = [ stored_in_db: null, premium_field: false, }, + { + field_name: "daily_spend_per_user_threshold", + field_description: "Daily spend threshold per user", + field_type: "Float", + field_value: 5.5, + stored_in_db: true, + premium_field: false, + }, ]; const renderForm = ( @@ -170,6 +178,19 @@ describe("DynamicForm change notifications", () => { expect(handleInputChange).toHaveBeenCalledWith("daily_report_frequency", 128); }); + it("renders a Float field as a decimal-friendly number input and reports changes as numbers", async () => { + const user = userEvent.setup(); + const { handleInputChange } = renderForm(); + + const input = screen.getByDisplayValue("5.5"); + expect(input).toHaveAttribute("type", "number"); + expect(input).toHaveAttribute("step", "any"); + + await user.type(input, "1"); + + expect(handleInputChange).toHaveBeenCalledWith("daily_spend_per_user_threshold", 5.51); + }); + it("reports a reset with the field name and its row index", async () => { const user = userEvent.setup(); const { handleResetField } = renderForm(); @@ -216,7 +237,7 @@ describe("DynamicForm presentation", () => { expect(screen.getByText("daily_report_frequency")).toBeInTheDocument(); expect(screen.getByText("How often the report runs")).toBeInTheDocument(); - expect(screen.getByText("In DB")).toBeInTheDocument(); + expect(screen.getAllByText("In DB")).toHaveLength(2); expect(screen.getByText("In Config")).toBeInTheDocument(); expect(screen.getByText("Not Set")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/alerting/dynamic_form.tsx b/ui/litellm-dashboard/src/components/alerting/dynamic_form.tsx index be5e76ab0df..42aa58ca0ea 100644 --- a/ui/litellm-dashboard/src/components/alerting/dynamic_form.tsx +++ b/ui/litellm-dashboard/src/components/alerting/dynamic_form.tsx @@ -63,11 +63,11 @@ const DynamicForm: React.FC = ({ }; const renderControl = (setting: AlertingSetting) => { - if (setting.field_type === "Integer") { + if (setting.field_type === "Integer" || setting.field_type === "Float") { return ( handleNumericChange(setting, event.target.value)} /> diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.test.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.test.tsx index 6e627e3b45c..df21444cbad 100644 --- a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.test.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { describe, expect, it, vi, beforeEach } from "vitest"; -import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import KeyLifecycleSettings from "./KeyLifecycleSettings"; const CREATE_PLACEHOLDER = "e.g., 30d or leave empty to never expire"; @@ -167,8 +167,7 @@ describe("KeyLifecycleSettings", () => { await user.click(screen.getByRole("switch")); expect(await screen.findByText("Rotation Interval")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("90 days")); + await chooseSelectOption(user, screen.getByRole("combobox"), "90 days"); await waitFor(() => expect(screen.getAllByTitle("90 days").some(isRenderedSelection)).toBe(true)); expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("90d"); @@ -181,8 +180,7 @@ describe("KeyLifecycleSettings", () => { await user.click(screen.getByRole("switch")); expect(await screen.findByText("Rotation Interval")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Custom interval")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Custom interval"); expect(await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).toBeInTheDocument(); expect(screen.getByText("Supported formats: seconds (s), minutes (m), hours (h), days (d)")).toBeInTheDocument(); @@ -196,8 +194,7 @@ describe("KeyLifecycleSettings", () => { await user.click(screen.getByRole("switch")); expect(await screen.findByText("Rotation Interval")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Custom interval")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Custom interval"); const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d"); fireEvent.change(customInput, { target: { value: "14d" } }); @@ -213,14 +210,12 @@ describe("KeyLifecycleSettings", () => { await user.click(screen.getByRole("switch")); expect(await screen.findByText("Rotation Interval")).toBeInTheDocument(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Custom interval")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Custom interval"); const customInput = await screen.findByPlaceholderText("e.g., 1s, 5m, 2h, 14d"); fireEvent.change(customInput, { target: { value: "14d" } }); await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("14d")); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("7 days")); + await chooseSelectOption(user, screen.getByRole("combobox"), "7 days"); await waitFor(() => expect(screen.getByTestId("rotation-interval-value")).toHaveTextContent("7d")); expect(screen.queryByPlaceholderText("e.g., 1s, 5m, 2h, 14d")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx index bd7b56f1817..7a5db4667e9 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import ModelSelector from "./ModelSelector"; +import { chooseSelectOption } from "../../../tests/test-utils"; vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), @@ -9,8 +10,7 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ const openCustomModelInput = async () => { const user = userEvent.setup(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Enter custom model")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Enter custom model"); return screen.getByPlaceholderText("Enter custom model name"); }; diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.test.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.test.tsx new file mode 100644 index 00000000000..01b8a12d99c --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { chooseSelectOption } from "../../../tests/test-utils"; +import type { Team } from "../key_team_helpers/key_list"; +import TeamDropdown from "./team_dropdown"; + +const TEAMS = [ + { team_id: "team-1", team_alias: "Alpha Team" }, + { team_id: "team-2", team_alias: "Beta Team" }, +] as unknown as Team[]; + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useInfiniteTeams: () => ({ + data: { pages: [{ teams: TEAMS }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }), +})); + +describe("TeamDropdown", () => { + it("emits the picked team's id and full object", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onTeamSelect = vi.fn(); + render(); + + await chooseSelectOption(user, screen.getByRole("combobox"), /^Beta Team/); + + expect(onChange).toHaveBeenCalledWith("team-2"); + expect(onTeamSelect).toHaveBeenCalledWith(TEAMS[1]); + }); + + it("emits null, never the empty string, when the selection is cleared", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onTeamSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Clear" })); + + expect(onChange).toHaveBeenCalledWith(null); + expect(onTeamSelect).toHaveBeenCalledWith(null); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 35121f41598..7d385c2a3f7 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -5,7 +5,7 @@ import { Team } from "../key_team_helpers/key_list"; interface TeamDropdownProps { value?: string; - onChange?: (value: string) => void; + onChange?: (value: string | null) => void; /** Callback with the full Team object (or null on clear). */ onTeamSelect?: (team: Team | null) => void; disabled?: boolean; @@ -47,7 +47,7 @@ const TeamDropdown: React.FC = ({ }, [data]); const handleChange = (teamId: string) => { - onChange?.(teamId); + onChange?.(teamId || null); if (onTeamSelect) { onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null); } diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx index 96041905d97..0ce82d6d050 100644 --- a/ui/litellm-dashboard/src/components/model_filters.tsx +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; import { Card } from "@/components/ui/card"; +import { matchesSearchTerm } from "@/utils/searchUtils"; interface ModelGroupInfo { model_group: string; @@ -76,7 +77,7 @@ const ModelFilters: React.FC = ({ const filteredData = useMemo(() => { return ( modelHubData?.filter((model) => { - const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesSearch = matchesSearchTerm(searchTerm, [model.model_group]); const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider); const matchesMode = selectedMode === "" || model.mode === selectedMode; diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 742d2593ade..768183907db 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -87,6 +87,7 @@ describe("ModelInfoView", () => { accessToken: "test-token", userID: "123", userRole: "Admin", + isViewOnly: false, onModelUpdate: vi.fn(), modelAccessGroups: ["group1", "group2"], }; @@ -328,6 +329,16 @@ describe("ModelInfoView", () => { }); }); + // A proxy_admin_viewer session reads "Admin" through effectiveSessionRole, but the update + // and delete endpoints 403 it, so the write buttons must not be offered. + it("should disable delete and update buttons for a view-only admin session", async () => { + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByTestId("delete-model-button")).toBeDisabled(); + }); + expect(screen.getByTestId("update-api-key-button")).toBeDisabled(); + }); + it("should disable delete button when model is not a DB model", async () => { const nonDbModelData = { ...defaultModelData, diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index f21e98e084c..35afcdb2985 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -53,6 +53,7 @@ interface ModelInfoViewProps { accessToken: string | null; userID: string | null; userRole: string | null; + isViewOnly: boolean; onModelUpdate?: (updatedModel: any) => void; modelAccessGroups: string[] | null; } @@ -117,6 +118,7 @@ export default function ModelInfoView({ accessToken, userID, userRole, + isViewOnly, onModelUpdate, modelAccessGroups, }: ModelInfoViewProps) { @@ -167,7 +169,7 @@ export default function ModelInfoView({ // Keep modelData variable name for backwards compatibility const modelData = transformedModelData; - const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, { + const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, { teamId: modelData?.model_info?.team_id, isDbModel: modelData?.model_info?.db_model === true, }); diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx index 875f89b5adc..fec46e98077 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx @@ -134,6 +134,27 @@ describe("PublicModelHub", () => { expect(within(gpt35Row as HTMLElement).getByText("Unknown")).toBeInTheDocument(); }); }); + it("shows no models when the search has no matches (LIT-5230 regression)", async () => { + const networkingModule = await import("./networking"); + vi.mocked(networkingModule.modelHubPublicModelsCall).mockResolvedValue([ + { model_group: "gpt-4", providers: ["openai"], mode: "chat" }, + { model_group: "claude-3", providers: ["anthropic"], mode: "chat" }, + ]); + + render(); + expect(await screen.findByText("gpt-4")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search model names... (smart search enabled)"), { + target: { value: "zzzz" }, + }); + + await waitFor(() => { + expect(screen.queryByText("gpt-4")).not.toBeInTheDocument(); + expect(screen.queryByText("claude-3")).not.toBeInTheDocument(); + expect(screen.getByText("No matching models")).toBeInTheDocument(); + }); + }); + it("handles non-array response gracefully (regression test for e.filter crash)", async () => { const networkingModule = await import("./networking"); // Mock the API to return an object (like an error response) instead of an array diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 171b7992325..f6364b5d9d1 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -47,6 +47,7 @@ import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets"; import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping"; import { MessageType } from "@/components/chat_ui/types"; import { getProviderLogoAndName } from "./provider_info_helpers"; +import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils"; interface PublicModelHubProps { accessToken?: string | null; @@ -236,52 +237,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const filteredData = useMemo(() => { if (!modelHubData || !Array.isArray(modelHubData)) return []; - let searchResults = modelHubData; - - // Apply search if there's a search term - if (searchTerm.trim()) { - const lowercaseSearch = searchTerm.toLowerCase(); - const searchWords = lowercaseSearch.split(/\s+/); - - // First, try flexible matching that handles different separators - const exactMatches = modelHubData.filter((model) => { - const modelName = model.model_group.toLowerCase(); - - // Check if it contains the exact search term - if (modelName.includes(lowercaseSearch)) { - return true; - } - - // Check if it contains all search words (handles spaces vs slashes/dashes) - return searchWords.every((word) => modelName.includes(word)); - }); - - // If we have exact matches, rank them by relevance - if (exactMatches.length > 0) { - searchResults = exactMatches.sort((a, b) => { - const aName = a.model_group.toLowerCase(); - const bName = b.model_group.toLowerCase(); - - // Calculate relevance scores - const aExactMatch = aName === lowercaseSearch ? 1000 : 0; - const bExactMatch = bName === lowercaseSearch ? 1000 : 0; - - const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0; - const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0; - - const aContainsWords = lowercaseSearch.split(/\s+/).every((word) => aName.includes(word)) ? 50 : 0; - const bContainsWords = lowercaseSearch.split(/\s+/).every((word) => bName.includes(word)) ? 50 : 0; - - const aLength = aName.length; - const bLength = bName.length; - - const aScore = aExactMatch + aStartsWith + aContainsWords + (1000 - aLength); - const bScore = bExactMatch + bStartsWith + bContainsWords + (1000 - bLength); - - return bScore - aScore; // Higher score first - }); - } - } + const searchResults = rankBySearchRelevance( + filterBySearchTerm(modelHubData, searchTerm, (model) => [model.model_group]), + searchTerm, + (model) => model.model_group, + ); // Apply other filters return searchResults.filter((model) => { @@ -310,43 +270,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const filteredAgentData = useMemo(() => { if (!agentHubData || !Array.isArray(agentHubData)) return []; - let searchResults = agentHubData; - - // Apply search if there's a search term - if (agentSearchTerm.trim()) { - const lowercaseSearch = agentSearchTerm.toLowerCase(); - const searchWords = lowercaseSearch.split(/\s+/); - - searchResults = agentHubData.filter((agent) => { - const agentName = agent.name.toLowerCase(); - const agentDescription = agent.description.toLowerCase(); - - // Check if it contains the exact search term - if (agentName.includes(lowercaseSearch) || agentDescription.includes(lowercaseSearch)) { - return true; - } - - // Check if it contains all search words - return searchWords.every((word) => agentName.includes(word) || agentDescription.includes(word)); - }); - - // Sort by relevance - searchResults = searchResults.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - - const aExactMatch = aName === lowercaseSearch ? 1000 : 0; - const bExactMatch = bName === lowercaseSearch ? 1000 : 0; - - const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0; - const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0; - - const aScore = aExactMatch + aStartsWith + (1000 - aName.length); - const bScore = bExactMatch + bStartsWith + (1000 - bName.length); - - return bScore - aScore; - }); - } + const searchResults = rankBySearchRelevance( + filterBySearchTerm(agentHubData, agentSearchTerm, (agent) => [agent.name, agent.description]), + agentSearchTerm, + (agent) => agent.name, + ); // Apply skill filters return searchResults.filter((agent) => { @@ -361,43 +289,11 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const filteredMcpData = useMemo(() => { if (!mcpHubData || !Array.isArray(mcpHubData)) return []; - let searchResults = mcpHubData; - - // Apply search if there's a search term - if (mcpSearchTerm.trim()) { - const lowercaseSearch = mcpSearchTerm.toLowerCase(); - const searchWords = lowercaseSearch.split(/\s+/); - - searchResults = mcpHubData.filter((server) => { - const serverName = server.server_name.toLowerCase(); - const serverDescription = (server.mcp_info?.description || "").toLowerCase(); - - // Check if it contains the exact search term - if (serverName.includes(lowercaseSearch) || serverDescription.includes(lowercaseSearch)) { - return true; - } - - // Check if it contains all search words - return searchWords.every((word) => serverName.includes(word) || serverDescription.includes(word)); - }); - - // Sort by relevance - searchResults = searchResults.sort((a, b) => { - const aName = a.server_name.toLowerCase(); - const bName = b.server_name.toLowerCase(); - - const aExactMatch = aName === lowercaseSearch ? 1000 : 0; - const bExactMatch = bName === lowercaseSearch ? 1000 : 0; - - const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0; - const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0; - - const aScore = aExactMatch + aStartsWith + (1000 - aName.length); - const bScore = bExactMatch + bStartsWith + (1000 - bName.length); - - return bScore - aScore; - }); - } + const searchResults = rankBySearchRelevance( + filterBySearchTerm(mcpHubData, mcpSearchTerm, (server) => [server.server_name, server.mcp_info?.description]), + mcpSearchTerm, + (server) => server.server_name, + ); // Apply transport filters return searchResults.filter((server) => { diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 72da01d0918..9cb55b6ed5c 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -293,6 +293,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, llm_too_slow: "LLM Responses Too Slow", llm_requests_hanging: "LLM Requests Hanging", budget_alerts: "Budget Alerts (API Keys, Users)", + user_spend_thresholds: "User Spend Thresholds (Daily/Monthly)", + user_spend_anomalies: "User Spend Anomaly Detection", db_exceptions: "Database Exceptions (Read/Write)", daily_reports: "Weekly/Monthly Spend Reports", outage_alerts: "Outage Alerts", diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index 7ead9bb64d4..f502ea77127 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { DataTable } from "./DataTable"; import { DataTableMultiSortHeader, DataTableSortHeader } from "./DataTableSortHeader"; import { DataTableViewOptions } from "./DataTableViewOptions"; +import { chooseSelectOption } from "../../../../tests/test-utils"; interface Person { id: string; @@ -176,16 +177,13 @@ describe("DataTable sorting", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Ascending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Ascending", "menuitem"); expect(names()).toEqual(["Alice", "Bob", "Charlie"]); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Descending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Descending", "menuitem"); expect(names()).toEqual(["Charlie", "Bob", "Alice"]); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Reset")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Reset", "menuitem"); expect(names()).toEqual(["Charlie", "Alice", "Bob"]); }); @@ -202,12 +200,10 @@ describe("DataTable sorting", () => { />, ); - await user.click(screen.getByTestId("sort-trigger-spend")); - await user.click(await screen.findByText("Budget descending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Budget descending", "menuitem"); expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "max_budget", desc: true }]); - await user.click(screen.getByTestId("sort-trigger-spend")); - await user.click(await screen.findByText("Spend ascending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Spend ascending", "menuitem"); expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "spend", desc: false }]); }); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx index 70403ba2efe..aeb6fa44e91 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; +import { chooseSelectOption } from "../../../../tests/test-utils"; interface Item { name: string; @@ -84,16 +85,13 @@ describe("DataTableSortHeader", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Descending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Descending", "menuitem"); expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="desc"]')).not.toBeNull(); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Ascending")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Ascending", "menuitem"); expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="asc"]')).not.toBeNull(); - await user.click(screen.getByTestId("sort-trigger-name")); - await user.click(await screen.findByText("Reset")); + await chooseSelectOption(user, screen.getByTestId("sort-trigger-name"), "Reset", "menuitem"); expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="none"]')).not.toBeNull(); }); diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx index d248cf311cc..04c8d3e9012 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { PaginatedSearchSelect } from "./PaginatedSearchSelect"; import type { SearchSelectOption } from "./SearchSelect"; +import { chooseSelectOption } from "../../../tests/test-utils"; const OPTIONS: SearchSelectOption[] = [ { label: "alias-alpha", value: "alias-alpha" }, @@ -63,8 +64,7 @@ describe("PaginatedSearchSelect", () => { } render(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("alias-beta")); + await chooseSelectOption(user, screen.getByRole("combobox"), "alias-beta"); expect(screen.getByRole("combobox")).toHaveValue("alias-beta"); await new Promise((resolve) => setTimeout(resolve, 400)); @@ -136,8 +136,7 @@ describe("PaginatedSearchSelect", () => { const onValueChange = vi.fn(); renderSelect({ onValueChange }); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("alias-beta")); + await chooseSelectOption(user, screen.getByRole("combobox"), "alias-beta"); expect(onValueChange).toHaveBeenCalledWith("alias-beta"); }); @@ -255,8 +254,7 @@ describe("PaginatedSearchSelect", () => { } render(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Beta Team")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Beta Team"); await user.click(screen.getByRole("button", { name: "refetch" })); expect(screen.getByRole("combobox")).toHaveValue("Beta Team"); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx index c0b9bc7d8ae..62a510268dd 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { SearchSelect } from "./SearchSelect"; +import { chooseSelectOption } from "../../../tests/test-utils"; const OPTIONS = [ { label: "Acme Prod", value: "team-1" }, @@ -71,8 +72,7 @@ describe("SearchSelect", () => { const onValueChange = vi.fn(); const user = userEvent.setup(); render(); - await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Growth")); + await chooseSelectOption(user, screen.getByRole("combobox"), "Growth"); expect(onValueChange).toHaveBeenCalledWith("team-2"); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index e0ec66e1ae2..5a35c7ae16b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import { ERROR_CODE_OPTIONS } from "./constants"; import { LOG_FILTER_IDS } from "./log_filter_logic"; import { RequestLogsFilters } from "./RequestLogsFilters"; @@ -125,8 +125,7 @@ describe("RequestLogsFilters", () => { const user = userEvent.setup(); const { set } = renderFilters(); - await user.click(await screen.findByPlaceholderText("Search an internal user")); - await user.click(await screen.findByText("alice@example.com")); + await chooseSelectOption(user, await screen.findByPlaceholderText("Search an internal user"), "alice@example.com"); expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com"); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx index 1a6afd7fcfe..8a56312572c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx @@ -25,31 +25,15 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
{/* Description */} {tool.description && ( -
- - {tool.description} - +
+ {tool.description}
)} {/* Parameters Table */} {parameterRows.length > 0 && (
- - Parameters - + Parameters
MetricValue
MetricValue
{row.metric}{row.value}
{row.metric}{row.value}
@@ -82,33 +66,10 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) { {/* If tool was called, show the arguments used */} {tool.called && tool.callData && ( -
- - Called With - -
-
+        
+ Called With +
+
               {JSON.stringify(tool.callData.arguments, null, 2)}
             
diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx index 2a2ceb644dc..d8431e52a51 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx @@ -20,19 +20,7 @@ export function JsonToolView({ tool }: JsonToolViewProps) { }; return ( -
+    
       {JSON.stringify(toolJson, null, 2)}
     
); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx index 112364f5ff3..26b39579859 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { ChevronDown, ChevronRight, Wrench } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; import { ParsedTool } from "./types"; import { ToolExpandedContent } from "./ToolExpandedContent"; @@ -16,34 +17,23 @@ export function ToolItem({ tool }: ToolItemProps) { const [expanded, setExpanded] = useState(false); return ( -
+
{/* Header Row - Always Visible */}
setExpanded(!expanded)} - style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "12px 16px", - cursor: "pointer", - background: expanded ? "#fafafa" : "#fff", - transition: "background 0.2s", - }} + className={cn( + "flex cursor-pointer items-center justify-between gap-3 px-4 py-3 text-card-foreground transition-colors", + expanded ? "bg-muted" : "bg-card", + )} > -
+
- + {tool.index}. {tool.name}
-
+
{tool.called ? "called" : "not called"} {expanded ? ( @@ -55,13 +45,7 @@ export function ToolItem({ tool }: ToolItemProps) { {/* Expanded Content */} {expanded && ( -
+
)} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e944062e15e..1e0a39542aa 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13289,6 +13289,58 @@ export interface paths { patch: operations["patch_user_scim_v2_Users__user_id__patch"]; trace?: never; }; + "/scim/v2/placeholders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Placeholders + * @description List user rows whose id is another account's SSO identity or email. + * + * An earlier release provisioned a group member it could not match as a user keyed + * by the raw member value, and that row now shadows the account the value really + * names, so every push of that member is refused. This lists those rows so an + * operator can fold each one into the account it shadows with + * ``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of + * its own or owns virtual keys is left out: someone uses that account. + */ + get: operations["list_placeholders_scim_v2_placeholders_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/scim/v2/placeholders/{user_id}/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Merge Placeholder + * @description Fold a placeholder user into the one account its id names by SSO identity or email. + * + * The account is added to every team the placeholder is on, then the placeholder is + * deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group + * push resolves the member value to the real account. Refused with 409 when the row + * has an SSO identity of its own, owns virtual keys, or names no account or several. + */ + post: operations["merge_placeholder_scim_v2_placeholders__user_id__merge_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/search": { parameters: { query?: never; @@ -22962,7 +23014,7 @@ export interface components { * @description Enum for alert types and management event types * @enum {string} */ - AlertType: "llm_exceptions" | "llm_too_slow" | "llm_requests_hanging" | "budget_alerts" | "spend_reports" | "failed_tracking_spend" | "db_exceptions" | "daily_reports" | "cooldown_deployment" | "new_model_added" | "model_deprecation_warnings" | "outage_alerts" | "region_outage_alerts" | "fallback_reports" | "new_virtual_key_created" | "virtual_key_updated" | "virtual_key_deleted" | "new_team_created" | "team_updated" | "team_deleted" | "new_internal_user_created" | "internal_user_updated" | "internal_user_deleted"; + AlertType: "llm_exceptions" | "llm_too_slow" | "llm_requests_hanging" | "budget_alerts" | "spend_reports" | "failed_tracking_spend" | "user_spend_thresholds" | "user_spend_anomalies" | "db_exceptions" | "daily_reports" | "cooldown_deployment" | "new_model_added" | "model_deprecation_warnings" | "outage_alerts" | "region_outage_alerts" | "fallback_reports" | "new_virtual_key_created" | "virtual_key_updated" | "virtual_key_deleted" | "new_team_created" | "team_updated" | "team_deleted" | "new_internal_user_created" | "internal_user_updated" | "internal_user_deleted"; /** AllowedVectorStoreIndexItem */ AllowedVectorStoreIndexItem: { /** Index Name */ @@ -25435,9 +25487,15 @@ export interface components { database_extra_connection_params?: { [key: string]: unknown; } | null; + /** + * Database Max Idle Connection Lifetime + * @description Prisma `max_idle_connection_lifetime` URL param (seconds). A pooled connection idle longer than this is closed and replaced instead of being handed to the next request. Defaults to 60 so connections are recycled before common infra idle timeouts (AWS NLB / RDS Proxy ~350s, many LBs 60-350s) silently drop them and requests fail with `Error { kind: Closed }`. A value pinned on the DATABASE_URL or set via `database_extra_connection_params` takes precedence. + * @default 60 + */ + database_max_idle_connection_lifetime: number | null; /** * Database Socket Timeout - * @description Prisma `socket_timeout` URL param (seconds). When set, an idle/slow connection that has not produced data within this window is closed. This is the main knob for capping idle DB connections from LiteLLM. + * @description Prisma `socket_timeout` URL param (seconds). When set, an in-flight operation that has not produced data within this window is aborted. For capping how long idle pooled connections are kept, see `database_max_idle_connection_lifetime`. */ database_socket_timeout?: number | null; /** @@ -29178,6 +29236,8 @@ export interface components { regional_processing_uplift_multiplier_us?: number | null; /** Rpm */ rpm?: number | null; + /** Rust */ + rust?: boolean | null; /** S3 Bucket Name */ s3_bucket_name?: string | null; /** S3 Encryption Key Id */ @@ -34921,6 +34981,27 @@ export interface components { /** Value */ value?: unknown | null; }; + /** + * SCIMPlaceholder + * @description A user row keyed by a value that names another account by SSO identity or email. + */ + SCIMPlaceholder: { + /** Placeholder User Id */ + placeholder_user_id: string; + /** Resolved User Ids */ + resolved_user_ids: string[]; + /** Team Ids */ + team_ids: string[]; + }; + /** SCIMPlaceholderMergeResult */ + SCIMPlaceholderMergeResult: { + /** Merged Into User Id */ + merged_into_user_id: string; + /** Placeholder User Id */ + placeholder_user_id: string; + /** Team Ids */ + team_ids: string[]; + }; /** SCIMServiceProviderConfig */ SCIMServiceProviderConfig: { /** Authenticationschemes */ @@ -38986,6 +39067,8 @@ export interface components { regional_processing_uplift_multiplier_us?: number | null; /** Rpm */ rpm?: number | null; + /** Rust */ + rust?: boolean | null; /** S3 Bucket Name */ s3_bucket_name?: string | null; /** S3 Encryption Key Id */ @@ -55852,6 +55935,70 @@ export interface operations { }; }; }; + list_placeholders_scim_v2_placeholders_get: { + parameters: { + query?: { + feature?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SCIMPlaceholder"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + merge_placeholder_scim_v2_placeholders__user_id__merge_post: { + parameters: { + query?: { + feature?: string | null; + }; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SCIMPlaceholderMergeResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; search_search_post: { parameters: { query?: { diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts index 179a9b7933a..afc2ecd210f 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from "vitest"; import { Team } from "@/components/networking"; -import { canModifyModel, modelCreationScope } from "./modelPermissions"; +import { canCreateModels, canModifyModel, modelCreationScope } from "./modelPermissions"; const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] => [{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[]; -const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin" }; -const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" }; -const MEMBER = { userRole: "Internal User", userID: "u-member" }; +const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin", isViewOnly: false }; +const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin", isViewOnly: false }; +const MEMBER = { userRole: "Internal User", userID: "u-member", isViewOnly: false }; +// proxy_admin_viewer sessions: effectiveSessionRole masquerades the role as "Admin". +const VIEW_ONLY_ADMIN = { userRole: "Admin", userID: "u-viewer", isViewOnly: true }; const noLimits = { disabledForInternalUsers: false }; @@ -40,9 +42,24 @@ describe("modelCreationScope", () => { // an unscoped create from them 403s. Treating them as admins here is what let a form submit // a payload the backend always rejected. it("does not treat an org admin as able to create unscoped", () => { - const orgAdmin = { userRole: "org_admin", userID: "u-org" }; + const orgAdmin = { userRole: "org_admin", userID: "u-org", isViewOnly: false }; expect(modelCreationScope(orgAdmin, { teams: teamWhere("u-org", "admin"), ...noLimits })).toBe("team-required"); }); + + // Server-side, POST /model/new 403s the viewer roles, so the "Admin" the masquerade + // reports must not read as a proxy admin here. + it("forbids a view-only admin session despite the masqueraded Admin role", () => { + expect(modelCreationScope(VIEW_ONLY_ADMIN, { teams: [], ...noLimits })).toBe("forbidden"); + expect(canCreateModels(VIEW_ONLY_ADMIN, { teams: [], ...noLimits })).toBe(false); + }); + + // _check_proxy_admin_viewer_access (route_checks.py) 403s /model/new on the session role + // alone, before the team-scoped carve-out in ModelManagementAuthChecks can run. + it("forbids a view-only admin even when they admin a team", () => { + expect(modelCreationScope(VIEW_ONLY_ADMIN, { teams: teamWhere("u-viewer", "admin"), ...noLimits })).toBe( + "forbidden", + ); + }); }); describe("canModifyModel", () => { @@ -80,6 +97,17 @@ describe("canModifyModel", () => { }); it("does not treat two absent identities as a match", () => { - expect(canModifyModel({ userRole: "Internal User", userID: null }, null, teamRow)).toBe(false); + expect(canModifyModel({ userRole: "Internal User", userID: null, isViewOnly: false }, null, teamRow)).toBe(false); + }); + + // PATCH /model/{id}/update and POST /model/delete 403 the viewer roles like /model/new does. + it("refuses a view-only admin session on a DB row", () => { + expect(canModifyModel(VIEW_ONLY_ADMIN, null, teamRow)).toBe(false); + }); + + // The route RBAC blocks /model/update and /model/delete for the viewer role before the + // team-scoped carve-out runs, so team-admin membership changes nothing here either. + it("refuses a view-only user even when they admin the owning team", () => { + expect(canModifyModel(VIEW_ONLY_ADMIN, teamWhere("u-viewer", "admin"), teamRow)).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.ts b/ui/litellm-dashboard/src/utils/modelPermissions.ts index b5914f9d7ea..843d9041026 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.ts @@ -3,20 +3,32 @@ import { Team } from "@/components/networking"; import { isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam } from "./roles"; /** - * The dashboard's mirror of ModelManagementAuthChecks in - * litellm/proxy/management_endpoints/model_management_endpoints.py. + * The dashboard's mirror of the two server layers that gate model writes: the role-level + * route RBAC (`_check_proxy_admin_viewer_access` in litellm/proxy/auth/route_checks.py), + * which 403s /model/new, /model/update, and /model/delete for every view-only session + * before the endpoint runs, and ModelManagementAuthChecks in + * litellm/proxy/management_endpoints/model_management_endpoints.py behind it. * - * Both questions below are answered there by exactly two inputs: the caller's role, and - * whether the caller admins the team named in `model_info.team_id`. `created_by` is written - * at creation and never read by an auth check, so it is deliberately absent here; gating on - * it hid controls from team admins the API accepts, and showed controls to former team admins - * the API rejects. + * Past that route gate, both questions below are answered by exactly two inputs: the + * caller's role, and whether the caller admins the team named in `model_info.team_id`. + * `created_by` is written at creation and never read by an auth check, so it is deliberately + * absent here; gating on it hid controls from team admins the API accepts, and showed + * controls to former team admins the API rejects. */ export interface ModelActor { userRole: string | null; userID: string | null; + /** + * From useAuthorized(). A proxy_admin_viewer session masquerades as "Admin" in userRole + * (effectiveSessionRole, for read parity), yet every management write 403s it, so the role + * alone cannot answer a write question. + */ + isViewOnly: boolean; } +const isWritableProxyAdmin = ({ userRole, isViewOnly }: ModelActor): boolean => + !isViewOnly && userRole != null && isProxyAdminRole(userRole); + /** How this actor must scope a deployment they create, or that they may not create one. */ export type ModelWriteScope = "forbidden" | "unscoped-ok" | "team-required"; @@ -33,20 +45,25 @@ const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): bo /** * POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a - * team; an unscoped create from anyone else is a 403. Returning the requirement rather than a - * pair of booleans keeps "may not create" and "may create unscoped" from being confused. + * team; an unscoped create from anyone else is a 403. A view-only session is 403d by the + * route RBAC on its role alone, so team-admin membership cannot rescue it. Returning the + * requirement rather than a pair of booleans keeps "may not create" and "may create + * unscoped" from being confused. */ export const modelCreationScope = ( - { userRole, userID }: ModelActor, + actor: ModelActor, { teams, disabledForInternalUsers }: ModelCreationLimits, ): ModelWriteScope => { - if (userRole != null && isProxyAdminRole(userRole)) { + if (actor.isViewOnly) { + return "forbidden"; + } + if (isWritableProxyAdmin(actor)) { return "unscoped-ok"; } if (disabledForInternalUsers) { return "forbidden"; } - if (userID != null && isUserTeamAdminForAnyTeam(teams, userID)) { + if (actor.userID != null && isUserTeamAdminForAnyTeam(teams, actor.userID)) { return "team-required"; } return "forbidden"; @@ -63,18 +80,18 @@ export interface ModelRowOrigin { /** May this actor edit or delete this specific deployment? */ export const canModifyModel = ( - { userRole, userID }: ModelActor, + actor: ModelActor, teams: Team[] | null, { teamId, isDbModel }: ModelRowOrigin, ): boolean => { - if (!isDbModel) { + if (actor.isViewOnly || !isDbModel) { return false; } - if (userRole != null && isProxyAdminRole(userRole)) { + if (isWritableProxyAdmin(actor)) { return true; } - if (userID == null || teamId == null) { + if (actor.userID == null || teamId == null) { return false; } - return isTeamAdminOf(teams, userID, teamId); + return isTeamAdminOf(teams, actor.userID, teamId); }; diff --git a/ui/litellm-dashboard/src/utils/searchUtils.test.ts b/ui/litellm-dashboard/src/utils/searchUtils.test.ts new file mode 100644 index 00000000000..4935e8ab15d --- /dev/null +++ b/ui/litellm-dashboard/src/utils/searchUtils.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { filterBySearchTerm, matchesSearchTerm, rankBySearchRelevance } from "./searchUtils"; + +describe("matchesSearchTerm", () => { + it("matches everything on an empty or whitespace-only term", () => { + expect(matchesSearchTerm("", ["anything"])).toBe(true); + expect(matchesSearchTerm(" ", ["anything"])).toBe(true); + }); + + it("matches a substring of any field, case-insensitively", () => { + expect(matchesSearchTerm("BILL", ["Billing Router", "routes invoices"])).toBe(true); + expect(matchesSearchTerm("invoice", ["Billing Router", "routes invoices"])).toBe(true); + }); + + it("matches when every word appears in some field", () => { + expect(matchesSearchTerm("router invoices", ["Billing Router", "routes invoices"])).toBe(true); + expect(matchesSearchTerm("router refunds", ["Billing Router", "routes invoices"])).toBe(false); + }); + + it("returns false when nothing matches", () => { + expect(matchesSearchTerm("zzzz", ["Billing Router", "routes invoices"])).toBe(false); + }); + + it("ignores null and undefined fields", () => { + expect(matchesSearchTerm("billing", [null, undefined, "Billing Router"])).toBe(true); + expect(matchesSearchTerm("billing", [null, undefined])).toBe(false); + }); +}); + +describe("filterBySearchTerm", () => { + const agents = [ + { name: "Billing Router", description: "routes invoices" }, + { name: "Support Bot", description: "handles tickets" }, + ]; + + it("keeps only items whose fields match", () => { + expect(filterBySearchTerm(agents, "tickets", (a) => [a.name, a.description])).toEqual([agents[1]]); + }); + + it("returns an empty list when nothing matches", () => { + expect(filterBySearchTerm(agents, "zzzz", (a) => [a.name, a.description])).toEqual([]); + }); + + it("returns all items for an empty term", () => { + expect(filterBySearchTerm(agents, "", (a) => [a.name, a.description])).toEqual(agents); + }); +}); + +describe("rankBySearchRelevance", () => { + it("orders exact match, then prefix match, then shorter names", () => { + const items = [{ name: "gpt-4o-mini-transcribe" }, { name: "gpt-4o" }, { name: "chatgpt-4o-latest" }]; + expect(rankBySearchRelevance(items, "gpt-4o", (m) => m.name).map((m) => m.name)).toEqual([ + "gpt-4o", + "gpt-4o-mini-transcribe", + "chatgpt-4o-latest", + ]); + }); + + it("keeps the original order for an empty term", () => { + const items = [{ name: "b" }, { name: "a" }]; + expect(rankBySearchRelevance(items, "", (m) => m.name)).toEqual(items); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/searchUtils.ts b/ui/litellm-dashboard/src/utils/searchUtils.ts new file mode 100644 index 00000000000..b256a5db0c4 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/searchUtils.ts @@ -0,0 +1,32 @@ +type SearchField = string | null | undefined; + +const normalizeTerm = (term: string): string => term.trim().toLowerCase(); + +export function matchesSearchTerm(term: string, fields: ReadonlyArray): boolean { + const needle = normalizeTerm(term); + if (needle === "") return true; + + const haystacks = fields.filter((field): field is string => typeof field === "string").map((f) => f.toLowerCase()); + if (haystacks.some((haystack) => haystack.includes(needle))) return true; + + return needle.split(/\s+/).every((word) => haystacks.some((haystack) => haystack.includes(word))); +} + +export function filterBySearchTerm( + items: ReadonlyArray, + term: string, + fields: (item: T) => ReadonlyArray, +): T[] { + return items.filter((item) => matchesSearchTerm(term, fields(item))); +} + +export function rankBySearchRelevance(items: ReadonlyArray, term: string, name: (item: T) => string): T[] { + const needle = normalizeTerm(term); + if (needle === "") return [...items]; + + const score = (item: T): number => { + const candidate = name(item).toLowerCase(); + return (candidate === needle ? 1000 : 0) + (candidate.startsWith(needle) ? 100 : 0) + (1000 - candidate.length); + }; + return [...items].sort((a, b) => score(b) - score(a)); +} diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index 162b8a3df7b..553726faff0 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -44,20 +44,14 @@ const pointerBlocked = (element: HTMLElement): boolean => { return false; }; -/** - * Opens a Base UI Select and picks an option by its accessible name. - * - * The option is in the DOM one render before the popup finishes entering, and until then its - * positioner still carries `pointer-events: none`, which user-event refuses to click. Waiting on - * the option text alone is a race that React 19's flush timing loses. - */ export const chooseSelectOption = async ( user: Pick, "click">, trigger: HTMLElement, optionName: string | RegExp, + role: "option" | "menuitem" | "menuitemradio" = "option", ) => { await user.click(trigger); - const option = await screen.findByRole("option", { name: optionName }); + const option = await screen.findByRole(role, { name: optionName }); await waitFor(() => expect(pointerBlocked(option)).toBe(false)); await user.click(option); };