mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(e2e): preserve model and key lifecycle helpers during staging sync
This commit is contained in:
commit
1785f44088
534 changed files with 28161 additions and 6105 deletions
|
|
@ -1440,6 +1440,7 @@ jobs:
|
|||
TEST_FILES=$(printf "%s\n" \
|
||||
tests/local_testing/test_dual_cache.py \
|
||||
tests/local_testing/test_redis_batch_optimizations.py \
|
||||
tests/local_testing/test_redis_increment_with_floor.py \
|
||||
tests/local_testing/test_router_utils.py)
|
||||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
|
|
@ -2648,6 +2649,19 @@ jobs:
|
|||
name: Start mock LLM server
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Start mock Presidio server
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_presidio_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Wait for mock Presidio server
|
||||
command: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8091/health >/dev/null 2>&1; then exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Mock Presidio server never answered /health on port 8091" >&2
|
||||
exit 1
|
||||
- run:
|
||||
name: Start LiteLLM proxy
|
||||
environment:
|
||||
|
|
@ -2778,6 +2792,19 @@ jobs:
|
|||
name: Start mock LLM server
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Start mock Presidio server
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_presidio_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Wait for mock Presidio server
|
||||
command: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8091/health >/dev/null 2>&1; then exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Mock Presidio server never answered /health on port 8091" >&2
|
||||
exit 1
|
||||
- run:
|
||||
name: Start LiteLLM proxy under a server root path
|
||||
environment:
|
||||
|
|
|
|||
2
.github/workflows/auto-close-duplicates.yml
vendored
2
.github/workflows/auto-close-duplicates.yml
vendored
|
|
@ -22,7 +22,7 @@ on:
|
|||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
sweep-tests:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
|
|
|||
45
.github/workflows/cost-map-guard.yml
vendored
Normal file
45
.github/workflows/cost-map-guard.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Cost map guard
|
||||
|
||||
on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's cost map files are read as data and never executed
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cost-map-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Fetch the pull request head and its merge base
|
||||
id: revisions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
- name: Run the guard
|
||||
env:
|
||||
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
uv run --frozen python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
name: Publish basedpyright base counts
|
||||
|
||||
# Every commit on litellm_internal_staging is some branch's future merge-base.
|
||||
# Every commit on main or litellm_internal_staging can become a future merge-base.
|
||||
# Publishing its per-rule basedpyright counts as an artifact lets
|
||||
# scripts/type_check_gate.py download them in seconds instead of paying a
|
||||
# 60-110s second basedpyright pass on every fresh worktree or moved merge-base.
|
||||
|
|
@ -10,13 +10,13 @@ name: Publish basedpyright base counts
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref to compute and publish base counts for"
|
||||
description: "Ref to compute and publish base counts for (defaults to the workflow run's commit)"
|
||||
required: false
|
||||
default: litellm_internal_staging
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ jobs:
|
|||
sync_together_ai_models:
|
||||
if: github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: litellm_internal_staging
|
||||
ref: ${{ env.BASE_BRANCH }}
|
||||
persist-credentials: false
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
|
|
@ -63,6 +65,6 @@ jobs:
|
|||
gh pr create --title "feat(models): sync together_ai model registry" \
|
||||
--body-file "$RUNNER_TEMP/pr_body.md" \
|
||||
--head "$branch" \
|
||||
--base litellm_internal_staging
|
||||
--base "$BASE_BRANCH"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
|
|
|||
6
.github/workflows/test-code-quality.yml
vendored
6
.github/workflows/test-code-quality.yml
vendored
|
|
@ -74,6 +74,12 @@ jobs:
|
|||
- name: check_workflow_startup_safety
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_startup_safety.py
|
||||
|
||||
- name: check_workflow_job_name_collisions
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_job_name_collisions.py
|
||||
|
||||
- name: test_workflow_job_name_collisions
|
||||
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_workflow_job_name_collisions.py
|
||||
|
||||
- name: test_e2e_changed_gate
|
||||
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py
|
||||
|
||||
|
|
|
|||
1
.github/workflows/test-litellm-ui-unit.yml
vendored
1
.github/workflows/test-litellm-ui-unit.yml
vendored
|
|
@ -12,6 +12,7 @@ on:
|
|||
- "litellm_**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
|
||||
concurrency:
|
||||
|
|
|
|||
37
.github/workflows/test-model-map.yml
vendored
37
.github/workflows/test-model-map.yml
vendored
|
|
@ -1,37 +0,0 @@
|
|||
name: Validate model_prices_and_context_window.json
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate-model-prices-json:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate model_prices_and_context_window.json
|
||||
run: |
|
||||
jq empty model_prices_and_context_window.json
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Check model_prices_and_context_window.schema.json is in sync
|
||||
run: |
|
||||
uv run --frozen python ci_cd/generate_model_prices_schema.py --check
|
||||
3
.github/workflows/test-rust.yml
vendored
3
.github/workflows/test-rust.yml
vendored
|
|
@ -117,6 +117,9 @@ jobs:
|
|||
|
||||
- run: python tests/test_litellm/rust_bridge/native_route_wheel_test.py dist/*.whl
|
||||
|
||||
- name: Run pytest tests/test_litellm_rust with the compiled extension
|
||||
run: make test-rust-extension
|
||||
|
||||
- run: >-
|
||||
uv build --wheel --out-dir panic-dist
|
||||
--config-setting "maturin.build-args=--features panic-test,extension-module"
|
||||
|
|
|
|||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -116,6 +116,7 @@ jobs:
|
|||
tests/test_litellm/rerank_api
|
||||
tests/test_litellm/rust_bridge
|
||||
tests/test_litellm/sandbox
|
||||
tests/test_litellm/skills
|
||||
tests/test_litellm/test_router
|
||||
tests/test_litellm/vector_stores
|
||||
tests/test_litellm/videos
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ graph TD
|
|||
| `parallel_request_limiter` | `proxy/hooks/parallel_request_limiter_v3.py` | Rate limiting per key/user |
|
||||
| `cache_control_check` | `proxy/hooks/cache_control_check.py` | Cache validation |
|
||||
| `responses_id_security` | `proxy/hooks/responses_id_security.py` | Response ID validation |
|
||||
| `litellm_skills` | `proxy/hooks/skills_injection.py` | Skills injection |
|
||||
| `litellm_skills` | `proxy/hooks/litellm_skills/main.py` | Skills injection |
|
||||
|
||||
To add a new proxy hook, implement `CustomLogger` and register in `PROXY_HOOKS`.
|
||||
|
||||
|
|
@ -220,20 +220,20 @@ graph LR
|
|||
| Job | Interval | Purpose | Key Files |
|
||||
|-----|----------|---------|-----------|
|
||||
| `update_spend` | 60s | Batch write spend logs to PostgreSQL | `proxy/db/db_spend_update_writer.py` |
|
||||
| `reset_budget` | 10-12min | Reset budgets for keys/users/teams | `proxy/management_helpers/budget_reset_job.py` |
|
||||
| `reset_budget` | 10-12min | Reset budgets for keys/users/teams | `proxy/common_utils/reset_budget_job.py` |
|
||||
| `add_deployment` | 10s | Sync new model deployments from DB | `proxy/proxy_server.py` (`ProxyConfig`) |
|
||||
| `cleanup_old_spend_logs` | cron/interval | Delete old spend logs | `proxy/management_helpers/spend_log_cleanup.py` |
|
||||
| `check_batch_cost` | 30min | Calculate costs for batch jobs | `proxy/management_helpers/check_batch_cost_job.py` |
|
||||
| `check_responses_cost` | 30min | Calculate costs for responses API | `proxy/management_helpers/check_responses_cost_job.py` |
|
||||
| `process_rotations` | 1hr | Auto-rotate API keys | `proxy/management_helpers/key_rotation_manager.py` |
|
||||
| `cleanup_old_spend_logs` | cron/interval | Delete old spend logs | `proxy/db/db_transaction_queue/spend_log_cleanup.py` |
|
||||
| `check_batch_cost` | 30min | Calculate costs for batch jobs | `enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py` |
|
||||
| `check_responses_cost` | 30min | Calculate costs for responses API | `enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py` |
|
||||
| `process_rotations` | 1hr | Auto-rotate API keys | `proxy/common_utils/key_rotation_manager.py` |
|
||||
| `_run_background_health_check` | continuous | Health check model deployments | `proxy/proxy_server.py` |
|
||||
| `send_weekly_spend_report` | weekly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
|
||||
| `send_monthly_spend_report` | monthly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
|
||||
|
||||
**Cost Attribution Flow:**
|
||||
1. LLM response returns to `utils.py` wrapper after `litellm.acompletion()` completes
|
||||
2. `update_response_metadata()` (`llm_response_utils/response_metadata.py`) is called
|
||||
3. `logging_obj._response_cost_calculator()` (`litellm_logging.py`) calculates cost via `litellm.completion_cost()` (`cost_calculator.py`)
|
||||
2. `update_response_metadata()` (`litellm_core_utils/llm_response_utils/response_metadata.py`) is called
|
||||
3. `logging_obj._response_cost_calculator()` (`litellm_core_utils/litellm_logging.py`) calculates cost via `litellm.completion_cost()` (`cost_calculator.py`)
|
||||
4. Cost is stored in `response._hidden_params["response_cost"]`
|
||||
5. `proxy/common_request_processing.py` extracts cost from `hidden_params` and adds to response headers (`x-litellm-response-cost`)
|
||||
6. `logging_obj.async_success_handler()` triggers callbacks including `_ProxyDBLogger.async_log_success_event()`
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Never test structure of code only function of it
|
|||
|
||||
End-to-end tests belong in `tests/e2e/` and must follow the harness conventions documented in that directory's `CLAUDE.md`
|
||||
|
||||
When creating PRs, don't set base to `main`. `litellm_internal_staging` is the default base branch and serves that purpose for both internal and external / OSS contributions
|
||||
When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD`
|
||||
|
||||
When writing a PR body, treat the comments and imperative instructions inside .github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
|
||||
|
||||
|
|
@ -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 --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
|
||||
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, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it
|
||||
|
||||
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
|
||||
|
|
@ -52,7 +52,7 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
|
|||
|
||||
Python max line length is 120, not 88
|
||||
|
||||
Never edit or commit `ruff-strict-budget.json`, `type-discipline-budget.json`, `basedpyright-code-budget.json`, or `test-quality-budget.json` on a PR branch, and don't run `make lint-budget-update` there. A scheduled Devin automation lowers the limits on `litellm_internal_staging` in its own PR by exactly what landed since the last ratchet, so concurrent PRs don't fight over the same `"limit"` lines. If your branch already carries a budget edit, drop it before opening the PR
|
||||
Never edit or commit `ruff-strict-budget.json`, `type-discipline-budget.json`, `basedpyright-code-budget.json`, or `test-quality-budget.json` on a PR branch, and don't run `make lint-budget-update` there. A scheduled Devin automation lowers the limits on the default branch in its own PR by exactly what landed since the last ratchet, so concurrent PRs don't fight over the same `"limit"` lines. Keep the hosted automation's target in sync when the repository default changes. If your branch already carries a budget edit, drop it before opening the PR
|
||||
|
||||
`make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ When referencing or running models (coding, QA'ing, writing docs, writing tests,
|
|||
|
||||
Always pull before starting any work. The checkout or worktree may be sitting on a stale branch
|
||||
|
||||
If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
|
||||
If you're an internal contributor, when creating a new PR, the typical flow is to branch off the repository's current default branch and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
|
||||
|
||||
Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions or comments. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch
|
||||
|
||||
|
|
|
|||
|
|
@ -315,10 +315,12 @@ Ensure the UI builds successfully before submitting your PR:
|
|||
npm run build
|
||||
```
|
||||
|
||||
Local lint and budget checks follow origin's current default branch. They refresh it from the remote instead of trusting cached `origin/HEAD`. For an intentional comparison against another branch or commit, use `make check BASE_REF=<ref>` or the standalone gate's `--base <ref>` option. An explicit ref can also be used offline once it has been fetched locally. Without an override, unavailable remote metadata stops the check
|
||||
|
||||
## Submitting Your PR
|
||||
|
||||
1. **Push your branch**: `git push origin your-feature-branch`
|
||||
2. **Create a PR**: Go to GitHub and open a pull request against [`litellm_internal_staging`](https://github.com/BerriAI/litellm/tree/litellm_internal_staging), which is the default base branch. Do not target `main`.
|
||||
2. **Create a PR**: Go to GitHub and open a pull request against the repository's current default branch. Run `python3 scripts/default_branch.py --branch` to check its name
|
||||
3. **Fill out the PR template**: Provide clear description of changes
|
||||
4. **Wait for review**: Maintainers will review and provide feedback
|
||||
5. **Address feedback**: Make requested changes and push updates
|
||||
|
|
|
|||
73
Makefile
73
Makefile
|
|
@ -4,6 +4,7 @@
|
|||
.PHONY: help test test-unit test-unit-llms test-unit-proxy-guardrails test-unit-proxy-core test-unit-proxy-misc \
|
||||
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
|
||||
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
|
||||
test-rust-extension \
|
||||
info lint lint-inner lint-dev lint-checks format \
|
||||
lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \
|
||||
lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \
|
||||
|
|
@ -34,7 +35,7 @@ help:
|
|||
@echo " make lint-basedpyright-budget-update - Ratchet basedpyright limits down by what this branch fixed"
|
||||
@echo " make lint-format - Check ruff format formatting (matches CI)"
|
||||
@echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its limit"
|
||||
@echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches staging, simulates the merge)"
|
||||
@echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches the default branch, simulates the merge)"
|
||||
@echo " make lint-ruff-budget-update - Ratchet ruff-strict-budget.json limits down by what this branch fixed"
|
||||
@echo " make lint-test-quality - Gate the test suite against test-quality-budget.json"
|
||||
@echo " make lint-budget-update - Ratchet all budgets down (ruff + type-discipline + test quality + basedpyright)"
|
||||
|
|
@ -54,12 +55,16 @@ help:
|
|||
@echo " make test-proxy-unit-b - Run proxy_unit_tests (p-z, ~28 files)"
|
||||
@echo " make test-integration - Run integration tests"
|
||||
@echo " make test-unit-helm - Run helm unit tests"
|
||||
@echo " make test-rust-extension - Build the Rust extension and run its public Python tests"
|
||||
@echo ""
|
||||
@echo "Heavy targets (check, lint) queue for LITELLM_GATE_SLOTS machine-wide"
|
||||
@echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine."
|
||||
|
||||
UV := uv
|
||||
UV_RUN := $(UV) run --no-sync
|
||||
BASE_REF ?=
|
||||
export BASE_REF
|
||||
RESOLVE_BASE = python3 scripts/default_branch.py --base "$(BASE_REF)"
|
||||
|
||||
# Machine-wide slot queue for the heavy targets below; python3 + stdlib only, so
|
||||
# it runs before any venv exists. See scripts/gate_slot_lock.py.
|
||||
|
|
@ -67,7 +72,7 @@ GATE_SLOT_LOCK := python3 scripts/gate_slot_lock.py
|
|||
|
||||
LINT_DEP_INSTALL ?= install-dev
|
||||
LINT_E2E_DEP_INSTALL ?= lint-install
|
||||
LINT_DEP_BASE ?= lint-fetch-base
|
||||
LINT_DEP_BASE ?=
|
||||
LINT_JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)
|
||||
LINT_OUTPUT_SYNC := $(if $(filter output-sync,$(.FEATURES)),--output-sync=target,)
|
||||
|
||||
|
|
@ -130,10 +135,8 @@ format: install-dev
|
|||
format-check: install-dev
|
||||
cd litellm && $(UV_RUN) ruff format --check --exclude '/enterprise/' . && cd ..
|
||||
|
||||
# Single fetch of the PR base so the delta-based gates below share one network round
|
||||
# trip instead of each re-fetching when chained from `lint`.
|
||||
lint-fetch-base:
|
||||
git fetch origin litellm_internal_staging
|
||||
@$(RESOLVE_BASE)
|
||||
|
||||
# Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated
|
||||
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The
|
||||
|
|
@ -150,7 +153,9 @@ lint-install:
|
|||
# recursively, so 'litellm/*.py' covers nested modules and the top-level files that
|
||||
# CI's 'litellm/**/*.py' skips, which makes this target a superset of the CI step.
|
||||
lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
@files=$$(git diff --name-only --diff-filter=ACMR origin/litellm_internal_staging...HEAD -- 'litellm/*.py' | grep -v '^litellm/enterprise/' || true); \
|
||||
@base_ref=$$($(RESOLVE_BASE)) && \
|
||||
changed=$$(git diff --name-only --diff-filter=ACMR "$$base_ref...HEAD" -- 'litellm/*.py') && \
|
||||
files=$$(printf '%s\n' "$$changed" | grep -v '^litellm/enterprise/' || true) || exit $$?; \
|
||||
if [ -z "$$files" ]; then \
|
||||
echo "No changed litellm Python files to format-check."; \
|
||||
else \
|
||||
|
|
@ -167,7 +172,9 @@ lint-ruff: $(LINT_DEP_INSTALL)
|
|||
# https://github.com/astral-sh/ruff/discussions/10977
|
||||
# https://github.com/astral-sh/ruff/discussions/4049
|
||||
lint-format-changed: install-dev
|
||||
@git diff origin/main --unified=0 --no-color -- '*.py' | \
|
||||
@base_ref=$$($(RESOLVE_BASE)) && \
|
||||
diff=$$(git diff "$$base_ref" --unified=0 --no-color -- '*.py') && \
|
||||
printf '%s\n' "$$diff" | \
|
||||
perl -ne '\
|
||||
if (/^diff --git a\/(.*) b\//) { $$file = $$1; } \
|
||||
if (/^@@ .* \+(\d+)(?:,(\d+))? @@/) { \
|
||||
|
|
@ -182,20 +189,22 @@ lint-format-changed: install-dev
|
|||
done
|
||||
|
||||
lint-ruff-dev: install-dev
|
||||
@tmpfile=$$(mktemp /tmp/ruff-dev.XXXXXX) && \
|
||||
@base_ref=$$($(RESOLVE_BASE)) || exit $$?; \
|
||||
tmpfile=$$(mktemp /tmp/ruff-dev.XXXXXX) && \
|
||||
cd litellm && \
|
||||
($(UV_RUN) ruff check . --output-format=pylint || true) > "$$tmpfile" && \
|
||||
$(UV_RUN) diff-quality --violations=pylint "$$tmpfile" --compare-branch=origin/main && \
|
||||
$(UV_RUN) diff-quality --violations=pylint "$$tmpfile" --compare-branch="$$base_ref" && \
|
||||
cd .. ; \
|
||||
rm -f "$$tmpfile"
|
||||
|
||||
lint-ruff-FULL-dev: install-dev
|
||||
@files=$$(git diff --name-only origin/main -- '*.py'); \
|
||||
@base_ref=$$($(RESOLVE_BASE)) && \
|
||||
files=$$(git diff --name-only "$$base_ref" -- '*.py') || exit $$?; \
|
||||
if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \
|
||||
else echo "No changed .py files to check."; fi
|
||||
|
||||
lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
$(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging
|
||||
$(UV_RUN) python scripts/type_check_gate.py --base "$(BASE_REF)"
|
||||
|
||||
lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
|
||||
$(UV_RUN) basedpyright tests/e2e
|
||||
|
|
@ -203,37 +212,37 @@ lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
|
|||
# Type-discipline budget (mutable collections / casts / type guards / kwargs /
|
||||
# unexplained suppressions), the test-linting.yml step `make lint` used to omit.
|
||||
lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
$(UV_RUN) python scripts/type_discipline_gate.py --base origin/litellm_internal_staging
|
||||
$(UV_RUN) python scripts/type_discipline_gate.py --base "$(BASE_REF)"
|
||||
|
||||
# Test-quality budget (zero-assert / mock-echo tests, sys.path.insert, raw env writes,
|
||||
# litellm module-global mutation, credential-gated skips, conftest snapshot
|
||||
# inventory), counted across tests/ the same delta-vs-base way.
|
||||
lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
$(UV_RUN) python scripts/test_quality_gate.py --base origin/litellm_internal_staging
|
||||
$(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)"
|
||||
|
||||
# --update lowers each limit by what this branch fixed since its branch point, so
|
||||
# it needs the base ref fetched to resolve the merge-base.
|
||||
lint-basedpyright-budget-update: install-dev lint-fetch-base
|
||||
$(UV_RUN) python scripts/type_check_gate.py --update
|
||||
lint-basedpyright-budget-update: install-dev
|
||||
$(UV_RUN) python scripts/type_check_gate.py --update --base "$(BASE_REF)"
|
||||
|
||||
lint-format: format-check
|
||||
|
||||
lint-ruff-budget: install-dev
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
|
||||
|
||||
# Strict gate, invoked the same way CI does in test-linting.yml so a local pass
|
||||
# means the CI check will pass too.
|
||||
lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py --base origin/litellm_internal_staging
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
|
||||
|
||||
lint-ruff-budget-update: install-dev lint-fetch-base
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py --update
|
||||
lint-ruff-budget-update: install-dev
|
||||
$(UV_RUN) python scripts/ruff_strict_gate.py --update --base "$(BASE_REF)"
|
||||
|
||||
lint-type-discipline-budget-update: install-dev lint-fetch-base
|
||||
$(UV_RUN) python scripts/type_discipline_gate.py --update
|
||||
lint-type-discipline-budget-update: install-dev
|
||||
$(UV_RUN) python scripts/type_discipline_gate.py --update --base "$(BASE_REF)"
|
||||
|
||||
lint-test-quality-budget-update: install-dev lint-fetch-base
|
||||
$(UV_RUN) python scripts/test_quality_gate.py --update
|
||||
lint-test-quality-budget-update: install-dev
|
||||
$(UV_RUN) python scripts/test_quality_gate.py --update --base "$(BASE_REF)"
|
||||
|
||||
# Ratchet all budgets in one shot (ruff strict + type-discipline + test quality + basedpyright)
|
||||
lint-budget-update: lint-ruff-budget-update lint-type-discipline-budget-update lint-test-quality-budget-update lint-basedpyright-budget-update
|
||||
|
|
@ -249,14 +258,15 @@ check-import-safety: $(LINT_DEP_INSTALL)
|
|||
# runs the diff-scoped ruff format check, whole-tree ruff check, the strict-rule /
|
||||
# type-discipline / basedpyright budgets as a delta vs the base, then the circular-import
|
||||
# and import-safety checks. Steps that compare against the base resolve it the same way CI
|
||||
# does (merge-base with origin/litellm_internal_staging). Setup (env sync, Prisma client,
|
||||
# does (merge-base with origin's current default branch). Setup (env sync, Prisma client,
|
||||
# base fetch) runs once up front; the checks themselves are independent, so a sub-make
|
||||
# fans them out with -j and the fast ones finish under basedpyright's shadow.
|
||||
lint:
|
||||
@$(GATE_SLOT_LOCK) $(MAKE) lint-inner
|
||||
|
||||
lint-inner: lint-install lint-fetch-base
|
||||
$(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks
|
||||
lint-inner: lint-install
|
||||
@base_ref=$$($(RESOLVE_BASE)) && \
|
||||
$(MAKE) BASE_REF="$$base_ref" -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks
|
||||
|
||||
lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
|
||||
|
||||
|
|
@ -281,6 +291,17 @@ pre-commit:
|
|||
@$(MAKE) check
|
||||
|
||||
# Testing targets
|
||||
test-rust-extension:
|
||||
@temporary=$$(mktemp -d) && \
|
||||
trap 'rm -rf "$$temporary"' EXIT HUP INT TERM && \
|
||||
$(UV) build --python 3.12 --wheel --out-dir "$$temporary/wheels" && \
|
||||
set -- "$$temporary"/wheels/*.whl && \
|
||||
[ "$$#" -eq 1 ] && \
|
||||
UV_PROJECT_ENVIRONMENT="$$temporary/venv" $(UV) sync --python 3.12 --frozen --no-install-project --all-groups --all-extras && \
|
||||
$(UV) pip install --python "$$temporary/venv/bin/python" --no-deps "$$1" && \
|
||||
LITELLM_RUST=1 LITELLM_LOCAL_MODEL_COST_MAP=True \
|
||||
"$$temporary/venv/bin/python" -I -m pytest --import-mode=importlib -m requires_rust_extension tests/test_litellm_rust
|
||||
|
||||
test: install-test-deps
|
||||
$(UV_RUN) pytest tests/
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
"limit": 56
|
||||
},
|
||||
"reportPrivateUsage": {
|
||||
"limit": 1808
|
||||
"limit": 1804
|
||||
},
|
||||
"reportRedeclaration": {
|
||||
"limit": 8
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"limit": 21
|
||||
},
|
||||
"reportUnusedFunction": {
|
||||
"limit": 138
|
||||
"limit": 136
|
||||
},
|
||||
"reportUnusedImport": {
|
||||
"limit": 542
|
||||
|
|
|
|||
146
ci_cd/cost_map_guard.py
Normal file
146
ci_cd/cost_map_guard.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Guard the cost map on pull requests.
|
||||
|
||||
Every pull request gets the file checks: the three cost map files parse, the backup copy matches the root file,
|
||||
and the JSON schema is in sync and validates the map. Pull requests from the cost map sync bot (branches named
|
||||
litellm_cost_map_sync_*) additionally may only touch those three files and may only add or update models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from generate_model_prices_schema import SPECIAL_ROOT_KEYS, build_schema, render, validation_errors
|
||||
|
||||
COST_MAP_PATH: Final = "model_prices_and_context_window.json"
|
||||
BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
|
||||
SCHEMA_PATH: Final = "model_prices_and_context_window.schema.json"
|
||||
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH, SCHEMA_PATH)
|
||||
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
|
||||
|
||||
CostMap = dict[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Snapshot:
|
||||
cost_map: str
|
||||
backup: str
|
||||
schema: str
|
||||
|
||||
|
||||
def _parse_object(text: str, path: str) -> CostMap | str:
|
||||
try:
|
||||
parsed: Final = json.loads(text)
|
||||
except json.JSONDecodeError as error:
|
||||
return f"{path} is not valid JSON: {error}"
|
||||
return parsed if isinstance(parsed, dict) else f"{path} must be a JSON object at the root"
|
||||
|
||||
|
||||
def _rendered_schema(cost_map: CostMap) -> str:
|
||||
try:
|
||||
return render(build_schema(cost_map))
|
||||
except SystemExit as error:
|
||||
return str(error)
|
||||
|
||||
|
||||
def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]:
|
||||
schema_text: Final = _rendered_schema(head_map)
|
||||
if not schema_text.startswith("{"):
|
||||
return (schema_text,)
|
||||
backup_failure: Final = (
|
||||
()
|
||||
if head.backup == head.cost_map
|
||||
else (f"{BACKUP_PATH} differs from {COST_MAP_PATH}; copy the root file over it",)
|
||||
)
|
||||
schema_failure: Final = (
|
||||
()
|
||||
if head.schema == schema_text
|
||||
else (
|
||||
f"{SCHEMA_PATH} is out of sync with {COST_MAP_PATH}; "
|
||||
"run `python ci_cd/generate_model_prices_schema.py` and commit the result",
|
||||
)
|
||||
)
|
||||
return (
|
||||
*backup_failure,
|
||||
*schema_failure,
|
||||
*(
|
||||
f"{COST_MAP_PATH} does not validate against its schema: {error}"
|
||||
for error in validation_errors(head_map, json.loads(schema_text))[:20]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _entries(cost_map: CostMap) -> dict[str, dict[str, object]]:
|
||||
return {key: entry for key, entry in cost_map.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
base_map: Final = _parse_object(base.cost_map, COST_MAP_PATH)
|
||||
if isinstance(base_map, str):
|
||||
return (f"merge base: {base_map}",)
|
||||
base_entries: Final = _entries(base_map)
|
||||
head_entries: Final = _entries(head_map)
|
||||
removed_fields: Final = tuple(
|
||||
f"{key}.{field}"
|
||||
for key, entry in base_entries.items()
|
||||
if key in head_entries
|
||||
for field in entry
|
||||
if field not in head_entries[key]
|
||||
)
|
||||
return (
|
||||
*(
|
||||
f"bot PRs may only change the cost map files, not {path}"
|
||||
for path in changed_files
|
||||
if path not in GUARDED_PATHS
|
||||
),
|
||||
*(f"bot PRs may not remove models: {key}" for key in base_map if key not in head_map),
|
||||
*(f"bot PRs may not remove fields: {ref}" for ref in removed_fields),
|
||||
*(
|
||||
f"bot PRs may not change {key}"
|
||||
for key in sorted(SPECIAL_ROOT_KEYS)
|
||||
if base_map.get(key) != head_map.get(key)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def guard_failures(base: Snapshot, head: Snapshot, changed_files: Sequence[str], bot: bool) -> tuple[str, ...]:
|
||||
head_map: Final = _parse_object(head.cost_map, COST_MAP_PATH)
|
||||
if isinstance(head_map, str):
|
||||
return (head_map,)
|
||||
return (*_file_failures(head, head_map), *(_bot_failures(base, head_map, changed_files) if bot else ()))
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
result: Final = subprocess.run(("git", *args), check=False, capture_output=True, text=True)
|
||||
return result.stdout if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def snapshot(revision: str) -> Snapshot:
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") for path in GUARDED_PATHS))
|
||||
|
||||
|
||||
def main(argv: Sequence[str]) -> int:
|
||||
parser: Final = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", required=True, help="merge base of the pull request")
|
||||
parser.add_argument("--head", required=True, help="head commit of the pull request")
|
||||
parser.add_argument("--head-ref", required=True, help="head branch name of the pull request")
|
||||
args: Final = parser.parse_args(argv)
|
||||
bot: Final = args.head_ref.startswith(BOT_BRANCH_PREFIX)
|
||||
changed_files: Final = tuple(_git("diff", "--name-only", args.base, args.head).splitlines())
|
||||
failures: Final = guard_failures(snapshot(args.base), snapshot(args.head), changed_files, bot)
|
||||
contract: Final = "bot contract enforced" if bot else "human PR, file checks only"
|
||||
if failures:
|
||||
print(f"cost map guard failed ({contract}):")
|
||||
print("\n".join(f"- {failure}" for failure in failures))
|
||||
return 1
|
||||
print(f"cost map guard passed ({contract})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
|
@ -6,12 +6,9 @@ import subprocess
|
|||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import testing.postgresql
|
||||
|
||||
from typing import Final
|
||||
|
||||
DESTRUCTIVE_PATTERN = re.compile(r"\bDROP\s+(COLUMN|TABLE|INDEX)\b", re.IGNORECASE)
|
||||
DEFAULT_BASE_BRANCH = "litellm_internal_staging"
|
||||
|
||||
|
||||
def _find_destructive_statements(sql: str) -> list:
|
||||
|
|
@ -94,31 +91,57 @@ def _print_stale_branch_refusal(base_branch: str, behind: int) -> None:
|
|||
print(banner, file=out)
|
||||
|
||||
|
||||
def _check_branch_freshness(root_dir: Path, base_branch: str) -> None:
|
||||
def _default_base_branch(root_dir: Path) -> str:
|
||||
try:
|
||||
result: Final = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve().parents[1] / "scripts" / "default_branch.py"),
|
||||
"--repo-root",
|
||||
str(root_dir),
|
||||
"--branch",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
_print_freshness_failure(
|
||||
"default branch",
|
||||
"Could not discover origin's default branch. Pass --base-branch <name> to choose one.",
|
||||
exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc),
|
||||
)
|
||||
sys.exit(3)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _check_branch_freshness(root_dir: Path, base_branch: str | None = None) -> None:
|
||||
"""Fetch origin/<base_branch> and exit 3 if HEAD is behind it."""
|
||||
resolved_branch: Final = base_branch or _default_base_branch(root_dir)
|
||||
cwd = str(root_dir)
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", base_branch],
|
||||
["git", "fetch", "origin", f"+refs/heads/{resolved_branch}:refs/remotes/origin/{resolved_branch}"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
_print_freshness_failure(base_branch, "git executable not found on PATH")
|
||||
_print_freshness_failure(resolved_branch, "git executable not found on PATH")
|
||||
sys.exit(3)
|
||||
except subprocess.CalledProcessError as e:
|
||||
_print_freshness_failure(
|
||||
base_branch,
|
||||
f"`git fetch origin {base_branch}` failed",
|
||||
resolved_branch,
|
||||
f"`git fetch origin {resolved_branch}` failed",
|
||||
e.stderr or "",
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"HEAD..origin/{base_branch}"],
|
||||
["git", "rev-list", "--count", f"HEAD..origin/{resolved_branch}"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -127,23 +150,23 @@ def _check_branch_freshness(root_dir: Path, base_branch: str) -> None:
|
|||
behind = int(result.stdout.strip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
_print_freshness_failure(
|
||||
base_branch,
|
||||
f"`git rev-list HEAD..origin/{base_branch}` failed",
|
||||
resolved_branch,
|
||||
f"`git rev-list HEAD..origin/{resolved_branch}` failed",
|
||||
e.stderr or "",
|
||||
)
|
||||
sys.exit(3)
|
||||
except ValueError:
|
||||
_print_freshness_failure(
|
||||
base_branch,
|
||||
resolved_branch,
|
||||
"could not parse commit count from `git rev-list`",
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
if behind > 0:
|
||||
_print_stale_branch_refusal(base_branch, behind)
|
||||
_print_stale_branch_refusal(resolved_branch, behind)
|
||||
sys.exit(3)
|
||||
|
||||
print(f"Branch freshness OK: up to date with origin/{base_branch}.")
|
||||
print(f"Branch freshness OK: up to date with origin/{resolved_branch}.")
|
||||
|
||||
|
||||
def _print_destructive_refusal(destructive_lines: list) -> None:
|
||||
|
|
@ -198,7 +221,7 @@ def _print_destructive_refusal(destructive_lines: list) -> None:
|
|||
def create_migration(
|
||||
migration_name: str = None,
|
||||
allow_destructive: bool = False,
|
||||
base_branch: str = DEFAULT_BASE_BRANCH,
|
||||
base_branch: str | None = None,
|
||||
skip_freshness_check: bool = False,
|
||||
):
|
||||
"""
|
||||
|
|
@ -211,7 +234,7 @@ def create_migration(
|
|||
DROP COLUMN, DROP TABLE, or DROP INDEX statements. Without this
|
||||
flag, the script exits non-zero and prints guidance.
|
||||
base_branch (str): Branch to check freshness against
|
||||
(default: "litellm_internal_staging").
|
||||
(default: origin's current default branch).
|
||||
skip_freshness_check (bool): Skip the "branch is up to date" check.
|
||||
Only for intentional migrations against an older base.
|
||||
"""
|
||||
|
|
@ -225,6 +248,8 @@ def create_migration(
|
|||
else:
|
||||
_check_branch_freshness(root_dir, base_branch)
|
||||
|
||||
import testing.postgresql
|
||||
|
||||
try:
|
||||
migrations_dir = (
|
||||
root_dir / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
|
||||
|
|
@ -342,9 +367,8 @@ if __name__ == "__main__":
|
|||
)
|
||||
parser.add_argument(
|
||||
"--base-branch",
|
||||
default=DEFAULT_BASE_BRANCH,
|
||||
help=(
|
||||
f"Branch to check freshness against (default: {DEFAULT_BASE_BRANCH}). "
|
||||
"Branch to check freshness against (default: origin's current default branch). "
|
||||
"The script fetches origin/<base-branch> and refuses to run if HEAD "
|
||||
"is behind it."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
#!/bin/sh
|
||||
|
||||
# stale samples from a previous container incarnation would be summed into the aggregate
|
||||
if [ -n "$PROMETHEUS_MULTIPROC_DIR" ]; then
|
||||
mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
|
||||
rm -f "$PROMETHEUS_MULTIPROC_DIR"/*.db
|
||||
fi
|
||||
|
||||
case "$USE_DDTRACE" in
|
||||
[Tt][Rr][Uu][Ee])
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
|
|
|
|||
|
|
@ -152,6 +152,13 @@ spec:
|
|||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsEnv" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.metricsServer.enabled }}
|
||||
{{- if eq (int .Values.metricsServer.port) (int .Values.service.port) }}
|
||||
{{- fail "metricsServer.port must differ from service.port" }}
|
||||
{{- end }}
|
||||
- name: PROMETHEUS_METRICS_PORT
|
||||
value: {{ .Values.metricsServer.port | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.migrationJob.enabled }}
|
||||
# Schema updates are owned by the dedicated migrations Job; skip
|
||||
# the proxy's startup `prisma db push` so N replicas don't race
|
||||
|
|
@ -189,6 +196,11 @@ spec:
|
|||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
{{- if .Values.metricsServer.enabled }}
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.metricsServer.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.livenessProbe.path | quote }}
|
||||
|
|
|
|||
17
helm/litellm-helm/templates/service-metrics.yaml
Normal file
17
helm/litellm-helm/templates/service-metrics.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{{- if .Values.metricsServer.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "litellm.fullname" . }}-metrics
|
||||
labels:
|
||||
{{- include "litellm.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: {{ .Values.metricsServer.port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
selector:
|
||||
{{- include "litellm.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
|
|
@ -26,7 +26,7 @@ spec:
|
|||
{{- toYaml .namespaceSelector.matchNames | nindent 4 }}
|
||||
{{- end }}
|
||||
endpoints:
|
||||
- port: http
|
||||
- port: {{ ternary "metrics" "http" $.Values.metricsServer.enabled }}
|
||||
path: /metrics/
|
||||
interval: {{ .interval }}
|
||||
scrapeTimeout: {{ .scrapeTimeout }}
|
||||
|
|
|
|||
106
helm/litellm-helm/tests/metrics_server_tests.yaml
Normal file
106
helm/litellm-helm/tests/metrics_server_tests.yaml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
suite: separate metrics server
|
||||
templates:
|
||||
- configmap-litellm.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- service-metrics.yaml
|
||||
- servicemonitor.yaml
|
||||
tests:
|
||||
- it: should not expose a metrics port or PROMETHEUS_METRICS_PORT by default
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].ports
|
||||
content:
|
||||
name: metrics
|
||||
any: true
|
||||
template: deployment.yaml
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: PROMETHEUS_METRICS_PORT
|
||||
any: true
|
||||
template: deployment.yaml
|
||||
- lengthEqual:
|
||||
path: spec.ports
|
||||
count: 1
|
||||
template: service.yaml
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
template: service-metrics.yaml
|
||||
|
||||
- it: should scrape the proxy port when the metrics server is disabled
|
||||
template: servicemonitor.yaml
|
||||
set:
|
||||
serviceMonitor.enabled: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.endpoints[0].port
|
||||
value: http
|
||||
|
||||
- it: should wire the separate metrics server through container, a ClusterIP metrics service and servicemonitor
|
||||
set:
|
||||
metricsServer.enabled: true
|
||||
metricsServer.port: 4101
|
||||
serviceMonitor.enabled: true
|
||||
service.type: LoadBalancer
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: PROMETHEUS_METRICS_PORT
|
||||
value: "4101"
|
||||
template: deployment.yaml
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].ports
|
||||
content:
|
||||
name: metrics
|
||||
containerPort: 4101
|
||||
protocol: TCP
|
||||
template: deployment.yaml
|
||||
- lengthEqual:
|
||||
path: spec.ports
|
||||
count: 1
|
||||
template: service.yaml
|
||||
- equal:
|
||||
path: spec.type
|
||||
value: LoadBalancer
|
||||
template: service.yaml
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: RELEASE-NAME-litellm-metrics
|
||||
template: service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.type
|
||||
value: ClusterIP
|
||||
template: service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.ports
|
||||
value:
|
||||
- port: 4101
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
template: service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.selector
|
||||
value:
|
||||
app.kubernetes.io/name: litellm
|
||||
app.kubernetes.io/instance: RELEASE-NAME
|
||||
template: service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.endpoints[0].port
|
||||
value: metrics
|
||||
template: servicemonitor.yaml
|
||||
- equal:
|
||||
path: spec.endpoints[0].path
|
||||
value: /metrics/
|
||||
template: servicemonitor.yaml
|
||||
|
||||
- it: should reject a metrics port equal to the proxy port
|
||||
template: deployment.yaml
|
||||
set:
|
||||
metricsServer.enabled: true
|
||||
metricsServer.port: 4000
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: metricsServer.port must differ from service.port
|
||||
|
|
@ -180,6 +180,16 @@ proxy_config:
|
|||
general_settings:
|
||||
master_key: os.environ/PROXY_MASTER_KEY
|
||||
|
||||
# Serve Prometheus /metrics from a separate process (PROMETHEUS_METRICS_PORT)
|
||||
# so a scrape never runs on an inference worker. Adds a `metrics` port to the
|
||||
# container and a dedicated ClusterIP `<release>-metrics` Service, and the
|
||||
# ServiceMonitor scrapes it instead of the proxy port. The separate port has
|
||||
# no virtual-key auth: keep it off public ingress. Needs the proxy image
|
||||
# v1.101.0 or newer.
|
||||
metricsServer:
|
||||
enabled: false
|
||||
port: 4001
|
||||
|
||||
resources:
|
||||
{}
|
||||
# Unset by default so the chart installs on small clusters such as Minikube, and so an
|
||||
|
|
|
|||
|
|
@ -441,3 +441,5 @@ ImplementationSpecific
|
|||
{{- .pathType -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.gateway.prometheusMultiprocDir" -}}/tmp/litellm_prometheus_multiproc{{- end -}}
|
||||
|
|
|
|||
|
|
@ -64,14 +64,25 @@ spec:
|
|||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsEnv" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.metricsServer.enabled }}
|
||||
{{- if eq (int .Values.gateway.metricsServer.port) 4000 }}
|
||||
{{- fail "gateway.metricsServer.port must differ from the gateway port 4000" }}
|
||||
{{- end }}
|
||||
- name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
|
||||
{{- end }}
|
||||
{{- include "litellm.envFrom" .Values.gateway | nindent 10 }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled }}
|
||||
volumeMounts:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
mountPath: /app/config/config.yaml
|
||||
subPath: config.yaml
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.metricsServer.enabled }}
|
||||
- name: prometheus-multiproc
|
||||
mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
|
@ -97,16 +108,54 @@ spec:
|
|||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.gateway.resources | nindent 12 }}
|
||||
{{- if .Values.gateway.metricsServer.enabled }}
|
||||
- name: metrics
|
||||
image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.gateway.image.pullPolicy }}
|
||||
{{- with .Values.gateway.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- litellm.proxy.prometheus_metrics_server
|
||||
- --port
|
||||
- {{ .Values.gateway.metricsServer.port | quote }}
|
||||
env:
|
||||
- name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.gateway.metricsServer.port }}
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: prometheus-multiproc
|
||||
mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
|
||||
readinessProbe:
|
||||
tcpSocket: { port: metrics }
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket: { port: metrics }
|
||||
periodSeconds: 15
|
||||
failureThreshold: 6
|
||||
resources:
|
||||
{{- toYaml .Values.gateway.metricsServer.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.gateway.extraContainers }}
|
||||
{{- tpl (toYaml .) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled }}
|
||||
volumes:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
configMap:
|
||||
name: {{ include "litellm.gateway.fullname" . }}-config
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.metricsServer.enabled }}
|
||||
- name: prometheus-multiproc
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
|
|
|||
18
helm/litellm/templates/gateway/service-metrics.yaml
Normal file
18
helm/litellm/templates/gateway/service-metrics.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{{- if and .Values.gateway.enabled .Values.gateway.metricsServer.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "litellm.gateway.fullname" . }}-metrics
|
||||
labels:
|
||||
{{- include "litellm.commonLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: gateway
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: {{ .Values.gateway.metricsServer.port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
selector:
|
||||
{{- include "litellm.gateway.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
148
helm/litellm/tests/metrics_server_tests.yaml
Normal file
148
helm/litellm/tests/metrics_server_tests.yaml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
suite: test gateway metrics sidecar
|
||||
templates:
|
||||
- gateway/configmap.yaml
|
||||
- gateway/deployment.yaml
|
||||
- gateway/service.yaml
|
||||
- gateway/service-metrics.yaml
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
tests:
|
||||
- it: adds no sidecar, volume, env or service port when the metrics server is off
|
||||
asserts:
|
||||
- lengthEqual:
|
||||
path: spec.template.spec.containers
|
||||
count: 1
|
||||
template: gateway/deployment.yaml
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: PROMETHEUS_MULTIPROC_DIR
|
||||
any: true
|
||||
template: gateway/deployment.yaml
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: prometheus-multiproc
|
||||
any: true
|
||||
template: gateway/deployment.yaml
|
||||
- lengthEqual:
|
||||
path: spec.ports
|
||||
count: 1
|
||||
template: gateway/service.yaml
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
template: gateway/service-metrics.yaml
|
||||
|
||||
- it: runs the metrics server as a sidecar over a shared multiproc dir and exposes it on a ClusterIP metrics service
|
||||
set:
|
||||
gateway.metricsServer.enabled: true
|
||||
gateway.metricsServer.port: 4101
|
||||
gateway.service.type: LoadBalancer
|
||||
gateway.image.tag: v1.101.0
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: /tmp/litellm_prometheus_multiproc
|
||||
template: gateway/deployment.yaml
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: prometheus-multiproc
|
||||
mountPath: /tmp/litellm_prometheus_multiproc
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].name
|
||||
value: metrics
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].image
|
||||
value: ghcr.io/berriai/litellm-gateway:v1.101.0
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].command
|
||||
value:
|
||||
- python
|
||||
- -m
|
||||
- litellm.proxy.prometheus_metrics_server
|
||||
- --port
|
||||
- "4101"
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].env
|
||||
value:
|
||||
- name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: /tmp/litellm_prometheus_multiproc
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].ports
|
||||
value:
|
||||
- name: metrics
|
||||
containerPort: 4101
|
||||
protocol: TCP
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].volumeMounts
|
||||
value:
|
||||
- name: prometheus-multiproc
|
||||
mountPath: /tmp/litellm_prometheus_multiproc
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].readinessProbe.tcpSocket.port
|
||||
value: metrics
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].livenessProbe.tcpSocket.port
|
||||
value: metrics
|
||||
template: gateway/deployment.yaml
|
||||
- equal:
|
||||
path: spec.template.spec.containers[1].resources.requests.cpu
|
||||
value: 50m
|
||||
template: gateway/deployment.yaml
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: prometheus-multiproc
|
||||
emptyDir: {}
|
||||
template: gateway/deployment.yaml
|
||||
- lengthEqual:
|
||||
path: spec.ports
|
||||
count: 1
|
||||
template: gateway/service.yaml
|
||||
- equal:
|
||||
path: spec.type
|
||||
value: LoadBalancer
|
||||
template: gateway/service.yaml
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: RELEASE-NAME-litellm-gateway-metrics
|
||||
template: gateway/service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.type
|
||||
value: ClusterIP
|
||||
template: gateway/service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.ports
|
||||
value:
|
||||
- port: 4101
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
template: gateway/service-metrics.yaml
|
||||
- equal:
|
||||
path: spec.selector
|
||||
value:
|
||||
app.kubernetes.io/name: litellm
|
||||
app.kubernetes.io/instance: RELEASE-NAME
|
||||
app.kubernetes.io/component: gateway
|
||||
template: gateway/service-metrics.yaml
|
||||
|
||||
- it: rejects a metrics port equal to the gateway port
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
gateway.metricsServer.enabled: true
|
||||
gateway.metricsServer.port: 4000
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: gateway.metricsServer.port must differ from the gateway port 4000
|
||||
|
|
@ -268,6 +268,22 @@ gateway:
|
|||
config:
|
||||
create: true
|
||||
proxy_config: {}
|
||||
# Serve Prometheus /metrics from a `metrics` sidecar container (same image,
|
||||
# `python -m litellm.proxy.prometheus_metrics_server`) that aggregates the
|
||||
# workers' PROMETHEUS_MULTIPROC_DIR samples over a shared emptyDir, so a
|
||||
# scrape never runs on an inference worker. Adds a `metrics` port to the pod
|
||||
# and a dedicated ClusterIP `<gateway>-metrics` Service; point your scrape
|
||||
# config at it. The port has no virtual-key auth: keep it off public ingress.
|
||||
# Needs the gateway image v1.101.0 or newer.
|
||||
metricsServer:
|
||||
enabled: false
|
||||
port: 4001
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
image:
|
||||
repository: ghcr.io/berriai/litellm-gateway
|
||||
tag: "" # defaults to .Chart.AppVersion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "LiteLLM_AutoRouterSession"
|
||||
ADD COLUMN IF NOT EXISTS "classifier_cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "classifier_cost_recorded_turns" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -1509,6 +1509,8 @@ model LiteLLM_AutoRouterSession {
|
|||
total_tokens BigInt @default(0)
|
||||
spend Float @default(0)
|
||||
saved_spend Float @default(0)
|
||||
classifier_cost Float @default(0)
|
||||
classifier_cost_recorded_turns Int @default(0)
|
||||
tier_turns Json @default("{}")
|
||||
|
||||
@@id([api_key, session_id, router_name])
|
||||
|
|
|
|||
|
|
@ -8,14 +8,10 @@ import tempfile
|
|||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Final, Optional
|
||||
|
||||
from litellm_proxy_extras import prisma_toolchain
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.replica_identity import (
|
||||
REPLICA_IDENTITY_FULL_ENV_VAR,
|
||||
apply_replica_identity_full,
|
||||
)
|
||||
from litellm_proxy_extras.prisma_toolchain import (
|
||||
PRISMA_COMMAND_TIMEOUT_ENV_VAR,
|
||||
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR,
|
||||
|
|
@ -23,6 +19,14 @@ from litellm_proxy_extras.prisma_toolchain import (
|
|||
prisma_command_timeout,
|
||||
prisma_migrate_deploy_timeout,
|
||||
)
|
||||
from litellm_proxy_extras.replica_identity import (
|
||||
REPLICA_IDENTITY_FULL_ENV_VAR,
|
||||
apply_replica_identity_full,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import psycopg
|
||||
import psycopg.sql
|
||||
|
||||
|
||||
def str_to_bool(value: Optional[str]) -> bool:
|
||||
|
|
@ -46,6 +50,28 @@ def _get_prisma_env() -> dict:
|
|||
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
|
||||
|
||||
_MIGRATION_DEADLOCK_MARKER = "deadlock detected"
|
||||
INDEX_REPAIR_ADVISORY_LOCK_KEY: Final = int.from_bytes(b"litellm", "big")
|
||||
_TRANSIENT_INDEX_SUFFIX_RE: Final = re.compile(r"_cc(?:new|old)\d*$")
|
||||
_INVALID_LITELLM_INDEXES_SQL: Final = (
|
||||
"SELECT n.nspname, c.relname, pg_size_pretty(pg_table_size(t.oid)) "
|
||||
"FROM pg_index i "
|
||||
"JOIN pg_class c ON c.oid = i.indexrelid "
|
||||
"JOIN pg_class t ON t.oid = i.indrelid "
|
||||
"JOIN pg_namespace n ON n.oid = t.relnamespace "
|
||||
"WHERE NOT i.indisvalid "
|
||||
" AND c.relkind = 'i' "
|
||||
" AND n.nspname = %s "
|
||||
" AND t.relname LIKE %s "
|
||||
" AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conindid = i.indexrelid) "
|
||||
"ORDER BY c.relname"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _InvalidIndex:
|
||||
schema: str
|
||||
name: str
|
||||
table_size: str
|
||||
|
||||
MAX_MIGRATE_DEPLOY_ATTEMPTS = 4
|
||||
|
||||
|
|
@ -624,7 +650,7 @@ class ProxyExtrasDBManager:
|
|||
def _strip_prisma_query_params(url: str) -> str:
|
||||
"""Remove Prisma-specific query params (connection_limit, pool_timeout,
|
||||
schema, etc.) from DATABASE_URL so psycopg can parse it."""
|
||||
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not parsed.query:
|
||||
|
|
@ -645,7 +671,7 @@ class ProxyExtrasDBManager:
|
|||
"target_session_attrs",
|
||||
}
|
||||
kept = [(k, v) for k, v in parse_qsl(parsed.query) if k in libpq_params]
|
||||
return urlunparse(parsed._replace(query=urlencode(kept)))
|
||||
return urlunparse(parsed._replace(query=urlencode(kept, quote_via=quote)))
|
||||
|
||||
@staticmethod
|
||||
def _warn_if_db_ahead_of_head(migrations_dir: str) -> None:
|
||||
|
|
@ -719,6 +745,95 @@ class ProxyExtrasDBManager:
|
|||
", ".join(sorted_hostile[:5]) + (" ..." if len(sorted_hostile) > 5 else ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invalid_litellm_indexes(
|
||||
conn: "psycopg.Connection[tuple[str, str, str]]", schema: str
|
||||
) -> tuple[_InvalidIndex, ...]:
|
||||
rows: Final = conn.execute(_INVALID_LITELLM_INDEXES_SQL, (schema, "LiteLLM\\_%")).fetchall()
|
||||
return tuple(_InvalidIndex(*row) for row in rows)
|
||||
|
||||
@staticmethod
|
||||
def _index_repair(index: _InvalidIndex) -> tuple["psycopg.sql.Composed", str]:
|
||||
from psycopg import sql
|
||||
|
||||
target: Final = sql.Identifier(index.schema, index.name)
|
||||
if _TRANSIENT_INDEX_SUFFIX_RE.search(index.name):
|
||||
return sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}").format(target), "Dropped leftover"
|
||||
return sql.SQL("REINDEX INDEX CONCURRENTLY {}").format(target), "Rebuilt"
|
||||
|
||||
@staticmethod
|
||||
def _repair_index(conn: "psycopg.Connection[tuple[str, str, str]]", index: _InvalidIndex) -> None:
|
||||
import psycopg
|
||||
|
||||
statement, action = ProxyExtrasDBManager._index_repair(index)
|
||||
try:
|
||||
conn.execute(statement)
|
||||
except psycopg.Error as e:
|
||||
logger.warning(
|
||||
"Could not repair invalid index %s.%s, will retry on the next startup. "
|
||||
"If this keeps happening, run `%s` by hand as the index owner. Error: %s",
|
||||
index.schema,
|
||||
index.name,
|
||||
statement.as_string(conn),
|
||||
e,
|
||||
)
|
||||
return
|
||||
logger.info("%s invalid index %s.%s", action, index.schema, index.name)
|
||||
|
||||
@staticmethod
|
||||
def repair_invalid_indexes(lock_timeout: str = "30s") -> bool:
|
||||
"""Rebuild LiteLLM indexes an interrupted CREATE INDEX CONCURRENTLY left
|
||||
INVALID (a migration deadlock between replicas is the usual cause; the
|
||||
retried migration skips them because of IF NOT EXISTS). Never raises:
|
||||
returns True when no invalid index remains, False when the repair was
|
||||
skipped or failed and will be retried on the next startup. Looks in the
|
||||
schema DATABASE_URL names, the only URL Prisma migrates through, but
|
||||
connects over DIRECT_URL when set: the session settings, the advisory
|
||||
lock and REINDEX CONCURRENTLY all need one server session, which a
|
||||
transaction pooler does not give."""
|
||||
prisma_url: Final = os.getenv("DATABASE_URL")
|
||||
if not prisma_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"psycopg is not installed; skipping the invalid index check. "
|
||||
"Install the litellm[extra_proxy] extra, which includes psycopg."
|
||||
)
|
||||
return False
|
||||
|
||||
schema: Final = ProxyExtrasDBManager._prisma_schema_param(prisma_url) or "public"
|
||||
cleaned_url: Final = ProxyExtrasDBManager._strip_prisma_query_params(os.getenv("DIRECT_URL") or prisma_url)
|
||||
try:
|
||||
with psycopg.connect(cleaned_url, connect_timeout=10, autocommit=True) as conn:
|
||||
conn.execute("SET statement_timeout = 0")
|
||||
conn.execute(sql.SQL("SET lock_timeout = {}").format(sql.Literal(lock_timeout)))
|
||||
found: Final = ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema)
|
||||
if not found:
|
||||
return True
|
||||
logger.warning(
|
||||
"Found %d invalid index(es) left by an interrupted CREATE INDEX "
|
||||
"CONCURRENTLY, rebuilding: %s",
|
||||
len(found),
|
||||
", ".join(f"{index.name} (table size {index.table_size})" for index in found),
|
||||
)
|
||||
lock_row: Final = conn.execute(
|
||||
"SELECT pg_try_advisory_lock(%s)", (INDEX_REPAIR_ADVISORY_LOCK_KEY,)
|
||||
).fetchone()
|
||||
if lock_row is None or not lock_row[0]:
|
||||
logger.info("Another replica is already rebuilding the invalid indexes, skipping")
|
||||
return False
|
||||
for index in ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema):
|
||||
ProxyExtrasDBManager._repair_index(conn, index)
|
||||
remaining: Final = ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema)
|
||||
except psycopg.Error as e:
|
||||
logger.warning("Could not check for invalid indexes, will retry on the next startup. Error: %s", e)
|
||||
return False
|
||||
return not remaining
|
||||
|
||||
@staticmethod
|
||||
def _setup_database_v2(use_migrate: bool) -> bool:
|
||||
"""
|
||||
|
|
@ -994,6 +1109,7 @@ class ProxyExtrasDBManager:
|
|||
use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
|
||||
)
|
||||
if migrated:
|
||||
ProxyExtrasDBManager.repair_invalid_indexes()
|
||||
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
|
||||
return migrated
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_n
|
|||
|
||||
## What It Does
|
||||
|
||||
1. **Verifies the current branch is up to date with `origin/litellm_internal_staging`** (see [Branch freshness](#branch-freshness-check))
|
||||
1. **Verifies the current branch is up to date with origin's current default branch** (see [Branch freshness](#branch-freshness-check))
|
||||
2. Creates temp PostgreSQL DB
|
||||
3. Applies existing migrations
|
||||
4. Compares with `schema.prisma`
|
||||
|
|
@ -57,11 +57,11 @@ uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_n
|
|||
|
||||
## Branch Freshness Check
|
||||
|
||||
Before generating anything, `run_migration.py` runs `git fetch origin <base>` and refuses to proceed if `HEAD` is behind `origin/<base>`. Default base is `litellm_internal_staging` (the branch PRs target). A previous incident saw a stale branch silently drop production columns; freshness is the first-line defense.
|
||||
Before generating anything, `run_migration.py` runs `git fetch origin <base>` and refuses to proceed if `HEAD` is behind `origin/<base>`. The default base is discovered from origin's advertised HEAD on each run, so an existing clone follows a default-branch change without trusting cached `origin/HEAD`. If discovery or fetching fails, migration generation stops. A previous incident saw a stale branch silently drop production columns; freshness is the first-line defense.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--base-branch <name>` — check against a different base (e.g. `main`). Default is `litellm_internal_staging`.
|
||||
- `--base-branch <name>` — check against a different base (e.g. a release branch). Defaults to origin's current default branch
|
||||
- `--skip-freshness-check` — bypass entirely. Only for intentional migrations against an older base.
|
||||
|
||||
When the guard fires:
|
||||
|
|
@ -69,8 +69,9 @@ When the guard fires:
|
|||
1. Update your branch:
|
||||
|
||||
```bash
|
||||
git fetch origin && git rebase origin/litellm_internal_staging
|
||||
# or git merge origin/litellm_internal_staging — whichever matches your workflow
|
||||
base_branch=$(python3 scripts/default_branch.py --branch) &&
|
||||
git fetch origin "+refs/heads/$base_branch:refs/remotes/origin/$base_branch" &&
|
||||
git rebase "origin/$base_branch"
|
||||
```
|
||||
2. Re-run `run_migration.py`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.94"
|
||||
version = "0.4.95"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.94"
|
||||
version = "0.4.95"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
2
litellm-rust/Cargo.lock
generated
2
litellm-rust/Cargo.lock
generated
|
|
@ -1415,6 +1415,8 @@ dependencies = [
|
|||
"litellm-config",
|
||||
"litellm-core",
|
||||
"reqwest",
|
||||
"rustls 0.23.42",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ pythonize = "0.29.0"
|
|||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] }
|
||||
rstest = "0.26.1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0", features = ["float_roundtrip"] }
|
||||
sha2 = "0.10"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ litellm-config.workspace = true
|
|||
# reqwest (rustls + json) is used by io/ocr and ships realtime logs to the
|
||||
# Python proxy callbacks API.
|
||||
reqwest.workspace = true
|
||||
# rustls and its root store are direct dependencies so `io::tls` can build the
|
||||
# one TLS config the outbound dials use; see that module for why it has to.
|
||||
rustls.workspace = true
|
||||
rustls-native-certs.workspace = true
|
||||
# `sync` powers the bounded mpsc channel the realtime logger drains.
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ pub mod ocr;
|
|||
pub mod realtime;
|
||||
pub mod realtime_pool;
|
||||
pub mod responses_ws;
|
||||
pub(crate) mod tls;
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ use tokio_tungstenite::tungstenite::Message;
|
|||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use litellm_core::providers::openai::realtime::transformation::OPENAI_REALTIME_CONFIG;
|
||||
|
||||
use crate::io::tls::connect_upstream;
|
||||
|
||||
/// Environment variable holding the OpenAI API key (last-resort fallback).
|
||||
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
|
||||
|
||||
|
|
@ -84,7 +86,7 @@ pub(crate) async fn dial_upstream(
|
|||
.map_err(|err| Error::Auth(err.to_string()))?,
|
||||
);
|
||||
|
||||
let (upstream, _response) = connect_async(request)
|
||||
let (upstream, _response) = connect_upstream(request)
|
||||
.await
|
||||
.map_err(|err| Error::Network(err.to_string()))?;
|
||||
Ok(upstream)
|
||||
|
|
@ -284,6 +286,33 @@ mod tests {
|
|||
serde_json::from_str(raw).expect("valid event json")
|
||||
}
|
||||
|
||||
/// The realtime dial has to reach a `wss://` upstream without a process-wide
|
||||
/// crypto provider installed, which is what dialing through `io::tls` buys.
|
||||
#[tokio::test]
|
||||
async fn dial_upstream_over_wss_reports_an_error_instead_of_panicking() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind a loopback port");
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.expect("read the bound address")
|
||||
.port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((stream, _peer)) = listener.accept().await {
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
|
||||
let result = dial_upstream(
|
||||
"gpt-realtime",
|
||||
"sk-test",
|
||||
Some(&format!("wss://127.0.0.1:{port}")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(Error::Network(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_prefers_param_then_blank_falls_through() {
|
||||
assert_eq!(resolve_api_key(Some("sk-test")).unwrap(), "sk-test");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ use tokio_tungstenite::tungstenite::Message;
|
|||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use crate::io::tls::connect_upstream;
|
||||
|
||||
use crate::constants::{
|
||||
DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS,
|
||||
|
|
@ -49,14 +51,14 @@ impl ResponsesWebSocketConnection {
|
|||
.map_err(|error| Error::InvalidRequest(error.to_string()))?;
|
||||
request.headers_mut().insert(header_name, header_value);
|
||||
}
|
||||
let connect = connect_async(request);
|
||||
let connect = connect_upstream(request);
|
||||
let result = match timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| {
|
||||
Error::Network("Responses WebSocket connection timed out".to_string())
|
||||
})?,
|
||||
None => connect.await,
|
||||
};
|
||||
let (socket, _) = result.map_err(|error| match error {
|
||||
let (socket, _) = result.map_err(|error| match *error {
|
||||
tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http {
|
||||
status: response.status().as_u16(),
|
||||
body: String::new(),
|
||||
|
|
@ -138,13 +140,13 @@ async fn dial_upstream(
|
|||
);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS),
|
||||
connect_async(request),
|
||||
connect_upstream(request),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::Network("Responses WebSocket connection timed out".to_string()))?;
|
||||
result
|
||||
.map(|(socket, _)| socket)
|
||||
.map_err(|error| match error {
|
||||
.map_err(|error| match *error {
|
||||
tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http {
|
||||
status: response.status().as_u16(),
|
||||
body: String::new(),
|
||||
|
|
@ -324,6 +326,29 @@ mod tests {
|
|||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::accept_async;
|
||||
|
||||
/// The Responses dial has to reach a `wss://` upstream without a process-wide
|
||||
/// crypto provider installed, which is what dialing through `io::tls` buys.
|
||||
#[tokio::test]
|
||||
async fn dial_upstream_over_wss_reports_an_error_instead_of_panicking() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind a loopback port");
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.expect("read the bound address")
|
||||
.port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((stream, _peer)) = listener.accept().await {
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
|
||||
let result =
|
||||
dial_upstream("gpt-5", "sk-test", Some(&format!("wss://127.0.0.1:{port}"))).await;
|
||||
|
||||
assert!(matches!(result, Err(Error::Network(_))));
|
||||
}
|
||||
|
||||
async fn websocket_base() -> (String, tokio::task::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let address = listener.local_addr().expect("local address");
|
||||
|
|
|
|||
80
litellm-rust/crates/ai-gateway/src/io/tls.rs
Normal file
80
litellm-rust/crates/ai-gateway/src/io/tls.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! Outbound WebSocket dials over a TLS config this crate builds once and owns.
|
||||
//!
|
||||
//! `reqwest/rustls-tls` enables `rustls/ring` and `litellm-core`'s `bedrock-auth`
|
||||
//! enables `rustls/aws-lc-rs`, so the bare `ClientConfig::builder()` that
|
||||
//! `tokio-tungstenite` uses when handed no connector panics rather than guess
|
||||
//! between them. Naming ring on a connector of our own settles that for these
|
||||
//! dials without touching the process-wide default, and building the config
|
||||
//! once keeps the platform trust store, which `tokio-tungstenite` would
|
||||
//! otherwise re-read on every dial, off the dial path.
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::tungstenite::Error;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::error::TlsError;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::Response;
|
||||
use tokio_tungstenite::{
|
||||
Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
|
||||
};
|
||||
|
||||
static TLS_CONFIG: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||
|
||||
fn build_config() -> Result<ClientConfig, Box<Error>> {
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let roots = {
|
||||
let mut store = RootCertStore::empty();
|
||||
let (added, _ignored) = store.add_parsable_certificates(native.certs);
|
||||
if added == 0 {
|
||||
return Err(Box::new(Error::Io(io::Error::other(format!(
|
||||
"no usable native root certificates: {:?}",
|
||||
native.errors
|
||||
)))));
|
||||
}
|
||||
store
|
||||
};
|
||||
|
||||
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map(|builder| builder.with_root_certificates(roots).with_no_client_auth())
|
||||
.map_err(|error| Box::new(Error::Tls(TlsError::Rustls(error))))
|
||||
}
|
||||
|
||||
fn tls_config() -> Result<Arc<ClientConfig>, Box<Error>> {
|
||||
if let Some(config) = TLS_CONFIG.get() {
|
||||
return Ok(Arc::clone(config));
|
||||
}
|
||||
let built = Arc::new(build_config()?);
|
||||
Ok(Arc::clone(TLS_CONFIG.get_or_init(|| built)))
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_upstream<R>(
|
||||
request: R,
|
||||
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), Box<Error>>
|
||||
where
|
||||
R: IntoClientRequest + Unpin,
|
||||
{
|
||||
let request = request.into_client_request().map_err(Box::new)?;
|
||||
let connector = match request.uri().scheme_str() {
|
||||
Some("wss") => Some(Connector::Rustls(tls_config()?)),
|
||||
_ => None,
|
||||
};
|
||||
connect_async_tls_with_config(request, None, false, connector)
|
||||
.await
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_config;
|
||||
|
||||
#[test]
|
||||
fn builds_a_usable_config_with_both_provider_features_enabled() {
|
||||
let config = build_config().expect("a client config");
|
||||
|
||||
assert!(!config.crypto_provider().cipher_suites.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -265,6 +265,12 @@ impl CallLifecycleHooks<PreparedOcrRequest, PreparedOcrRequest, Value> for OcrLi
|
|||
Box::pin(async move { Ok(request) })
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "success_callback",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
fn async_log_success_event<'a>(
|
||||
&'a self,
|
||||
context: &'a CallLifecycleContext,
|
||||
|
|
@ -288,6 +294,12 @@ impl CallLifecycleHooks<PreparedOcrRequest, PreparedOcrRequest, Value> for OcrLi
|
|||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "failure_callback",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
fn async_log_failure_event<'a>(
|
||||
&'a self,
|
||||
context: &'a CallLifecycleContext,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
//! Guards the wiring, not just the helper: a `wss://` dial through the public
|
||||
//! API has to resolve its own crypto provider, in a test binary where nothing
|
||||
//! has installed a process-wide one, and has to leave it uninstalled.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn dead_tls_server() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind a loopback port");
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.expect("read the bound address")
|
||||
.port();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok((stream, _peer)) = listener.accept().await {
|
||||
drop(stream);
|
||||
}
|
||||
});
|
||||
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dialing_wss_returns_an_error_instead_of_panicking() {
|
||||
let port = dead_tls_server().await;
|
||||
|
||||
let result = ResponsesWebSocketConnection::connect_url(
|
||||
&format!("wss://127.0.0.1:{port}/"),
|
||||
&HashMap::new(),
|
||||
Some(Duration::from_secs(10)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a plain TCP server cannot finish a TLS handshake"
|
||||
);
|
||||
assert!(
|
||||
rustls::crypto::CryptoProvider::get_default().is_none(),
|
||||
"the dial settles its provider on its own connector, not process-wide"
|
||||
);
|
||||
}
|
||||
|
|
@ -11,9 +11,13 @@ use litellm_ai_gateway::integrations::custom_logger::{
|
|||
use litellm_ai_gateway::integrations::types::RequestMetadata;
|
||||
use litellm_ai_gateway::ocr::{OcrRequest, ocr};
|
||||
use litellm_core::error::Error;
|
||||
#[cfg(feature = "trace-parity")]
|
||||
use litellm_core::observability::FunctionTrace;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
#[cfg(feature = "trace-parity")]
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
async fn read_http_headers(socket: &mut TcpStream) -> String {
|
||||
let mut request = Vec::new();
|
||||
|
|
@ -320,14 +324,17 @@ async fn ocr_lifecycle_runs_pre_during_and_success_hooks() {
|
|||
GuardrailEventHook::PreCall,
|
||||
GuardrailEventHook::DuringCall,
|
||||
]));
|
||||
let response = ocr(OcrRequest {
|
||||
#[cfg(feature = "trace-parity")]
|
||||
let trace = FunctionTrace::default();
|
||||
let api_base = format!("http://{addr}");
|
||||
let call = 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}")),
|
||||
api_base: Some(&api_base),
|
||||
custom_llm_provider: Some("mistral"),
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
|
|
@ -339,9 +346,10 @@ async fn ocr_lifecycle_runs_pre_during_and_success_hooks() {
|
|||
..Default::default()
|
||||
},
|
||||
litellm_call_id: Some("ocr-call-1"),
|
||||
})
|
||||
.await
|
||||
.expect("ocr request succeeds");
|
||||
});
|
||||
#[cfg(feature = "trace-parity")]
|
||||
let call = call.with_subscriber(trace.dispatcher());
|
||||
let response = call.await.expect("ocr request succeeds");
|
||||
|
||||
assert_eq!(response["pages"][0]["markdown"], "ok");
|
||||
assert_eq!(
|
||||
|
|
@ -359,6 +367,16 @@ async fn ocr_lifecycle_runs_pre_during_and_success_hooks() {
|
|||
error_kind: None,
|
||||
}]
|
||||
);
|
||||
#[cfg(feature = "trace-parity")]
|
||||
assert_eq!(
|
||||
trace
|
||||
.events()
|
||||
.iter()
|
||||
.filter(|event| event.function.ends_with("_callback"))
|
||||
.map(|event| event.function)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["success_callback"]
|
||||
);
|
||||
|
||||
let request = server.await.expect("server task completes");
|
||||
assert!(request.contains(r#""guarded_pre":true"#), "{request}");
|
||||
|
|
@ -388,14 +406,17 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() {
|
|||
});
|
||||
|
||||
let logger = Arc::new(RecordingOcrLogger::default());
|
||||
let err = ocr(OcrRequest {
|
||||
#[cfg(feature = "trace-parity")]
|
||||
let trace = FunctionTrace::default();
|
||||
let api_base = format!("http://{addr}");
|
||||
let call = 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}")),
|
||||
api_base: Some(&api_base),
|
||||
custom_llm_provider: Some("mistral"),
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
|
|
@ -404,9 +425,10 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() {
|
|||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: Some("ocr-call-2"),
|
||||
})
|
||||
.await
|
||||
.expect_err("provider error propagates");
|
||||
});
|
||||
#[cfg(feature = "trace-parity")]
|
||||
let call = call.with_subscriber(trace.dispatcher());
|
||||
let err = call.await.expect_err("provider error propagates");
|
||||
|
||||
assert!(matches!(err, Error::Http { status: 500, .. }));
|
||||
server.await.expect("server task completes");
|
||||
|
|
@ -421,6 +443,16 @@ async fn ocr_lifecycle_runs_failure_hook_on_provider_error() {
|
|||
error_kind: Some("HttpError".to_string()),
|
||||
}]
|
||||
);
|
||||
#[cfg(feature = "trace-parity")]
|
||||
assert_eq!(
|
||||
trace
|
||||
.events()
|
||||
.iter()
|
||||
.filter(|event| event.function.ends_with("_callback"))
|
||||
.map(|event| event.function)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["failure_callback"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::fmt::Display;
|
||||
use std::future::Future;
|
||||
|
||||
use litellm_core::observability::{FunctionTrace, FunctionTraceEvent};
|
||||
|
|
@ -6,17 +7,32 @@ use tracing::instrument::WithSubscriber;
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TracedResponse<T> {
|
||||
response: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
response: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
trace: Vec<FunctionTraceEvent>,
|
||||
}
|
||||
|
||||
pub(crate) async fn capture<T, E>(
|
||||
future: impl Future<Output = Result<T, E>>,
|
||||
) -> Result<TracedResponse<T>, E> {
|
||||
) -> Result<TracedResponse<T>, E>
|
||||
where
|
||||
E: Display,
|
||||
{
|
||||
let trace = FunctionTrace::default();
|
||||
let response = future.with_subscriber(trace.dispatcher()).await?;
|
||||
Ok(TracedResponse {
|
||||
response,
|
||||
trace: trace.events(),
|
||||
let result = future.with_subscriber(trace.dispatcher()).await;
|
||||
let events = trace.events();
|
||||
Ok(match result {
|
||||
Ok(response) => TracedResponse {
|
||||
response: Some(response),
|
||||
error: None,
|
||||
trace: events,
|
||||
},
|
||||
Err(error) => TracedResponse {
|
||||
response: None,
|
||||
error: Some(error.to_string()),
|
||||
trace: events,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -486,10 +486,11 @@ asyncio.run(exercise())
|
|||
let code = CString::new(
|
||||
r#"
|
||||
result = routes.echo("traced")
|
||||
assert result == {
|
||||
"response": "traced",
|
||||
"trace": [{"function": "execute_echo", "depth": 0}],
|
||||
}
|
||||
assert result["response"] == "traced", result
|
||||
assert [event["function"] for event in result["trace"]] == ["execute_echo"], result
|
||||
failure = routes.echo("error")
|
||||
assert failure["error"] == "invalid request: synthetic error", failure
|
||||
assert [event["function"] for event in failure["trace"]] == ["execute_echo"], failure
|
||||
"#,
|
||||
)
|
||||
.expect("Python source should not contain null bytes");
|
||||
|
|
|
|||
|
|
@ -495,6 +495,7 @@ public_model_groups: Optional[List[str]] = None
|
|||
public_agent_groups: Optional[List[str]] = None
|
||||
agent_search_embedding_model: Optional[str] = None
|
||||
mcp_tool_search: Optional[Mapping[str, object]] = None
|
||||
skill_search_embedding_model: Optional[str] = None
|
||||
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
|
||||
# New format: { "displayName": { "url": "...", "index": 0 } }
|
||||
# Old format: { "displayName": "url" } (for backward compatibility)
|
||||
|
|
@ -2001,6 +2002,9 @@ if TYPE_CHECKING:
|
|||
from .llms.hosted_vllm.responses.transformation import (
|
||||
HostedVLLMResponsesAPIConfig as HostedVLLMResponsesAPIConfig,
|
||||
)
|
||||
from .llms.fireworks_ai.responses.transformation import (
|
||||
FireworksAIResponsesAPIConfig as FireworksAIResponsesAPIConfig,
|
||||
)
|
||||
from .llms.github_copilot.chat.transformation import (
|
||||
GithubCopilotConfig as GithubCopilotConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"XAIResponsesAPIConfig",
|
||||
"LiteLLMProxyResponsesAPIConfig",
|
||||
"HostedVLLMResponsesAPIConfig",
|
||||
"FireworksAIResponsesAPIConfig",
|
||||
"VolcEngineResponsesAPIConfig",
|
||||
"PerplexityResponsesConfig",
|
||||
"DatabricksResponsesAPIConfig",
|
||||
|
|
@ -957,6 +958,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
".llms.hosted_vllm.responses.transformation",
|
||||
"HostedVLLMResponsesAPIConfig",
|
||||
),
|
||||
"FireworksAIResponsesAPIConfig": (
|
||||
".llms.fireworks_ai.responses.transformation",
|
||||
"FireworksAIResponsesAPIConfig",
|
||||
),
|
||||
"VolcEngineResponsesAPIConfig": (
|
||||
".llms.volcengine.responses.transformation",
|
||||
"VolcEngineResponsesAPIConfig",
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ def create_batch(
|
|||
timeout=timeout,
|
||||
max_retries=optional_params.max_retries,
|
||||
create_batch_data=_create_batch_request,
|
||||
custom_endpoint=optional_params.get("custom_endpoint"),
|
||||
)
|
||||
else:
|
||||
raise litellm.exceptions.BadRequestError(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ from contextvars import ContextVar
|
|||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import print_verbose, verbose_logger
|
||||
from litellm.constants import (
|
||||
|
|
@ -80,11 +82,29 @@ class _AsyncRedisCommands(Protocol):
|
|||
|
||||
def pipeline(self, transaction: bool = True) -> "Pipeline[bytes]": ...
|
||||
|
||||
def eval(self, script: str, numkeys: int, *keys_and_args: str | bytes | float) -> Awaitable[object]: ...
|
||||
|
||||
|
||||
_BREAKER_GUARD_FRAME_NAMES: Final = frozenset(
|
||||
{"<lambda>", "wrapper", "_run_under_circuit_breaker", "_run_under_circuit_breaker_sync"}
|
||||
)
|
||||
|
||||
_INCREMENT_WITH_FLOOR_LUA: Final = (
|
||||
"local count = redis.call('INCRBY', KEYS[1], ARGV[1]) "
|
||||
"if count < 0 then count = redis.call('INCRBY', KEYS[1], -count) end "
|
||||
"if redis.call('TTL', KEYS[1]) < 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end "
|
||||
"return count"
|
||||
)
|
||||
|
||||
_LUA_COUNT: Final = TypeAdapter(int)
|
||||
_OPTIONAL_COUNTS: Final = TypeAdapter(tuple[int | None, ...])
|
||||
|
||||
|
||||
def _decoded_counts(values: Sequence[bytes | str | None]) -> tuple[int | None, ...]:
|
||||
return _OPTIONAL_COUNTS.validate_python(
|
||||
tuple(value.decode("utf-8") if isinstance(value, bytes) else value for value in values)
|
||||
)
|
||||
|
||||
|
||||
def _get_call_stack_info(num_frames: int = 2) -> str:
|
||||
"""
|
||||
|
|
@ -736,6 +756,43 @@ class RedisCache(BaseCache):
|
|||
)
|
||||
raise e
|
||||
|
||||
@_redis_circuit_breaker_guard_sync
|
||||
def increment_with_floor(self, key: str, value: int, ttl: int) -> int:
|
||||
"""Add ``value`` to ``key``, clamp the result at zero, and give a new key ``ttl``, in one Lua call.
|
||||
|
||||
A counter whose key expired while a request was still in flight would otherwise be
|
||||
recreated negative by that request's decrement. Clamping inside the same call is what
|
||||
keeps it safe: a separate corrective write could land after another pod's increment and
|
||||
erase it.
|
||||
|
||||
The TTL is set only on a key that has none, so a counter expires ``ttl`` after it was
|
||||
created rather than ``ttl`` after it was last touched. Refreshing it on every touch
|
||||
would keep a count a dead worker never decremented alive for as long as the group
|
||||
takes traffic. Returns the resulting count.
|
||||
"""
|
||||
namespaced_key: Final = self.check_and_fix_namespace(key=key)
|
||||
count: Final[object] = self.redis_client.eval( # pyright: ignore[reportAttributeAccessIssue] # stubs omit eval
|
||||
_INCREMENT_WITH_FLOOR_LUA, 1, namespaced_key, value, ttl
|
||||
)
|
||||
return _LUA_COUNT.validate_python(count)
|
||||
|
||||
@_redis_circuit_breaker_guard_sync
|
||||
def batch_get_counts(self, key_list: list[str]) -> tuple[int | None, ...]:
|
||||
"""Read integer counters for ``key_list``, in order, raising when Redis cannot answer.
|
||||
|
||||
``batch_get_cache`` swallows every failure and returns an empty dict, which the caller
|
||||
cannot tell apart from "every counter is unset". A caller that has to fall back to its
|
||||
own numbers when Redis is unreachable needs the failure, not a dict of zeros.
|
||||
"""
|
||||
namespaced_keys: Final = [self.check_and_fix_namespace(key=key) for key in key_list]
|
||||
return _decoded_counts(self._run_redis_mget_operation(keys=namespaced_keys))
|
||||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def async_batch_get_counts(self, key_list: list[str]) -> tuple[int | None, ...]:
|
||||
"""Async twin of ``batch_get_counts``, raising on failure the same way."""
|
||||
namespaced_keys: Final = [self.check_and_fix_namespace(key=key) for key in key_list]
|
||||
return _decoded_counts(await self._async_run_redis_mget_operation(keys=namespaced_keys))
|
||||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def async_scan_iter(self, pattern: str, count: int = 100) -> list:
|
||||
start_time: Final = time.time()
|
||||
|
|
@ -1241,6 +1298,14 @@ class RedisCache(BaseCache):
|
|||
result = result.decode()
|
||||
return float(result)
|
||||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def async_increment_with_floor(self, key: str, value: int, ttl: int) -> int:
|
||||
"""Async twin of ``increment_with_floor``, sharing its Lua script and its guarantees."""
|
||||
_redis_client: Final = self._async_commands()
|
||||
namespaced_key: Final = self.check_and_fix_namespace(key=key)
|
||||
count: Final = await _redis_client.eval(_INCREMENT_WITH_FLOOR_LUA, 1, namespaced_key, value, ttl)
|
||||
return _LUA_COUNT.validate_python(count)
|
||||
|
||||
async def flush_cache_buffer(self):
|
||||
print_verbose(f"flushing to redis....reached size of buffer {len(self.redis_batch_writing_buffer)}")
|
||||
await self.async_set_cache_pipeline(self.redis_batch_writing_buffer)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ Handler for transforming /chat/completions api requests to litellm.responses req
|
|||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast, get_args
|
||||
|
||||
from openai.types.chat import ChatCompletion
|
||||
from openai.types.responses import Response
|
||||
from openai.types.responses.custom_tool_param import CustomToolParam
|
||||
from openai.types.responses.response_input_param import (
|
||||
FunctionCallOutput,
|
||||
|
|
@ -33,7 +36,7 @@ from litellm.responses.sse_output_recovery import (
|
|||
record_output_item_chunk,
|
||||
record_output_text_chunk,
|
||||
)
|
||||
from litellm.responses.utils import normalize_responses_api_stream_options
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils, normalize_responses_api_stream_options
|
||||
from litellm.types.llms.openai import (
|
||||
REASONING_EFFORT,
|
||||
ChatCompletionAnnotation,
|
||||
|
|
@ -43,6 +46,7 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionToolParamFunctionChunk,
|
||||
Reasoning,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamEvents,
|
||||
)
|
||||
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream
|
||||
|
|
@ -54,7 +58,7 @@ if TYPE_CHECKING:
|
|||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm import LiteLLMLoggingObj, ModelResponse
|
||||
from litellm import LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.types.llms.openai import (
|
||||
ALL_RESPONSES_API_TOOL_PARAMS,
|
||||
|
|
@ -69,6 +73,28 @@ if TYPE_CHECKING:
|
|||
from litellm.types.utils import Choices
|
||||
|
||||
|
||||
_CHAT_COMPLETION_FIELDS: Final = frozenset((*ModelResponse.model_fields, "usage"))
|
||||
_RESPONSES_API_ONLY_FIELDS: Final = frozenset((*Response.model_fields, *ResponsesAPIResponse.model_fields)) - frozenset(
|
||||
ChatCompletion.model_fields
|
||||
)
|
||||
|
||||
|
||||
def _provider_metadata(response_fields: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in (response_fields.items() if response_fields else ())
|
||||
if value is not None and key not in _CHAT_COMPLETION_FIELDS and key not in _RESPONSES_API_ONLY_FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _upstream_response_id(response_id: str | None) -> str | None:
|
||||
if response_id is None:
|
||||
return None
|
||||
return ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(response_id)
|
||||
|
||||
|
||||
class _ReasoningSummaryText(TypedDict):
|
||||
type: str
|
||||
text: str
|
||||
|
|
@ -344,7 +370,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
and isinstance(tool_call.get("custom"), dict)
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
leading_system_count: Final = next(
|
||||
(index for index, msg in enumerate(messages) if msg.get("role") != "system"),
|
||||
len(messages),
|
||||
)
|
||||
|
||||
for index, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
content = msg.get("content", "")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
|
@ -352,7 +383,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
|
||||
if role == "system":
|
||||
# Extract system message as instructions
|
||||
if isinstance(content, str):
|
||||
if isinstance(content, str) and index < leading_system_count:
|
||||
if instructions:
|
||||
# Concatenate multiple system prompts with a space
|
||||
instructions = f"{instructions} {content}"
|
||||
|
|
@ -904,6 +935,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_response.usage),
|
||||
)
|
||||
|
||||
model_response.id = _upstream_response_id(raw_response.id) or raw_response.id
|
||||
for key, value in _provider_metadata(raw_response.model_extra).items():
|
||||
setattr(model_response, key, value)
|
||||
|
||||
# Preserve hidden params from the ResponsesAPIResponse, especially the headers
|
||||
# which contain important provider information like x-request-id
|
||||
raw_response_hidden_params: Final = getattr(raw_response, "_hidden_params", {})
|
||||
|
|
@ -1359,14 +1394,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
if event_type == "response.created":
|
||||
# Initial response creation event
|
||||
verbose_logger.debug("Chat provider: response.created -> %s", parsed_chunk)
|
||||
created_response: Final = parsed_chunk.get("response")
|
||||
return ModelResponseStream(
|
||||
id=_upstream_response_id(created_response.get("id")) if created_response else None,
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=""),
|
||||
finish_reason=None,
|
||||
)
|
||||
]
|
||||
],
|
||||
)
|
||||
elif event_type == "response.output_item.added":
|
||||
# New output item added
|
||||
|
|
@ -1534,6 +1571,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
|
||||
usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(response_data.get("usage"))
|
||||
provider_metadata: Final = _provider_metadata(response_data)
|
||||
return ModelResponseStream(
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
|
|
@ -1546,6 +1584,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
)
|
||||
],
|
||||
usage=usage,
|
||||
provider_specific_fields=dict(provider_metadata) or None, # mutable-ok: field is typed dict
|
||||
)
|
||||
else:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ S3_PREFIX_DIGEST_CHARS: Final = 16
|
|||
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))
|
||||
budget_reservation_disabled_info_emitted = False
|
||||
DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE = int(os.getenv("DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE", 1))
|
||||
DEFAULT_SQS_BATCH_SIZE: Final = int(os.getenv("DEFAULT_SQS_BATCH_SIZE", 512))
|
||||
SQS_SEND_MESSAGE_ACTION: Final = "SendMessage"
|
||||
|
|
@ -72,6 +73,9 @@ DEFAULT_MAX_TOKENS: Final = int(os.getenv("DEFAULT_MAX_TOKENS", 4096))
|
|||
DEFAULT_ALLOWED_FAILS: Final = int(os.getenv("DEFAULT_ALLOWED_FAILS", 3))
|
||||
DEFAULT_REDIS_SYNC_INTERVAL: Final = int(os.getenv("DEFAULT_REDIS_SYNC_INTERVAL", 1))
|
||||
DEFAULT_COOLDOWN_TIME_SECONDS: Final = int(os.getenv("DEFAULT_COOLDOWN_TIME_SECONDS", 5))
|
||||
DEFAULT_COOLDOWN_REDIS_READ_INTERVAL_SECONDS: Final = float(
|
||||
os.getenv("DEFAULT_COOLDOWN_REDIS_READ_INTERVAL_SECONDS", "1")
|
||||
)
|
||||
DEFAULT_REPLICATE_POLLING_RETRIES: Final = int(os.getenv("DEFAULT_REPLICATE_POLLING_RETRIES", 5))
|
||||
DEFAULT_REPLICATE_POLLING_DELAY_SECONDS: Final = int(os.getenv("DEFAULT_REPLICATE_POLLING_DELAY_SECONDS", 1))
|
||||
DEFAULT_IMAGE_TOKEN_COUNT: Final = int(os.getenv("DEFAULT_IMAGE_TOKEN_COUNT", 250))
|
||||
|
|
@ -1457,6 +1461,8 @@ LITELLM_METADATA_FIELD: Final = "litellm_metadata"
|
|||
OLD_LITELLM_METADATA_FIELD: Final = "metadata"
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY: Final = "_complexity_router_return_raw_model_name"
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY: Final = "_session_deployment_affinity_ttl"
|
||||
OUTPUT_TOKEN_CEILING_PARAMS: Final = frozenset({"max_tokens", "max_completion_tokens", "max_output_tokens"})
|
||||
CLIENT_OUTPUT_CEILING_METADATA_KEY: Final = "_client_output_ceiling"
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
|
||||
|
|
@ -1901,6 +1907,16 @@ HTTP_FRAMING_HEADERS: Final[frozenset[str]] = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
PROVIDER_REQUEST_ID_HEADERS: Final[tuple[str, ...]] = (
|
||||
"x-amzn-requestid",
|
||||
"x-request-id",
|
||||
"request-id",
|
||||
"x-ms-request-id",
|
||||
"apim-request-id",
|
||||
"x-goog-request-id",
|
||||
"cf-ray",
|
||||
)
|
||||
|
||||
# Browser-facing security headers that a malicious or misconfigured upstream
|
||||
# provider must not be able to set on the proxy's own response.
|
||||
BROWSER_SECURITY_HEADERS: Final[frozenset[str]] = frozenset(
|
||||
|
|
|
|||
|
|
@ -2414,6 +2414,46 @@ class RealtimeAPITokenUsageProcessor(BaseTokenUsageProcessor):
|
|||
)
|
||||
|
||||
|
||||
_RESPONSES_WS_BILLABLE_EVENT_TYPES: Final = frozenset({"response.completed", "response.incomplete"})
|
||||
|
||||
|
||||
class _ResponsesWsEventResponse(BaseModel):
|
||||
usage: Mapping[str, object] | None = None
|
||||
|
||||
|
||||
class _ResponsesWsEvent(BaseModel):
|
||||
type: str = ""
|
||||
response: _ResponsesWsEventResponse | None = None
|
||||
|
||||
|
||||
class ResponsesWebSocketTokenUsageProcessor(BaseTokenUsageProcessor):
|
||||
@staticmethod
|
||||
def collect_usage_from_responses_ws_results(
|
||||
results: Sequence[Mapping[str, object]],
|
||||
) -> tuple[Usage, ...]:
|
||||
events: Final = tuple(_ResponsesWsEvent.model_validate(result) for result in results)
|
||||
return tuple(
|
||||
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( # pyright: ignore[reportPrivateUsage] # same shared transform the realtime processor uses
|
||||
event.response.usage
|
||||
)
|
||||
for event in events
|
||||
if event.type in _RESPONSES_WS_BILLABLE_EVENT_TYPES
|
||||
and event.response is not None
|
||||
and event.response.usage is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def collect_and_combine_usage_from_responses_ws_results(
|
||||
results: Sequence[Mapping[str, object]],
|
||||
) -> Usage:
|
||||
collected_usage_objects: Final = ResponsesWebSocketTokenUsageProcessor.collect_usage_from_responses_ws_results(
|
||||
results
|
||||
)
|
||||
return ResponsesWebSocketTokenUsageProcessor.combine_usage_objects(
|
||||
list(collected_usage_objects) # mutable-ok: combine_usage_objects requires a list parameter
|
||||
)
|
||||
|
||||
|
||||
_TRANSCRIPTION_COMPLETED_EVENT_TYPE: Final = "conversation.item.input_audio_transcription.completed"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,7 @@ class Timeout(openai.APITimeoutError):
|
|||
num_retries: int | None = None,
|
||||
headers: dict | None = None,
|
||||
exception_status_code: int | None = None,
|
||||
response: httpx.Response | None = None,
|
||||
):
|
||||
request: Final = httpx.Request(
|
||||
method="POST",
|
||||
|
|
@ -352,6 +353,8 @@ class Timeout(openai.APITimeoutError):
|
|||
self.max_retries = max_retries
|
||||
self.num_retries = num_retries
|
||||
self.headers = headers
|
||||
if response is not None:
|
||||
self.response = response
|
||||
|
||||
# custom function to convert to str
|
||||
def __str__(self):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from collections.abc import Iterable, Mapping, Sequence
|
|||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_prompt_management import CustomPromptManagement
|
||||
|
|
@ -23,6 +25,7 @@ from litellm.integrations.prompt_management_base import PromptManagementClient
|
|||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.llms.anthropic.common_utils import is_claude_code_one_shot_subagent_request
|
||||
from litellm.types.integrations.anthropic_cache_control_hook import (
|
||||
GATEWAY_INJECTED_CACHE_METADATA_KEY,
|
||||
GATEWAY_INJECTED_FOR_EVERY_DEPLOYMENT,
|
||||
|
|
@ -62,10 +65,26 @@ OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES: Final = frozenset(
|
|||
)
|
||||
OPENAI_API_HOST: Final = "api.openai.com"
|
||||
OPENAI_API_BASE_ENV_VARS: Final = ("OPENAI_BASE_URL", "OPENAI_API_BASE")
|
||||
_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
|
||||
_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
|
||||
|
||||
AllToolParamValues = ChatCompletionToolParam | AllAnthropicToolsValues
|
||||
|
||||
|
||||
def _validated_object_mapping(value: object) -> dict[object, object] | None:
|
||||
try:
|
||||
return _OBJECT_MAPPING_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _validated_object_list(value: object) -> list[object] | None:
|
||||
try:
|
||||
return _OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def supports_openai_prompt_cache_breakpoint(model: str) -> bool:
|
||||
model_map_flag: Final = _model_map_prompt_cache_breakpoint_flag(model)
|
||||
if model_map_flag is not None:
|
||||
|
|
@ -114,6 +133,36 @@ CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_
|
|||
|
||||
|
||||
class AnthropicCacheControlHook(CustomPromptManagement):
|
||||
@staticmethod
|
||||
def _request_value(request_kwargs: object, key: str) -> object:
|
||||
request_mapping: Final = _validated_object_mapping(request_kwargs)
|
||||
if request_mapping is None:
|
||||
return None
|
||||
return request_mapping.get(key)
|
||||
|
||||
@staticmethod
|
||||
def _request_user_agent(request_kwargs: object) -> str | None:
|
||||
proxy_server_request: Final = AnthropicCacheControlHook._request_value(request_kwargs, "proxy_server_request")
|
||||
proxy_server_request_mapping: Final = _validated_object_mapping(proxy_server_request)
|
||||
if proxy_server_request_mapping is None:
|
||||
return None
|
||||
headers: Final = proxy_server_request_mapping.get("headers")
|
||||
headers_mapping: Final = _validated_object_mapping(headers)
|
||||
if headers_mapping is None:
|
||||
return None
|
||||
user_agent: Final = next(
|
||||
(value for key, value in headers_mapping.items() if isinstance(key, str) and key.lower() == "user-agent"),
|
||||
None,
|
||||
)
|
||||
return user_agent if isinstance(user_agent, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _request_system(request_kwargs: object) -> str | list[object] | None:
|
||||
system: Final = AnthropicCacheControlHook._request_value(request_kwargs, "system")
|
||||
if isinstance(system, str):
|
||||
return system
|
||||
return _validated_object_list(system)
|
||||
|
||||
def get_chat_completion_prompt(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -520,12 +569,13 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
points: Sequence[CacheControlInjectionPoint],
|
||||
messages: list[AllMessageValues],
|
||||
tools: list[object] | None,
|
||||
cache_control: object,
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
api_base: object,
|
||||
prompt_cache_options: object,
|
||||
) -> Sequence[Mapping[str, object]] | None:
|
||||
if AnthropicCacheControlHook._should_stand_down(points, messages, None, tools):
|
||||
if AnthropicCacheControlHook._should_stand_down(points, messages, None, tools, cache_control):
|
||||
return None
|
||||
return AnthropicCacheControlHook._stamped_with_dialect(
|
||||
points, model, custom_llm_provider, api_base, prompt_cache_options
|
||||
|
|
@ -561,6 +611,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
messages: list[AllMessageValues],
|
||||
system: str | list | None,
|
||||
tools: list | None,
|
||||
cache_control: object = None,
|
||||
) -> bool:
|
||||
"""Whether configured injection points must yield to client-set cache_control.
|
||||
|
||||
|
|
@ -573,13 +624,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
"""
|
||||
if all(point.get("_litellm_judged") for point in points):
|
||||
return False
|
||||
return AnthropicCacheControlHook._request_has_cache_control(messages, system, tools)
|
||||
return AnthropicCacheControlHook._request_has_cache_control(messages, system, tools, cache_control)
|
||||
|
||||
@staticmethod
|
||||
def _request_has_cache_control(
|
||||
messages: list[AllMessageValues],
|
||||
system: str | list | None,
|
||||
tools: list | None = None,
|
||||
cache_control: object = None,
|
||||
) -> bool:
|
||||
"""Return True if the request already carries any client-supplied cache_control.
|
||||
|
||||
|
|
@ -591,6 +643,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
carry the mark either at the top level (Anthropic shape) or nested under
|
||||
``function`` (OpenAI shape); the Anthropic chat transform accepts both.
|
||||
"""
|
||||
if cache_control is not None:
|
||||
return True
|
||||
if AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) > 0:
|
||||
return True
|
||||
if tools is not None:
|
||||
|
|
@ -612,6 +666,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
custom_llm_provider: str | None,
|
||||
tools: list | None = None,
|
||||
enable_prompt_caching: bool | None = None,
|
||||
cache_control: object = None,
|
||||
request_kwargs: object = None,
|
||||
) -> list[CacheControlInjectionPoint]:
|
||||
"""Default breakpoints when ``litellm.enable_anthropic_prompt_caching`` is on.
|
||||
|
||||
|
|
@ -649,7 +705,12 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
if not supports_prompt_caching(model=model, custom_llm_provider=provider):
|
||||
return []
|
||||
|
||||
if AnthropicCacheControlHook._request_has_cache_control(messages, system, tools):
|
||||
if AnthropicCacheControlHook._request_has_cache_control(messages, system, tools, cache_control):
|
||||
return []
|
||||
|
||||
if is_claude_code_one_shot_subagent_request(
|
||||
messages, system, tools, AnthropicCacheControlHook._request_user_agent(request_kwargs)
|
||||
):
|
||||
return []
|
||||
|
||||
control: Final = AnthropicCacheControlHook._default_control()
|
||||
|
|
@ -665,6 +726,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
models: Iterable[str],
|
||||
tools: list[AllToolParamValues] | None = None,
|
||||
enable_prompt_caching: bool | None = None,
|
||||
request_kwargs: object = None,
|
||||
) -> list[AllMessageValues]:
|
||||
"""Return the messages auto prompt caching will send, default breakpoints included.
|
||||
|
||||
|
|
@ -681,11 +743,13 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
for candidate in (
|
||||
AnthropicCacheControlHook.get_default_injection_points(
|
||||
messages=messages,
|
||||
system=None,
|
||||
model=model,
|
||||
custom_llm_provider=None,
|
||||
tools=tools,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
system=AnthropicCacheControlHook._request_system(request_kwargs),
|
||||
cache_control=AnthropicCacheControlHook._request_value(request_kwargs, "cache_control"),
|
||||
request_kwargs=request_kwargs,
|
||||
)
|
||||
for model in models
|
||||
)
|
||||
|
|
@ -730,6 +794,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
non_default_params["cache_control_injection_points"],
|
||||
messages,
|
||||
tools,
|
||||
non_default_params.get("cache_control"),
|
||||
model,
|
||||
custom_llm_provider,
|
||||
api_base,
|
||||
|
|
@ -747,6 +812,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
tools=tools,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
cache_control=non_default_params.get("cache_control"),
|
||||
request_kwargs=non_default_params,
|
||||
)
|
||||
if points:
|
||||
non_default_params["cache_control_injection_points"] = points
|
||||
|
|
@ -853,10 +920,13 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
enable_prompt_caching: Final = cast( # cast-ok: kwargs is untyped; key stamped as bool by the proxy
|
||||
bool | None, kwargs.pop("enable_prompt_caching", None)
|
||||
)
|
||||
cache_control: Final = kwargs.get("cache_control")
|
||||
configured: Final = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list
|
||||
list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None)
|
||||
)
|
||||
if configured and AnthropicCacheControlHook._should_stand_down(configured, typed_messages, system, tools):
|
||||
if configured and AnthropicCacheControlHook._should_stand_down(
|
||||
configured, typed_messages, system, tools, cache_control
|
||||
):
|
||||
return messages, system
|
||||
injection_points: list[CacheControlInjectionPoint] = configured or []
|
||||
if not injection_points and model is not None:
|
||||
|
|
@ -867,6 +937,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
cache_control=cache_control,
|
||||
request_kwargs=kwargs,
|
||||
)
|
||||
if not injection_points:
|
||||
return messages, system
|
||||
|
|
|
|||
|
|
@ -356,6 +356,12 @@
|
|||
"description": "OpenTelemetry collector endpoint URL",
|
||||
"required": true
|
||||
},
|
||||
"otel_traces_endpoint": {
|
||||
"type": "text",
|
||||
"ui_name": "Traces Endpoint URL",
|
||||
"description": "Complete trace export URL used verbatim when the collector does not serve /v1/traces (OTel v2 only)",
|
||||
"required": false
|
||||
},
|
||||
"otel_headers": {
|
||||
"type": "text",
|
||||
"ui_name": "Headers",
|
||||
|
|
|
|||
|
|
@ -601,6 +601,12 @@ class CustomGuardrail(CustomLogger):
|
|||
event_hook: GuardrailEventHooks | list[GuardrailEventHooks] | Mode | None,
|
||||
supported_event_hooks: list[GuardrailEventHooks],
|
||||
) -> None:
|
||||
allowed_hooks: Final = frozenset(supported_event_hooks) | (
|
||||
frozenset((GuardrailEventHooks.logging_only,))
|
||||
if self.uses_apply_guardrail_interface() and not self.use_native_lifecycle_hooks
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
def _validate_event_hook_list_is_in_supported_event_hooks(
|
||||
event_hook: list[GuardrailEventHooks] | list[str],
|
||||
supported_event_hooks: list[GuardrailEventHooks],
|
||||
|
|
@ -608,7 +614,7 @@ class CustomGuardrail(CustomLogger):
|
|||
for hook in event_hook:
|
||||
if isinstance(hook, str):
|
||||
hook = GuardrailEventHooks(hook)
|
||||
if hook not in supported_event_hooks:
|
||||
if hook not in allowed_hooks:
|
||||
raise ValueError(f"Event hook {hook} is not in the supported event hooks {supported_event_hooks}")
|
||||
|
||||
if event_hook is None:
|
||||
|
|
@ -629,7 +635,7 @@ class CustomGuardrail(CustomLogger):
|
|||
default_list = event_hook.default if isinstance(event_hook.default, list) else [event_hook.default]
|
||||
_validate_event_hook_list_is_in_supported_event_hooks(default_list, supported_event_hooks)
|
||||
elif isinstance(event_hook, GuardrailEventHooks):
|
||||
if event_hook not in supported_event_hooks:
|
||||
if event_hook not in allowed_hooks:
|
||||
raise ValueError(f"Event hook {event_hook} is not in the supported event hooks {supported_event_hooks}")
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -773,7 +779,7 @@ class CustomGuardrail(CustomLogger):
|
|||
def uses_apply_guardrail_interface(self) -> bool:
|
||||
return type(self).apply_guardrail is not CustomGuardrail.apply_guardrail
|
||||
|
||||
def _deployment_pre_call_target(self) -> "CustomLogger":
|
||||
def _deployment_hook_target(self) -> "CustomLogger":
|
||||
if not self.uses_apply_guardrail_interface() or self.use_native_lifecycle_hooks:
|
||||
return self
|
||||
try:
|
||||
|
|
@ -802,7 +808,7 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
# CHECK IF GUARDRAIL REJECTS THE REQUEST
|
||||
if call_type == CallTypes.completion or call_type == CallTypes.acompletion:
|
||||
target: Final = self._deployment_pre_call_target()
|
||||
target: Final = self._deployment_hook_target()
|
||||
if target is not self:
|
||||
kwargs["guardrail_to_apply"] = self
|
||||
result: Final = await target.async_pre_call_hook(
|
||||
|
|
@ -845,7 +851,9 @@ class CustomGuardrail(CustomLogger):
|
|||
return None
|
||||
|
||||
# CHECK IF GUARDRAIL REJECTS THE REQUEST
|
||||
result: Final = await self.async_post_call_success_hook(
|
||||
target: Final = self._deployment_hook_target()
|
||||
hook_request_data: Final = {**request_data, "guardrail_to_apply": self} if target is not self else request_data
|
||||
result: Final = await target.async_post_call_success_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_id=request_data.get("user_api_key_user_id"),
|
||||
team_id=request_data.get("user_api_key_team_id"),
|
||||
|
|
@ -853,7 +861,7 @@ class CustomGuardrail(CustomLogger):
|
|||
api_key=request_data.get("user_api_key_hash"),
|
||||
request_route=request_data.get("user_api_key_request_route"),
|
||||
),
|
||||
data=request_data,
|
||||
data=hook_request_data,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,14 @@ class ExporterSpec(BaseModel):
|
|||
description="console | in_memory | otlp_http | otlp_grpc | <factory kind>",
|
||||
)
|
||||
endpoint: str | None = None
|
||||
traces_endpoint: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Complete OTLP/HTTP trace URL, used verbatim. Set this when the "
|
||||
"collector serves traces on a path other than ``/v1/traces``; "
|
||||
"``endpoint`` is a base URL the signal path is appended to."
|
||||
),
|
||||
)
|
||||
headers: str | None = None
|
||||
owner: ExporterOwner | None = Field(
|
||||
default=None,
|
||||
|
|
@ -127,6 +135,14 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
default=None,
|
||||
validation_alias=AliasChoices("OTEL_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"),
|
||||
)
|
||||
traces_endpoint: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("OTEL_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"),
|
||||
description=(
|
||||
"Complete OTLP/HTTP trace URL for the single-destination shorthand, "
|
||||
"used verbatim instead of ``endpoint`` + ``/v1/traces``."
|
||||
),
|
||||
)
|
||||
headers: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("OTEL_HEADERS", "OTEL_EXPORTER_OTLP_HEADERS"),
|
||||
|
|
@ -250,7 +266,7 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
@model_validator(mode="after")
|
||||
def _normalize(self) -> "OpenTelemetryV2Config":
|
||||
# An endpoint with the default exporter kind implies OTLP/HTTP.
|
||||
if self.endpoint and self.exporter == "console":
|
||||
if (self.endpoint or self.traces_endpoint) and self.exporter == "console":
|
||||
self.exporter = "otlp_http"
|
||||
# When no explicit destinations are given, fold the single-destination
|
||||
# shorthand into one spec so the provider always has a destination.
|
||||
|
|
@ -259,6 +275,7 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
ExporterSpec(
|
||||
kind=self.exporter,
|
||||
endpoint=self.endpoint,
|
||||
traces_endpoint=self.traces_endpoint,
|
||||
headers=self.headers,
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ def _exporter_from_spec(spec: ExporterSpec) -> SpanExporter:
|
|||
)
|
||||
|
||||
return HTTPExporter(
|
||||
endpoint=_otlp_traces_endpoint(spec.endpoint),
|
||||
endpoint=spec.traces_endpoint or _otlp_traces_endpoint(spec.endpoint),
|
||||
headers=parse_headers(spec.headers),
|
||||
)
|
||||
if kind in _OTLP_GRPC_KINDS:
|
||||
|
|
@ -201,7 +201,14 @@ def build_span_exporter(config: OpenTelemetryV2Config) -> SpanExporter:
|
|||
``exporter`` / ``endpoint`` / ``headers`` fields. To configure multiple
|
||||
exporters, populate ``config.exporters`` directly.
|
||||
"""
|
||||
return _exporter_from_spec(ExporterSpec(kind=config.exporter, endpoint=config.endpoint, headers=config.headers))
|
||||
return _exporter_from_spec(
|
||||
ExporterSpec(
|
||||
kind=config.exporter,
|
||||
endpoint=config.endpoint,
|
||||
traces_endpoint=config.traces_endpoint,
|
||||
headers=config.headers,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _otlp_metrics_endpoint(endpoint: str | None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -14,21 +14,48 @@ from typing import Final
|
|||
from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes
|
||||
|
||||
|
||||
def _segment_matches(route_segment: str, pattern_segment: str) -> bool:
|
||||
"""
|
||||
Match one concrete path segment against one pattern segment.
|
||||
A bare placeholder ({param}) matches any segment; a placeholder with a
|
||||
literal suffix ({model}:generateContent) requires the segment to end with
|
||||
that suffix and have a non-empty value before it.
|
||||
"""
|
||||
if not pattern_segment.startswith("{"):
|
||||
return route_segment == pattern_segment
|
||||
placeholder_end: Final = pattern_segment.find("}")
|
||||
if placeholder_end == -1:
|
||||
return route_segment == pattern_segment
|
||||
literal_suffix: Final = pattern_segment[placeholder_end + 1 :]
|
||||
if not literal_suffix:
|
||||
return True
|
||||
return route_segment.endswith(literal_suffix) and len(route_segment) > len(literal_suffix)
|
||||
|
||||
|
||||
def _pattern_tail_spans_segments(pattern_tail: str) -> bool:
|
||||
"""
|
||||
Whether the pattern's last segment is a suffixed placeholder
|
||||
({model}:generateContent) that may absorb extra route segments, mirroring
|
||||
FastAPI's {model_name:path} converter for slash-containing model names.
|
||||
"""
|
||||
return pattern_tail.startswith("{") and "}" in pattern_tail and not pattern_tail.endswith("}")
|
||||
|
||||
|
||||
def _route_matches_pattern(route: str, pattern: str) -> bool:
|
||||
"""
|
||||
Return True if the concrete route matches the pattern.
|
||||
Pattern segments like {param} match any single path segment.
|
||||
Pattern segments like {param} match any single path segment, and a
|
||||
suffixed placeholder in the last segment may span multiple segments.
|
||||
"""
|
||||
route_parts: Final = route.strip("/").split("/")
|
||||
pattern_parts: Final = pattern.strip("/").split("/")
|
||||
if len(route_parts) != len(pattern_parts):
|
||||
if len(route_parts) < len(pattern_parts):
|
||||
return False
|
||||
for r, p in zip(route_parts, pattern_parts):
|
||||
if p.startswith("{") and p.endswith("}"):
|
||||
continue
|
||||
if r != p:
|
||||
return False
|
||||
return True
|
||||
if len(route_parts) > len(pattern_parts) and not _pattern_tail_spans_segments(pattern_parts[-1]):
|
||||
return False
|
||||
head_count: Final = len(pattern_parts) - 1
|
||||
merged_parts: Final = (*route_parts[:head_count], "/".join(route_parts[head_count:]))
|
||||
return all(_segment_matches(r, p) for r, p in zip(merged_parts, pattern_parts))
|
||||
|
||||
|
||||
def get_call_types_for_route(route: str) -> Sequence[CallTypes] | None:
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@ from litellm.types.utils import CredentialItem
|
|||
|
||||
|
||||
class CredentialAccessor:
|
||||
@staticmethod
|
||||
def find_credential(credential_name: str) -> CredentialItem | None:
|
||||
return next(
|
||||
(credential for credential in litellm.credential_list if credential.credential_name == credential_name),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_credential_values(credential_name: str) -> dict:
|
||||
"""Safe accessor for credentials."""
|
||||
|
||||
if not litellm.credential_list:
|
||||
return {}
|
||||
for credential in litellm.credential_list:
|
||||
if credential.credential_name == credential_name:
|
||||
return credential.credential_values.copy()
|
||||
return {}
|
||||
credential: Final = CredentialAccessor.find_credential(credential_name)
|
||||
return {} if credential is None else credential.credential_values.copy()
|
||||
|
||||
@staticmethod
|
||||
def upsert_credentials(credentials: list[CredentialItem]):
|
||||
|
|
|
|||
|
|
@ -860,6 +860,7 @@ def _map_bedrock_exception(
|
|||
message=mantle_context_window_message,
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
if (
|
||||
"too many tokens" in error_str
|
||||
|
|
@ -873,6 +874,7 @@ def _map_bedrock_exception(
|
|||
message=f"BedrockException: Context Window Error - {error_str}",
|
||||
model=model,
|
||||
llm_provider="bedrock",
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
elif "Conversation blocks and tool result blocks cannot be provided in the same turn." in error_str:
|
||||
raise BadRequestError(
|
||||
|
|
@ -924,12 +926,14 @@ def _map_bedrock_exception(
|
|||
message=f"BedrockException: Timeout Error - {error_str}",
|
||||
model=model,
|
||||
llm_provider="bedrock",
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
elif "Could not process image" in error_str:
|
||||
raise litellm.InternalServerError(
|
||||
message=f"BedrockException - {error_str}",
|
||||
model=model,
|
||||
llm_provider="bedrock",
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
elif hasattr(original_exception, "status_code"):
|
||||
if original_exception.status_code == 500:
|
||||
|
|
@ -937,10 +941,7 @@ def _map_bedrock_exception(
|
|||
message=f"BedrockException - {original_exception.message}",
|
||||
llm_provider="bedrock",
|
||||
model=model,
|
||||
response=httpx.Response(
|
||||
status_code=500,
|
||||
request=httpx.Request(method="POST", url="https://api.openai.com/v1/"),
|
||||
),
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
elif original_exception.status_code == 401:
|
||||
raise AuthenticationError(
|
||||
|
|
@ -969,6 +970,7 @@ def _map_bedrock_exception(
|
|||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
litellm_debug_info=extra_information,
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
elif original_exception.status_code == 422:
|
||||
raise BadRequestError(
|
||||
|
|
@ -1001,6 +1003,7 @@ def _map_bedrock_exception(
|
|||
llm_provider=custom_llm_provider,
|
||||
litellm_debug_info=extra_information,
|
||||
exception_status_code=original_exception.status_code,
|
||||
response=getattr(original_exception, "response", None),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,13 +21,10 @@ AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# The per-deployment Rust opt-in.
|
||||
RUST_KWARG_KEY: Final = "rust"
|
||||
|
||||
# Keys `completion()` forwards from its own kwargs into `get_litellm_params`,
|
||||
# which are otherwise invisible to it because that call site passes explicit
|
||||
# named arguments rather than `**kwargs`.
|
||||
FORWARDED_KWARGS_KEYS: Final = AWS_CREDENTIAL_KWARGS_KEYS | frozenset({RUST_KWARG_KEY})
|
||||
FORWARDED_KWARGS_KEYS: Final = AWS_CREDENTIAL_KWARGS_KEYS
|
||||
|
||||
# Pre-define optional kwargs keys as frozenset for O(1) lookups
|
||||
# These are extracted from kwargs only if present, avoiding unnecessary .get() calls
|
||||
|
|
@ -58,10 +55,6 @@ OPTIONAL_KWARGS_KEYS: Final = (
|
|||
"itpm",
|
||||
"otpm",
|
||||
"use_xai_oauth",
|
||||
# The per-deployment Rust opt-in. `all_litellm_params` keeps it out
|
||||
# of the provider body; this keeps it *in* litellm_params, which is
|
||||
# where the chat completions handlers read it from.
|
||||
RUST_KWARG_KEY,
|
||||
}
|
||||
)
|
||||
| AWS_CREDENTIAL_KWARGS_KEYS
|
||||
|
|
|
|||
|
|
@ -53,12 +53,14 @@ class GetModelCostMap:
|
|||
|
||||
_backup_model_count: int = -1 # -1 = not yet loaded
|
||||
|
||||
@staticmethod
|
||||
def read_local_model_cost_map_text() -> str:
|
||||
return files("litellm").joinpath("model_prices_and_context_window_backup.json").read_text(encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def load_local_model_cost_map() -> dict:
|
||||
"""Load the local backup model cost map bundled with the package."""
|
||||
content: Final = json.loads(
|
||||
files("litellm").joinpath("model_prices_and_context_window_backup.json").read_text(encoding="utf-8")
|
||||
)
|
||||
content: Final = json.loads(GetModelCostMap.read_local_model_cost_map_text())
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ from litellm.caching.caching_handler import LLMCachingHandler
|
|||
from litellm.constants import (
|
||||
DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT,
|
||||
DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT,
|
||||
PROVIDER_REQUEST_ID_HEADERS,
|
||||
SENTRY_DENYLIST,
|
||||
SENTRY_PII_DENYLIST,
|
||||
)
|
||||
from litellm.cost_calculator import (
|
||||
RealtimeAPITokenUsageProcessor,
|
||||
ResponsesWebSocketTokenUsageProcessor,
|
||||
_select_model_name_for_cost_calc,
|
||||
)
|
||||
from litellm.exceptions import (
|
||||
|
|
@ -255,6 +257,30 @@ _in_memory_loggers: Final[list[CustomLogger]] = []
|
|||
|
||||
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggingMetadata.__annotations__.keys())
|
||||
|
||||
|
||||
def _get_provider_request_id(original_exception: Exception) -> str | None:
|
||||
try:
|
||||
error_response: Final = getattr(original_exception, "response", None)
|
||||
header_sources: Final = (
|
||||
_get_response_headers(original_exception),
|
||||
getattr(error_response, "headers", None),
|
||||
getattr(original_exception, "litellm_response_headers", None),
|
||||
)
|
||||
return next(
|
||||
(
|
||||
str(value)
|
||||
for expected_header_name in PROVIDER_REQUEST_ID_HEADERS
|
||||
for headers in header_sources
|
||||
if isinstance(headers, Mapping)
|
||||
for header_name, value in headers.items()
|
||||
if isinstance(header_name, str) and header_name.lower() == expected_header_name and value
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
### GLOBAL VARIABLES ###
|
||||
|
||||
# Cache custom pricing keys as frozenset for O(1) lookups instead of looping through 49 keys
|
||||
|
|
@ -2003,6 +2029,17 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
results=result,
|
||||
)
|
||||
|
||||
elif self.call_type == CallTypes.aresponses_websocket.value and isinstance(result, list): # pyright: ignore[reportUnknownMemberType] # Logging.call_type is untyped
|
||||
combined_ws_usage: Final = (
|
||||
ResponsesWebSocketTokenUsageProcessor.collect_and_combine_usage_from_responses_ws_results(
|
||||
results=result # pyright: ignore[reportUnknownArgumentType] # raw event dicts from the WS stream
|
||||
)
|
||||
)
|
||||
logging_result = LiteLLMRealtimeStreamLoggingObject(
|
||||
usage=combined_ws_usage,
|
||||
results=result, # pyright: ignore[reportUnknownArgumentType] # raw event dicts from the WS stream
|
||||
)
|
||||
|
||||
elif (
|
||||
self.call_type == CallTypes.llm_passthrough_route.value
|
||||
or self.call_type == CallTypes.allm_passthrough_route.value
|
||||
|
|
@ -3909,11 +3946,12 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
|
||||
served_id: Final = _provider_response_id(result)
|
||||
try:
|
||||
return LiteLLMResponsesTransformationHandler().transform_response(
|
||||
translated: Final = LiteLLMResponsesTransformationHandler().transform_response(
|
||||
model=self.model,
|
||||
raw_response=result,
|
||||
model_response=litellm.ModelResponse(id=_provider_response_id(result)),
|
||||
model_response=litellm.ModelResponse(id=served_id),
|
||||
logging_obj=self,
|
||||
request_data={},
|
||||
messages=[],
|
||||
|
|
@ -3921,6 +3959,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
litellm_params={},
|
||||
encoding=litellm.encoding,
|
||||
)
|
||||
translated.id = served_id or translated.id
|
||||
return translated
|
||||
except Exception as e:
|
||||
verbose_logger.debug(
|
||||
"Responses API -> ModelResponse translation failed for "
|
||||
|
|
@ -3928,7 +3968,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"usage-only ModelResponse to keep the spend_logs row.",
|
||||
str(e),
|
||||
)
|
||||
model_response: Final = litellm.ModelResponse(id=_provider_response_id(result))
|
||||
model_response: Final = litellm.ModelResponse(id=served_id)
|
||||
model_response.model = self.model
|
||||
usage: Final = getattr(result, "usage", None)
|
||||
if usage is not None and ResponseAPILoggingUtils._is_response_api_usage(usage):
|
||||
|
|
@ -5661,6 +5701,7 @@ class StandardLoggingPayloadSetup:
|
|||
rate_limit_category: Final = validate_rate_limit_category(getattr(original_exception, "category", None))
|
||||
rate_limit_type: Final = validate_rate_limit_type(getattr(original_exception, "rate_limit_type", None))
|
||||
budget_error: Final = original_exception if isinstance(original_exception, BudgetExceededError) else None
|
||||
provider_request_id: Final = _get_provider_request_id(original_exception) if original_exception else None
|
||||
|
||||
return StandardLoggingPayloadErrorInformation(
|
||||
error_code=error_status,
|
||||
|
|
@ -5668,6 +5709,7 @@ class StandardLoggingPayloadSetup:
|
|||
llm_provider=_llm_provider_in_exception,
|
||||
traceback=_redact_string(traceback_info),
|
||||
error_message=_redact_string(error_message),
|
||||
error_provider_request_id=provider_request_id,
|
||||
error_rate_limit_category=rate_limit_category,
|
||||
error_rate_limit_type=rate_limit_type,
|
||||
error_budget_entity_type=budget_error.entity_type if budget_error else None,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.types.utils import (
|
|||
PromptTokensDetailsWrapper,
|
||||
ServiceTier,
|
||||
Usage,
|
||||
text_tokens_without_nested_reasoning,
|
||||
)
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
|
|
@ -513,6 +514,7 @@ def _get_token_base_cost(
|
|||
current_time: datetime | None = None,
|
||||
*,
|
||||
threshold_is_inclusive: bool = False,
|
||||
missing_cache_read_uses_input: bool = False,
|
||||
) -> tuple[float, float, float, float, float]:
|
||||
"""
|
||||
Return prompt cost, completion cost, and cache costs for a given model and usage.
|
||||
|
|
@ -523,6 +525,9 @@ def _get_token_base_cost(
|
|||
`threshold_is_inclusive` switches that comparison to >=, for providers such as xAI
|
||||
that bill the higher tier once the prompt reaches the threshold.
|
||||
|
||||
`missing_cache_read_uses_input` resolves an absent cache-read rate to the resolved
|
||||
input rate instead of 0.0; an explicit 0.0 rate stays a real price either way.
|
||||
|
||||
Returns:
|
||||
Tuple[float, float, float, float] - (prompt_cost, completion_cost, cache_creation_cost, cache_read_cost)
|
||||
"""
|
||||
|
|
@ -550,29 +555,16 @@ def _get_token_base_cost(
|
|||
float,
|
||||
_get_cost_per_unit(model_info, "cache_creation_input_token_cost_above_1hr"),
|
||||
)
|
||||
cache_read_cost = cast(float, _get_cost_per_unit(model_info, cache_read_cost_key))
|
||||
cache_read_cost = _get_cost_per_unit(model_info, cache_read_cost_key, default_value=None)
|
||||
|
||||
## CHECK IF ABOVE THRESHOLD
|
||||
# Optimization: collect threshold keys first to avoid sorting all model_info keys.
|
||||
# Most models don't have threshold pricing, so we can return early.
|
||||
# Exclude service_tier-specific variants (e.g. input_cost_per_token_above_200k_tokens_priority)
|
||||
# so that the threshold detection loop only processes standard keys. The
|
||||
# service_tier-specific above-threshold key is resolved later via _get_service_tier_cost_key.
|
||||
threshold_keys: Final = [
|
||||
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 _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+)
|
||||
threshold: float | None = None
|
||||
|
|
@ -661,10 +653,7 @@ def _get_token_base_cost(
|
|||
),
|
||||
)
|
||||
|
||||
cache_read_cost = cast(
|
||||
float,
|
||||
_get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost),
|
||||
)
|
||||
cache_read_cost = _get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost)
|
||||
|
||||
break
|
||||
except (IndexError, ValueError):
|
||||
|
|
@ -672,6 +661,17 @@ def _get_token_base_cost(
|
|||
except Exception:
|
||||
continue
|
||||
|
||||
if cache_read_cost is None:
|
||||
cache_read_cost = (
|
||||
_off_peak_rate(
|
||||
_open_off_peak_block(model_info, current_time) or MappingProxyType({}),
|
||||
"input_cost_per_token",
|
||||
prompt_base_cost,
|
||||
)
|
||||
if missing_cache_read_uses_input
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return _apply_off_peak_to_base_costs(
|
||||
model_info,
|
||||
current_time,
|
||||
|
|
@ -860,7 +860,7 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
|
|||
)
|
||||
or 0
|
||||
)
|
||||
text_tokens: Final = (
|
||||
reported_text_tokens: Final = (
|
||||
cast(
|
||||
int | None,
|
||||
getattr(usage.completion_tokens_details, "text_tokens", None),
|
||||
|
|
@ -882,6 +882,12 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
|
|||
or 0
|
||||
)
|
||||
video_tokens: Final = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0))
|
||||
text_tokens: Final = text_tokens_without_nested_reasoning(
|
||||
completion_tokens=usage.completion_tokens,
|
||||
text_tokens=reported_text_tokens,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
other_modality_tokens=audio_tokens + image_tokens + video_tokens,
|
||||
)
|
||||
|
||||
return CompletionTokensDetailsResult(
|
||||
audio_tokens=audio_tokens,
|
||||
|
|
@ -1409,6 +1415,57 @@ def get_token_type_cost_breakdown(
|
|||
)
|
||||
|
||||
|
||||
def calculate_prompt_caching_savings(
|
||||
model_info: ModelInfo,
|
||||
usage: Usage,
|
||||
custom_llm_provider: str | None,
|
||||
service_tier: str | None = None,
|
||||
data_residency: str | None = None,
|
||||
vertex_location: str | None = None,
|
||||
billed_at: datetime | None = None,
|
||||
) -> float:
|
||||
"""Read discount minus write premium, using the biller's rate and TTL resolution.
|
||||
|
||||
Missing reads and unpublished (missing/zero) writes claim no saving or premium;
|
||||
explicit zero reads remain free. An unpublished 1h price uses the ordinary write rate.
|
||||
``billed_at`` is the request's completion time, so off-peak windows resolve as the
|
||||
biller saw them rather than at the later spend write.
|
||||
"""
|
||||
prompt_base_cost, _, cache_creation_cost, cache_creation_cost_above_1hr, cache_read_cost = _get_token_base_cost(
|
||||
model_info=model_info,
|
||||
usage=usage,
|
||||
service_tier=service_tier,
|
||||
current_time=billed_at,
|
||||
threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider),
|
||||
missing_cache_read_uses_input=True,
|
||||
)
|
||||
write_rate: Final = cache_creation_cost or prompt_base_cost
|
||||
write_rate_1h: Final = cache_creation_cost_above_1hr or write_rate
|
||||
prompt_tokens_details: Final = parse_prompt_tokens_details(usage)
|
||||
cache_read_tokens: Final = max(prompt_tokens_details["cache_hit_tokens"], 0)
|
||||
cache_creation_tokens: Final = max(prompt_tokens_details["cache_creation_tokens"], 0)
|
||||
details: Final = prompt_tokens_details["cache_creation_token_details"]
|
||||
cache_creation_details: Final = (
|
||||
CacheCreationTokenDetails(
|
||||
ephemeral_5m_input_tokens=max(details.ephemeral_5m_input_tokens or 0, 0),
|
||||
ephemeral_1h_input_tokens=max(details.ephemeral_1h_input_tokens or 0, 0),
|
||||
)
|
||||
if details is not None
|
||||
else None
|
||||
)
|
||||
read_discount: Final = cache_read_tokens * max(prompt_base_cost - cache_read_cost, 0.0)
|
||||
write_premium: Final = calculate_cache_writing_cost(
|
||||
cache_creation_tokens=cache_creation_tokens,
|
||||
cache_creation_token_details=cache_creation_details,
|
||||
cache_creation_cost_above_1hr=write_rate_1h - prompt_base_cost,
|
||||
cache_creation_cost=write_rate - prompt_base_cost,
|
||||
)
|
||||
uplift: Final = _get_regional_uplift_multiplier(model_info, data_residency) * get_vertex_regional_endpoint_uplift(
|
||||
model_info, vertex_location
|
||||
)
|
||||
return (read_discount - write_premium) * uplift
|
||||
|
||||
|
||||
def calculate_image_response_cost_from_usage(
|
||||
model: str,
|
||||
image_response: ImageResponse,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
|
||||
def get_response_headers(_response_headers: dict | None = None) -> dict:
|
||||
def get_response_headers(_response_headers: Mapping[str, str] | None = None) -> dict:
|
||||
"""
|
||||
|
||||
Sets the Appropriate OpenAI headers for the response and forward all headers as llm_provider-{header}
|
||||
|
|
@ -31,7 +32,7 @@ def get_response_headers(_response_headers: dict | None = None) -> dict:
|
|||
return {**llm_provider_headers, **openai_headers}
|
||||
|
||||
|
||||
def _get_llm_provider_headers(response_headers: dict) -> dict:
|
||||
def _get_llm_provider_headers(response_headers: Mapping[str, str]) -> dict:
|
||||
"""
|
||||
Adds a llm_provider-{header} to all headers that are not already prefixed with llm_provider
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
Helper functions to handle images passed in messages
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from httpx import Response
|
||||
|
|
@ -11,9 +15,11 @@ import litellm
|
|||
from litellm import verbose_logger
|
||||
from litellm.caching.caching import InMemoryCache
|
||||
from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
MAX_IMGS_IN_MEMORY: Final = 10
|
||||
MAX_CONCURRENT_REMOTE_MEDIA_FETCHES: Final = 20
|
||||
|
||||
in_memory_cache: Final = InMemoryCache(max_size_in_memory=MAX_IMGS_IN_MEMORY)
|
||||
|
||||
|
|
@ -72,6 +78,14 @@ def _process_image_response(response: Response, url: str) -> str:
|
|||
return result
|
||||
|
||||
|
||||
def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchError":
|
||||
verbose_logger.warning("Image fetch of %s rejected before any request went out: %s", url, verdict)
|
||||
return litellm.ImageFetchError(
|
||||
"Error: Unable to fetch image from URL. The proxy could not resolve this host or its URL policy rejected it; "
|
||||
f"an admin can check the proxy log and `user_url_allowed_hosts` in general_settings. url={url}"
|
||||
)
|
||||
|
||||
|
||||
async def async_convert_url_to_base64(url: str) -> str:
|
||||
if url.startswith("data:") and ";base64," in url:
|
||||
return url
|
||||
|
|
@ -93,6 +107,8 @@ async def async_convert_url_to_base64(url: str) -> str:
|
|||
return _process_image_response(response, url)
|
||||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except SSRFError as e:
|
||||
raise _rejected_image_fetch(url, e) from e
|
||||
except Exception:
|
||||
pass
|
||||
raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}")
|
||||
|
|
@ -119,8 +135,192 @@ def convert_url_to_base64(url: str) -> str:
|
|||
return _process_image_response(response, url)
|
||||
except litellm.ImageFetchError:
|
||||
raise
|
||||
except SSRFError as e:
|
||||
raise _rejected_image_fetch(url, e) from e
|
||||
except Exception as e:
|
||||
verbose_logger.exception(e)
|
||||
raise litellm.ImageFetchError(
|
||||
f"Error: Unable to fetch image from URL after 3 attempts. url={url}",
|
||||
)
|
||||
|
||||
|
||||
_REMOTE_URL_PREFIXES: Final = ("http://", "https://")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RemoteImage:
|
||||
part: Mapping[str, object]
|
||||
image_url: Mapping[str, object] | None
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RemoteFile:
|
||||
part: Mapping[str, object]
|
||||
file: Mapping[str, object]
|
||||
url: str
|
||||
|
||||
|
||||
def _as_mapping(value: object) -> Mapping[str, object] | None:
|
||||
return value if isinstance(value, Mapping) else None # pyright: ignore[reportUnknownVariableType] # fields are parsed one by one
|
||||
|
||||
|
||||
def _remote_url(candidate: object) -> str | None:
|
||||
return candidate if isinstance(candidate, str) and candidate.startswith(_REMOTE_URL_PREFIXES) else None
|
||||
|
||||
|
||||
_ANTHROPIC_MEDIA_BLOCK_TYPES: Final = frozenset({"document", "image"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RemoteSource:
|
||||
part: Mapping[str, object]
|
||||
source: Mapping[str, object]
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteMedia:
|
||||
url: str
|
||||
fields: Mapping[str, object]
|
||||
|
||||
|
||||
_NO_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
def inline_every_remote_url(_media: RemoteMedia) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _parse_remote_image(fields: Mapping[str, object]) -> _RemoteImage | None:
|
||||
if fields.get("type") != "image_url":
|
||||
return None
|
||||
image_url: Final = fields.get("image_url")
|
||||
image_url_fields: Final = _as_mapping(image_url)
|
||||
url: Final = _remote_url(image_url_fields.get("url") if image_url_fields is not None else image_url)
|
||||
return _RemoteImage(fields, image_url_fields, url) if url is not None else None
|
||||
|
||||
|
||||
def _parse_remote_file(fields: Mapping[str, object]) -> _RemoteFile | None:
|
||||
file: Final = _as_mapping(fields.get("file")) if fields.get("type") == "file" else None
|
||||
url: Final = _remote_url(file.get("file_id")) if file is not None else None
|
||||
return _RemoteFile(fields, file, url) if file is not None and url is not None else None
|
||||
|
||||
|
||||
def _parse_remote_source(fields: Mapping[str, object]) -> _RemoteSource | None:
|
||||
source: Final = _as_mapping(fields.get("source")) if fields.get("type") in _ANTHROPIC_MEDIA_BLOCK_TYPES else None
|
||||
url: Final = _remote_url(source.get("url")) if source is not None and source.get("type") == "url" else None
|
||||
return _RemoteSource(fields, source, url) if source is not None and url is not None else None
|
||||
|
||||
|
||||
def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | _RemoteSource | None:
|
||||
fields: Final = _as_mapping(part)
|
||||
if fields is None:
|
||||
return None
|
||||
return _parse_remote_image(fields) or _parse_remote_file(fields) or _parse_remote_source(fields)
|
||||
|
||||
|
||||
def _remote_media(remote: _RemoteImage | _RemoteFile | _RemoteSource) -> RemoteMedia:
|
||||
match remote:
|
||||
case _RemoteImage(_, image_url, url):
|
||||
return RemoteMedia(url, image_url if image_url is not None else _NO_FIELDS)
|
||||
case _RemoteFile(_, file, url):
|
||||
return RemoteMedia(url, file)
|
||||
case _RemoteSource(_, source, url):
|
||||
return RemoteMedia(url, source)
|
||||
|
||||
|
||||
_PDF_FORMAT: Final = MappingProxyType({"format": "application/pdf"})
|
||||
|
||||
|
||||
def _inferred_format(file: Mapping[str, object], url: str) -> Mapping[str, str]:
|
||||
return _PDF_FORMAT if "format" not in file and url.lower().endswith(".pdf") else MappingProxyType({})
|
||||
|
||||
|
||||
def _inlined_image_url(image_url: Mapping[str, object] | None, data_url: str) -> Mapping[str, object] | str:
|
||||
return {**image_url, "url": data_url} if image_url is not None else data_url # mutable-ok: json-serialized part
|
||||
|
||||
|
||||
def _inlined_file(file: Mapping[str, object], url: str, data_url: str) -> Mapping[str, object]:
|
||||
kept: Final = {k: v for k, v in file.items() if k != "file_id"} # mutable-ok: json-serialized message part
|
||||
return {**kept, **_inferred_format(file, url), "file_data": data_url} # mutable-ok: json-serialized part
|
||||
|
||||
|
||||
def _base64_source(url: str, data_url: str) -> Mapping[str, str]:
|
||||
fetched_media_type, data = data_url.removeprefix("data:").split(";base64,", 1)
|
||||
media_type: Final = "application/pdf" if url.lower().endswith(".pdf") else fetched_media_type
|
||||
return {"type": "base64", "media_type": media_type, "data": data} # mutable-ok: json-serialized message part
|
||||
|
||||
|
||||
def _inline(remote: _RemoteImage | _RemoteFile | _RemoteSource, data_url: str) -> Mapping[str, object]:
|
||||
match remote:
|
||||
case _RemoteImage(part, image_url, _):
|
||||
return {**part, "image_url": _inlined_image_url(image_url, data_url)} # mutable-ok: json-serialized part
|
||||
case _RemoteFile(part, file, url):
|
||||
return {**part, "file": _inlined_file(file, url, data_url)} # mutable-ok: json-serialized message part
|
||||
case _RemoteSource(part, _, url):
|
||||
return {**part, "source": _base64_source(url, data_url)} # mutable-ok: json-serialized message part
|
||||
|
||||
|
||||
def _content_parts(message: Mapping[str, object]) -> tuple[object, ...]:
|
||||
content: Final = message.get("content")
|
||||
return tuple(content) if isinstance(content, list) else () # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # parts are parsed one by one
|
||||
|
||||
|
||||
def _inline_part(part: object, data_urls: Mapping[str, str], should_inline: Callable[[RemoteMedia], bool]) -> object:
|
||||
remote: Final = _parse_remote_part(part)
|
||||
if remote is None or not should_inline(_remote_media(remote)):
|
||||
return part
|
||||
data_url: Final = data_urls.get(remote.url)
|
||||
return _inline(remote, data_url) if data_url is not None else part
|
||||
|
||||
|
||||
def _inline_message(
|
||||
message: AllMessageValues, data_urls: Mapping[str, str], should_inline: Callable[[RemoteMedia], bool]
|
||||
) -> AllMessageValues:
|
||||
parts: Final = _content_parts(message)
|
||||
if not parts:
|
||||
return message
|
||||
inlined_parts: Final = [ # mutable-ok: content must stay a list for the transforms' isinstance checks
|
||||
_inline_part(part, data_urls, should_inline) for part in parts
|
||||
]
|
||||
inlined_message: Final = {**message, "content": inlined_parts} # mutable-ok: json-serialized message
|
||||
return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined
|
||||
|
||||
|
||||
async def _fetch_data_url(url: str, in_flight: asyncio.Semaphore) -> str:
|
||||
async with in_flight:
|
||||
return await async_convert_url_to_base64(url)
|
||||
|
||||
|
||||
async def _fetch_data_urls(remote_urls: tuple[str, ...]) -> tuple[str, ...]:
|
||||
in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES)
|
||||
fetches: Final = tuple(asyncio.create_task(_fetch_data_url(url, in_flight)) for url in remote_urls)
|
||||
try:
|
||||
return tuple(await asyncio.gather(*fetches))
|
||||
except BaseException:
|
||||
for fetch in fetches:
|
||||
fetch.cancel()
|
||||
await asyncio.gather(*fetches, return_exceptions=True)
|
||||
raise
|
||||
|
||||
|
||||
async def async_inline_remote_media(
|
||||
messages: list[AllMessageValues], # mutable-ok: every transform_request takes list[AllMessageValues]
|
||||
should_inline: Callable[[RemoteMedia], bool] = inline_every_remote_url,
|
||||
) -> list[AllMessageValues]: # mutable-ok: every transform_request takes list[AllMessageValues]
|
||||
remote_urls: Final = tuple(
|
||||
dict.fromkeys(
|
||||
remote.url
|
||||
for message in messages
|
||||
for part in _content_parts(message)
|
||||
if (remote := _parse_remote_part(part)) is not None and should_inline(_remote_media(remote))
|
||||
)
|
||||
)
|
||||
if not remote_urls:
|
||||
return messages
|
||||
data_urls: Final = await _fetch_data_urls(remote_urls)
|
||||
inlined: Final = MappingProxyType(dict(zip(remote_urls, data_urls, strict=True)))
|
||||
return [ # mutable-ok: transform_request takes a list
|
||||
_inline_message(message, inlined, should_inline) for message in messages
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Admins can opt out via two ``litellm`` globals (wired from proxy config):
|
|||
check but still resolve DNS and still rewrite HTTP to the resolved IP.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import Any, Final, Protocol
|
||||
|
|
@ -471,7 +472,7 @@ async def async_safe_get(client: Any, url: str, **kwargs: Any) -> httpx.Response
|
|||
kwargs.pop("follow_redirects", None)
|
||||
headers_view: Final[_CallerHeadersView] = {"headers": kwargs.pop("headers", {})}
|
||||
for _ in range(_MAX_REDIRECTS):
|
||||
validated_url, original_host = validate_url(url)
|
||||
validated_url, original_host = await asyncio.to_thread(validate_url, url)
|
||||
response = await fetcher.get(
|
||||
validated_url,
|
||||
headers={**headers_view["headers"], "Host": original_host},
|
||||
|
|
|
|||
|
|
@ -67,6 +67,96 @@ _BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$")
|
|||
_INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$")
|
||||
_DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$")
|
||||
_DOTTED_VERSION_RE: Final = re.compile(r"(\d)\.(\d)")
|
||||
_CLAUDE_CODE_BILLING_HEADER_PREFIX: Final = "x-anthropic-billing-header:"
|
||||
_CLAUDE_CODE_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
|
||||
_CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def is_claude_code_user_agent(user_agent: str) -> bool:
|
||||
return user_agent.startswith("claude-cli/")
|
||||
|
||||
|
||||
def _validated_claude_code_mapping(value: object) -> dict[object, object] | None:
|
||||
try:
|
||||
return _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _validated_claude_code_list(value: object) -> list[object] | None:
|
||||
try:
|
||||
return _CLAUDE_CODE_OBJECT_LIST_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _claude_code_billing_fields(text: str) -> tuple[tuple[str, str], ...] | None:
|
||||
stripped: Final = text.strip()
|
||||
if "\n" in stripped or "\r" in stripped or not stripped.startswith(_CLAUDE_CODE_BILLING_HEADER_PREFIX):
|
||||
return None
|
||||
fields: Final = tuple(
|
||||
field
|
||||
for raw_field in stripped.removeprefix(_CLAUDE_CODE_BILLING_HEADER_PREFIX).split(";")
|
||||
if (field := raw_field.strip())
|
||||
)
|
||||
if not fields or any("=" not in field for field in fields):
|
||||
return None
|
||||
parsed_fields: Final = tuple(
|
||||
(parts[0].strip(), parts[1].strip()) for field in fields for parts in (field.split("=", 1),)
|
||||
)
|
||||
if any(not key or not value for key, value in parsed_fields):
|
||||
return None
|
||||
return parsed_fields
|
||||
|
||||
|
||||
def _claude_code_billing_texts(system: object) -> tuple[str, ...] | None:
|
||||
if isinstance(system, str):
|
||||
return (system,)
|
||||
blocks: Final = _validated_claude_code_list(system)
|
||||
if blocks is None:
|
||||
return None
|
||||
block_mappings: Final = tuple(_validated_claude_code_mapping(block) for block in blocks)
|
||||
if any(block is None for block in block_mappings):
|
||||
return None
|
||||
text_values: Final = tuple(
|
||||
block.get("text") for block in block_mappings if block is not None and block.get("type") == "text"
|
||||
)
|
||||
if len(text_values) != len(blocks) or any(not isinstance(text, str) for text in text_values):
|
||||
return None
|
||||
meaningful_text: Final = tuple(text for text in text_values if isinstance(text, str) and text.strip())
|
||||
return meaningful_text or None
|
||||
|
||||
|
||||
def _is_claude_code_subagent_billing_system(system: object) -> bool:
|
||||
billing_texts: Final = _claude_code_billing_texts(system)
|
||||
if billing_texts is None:
|
||||
return False
|
||||
billing_fields: Final = tuple(
|
||||
fields for text in billing_texts if (fields := _claude_code_billing_fields(text)) is not None
|
||||
)
|
||||
if len(billing_fields) != len(billing_texts):
|
||||
return False
|
||||
subagent_values: Final = tuple(
|
||||
value for fields in billing_fields for key, value in fields if key == "cc_is_subagent"
|
||||
)
|
||||
return subagent_values == ("true",)
|
||||
|
||||
|
||||
def is_claude_code_one_shot_subagent_request(
|
||||
messages: list[AllMessageValues],
|
||||
system: object,
|
||||
tools: object,
|
||||
user_agent: str | None,
|
||||
) -> bool:
|
||||
only_message: Final = _validated_claude_code_mapping(messages[0]) if len(messages) == 1 else None
|
||||
return (
|
||||
user_agent is not None
|
||||
and is_claude_code_user_agent(user_agent)
|
||||
and not tools
|
||||
and only_message is not None
|
||||
and only_message.get("role") == "user"
|
||||
and _is_claude_code_subagent_billing_system(system)
|
||||
)
|
||||
|
||||
|
||||
def _strip_bedrock_id_suffixes(model: str) -> str:
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ async def anthropic_messages_with_mcp(
|
|||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
mcp_references, other_tools = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(tools)
|
||||
|
||||
if not mcp_references:
|
||||
return await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
|
|
|
|||
|
|
@ -411,6 +411,10 @@ class BaseConfig(ABC):
|
|||
def has_custom_stream_wrapper(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def uses_async_transform_request(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_stream_param_in_request_body(self) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import types
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -102,6 +103,24 @@ class BaseImageEditConfig(ABC):
|
|||
) -> tuple[dict, RequestFiles]:
|
||||
pass
|
||||
|
||||
async def async_transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: FileTypes | None,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict, RequestFiles]:
|
||||
return self.transform_image_edit_request(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image,
|
||||
image_edit_optional_request_params=dict(image_edit_optional_request_params),
|
||||
litellm_params=litellm_params,
|
||||
headers=dict(headers),
|
||||
)
|
||||
|
||||
def finalize_image_edit_request_data(self, data: dict, resolved_request_url: str) -> dict:
|
||||
"""
|
||||
Last pass on the request dict after ``transform_image_edit_request``, using the
|
||||
|
|
|
|||
|
|
@ -667,7 +667,12 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise BedrockError(status_code=response.status_code, message=str(response.read()))
|
||||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=str(response.read()),
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
|
|
@ -690,6 +695,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=f"AgentCore: Failed to read/parse JSON response body: {e}",
|
||||
headers=response.headers,
|
||||
)
|
||||
parsed: Final = self._parse_json_response(response_json)
|
||||
|
||||
|
|
@ -880,7 +886,12 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise BedrockError(status_code=response.status_code, message=str(await response.aread()))
|
||||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=str(await response.aread()),
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
|
|
@ -903,6 +914,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=f"AgentCore: Failed to read/parse JSON response body: {e}",
|
||||
headers=response.headers,
|
||||
)
|
||||
parsed: Final = self._parse_json_response(response_json)
|
||||
|
||||
|
|
@ -1031,6 +1043,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
message=f"Error processing response: {e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
def validate_environment(
|
||||
|
|
@ -1046,7 +1059,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
return headers
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
|
||||
return BedrockError(status_code=status_code, message=error_message)
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def should_fake_stream(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from litellm.types.utils import ModelResponse
|
|||
from litellm.utils import CustomStreamWrapper
|
||||
|
||||
from ..base_aws_llm import BaseAWSLLM, Credentials, bedrock_bearer_token
|
||||
from ..common_utils import BedrockError, _get_all_bedrock_regions
|
||||
from ..common_utils import BedrockError, _get_all_bedrock_regions, error_response_text
|
||||
from .invoke_handler import AWSEventStreamDecoder, MockResponseIterator, make_call
|
||||
|
||||
|
||||
|
|
@ -66,7 +66,12 @@ def make_sync_call(
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise BedrockError(status_code=response.status_code, message=str(response.read()))
|
||||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=str(response.read()),
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
if fake_stream:
|
||||
model_response: Final[ModelResponse] = litellm.AmazonConverseConfig()._transform_response(
|
||||
|
|
@ -247,7 +252,12 @@ class BedrockConverseLLM(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=error_response_text(err.response),
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
@ -594,7 +604,12 @@ class BedrockConverseLLM(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=error_response_text(err.response),
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
|
|||
|
|
@ -2255,6 +2255,7 @@ class AmazonConverseConfig(BaseConfig):
|
|||
raise BedrockError(
|
||||
message=f"Error converting to valid response block={e}. File an issue if litellm error - https://github.com/BerriAI/litellm/issues",
|
||||
status_code=422,
|
||||
headers=response.headers,
|
||||
)
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
message=f"Error processing response: {e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
def validate_environment(
|
||||
|
|
@ -485,7 +486,7 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM):
|
|||
return headers
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
|
||||
return BedrockError(status_code=status_code, message=error_message)
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def should_fake_stream(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from litellm.types.utils import GenericStreamingChunk as GChunk
|
|||
from ..common_utils import (
|
||||
BedrockError,
|
||||
build_bedrock_stream_error,
|
||||
error_response_text,
|
||||
get_bedrock_response_stream_shape,
|
||||
get_bedrock_tool_name,
|
||||
)
|
||||
|
|
@ -184,7 +185,12 @@ async def make_call(
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise BedrockError(status_code=response.status_code, message=response.text)
|
||||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=error_response_text(response),
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
if fake_stream:
|
||||
model_response: Final[ModelResponse] = litellm.AmazonConverseConfig()._transform_response(
|
||||
|
|
@ -228,9 +234,16 @@ async def make_call(
|
|||
)
|
||||
|
||||
return completion_stream, response.headers
|
||||
except BedrockError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as err:
|
||||
error_code: Final = err.response.status_code
|
||||
raise BedrockError(status_code=error_code, message=err.response.text)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=error_response_text(err.response),
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
except Exception as e:
|
||||
|
|
@ -270,7 +283,12 @@ def make_sync_call(
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise BedrockError(status_code=response.status_code, message=response.text)
|
||||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=error_response_text(response),
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
if fake_stream:
|
||||
model_response: Final[ModelResponse] = litellm.AmazonConverseConfig()._transform_response(
|
||||
|
|
@ -314,9 +332,16 @@ def make_sync_call(
|
|||
)
|
||||
|
||||
return completion_stream, response.headers
|
||||
except BedrockError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as err:
|
||||
error_code: Final = err.response.status_code
|
||||
raise BedrockError(status_code=error_code, message=err.response.text)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=error_response_text(err.response),
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -247,4 +247,4 @@ class AmazonMoonshotConfig(AmazonInvokeConfig, MoonshotChatConfig):
|
|||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BedrockError:
|
||||
"""Return the appropriate error class for Bedrock."""
|
||||
return BedrockError(status_code=status_code, message=error_message)
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
|
|
|||
|
|
@ -182,4 +182,4 @@ class AmazonBedrockOpenAIConfig(OpenAIGPTConfig, BaseAWSLLM):
|
|||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BedrockError:
|
||||
"""Return the appropriate error class for Bedrock."""
|
||||
return BedrockError(status_code=status_code, message=error_message)
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig):
|
|||
raise BedrockError(
|
||||
message=f"Error parsing response: {raw_response.text}, error: {e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
verbose_logger.debug(
|
||||
|
|
@ -241,6 +242,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig):
|
|||
raise BedrockError(
|
||||
message=f"Error setting response content: {e}. Response: {completion_response}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
# Calculate usage from headers
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
convert_to_anthropic_image_obj,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
||||
async_convert_url_to_base64,
|
||||
async_inline_remote_media,
|
||||
convert_url_to_base64,
|
||||
)
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
|
@ -172,6 +172,10 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
|||
|
||||
return _anthropic_request
|
||||
|
||||
@property
|
||||
def uses_async_transform_request(self) -> bool:
|
||||
return True
|
||||
|
||||
async def async_transform_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -180,26 +184,14 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
|||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
_anthropic_request: Final = self._build_bedrock_anthropic_request_base(
|
||||
return self.transform_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
messages=await async_inline_remote_media(messages),
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
await self._async_convert_document_url_sources_to_base64(_anthropic_request)
|
||||
beta_list: Final = self._compute_bedrock_invoke_beta_headers(
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
headers=headers,
|
||||
)
|
||||
if beta_list:
|
||||
_anthropic_request["anthropic_beta"] = beta_list
|
||||
|
||||
return _anthropic_request
|
||||
|
||||
def _build_bedrock_anthropic_request_base(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -321,45 +313,6 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
|||
"data": image_chunk["data"],
|
||||
}
|
||||
|
||||
async def _async_convert_document_url_sources_to_base64(self, anthropic_request: dict) -> None:
|
||||
"""
|
||||
Async version of document URL conversion for async completion paths.
|
||||
"""
|
||||
messages: Final = anthropic_request.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for block in content:
|
||||
if not isinstance(block, dict) or block.get("type") != "document":
|
||||
continue
|
||||
source = block.get("source")
|
||||
if not isinstance(source, dict) or source.get("type") != "url":
|
||||
continue
|
||||
source_url = source.get("url")
|
||||
if not isinstance(source_url, str):
|
||||
continue
|
||||
|
||||
inferred_format: str | None = None
|
||||
if source_url.lower().endswith(".pdf"):
|
||||
inferred_format = "application/pdf"
|
||||
base64_url = await async_convert_url_to_base64(url=source_url)
|
||||
image_chunk = convert_to_anthropic_image_obj(
|
||||
openai_image_url=base64_url,
|
||||
format=inferred_format,
|
||||
)
|
||||
block["source"] = {
|
||||
"type": "base64",
|
||||
"media_type": image_chunk["media_type"],
|
||||
"data": image_chunk["data"],
|
||||
}
|
||||
|
||||
def _normalize_bedrock_tool_search_tools(self, optional_params: dict) -> dict:
|
||||
"""
|
||||
Convert tool search entries to the format supported by the Bedrock Invoke API.
|
||||
|
|
|
|||
|
|
@ -295,7 +295,11 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
try:
|
||||
completion_response: Final = raw_response.json()
|
||||
except Exception:
|
||||
raise BedrockError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
raise BedrockError(
|
||||
message=raw_response.text,
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
verbose_logger.debug(
|
||||
"bedrock invoke response % s",
|
||||
json.dumps(completion_response, indent=4, default=str),
|
||||
|
|
@ -363,6 +367,7 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
message=f"Error processing={raw_response.text}, Received error={e}",
|
||||
status_code=422,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -384,6 +389,7 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
message=f"Error parsing received text={outputText}.\nError-{e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
## CALCULATING USAGE - bedrock returns usage in the headers
|
||||
|
|
@ -431,7 +437,7 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
return merge_bedrock_invoke_headers(headers, guardrail_headers, metadata_headers, owned_names)
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
|
||||
return BedrockError(status_code=status_code, message=error_message)
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
@track_llm_api_timing()
|
||||
async def get_async_custom_stream_wrapper(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth.
|
|||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media
|
||||
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
|
||||
AmazonAnthropicClaudeConfig,
|
||||
)
|
||||
|
|
@ -110,21 +111,13 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
|
|||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
model_id: Final = model.replace("mantle/", "", 1)
|
||||
|
||||
request: Final = self._build_bedrock_anthropic_request_base(
|
||||
model=model_id,
|
||||
messages=messages,
|
||||
return self.transform_request(
|
||||
model=model,
|
||||
messages=await async_inline_remote_media(messages),
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
await self._async_convert_document_url_sources_to_base64(request)
|
||||
return self._restore_mantle_body_fields(
|
||||
request=request,
|
||||
model_id=model_id,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
CLAUDE_PLATFORM_SERVICE_NAME: Final = "aws-external-anthropic"
|
||||
|
|
@ -15,6 +18,14 @@ def strip_claude_platform_route(model: str) -> str:
|
|||
|
||||
|
||||
class BedrockClaudePlatformMixin(BaseAWSLLM):
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
@staticmethod
|
||||
def _get_workspace_id(optional_params: dict, litellm_params: dict) -> str | None:
|
||||
workspace_id = (
|
||||
|
|
|
|||
|
|
@ -33,8 +33,53 @@ if TYPE_CHECKING:
|
|||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
|
||||
_ERROR_REQUEST_URL: Final = "https://docs.litellm.ai/docs"
|
||||
|
||||
|
||||
def error_response_text(response: httpx.Response) -> str:
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead:
|
||||
return response.reason_phrase
|
||||
|
||||
|
||||
def _synthesize_error_response(
|
||||
*, status_code: int, headers: dict[str, object] | httpx.Headers, request: httpx.Request | None
|
||||
) -> tuple[httpx.Request, httpx.Response]:
|
||||
error_request: Final = request or httpx.Request(method="POST", url=_ERROR_REQUEST_URL)
|
||||
safe_headers: Final = (
|
||||
headers
|
||||
if isinstance(headers, httpx.Headers)
|
||||
else tuple((key, value) for key, value in headers.items() if isinstance(value, (str, bytes)))
|
||||
)
|
||||
return error_request, httpx.Response(status_code=status_code, headers=safe_headers, request=error_request)
|
||||
|
||||
|
||||
class BedrockError(BaseLLMException):
|
||||
pass
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: dict[str, object] | httpx.Headers | None = None,
|
||||
request: httpx.Request | None = None,
|
||||
response: httpx.Response | None = None,
|
||||
body: dict[str, object] | None = None,
|
||||
status_code_is_synthesized: bool = False,
|
||||
) -> None:
|
||||
error_request, error_response = (
|
||||
_synthesize_error_response(status_code=status_code, headers=headers, request=request)
|
||||
if response is None and headers
|
||||
else (request, response)
|
||||
)
|
||||
super().__init__(
|
||||
status_code=status_code,
|
||||
message=message,
|
||||
headers=headers,
|
||||
request=error_request,
|
||||
response=error_response,
|
||||
body=body,
|
||||
status_code_is_synthesized=status_code_is_synthesized,
|
||||
)
|
||||
|
||||
|
||||
_BEDROCK_AWS_AUTH_PARAMETER_KEYS: Final[tuple[str, ...]] = (
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
raise BedrockError(
|
||||
status_code=response.status_code,
|
||||
message=error_text,
|
||||
headers=response.headers,
|
||||
response=response,
|
||||
)
|
||||
|
||||
bedrock_response: Final = response.json()
|
||||
|
|
@ -124,6 +126,8 @@ class BedrockCountTokensHandler(BedrockCountTokensConfig):
|
|||
raise BedrockError(
|
||||
status_code=e.response.status_code,
|
||||
message=e.response.text,
|
||||
headers=e.response.headers,
|
||||
response=e.response,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.error("Error in CountTokens handler: %s", e)
|
||||
|
|
|
|||
|
|
@ -132,7 +132,12 @@ class BedrockEmbedding(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
@ -161,7 +166,12 @@ class BedrockEmbedding(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import httpx
|
|||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import FileTypes, ImageObject, ImageResponse
|
||||
|
|
@ -228,6 +229,14 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig):
|
|||
"""
|
||||
return _supports_nova_canvas_image_edit_from_model_cost(model or "")
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
return [
|
||||
"n",
|
||||
|
|
|
|||
|
|
@ -114,7 +114,12 @@ class BedrockImageEdit(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
@ -156,7 +161,12 @@ class BedrockImageEdit(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, Final
|
|||
import httpx
|
||||
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.llms.stability import (
|
||||
OPENAI_SIZE_TO_STABILITY_ASPECT_RATIO,
|
||||
|
|
@ -84,6 +85,14 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig):
|
|||
return True
|
||||
return False
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
Return list of OpenAI params supported by Bedrock Stability.
|
||||
|
|
|
|||
|
|
@ -119,7 +119,12 @@ class BedrockImageGeneration(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
### FORMAT RESPONSE TO OPENAI FORMAT ###
|
||||
|
|
@ -162,7 +167,12 @@ class BedrockImageGeneration(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
|
|||
AmazonInvokeConfig,
|
||||
)
|
||||
from litellm.llms.bedrock.common_utils import (
|
||||
BedrockError,
|
||||
apply_bedrock_invoke_structured_output,
|
||||
ensure_bedrock_anthropic_messages_tool_names,
|
||||
get_anthropic_beta_from_headers,
|
||||
|
|
@ -79,6 +80,14 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
|
||||
BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = frozenset(BedrockInvokeAnthropicMessagesRequest.__annotations__.keys())
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
BaseAnthropicMessagesConfig.__init__(self, **kwargs)
|
||||
AmazonInvokeConfig.__init__(self, **kwargs)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import json
|
|||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final, Optional, cast
|
||||
|
||||
import httpx
|
||||
from httpx import Response
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
|
||||
|
||||
from ..base_aws_llm import BaseAWSLLM
|
||||
from ..common_utils import BedrockEventStreamDecoderBase, BedrockModelInfo
|
||||
from ..common_utils import BedrockError, BedrockEventStreamDecoderBase, BedrockModelInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from httpx import URL
|
||||
|
|
@ -18,6 +19,14 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamDecoderBase, BasePassthroughConfig):
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def is_streaming_request(self, endpoint: str, request_data: dict) -> bool:
|
||||
return "stream" in endpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import json
|
|||
import uuid as uuid_lib
|
||||
from typing import Final, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.bedrock.realtime.trigger_audio import ready_trigger_pcm
|
||||
from litellm.types.llms.openai import (
|
||||
OpenAIRealtimeContentPartDone,
|
||||
|
|
@ -121,6 +123,14 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
self._cumulative_usage = BedrockUsageEvent()
|
||||
self._reported_usage = BedrockUsageEvent()
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def validate_environment(self, headers: dict, model: str, api_key: str | None = None) -> dict:
|
||||
"""Validate environment - no special validation needed for Bedrock."""
|
||||
return headers
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ class BedrockRerankHandler(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
@ -117,7 +122,12 @@ class BedrockRerankHandler(BaseAWSLLM):
|
|||
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)
|
||||
raise BedrockError(
|
||||
status_code=error_code,
|
||||
message=err.response.text,
|
||||
headers=err.response.headers,
|
||||
response=err.response,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise BedrockError(status_code=408, message="Timeout error occurred.")
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ from typing import Final
|
|||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
SearchResponse,
|
||||
|
|
@ -380,6 +379,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
status_code=raw_response.status_code if raw_response.status_code >= 400 else 502,
|
||||
message=f"AgentCore gateway MCP error: {error}",
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
# A failed tools/call is reported in-band, as HTTP 200 with result.isError
|
||||
|
|
@ -389,6 +389,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
status_code=raw_response.status_code if raw_response.status_code >= 400 else 502,
|
||||
message=f"AgentCore web search tool error: {self._tool_error_message(response_json)}",
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
text_items: Final = tuple(
|
||||
|
|
@ -440,6 +441,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
|
|||
raise BedrockError(
|
||||
status_code=502,
|
||||
message=f"AgentCore gateway returned SSE without a JSON data frame: {text[:200]}",
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
def get_error_class(
|
||||
|
|
@ -448,7 +450,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
|
|||
status_code: int,
|
||||
headers: dict, # mutable-ok: BaseSearchConfig.get_error_class takes the response headers as a dict
|
||||
) -> Exception:
|
||||
return BaseLLMException(
|
||||
return BedrockError(
|
||||
status_code=status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.types.integrations.rag.bedrock_knowledgebase import (
|
||||
BedrockKBContent,
|
||||
BedrockKBResponse,
|
||||
|
|
@ -38,6 +39,14 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
BaseVectorStoreConfig.__init__(self)
|
||||
BaseAWSLLM.__init__(self)
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: base passes response headers as a dict
|
||||
) -> BedrockError:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def get_auth_credentials(self, litellm_params: dict) -> BaseVectorStoreAuthCredentials:
|
||||
return {}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ Auth: Bearer token (litellm_params.api_key, BEDROCK_MANTLE_API_KEY, or the
|
|||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
|
@ -24,6 +26,8 @@ from litellm.secret_managers.main import get_secret_str
|
|||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
from ...base_llm.chat.transformation import BaseLLMException
|
||||
from ...bedrock.common_utils import BedrockError
|
||||
from ...openai_like.chat.transformation import OpenAILikeChatConfig
|
||||
from ..common_utils import mantle_base_segment
|
||||
|
||||
|
|
@ -45,6 +49,11 @@ class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig):
|
|||
def get_config(cls):
|
||||
return super().get_config()
|
||||
|
||||
def get_error_class(
|
||||
self, error_message: str, status_code: int, headers: dict[str, object] | httpx.Headers
|
||||
) -> BaseLLMException:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def _get_openai_compatible_provider_info(
|
||||
self,
|
||||
api_base: str | None,
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ import json
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.bedrock_mantle.common_utils import (
|
||||
MANTLE_HOST_RE,
|
||||
BedrockMantleAuthMixin,
|
||||
|
|
@ -98,6 +101,11 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
|
|||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.BEDROCK_MANTLE
|
||||
|
||||
def get_error_class(
|
||||
self, error_message: str, status_code: int, headers: dict[str, object] | httpx.Headers
|
||||
) -> BaseLLMException:
|
||||
return BedrockError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ API Reference: https://docs.bfl.ai/
|
|||
|
||||
import base64
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -16,7 +17,7 @@ from httpx._types import RequestFiles
|
|||
|
||||
import litellm
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.litellm_core_utils.url_utils import safe_get
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
|
|
@ -37,6 +38,22 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
_BFL_REQUEST_PARAMS: Final = (
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
"aspect_ratio",
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
)
|
||||
|
||||
|
||||
class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
||||
"""
|
||||
Configuration for Black Forest Labs image editing.
|
||||
|
|
@ -85,34 +102,10 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
BFL-specific params are passed through directly.
|
||||
"""
|
||||
optional_params: Final[dict[str, object]] = {}
|
||||
|
||||
# Pass through BFL-specific params
|
||||
bfl_params: Final = [
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
# Kontext-specific
|
||||
"aspect_ratio",
|
||||
# Fill/Inpaint-specific
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
# Expand-specific
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
|
||||
# Convert TypedDict to regular dict for access
|
||||
params_dict: Final = dict(image_edit_optional_params)
|
||||
|
||||
for param in bfl_params:
|
||||
if param in params_dict:
|
||||
value = params_dict[param]
|
||||
if value is not None:
|
||||
optional_params[param] = value
|
||||
params: Final[Mapping[str, object]] = image_edit_optional_params
|
||||
for param in _BFL_REQUEST_PARAMS:
|
||||
if (value := params.get(param)) is not None:
|
||||
optional_params[param] = value
|
||||
|
||||
# Set default output format
|
||||
if "output_format" not in optional_params:
|
||||
|
|
@ -251,23 +244,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
"input_image": b64_image,
|
||||
}
|
||||
|
||||
# Add optional params (only BFL-recognized parameters)
|
||||
bfl_request_params: Final = [
|
||||
"seed",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"prompt_upsampling",
|
||||
"aspect_ratio",
|
||||
"steps",
|
||||
"guidance",
|
||||
"grow_mask",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
]
|
||||
for key, value in image_edit_optional_request_params.items():
|
||||
if key in bfl_request_params and value is not None:
|
||||
if key in _BFL_REQUEST_PARAMS and value is not None:
|
||||
request_body[key] = value
|
||||
|
||||
# Handle mask if provided (for inpainting)
|
||||
|
|
@ -277,7 +255,39 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig):
|
|||
request_body["mask"] = base64.b64encode(mask_bytes).decode("utf-8")
|
||||
|
||||
# BFL uses JSON, not multipart - return empty files
|
||||
return request_body, []
|
||||
return request_body, ()
|
||||
|
||||
async def async_transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: FileTypes | None,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict, RequestFiles]:
|
||||
downloaded_image: Final = await self._fetch_remote_image(image)
|
||||
downloaded_mask: Final = await self._fetch_remote_image(image_edit_optional_request_params.get("mask"))
|
||||
return self.transform_image_edit_request(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
image=image if downloaded_image is None else downloaded_image,
|
||||
image_edit_optional_request_params=(
|
||||
dict(image_edit_optional_request_params)
|
||||
if downloaded_mask is None
|
||||
else {**image_edit_optional_request_params, "mask": downloaded_mask}
|
||||
),
|
||||
litellm_params=litellm_params,
|
||||
headers=dict(headers),
|
||||
)
|
||||
|
||||
async def _fetch_remote_image(self, image: object) -> bytes | None:
|
||||
candidate: Final = image[0] if isinstance(image, list) and image else image
|
||||
if not isinstance(candidate, str) or not candidate.startswith(("http://", "https://")):
|
||||
return None
|
||||
response: Final = await async_safe_get(litellm.module_level_aclient, candidate, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def transform_image_edit_response(
|
||||
self,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue