mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into devin_ai_fix_file_list_cursor_leak_36087
This commit is contained in:
commit
508e0dbb35
990 changed files with 24974 additions and 9711 deletions
|
|
@ -17,3 +17,24 @@
|
|||
|
||||
# style: unify ruff format width on 120 (#31518)
|
||||
48b5a5a0cc5a694a11219416ee0b6eb6e620e74e
|
||||
|
||||
# refactor(imports): move collections.abc names out of typing (#35495)
|
||||
397e8e4918777e4e60a7f5e88699e0a9a7dabb3d
|
||||
|
||||
# refactor(lint): apply every safe ruff autofix and zero 28 strict-rule budgets (#35495)
|
||||
b604e2b20c6db2099085a2f0e59b7e99e87eed6f
|
||||
|
||||
# refactor(logging): drop redundant !s conversion flags from f-strings (#35546)
|
||||
7b2d3440cba3160277470f7a0180098ae9b87864
|
||||
|
||||
# perf: build log messages lazily so filtered-out log records cost nothing (#35703)
|
||||
c9887a1f94bc1e7e4bdfe64d640f0509a0bc19dd
|
||||
|
||||
# feat(lint): enforce Final on locals and freeze function parameters (#35807)
|
||||
2708620d6a599cc73c1950a942d26ac26a7ed3d4
|
||||
|
||||
# chore(lint): remove litellm/types from the ruff lint exclusion (#35926)
|
||||
4e32a8bf6a1e1af1e04b67c759841ccef44b2235
|
||||
|
||||
# chore(lint): strip inert type: ignore comments and zero LIT009/LIT010/LIT011 headroom (#35928)
|
||||
338e411103ad5d7003e97f34f04fa36bca542dbe
|
||||
|
|
|
|||
7
.github/ci-coverage-allowlist.yml
vendored
7
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -136,13 +136,6 @@ test_paths:
|
|||
- tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py
|
||||
|
||||
dockerfiles:
|
||||
- reason: >-
|
||||
The componentized images the microservices chart deploys are built by no job; wiring both into
|
||||
the scan workflow costs a full image build each and is deferred to a change that prices the
|
||||
whole set
|
||||
paths:
|
||||
- backend/Dockerfile
|
||||
- gateway/Dockerfile
|
||||
- reason: >-
|
||||
The dashboard container is a static Next.js export served by nginx, and the dashboard build
|
||||
and lint workflows already exercise that output, so building the image adds no signal about it
|
||||
|
|
|
|||
27
.github/pull_request_template.md
vendored
27
.github/pull_request_template.md
vendored
|
|
@ -13,6 +13,33 @@ How it solves it:
|
|||
- <blah>
|
||||
- ...
|
||||
|
||||
## User Flow
|
||||
|
||||
<!-- Two ordered lists, Before and After, walking the same end user through the same task, written strictly from that user's seat
|
||||
Read the linked issue, ticket, or customer thread first so the flow reflects the real application and the routes its users actually hit; don't invent a generic scenario
|
||||
Lead each list with one plain sentence saying where the flow fails (Before) or succeeds (After), then number the steps
|
||||
Every step is something the user does or observes: the HTTP method and full URL they hit, what they sent, and what visibly came back (status code, error text, the shape of an ID). UI steps name the page URL and what is on screen
|
||||
No LiteLLM internals: never name functions, files, DB tables, config classes, hooks, callbacks, or code paths. "The upload hands back an ID that looks like OpenAI's own `file-abc123` instead of the scrambled one the gateway returned" is right, "no managed-file row was registered" is wrong
|
||||
Keep the two lists step-for-step identical until they diverge, so the changed step is obvious
|
||||
If the bug had a security or authorization consequence, end each list with what another user could or could no longer do
|
||||
Regenerate this section whenever new commits change the PR's behavior, so it never describes an older revision
|
||||
|
||||
Example:
|
||||
|
||||
Before: a developer whose app streams chat completions gets no token counts back, so their cost dashboard reads zero
|
||||
|
||||
1. They send POST https://litellm-domain/v1/chat/completions with `"stream": true` and no `stream_options`
|
||||
2. The last SSE chunk arrives with `"usage": null`, so their app records 0 prompt and 0 completion tokens
|
||||
3. They open https://litellm-domain/ui/?page=logs and see the request logged at $0 spend
|
||||
|
||||
After: the same request comes back with real token counts, so the dashboard shows real spend
|
||||
|
||||
1. The proxy admin sets `always_include_stream_usage: true` and restarts the proxy
|
||||
2. The developer sends the same POST https://litellm-domain/v1/chat/completions with `"stream": true` and no `stream_options`
|
||||
3. The last SSE chunk now carries a `usage` object with real prompt and completion token counts
|
||||
4. https://litellm-domain/ui/?page=logs shows that request at non-zero spend
|
||||
-->
|
||||
|
||||
## Relevant issues
|
||||
|
||||
<!-- e.g., "Fixes #000" -->
|
||||
|
|
|
|||
4
.github/workflows/check-schema-sync.yml
vendored
4
.github/workflows/check-schema-sync.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
check-sync:
|
||||
name: Verify schema.prisma copies match root
|
||||
|
|
|
|||
44
.github/workflows/check-ui-api-types.yml
vendored
44
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -2,18 +2,19 @@ name: Check UI API Types Sync
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "litellm/proxy/**"
|
||||
- "litellm/types/**"
|
||||
- "ui/litellm-dashboard/src/lib/http/schema.d.ts"
|
||||
- "ui/litellm-dashboard/scripts/gen-api-types.mjs"
|
||||
- "ui/litellm-dashboard/package.json"
|
||||
- "ui/litellm-dashboard/package-lock.json"
|
||||
- ".github/workflows/check-ui-api-types.yml"
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-sync:
|
||||
name: Verify schema.d.ts matches the proxy OpenAPI spec
|
||||
|
|
@ -24,18 +25,39 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changes that can affect the generated types
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! base="$(git rev-parse --verify --quiet HEAD^2 >/dev/null && git rev-parse HEAD^1)"; then
|
||||
echo "Not a pull request merge commit, running the full check."
|
||||
echo "relevant=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files="$(git diff --name-only "$base" HEAD)"
|
||||
if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/check-ui-api-types\.yml$)' <<< "$files"; then
|
||||
echo "relevant=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No proxy, types or generator changes in this pull request, nothing to verify."
|
||||
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
|
|
@ -46,14 +68,17 @@ jobs:
|
|||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Install backend dependencies
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
|
||||
|
||||
- name: Generate Prisma client
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
env:
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version-file: ui/litellm-dashboard/.nvmrc
|
||||
|
|
@ -61,16 +86,19 @@ jobs:
|
|||
cache-dependency-path: ui/litellm-dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard dependencies
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
working-directory: ui/litellm-dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Regenerate types from the live spec
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
working-directory: ui/litellm-dashboard
|
||||
env:
|
||||
LITELLM_PYTHON: "uv run --no-sync python"
|
||||
run: npm run gen:api
|
||||
|
||||
- name: Fail if types are stale
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
|
||||
echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."
|
||||
|
|
|
|||
4
.github/workflows/conventional-commits.yml
vendored
4
.github/workflows/conventional-commits.yml
vendored
|
|
@ -14,6 +14,10 @@ on:
|
|||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
name: Validate PR title
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ on:
|
|||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Block fork dependency changes
|
||||
|
|
|
|||
4
.github/workflows/helm_unit_test.yml
vendored
4
.github/workflows/helm_unit_test.yml
vendored
|
|
@ -9,6 +9,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
65
.github/workflows/image-scan.yml
vendored
65
.github/workflows/image-scan.yml
vendored
|
|
@ -12,6 +12,11 @@ on:
|
|||
- docker/Dockerfile.non_root
|
||||
- migrations/Dockerfile
|
||||
- migrations/run.py
|
||||
- gateway/Dockerfile
|
||||
- gateway/main.py
|
||||
- backend/Dockerfile
|
||||
- backend/main.py
|
||||
- docker/component_entrypoint.sh
|
||||
- litellm-proxy-extras/**
|
||||
- tests/proxy_migration_tests/**
|
||||
- uv.lock
|
||||
|
|
@ -147,3 +152,63 @@ jobs:
|
|||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
|
||||
|
||||
gateway-image:
|
||||
name: gateway-image
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build gateway image
|
||||
run: docker build -f gateway/Dockerfile -t litellm-gateway-scan:${{ github.sha }} .
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify the gateway serves offline as a non-root uid
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-gateway-scan:${{ github.sha }}
|
||||
LITELLM_COMPONENT_PORT: "4000"
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
|
||||
|
||||
backend-image:
|
||||
name: backend-image
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build backend image
|
||||
run: docker build -f backend/Dockerfile -t litellm-backend-scan:${{ github.sha }} .
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify the backend serves offline as a non-root uid
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-backend-scan:${{ github.sha }}
|
||||
LITELLM_COMPONENT_PORT: "4001"
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
|
||||
|
|
|
|||
|
|
@ -43,22 +43,14 @@ jobs:
|
|||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --frozen --group proxy-dev --group e2e-dev
|
||||
|
||||
# Mirrors test-linting.yml's lint job: basedpyright resolves Prisma's
|
||||
# generated client only after `prisma generate`, and the published counts
|
||||
# must match what that job would measure for the same tree.
|
||||
- name: Generate Prisma client
|
||||
# The gate provisions its own measurement env (.venv-typecheck: a frozen
|
||||
# uv sync of its canonical dependency groups plus a generated Prisma
|
||||
# client), so no install step here can drift from what local runs measure.
|
||||
- name: Emit basedpyright counts for HEAD
|
||||
env:
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: |
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Emit basedpyright counts for HEAD
|
||||
run: |
|
||||
uv run --no-sync python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/basedpyright-counts"
|
||||
python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/basedpyright-counts"
|
||||
counts_file=$(ls "$RUNNER_TEMP"/basedpyright-counts/basedpyright-counts-*.json)
|
||||
echo "COUNTS_ARTIFACT_NAME=$(basename "$counts_file" .json)" >> "$GITHUB_ENV"
|
||||
|
||||
|
|
|
|||
5
.github/workflows/test-linting.yml
vendored
5
.github/workflows/test-linting.yml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -115,6 +119,7 @@ jobs:
|
|||
- name: Check basedpyright budget (delta vs base)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: |
|
||||
uv run --no-sync python scripts/type_check_gate.py --base "$GATE_BASE_SHA"
|
||||
|
||||
|
|
|
|||
4
.github/workflows/test-litellm-ui-build.yml
vendored
4
.github/workflows/test-litellm-ui-build.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-litellm-ui-lint.yml
vendored
4
.github/workflows/test-litellm-ui-lint.yml
vendored
|
|
@ -10,6 +10,10 @@ on:
|
|||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-mcp.yml
vendored
4
.github/workflows/test-mcp.yml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
4
.github/workflows/test-model-map.yaml
vendored
4
.github/workflows/test-model-map.yaml
vendored
|
|
@ -11,6 +11,10 @@ on:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -38,12 +38,15 @@ jobs:
|
|||
tests/test_litellm/proxy/google_endpoints
|
||||
tests/test_litellm/proxy/openai_files_endpoint
|
||||
tests/test_litellm/proxy/batches_endpoints
|
||||
tests/test_litellm/proxy/fine_tuning_endpoints
|
||||
tests/test_litellm/proxy/vector_store_files_endpoints
|
||||
tests/test_litellm/proxy/video_endpoints
|
||||
tests/test_litellm/proxy/response_api_endpoints
|
||||
tests/test_litellm/proxy/image_endpoints
|
||||
tests/test_litellm/proxy/vector_store_endpoints
|
||||
tests/test_litellm/proxy/agent_endpoints
|
||||
tests/test_litellm/proxy/a2a
|
||||
tests/test_litellm/proxy/credential_endpoints
|
||||
tests/test_litellm/proxy/discovery_endpoints
|
||||
tests/test_litellm/proxy/health_endpoints
|
||||
tests/test_litellm/proxy/shutdown
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
.python-version
|
||||
.venv
|
||||
.venv-typecheck
|
||||
.venv_policy_test
|
||||
.env
|
||||
.claude
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -9,7 +9,7 @@ Don't assume that the existing code is correct or the right way of doing things
|
|||
- easy to maintain/change
|
||||
- modern
|
||||
|
||||
In that order of importance
|
||||
In descending order of importance
|
||||
|
||||
When adding new features, add meaningful tests. Don't add tests that don't check anything substantial and is there just to make the code coverage pass. Yes, code coverage is important, but I'd rather have no signal whether the code is working than tests that don't fail when code is broken. The goal is to have tests that would fail before the feature was added/if the code was mutated in a way that breaks the feature and succeed only when the feature is fully working. I should run mutation testing and see > 90% kill rate
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We pref
|
|||
|
||||
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
|
||||
- don't use emojis
|
||||
- don't use "—". Instead, reach for ",", ".", conjunction words, ":", ";", etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them, ";" especially, also feels AI-y
|
||||
- don't use "—". Instead, reach for ",", ".", conjunction words, ":", ";", etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them, ";" especially, also feels AI-y. A word cap does not penalize you for adding more sentences: when writing under tight word budgets, prefer a period split or a conjunction over ";", and keep to at most one ";" per message
|
||||
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
|
||||
- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
|
||||
- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: if you're adding new line(s) before the next sentence, don't add a "."
|
||||
|
|
@ -41,11 +41,11 @@ Python max line length is 120, not 88
|
|||
|
||||
When you fix violations gated by `ruff-strict-budget.json`, `type-discipline-budget.json`, or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered limits so the ceilings ratchet down instead of leaving stale headroom. It measures the working tree, so it must contain exactly the fixes you're committing
|
||||
|
||||
`make pre-commit` always saves its complete output to a per-worktree log file 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, and re-run only after the working tree actually changed
|
||||
`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
|
||||
|
||||
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
|
||||
|
||||
If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
|
||||
If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
|
||||
|
||||
Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # <reason>`. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages
|
|||
|
||||
When working on a PR, keep the PR description in sync with new commits being made
|
||||
|
||||
Replies/rebuttals to AI PR review bots must be 15-25 word human-readable replies
|
||||
All GitHub comments must be human-readable and 15-25 words max
|
||||
|
||||
Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
|
|||
- Composition over inheritance
|
||||
- Never-nester: early returns over deep nesting
|
||||
- Don't throw; model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
|
||||
- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), etc.
|
||||
- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `MappingProxyType`, etc.
|
||||
- Annotate every variable with `: Final` (LIT010). Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011); `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: <reason>` explaining why
|
||||
- Use dependency injection
|
||||
- Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
|
||||
|
|
|
|||
26
Makefile
26
Makefile
|
|
@ -8,7 +8,7 @@
|
|||
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 \
|
||||
install-dev install-proxy-dev install-test-deps install-hooks \
|
||||
install-helm-unittest check-circular-imports check-import-safety pre-commit \
|
||||
install-helm-unittest check-circular-imports check-import-safety check pre-commit \
|
||||
lint-install lint-fetch-base bootstrap
|
||||
|
||||
# Default target
|
||||
|
|
@ -22,7 +22,8 @@ help:
|
|||
@echo " make install-test-deps - Install the full local test environment"
|
||||
@echo " make install-helm-unittest - Install helm unittest plugin"
|
||||
@echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
|
||||
@echo " make pre-commit - Run CI-equivalent lint on staged files (run before committing)"
|
||||
@echo " make check - Run CI-equivalent lint on staged files, or on the diff vs the base branch when nothing is staged"
|
||||
@echo " make pre-commit - Legacy alias for make check"
|
||||
@echo " make format - Apply ruff format code formatting"
|
||||
@echo " make format-check - Check ruff format code formatting (matches CI)"
|
||||
@echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)"
|
||||
|
|
@ -124,10 +125,10 @@ lint-fetch-base:
|
|||
git fetch origin litellm_internal_staging
|
||||
|
||||
# Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated
|
||||
# Prisma client, so basedpyright resolves the same modules CI does (without the generated
|
||||
# client the DB wrappers typed against it degrade to Unknown, drifting the budget from
|
||||
# CI's). --inexact tops up the venv instead of pruning the proxy extras gen:api and the
|
||||
# running proxy need.
|
||||
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The
|
||||
# budget gate itself no longer measures here (scripts/type_check_gate.py provisions its
|
||||
# own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras
|
||||
# gen:api and the running proxy need.
|
||||
lint-install:
|
||||
$(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev
|
||||
$(UV_RUN) python scripts/prisma_generate_if_needed.py
|
||||
|
|
@ -236,13 +237,20 @@ lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline
|
|||
# Faster linting for local development (only checks changed code)
|
||||
lint-dev: lint-format-changed check-circular-imports check-import-safety
|
||||
|
||||
# Run the gating CI checks against your staged files right before committing. Mirrors
|
||||
# Run the gating CI checks against your changes. Scopes to staged files when anything
|
||||
# is staged (warning about changed files left unstaged); with nothing staged it falls
|
||||
# back to the working tree's diff against the merge base with the base branch, so a
|
||||
# fresh merge commit or an unstaged working tree still gets checked. Mirrors
|
||||
# test-linting.yml (Python), test-litellm-ui-build.yml's frontend-lint (dashboard), and
|
||||
# check-ui-api-types.yml (API-type drift), skipping any whose files you didn't stage.
|
||||
# check-ui-api-types.yml (API-type drift), skipping any whose files aren't in scope.
|
||||
# Not auto-installed as a git hook so it never slows an unrelated human commit.
|
||||
pre-commit: bootstrap
|
||||
check: bootstrap
|
||||
./scripts/pre_commit_lint.sh
|
||||
|
||||
pre-commit:
|
||||
@echo "make pre-commit is a legacy alias; use make check" >&2
|
||||
@$(MAKE) check
|
||||
|
||||
# Testing targets
|
||||
test: install-test-deps
|
||||
$(UV_RUN) pytest tests/
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 29204
|
||||
"limit": 27731
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2634
|
||||
"limit": 2626
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 329
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"limit": 514
|
||||
},
|
||||
"reportCallIssue": {
|
||||
"limit": 117
|
||||
"limit": 116
|
||||
},
|
||||
"reportConstantRedefinition": {
|
||||
"limit": 40
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 9225
|
||||
"limit": 8807
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
@ -54,10 +54,10 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportMissingParameterType": {
|
||||
"limit": 5850
|
||||
"limit": 5835
|
||||
},
|
||||
"reportMissingTypeArgument": {
|
||||
"limit": 15833
|
||||
"limit": 15790
|
||||
},
|
||||
"reportMissingTypeStubs": {
|
||||
"limit": 40
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportOptionalMemberAccess": {
|
||||
"limit": 1078
|
||||
"limit": 1077
|
||||
},
|
||||
"reportOptionalOperand": {
|
||||
"limit": 0
|
||||
|
|
@ -84,34 +84,34 @@
|
|||
"limit": 56
|
||||
},
|
||||
"reportPrivateUsage": {
|
||||
"limit": 1825
|
||||
"limit": 1824
|
||||
},
|
||||
"reportRedeclaration": {
|
||||
"limit": 8
|
||||
},
|
||||
"reportReturnType": {
|
||||
"limit": 218
|
||||
"limit": 217
|
||||
},
|
||||
"reportTypedDictNotRequiredAccess": {
|
||||
"limit": 27
|
||||
"limit": 26
|
||||
},
|
||||
"reportUndefinedVariable": {
|
||||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 45145
|
||||
"limit": 45063
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 113
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 39881
|
||||
"limit": 39773
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 20258
|
||||
"limit": 20207
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 31429
|
||||
"limit": 31281
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 122
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
"limit": 5
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 864
|
||||
"limit": 862
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t
|
|||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -43,11 +43,15 @@ class CheckBatchCost:
|
|||
# the guaranteed-failing primary query on every subsequent cycle.
|
||||
self._has_batch_processed_column: bool = True
|
||||
|
||||
async def _get_user_info(self, batch_id, user_id) -> dict:
|
||||
async def _get_user_info(self, batch_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Look up user email and key alias by user_id for enriching the S3 callback metadata.
|
||||
Returns a dict with user_api_key_user_email and user_api_key_alias (both may be None).
|
||||
Returns an empty dict when user_id is None: batches created by a team or service
|
||||
account key carry no user id, and find_unique(where={"user_id": None}) raises.
|
||||
"""
|
||||
if not user_id:
|
||||
return {}
|
||||
try:
|
||||
user_row = await self.prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
|
|
@ -62,6 +66,66 @@ class CheckBatchCost:
|
|||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up user {user_id} for batch {batch_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def _get_key_alias(self, batch_id: str, api_key: str | None) -> str | None:
|
||||
"""Resolve the creating virtual key's alias from its hashed token."""
|
||||
if not api_key:
|
||||
return None
|
||||
try:
|
||||
key_row = await self.prisma_client.db.litellm_verificationtoken.find_unique(
|
||||
where={"token": api_key}
|
||||
)
|
||||
return getattr(key_row, "key_alias", None) if key_row is not None else None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up key alias for batch {batch_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_team_alias(self, team_id: str | None) -> str | None:
|
||||
"""Resolve a team's alias from its id."""
|
||||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
team_row = await self.prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
return getattr(team_row, "team_alias", None) if team_row is not None else None
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"CheckBatchCost: could not look up team alias for team {team_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _build_creator_attribution_metadata(
|
||||
self, job: "LiteLLM_ManagedObjectTable", batch_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Rebuild the spend-tracking metadata for the key, team, and tags that created the
|
||||
batch so the batch-cost spend log is attributed the same way a non-batch request
|
||||
is. Rows created before api_key and request_tags were persisted carry only
|
||||
created_by and team_id, and fall back to those. A named creating key owns
|
||||
user_api_key_alias; when it has no alias, or the key has since been rotated or
|
||||
deleted, the field keeps the creating user's alias that _get_user_info filled in,
|
||||
because a resolvable name is more useful on the spend row than a null.
|
||||
"""
|
||||
api_key = getattr(job, "api_key", None)
|
||||
team_id = getattr(job, "team_id", None)
|
||||
request_tags = getattr(job, "request_tags", None)
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"user_api_key_user_id": job.created_by,
|
||||
"user_api_key": api_key,
|
||||
"user_api_key_team_id": team_id,
|
||||
**(await self._get_user_info(batch_id, job.created_by)),
|
||||
}
|
||||
|
||||
key_alias = await self._get_key_alias(batch_id, api_key)
|
||||
if key_alias is not None:
|
||||
metadata["user_api_key_alias"] = key_alias
|
||||
team_alias = await self._get_team_alias(team_id)
|
||||
if team_alias is not None:
|
||||
metadata["user_api_key_team_alias"] = team_alias
|
||||
if isinstance(request_tags, list) and request_tags:
|
||||
metadata["tags"] = [tag for tag in request_tags if isinstance(tag, str)]
|
||||
|
||||
return metadata
|
||||
|
||||
async def _cleanup_stale_managed_objects(self) -> None:
|
||||
"""
|
||||
Mark managed objects older than MANAGED_OBJECT_STALENESS_CUTOFF_DAYS days
|
||||
|
|
@ -485,9 +549,6 @@ class CheckBatchCost:
|
|||
function_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
creator_user_id = job.created_by
|
||||
user_info = await self._get_user_info(batch_id, job.created_by)
|
||||
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={
|
||||
# set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks
|
||||
|
|
@ -496,11 +557,7 @@ class CheckBatchCost:
|
|||
"user-agent": CHECK_BATCH_COST_USER_AGENT,
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"user_api_key_user_id": creator_user_id,
|
||||
"user_api_key_team_id": getattr(job, "team_id", None),
|
||||
**user_info,
|
||||
},
|
||||
"metadata": await self._build_creator_attribution_metadata(job, batch_id),
|
||||
},
|
||||
optional_params={},
|
||||
)
|
||||
|
|
@ -657,6 +714,20 @@ class CheckBatchCost:
|
|||
|
||||
elif response.status in ("failed", "expired", "cancelled"):
|
||||
try:
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
_is_base64_encoded_unified_file_id,
|
||||
ensure_batch_response_managed_file_ids,
|
||||
)
|
||||
|
||||
response.id = job.unified_object_id
|
||||
await ensure_batch_response_managed_file_ids(
|
||||
response=response,
|
||||
managed_files_obj=self.proxy_logging_obj.get_proxy_hook("managed_files"),
|
||||
prisma_client=self.prisma_client,
|
||||
verbose_proxy_logger=verbose_proxy_logger,
|
||||
db_batch_object=job,
|
||||
unified_batch_id=_is_base64_encoded_unified_file_id(job.unified_object_id),
|
||||
)
|
||||
update_data = {
|
||||
"status": response.status,
|
||||
"file_object": response.model_dump_json(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import delete_cached_project_object
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
|
|
@ -514,6 +515,7 @@ async def update_project(
|
|||
litellm_proxy_admin_name,
|
||||
premium_user,
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -672,6 +674,11 @@ async def update_project(
|
|||
include={"litellm_budget_table": True, "object_permission": True},
|
||||
)
|
||||
|
||||
await delete_cached_project_object(
|
||||
project_id=data.project_id,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
return updated_project
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
|
|
@ -710,7 +717,7 @@ async def delete_project(
|
|||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client, user_api_key_cache
|
||||
|
||||
try:
|
||||
if not premium_user:
|
||||
|
|
@ -773,6 +780,11 @@ async def delete_project(
|
|||
prisma_models.LiteLLM_ProjectTable | None
|
||||
) = await prisma_client.db.litellm_projecttable.delete(where={"project_id": project_id})
|
||||
|
||||
await delete_cached_project_object(
|
||||
project_id=project_id,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
deleted_projects.append(deleted_project)
|
||||
|
||||
return deleted_projects
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.53"
|
||||
version = "0.1.54"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.53"
|
||||
version = "0.1.54"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- Add api_key and request_tags columns to LiteLLM_ManagedObjectTable
|
||||
-- Captured at batch-create time so CheckBatchCost can attribute batch-cost spend
|
||||
-- back to the creating virtual key (and its tags) even when created_by is null.
|
||||
ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN IF NOT EXISTS "api_key" TEXT;
|
||||
ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN IF NOT EXISTS "request_tags" JSONB DEFAULT '[]';
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "LiteLLM_AutoRouterSession" ADD COLUMN IF NOT EXISTS "tier_turns" JSONB NOT NULL DEFAULT '{}';
|
||||
|
|
@ -985,6 +985,8 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
|
|||
created_at DateTime @default(now())
|
||||
created_by String?
|
||||
team_id String?
|
||||
api_key String?
|
||||
request_tags Json? @default("[]")
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String?
|
||||
|
||||
|
|
@ -1439,6 +1441,7 @@ model LiteLLM_AutoRouterSession {
|
|||
total_tokens BigInt @default(0)
|
||||
spend Float @default(0)
|
||||
saved_spend Float @default(0)
|
||||
tier_turns Json @default("{}")
|
||||
|
||||
@@id([api_key, session_id, router_name])
|
||||
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.83"
|
||||
version = "0.4.84"
|
||||
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.83"
|
||||
version = "0.4.84"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -202,9 +202,9 @@ async def handle_a2a_localhost_retry(
|
|||
# Fix the agent card URL
|
||||
set_agent_card_url(agent_card, error.base_url)
|
||||
|
||||
# Reuse the httpx client LiteLLM attached at creation. It carries this agent's
|
||||
# trace-id and auth headers, so a fresh client would drop them. Only clients built
|
||||
# by ``create_a2a_client`` have it; an externally-supplied client cannot be retried.
|
||||
# Reuse the httpx client and call context LiteLLM attached at creation, since the
|
||||
# context carries this agent's trace-id/auth headers. Only clients built by
|
||||
# ``create_a2a_client`` have them; an externally-supplied client cannot be retried.
|
||||
httpx_client: Final = getattr(a2a_client, "_litellm_httpx_client", None)
|
||||
if httpx_client is None:
|
||||
raise RuntimeError(
|
||||
|
|
@ -220,5 +220,8 @@ async def handle_a2a_localhost_retry(
|
|||
),
|
||||
)
|
||||
new_client._litellm_httpx_client = httpx_client
|
||||
new_client._litellm_call_context = getattr( # pyright: ignore[reportAttributeAccessIssue] # LiteLLM-owned stash
|
||||
a2a_client, "_litellm_call_context", None
|
||||
)
|
||||
new_client._litellm_agent_card = agent_card
|
||||
return new_client
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from litellm.utils import client
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from a2a.client import Client as A2AClientType
|
||||
from a2a.client import ClientCallContext as A2ACallContextType
|
||||
from a2a.compat.v0_3.types import (
|
||||
AgentCard,
|
||||
Message,
|
||||
|
|
@ -45,7 +46,7 @@ A2A_SDK_AVAILABLE = False
|
|||
_a2a_conversions: Any = None
|
||||
|
||||
try:
|
||||
from a2a.client import Client, ClientConfig, create_client
|
||||
from a2a.client import Client, ClientCallContext, ClientConfig, create_client
|
||||
from a2a.compat.v0_3 import conversions as _a2a_conversions
|
||||
from a2a.compat.v0_3.types import (
|
||||
Message,
|
||||
|
|
@ -60,6 +61,7 @@ try:
|
|||
A2A_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
Client = None
|
||||
ClientCallContext = None
|
||||
ClientConfig = None
|
||||
create_client = None
|
||||
|
||||
|
|
@ -218,6 +220,10 @@ async def _send_message_via_completion_bridge(
|
|||
return LiteLLMSendMessageResponse.from_dict(response_dict, request_id=str(request.id))
|
||||
|
||||
|
||||
def _get_a2a_call_context(a2a_client: "A2AClientType") -> Optional["A2ACallContextType"]:
|
||||
return getattr(a2a_client, "_litellm_call_context", None)
|
||||
|
||||
|
||||
async def _send_message(a2a_client: "A2AClientType", request: "SendMessageRequest") -> "SendMessageResponse":
|
||||
"""Send a non-streaming message via a2a-sdk 1.x and return JSON-RPC response."""
|
||||
if _a2a_conversions is None:
|
||||
|
|
@ -227,7 +233,7 @@ async def _send_message(a2a_client: "A2AClientType", request: "SendMessageReques
|
|||
|
||||
pb_request: Final = _a2a_conversions.to_core_send_message_request(request)
|
||||
last_event = None
|
||||
async for event in a2a_client.send_message(pb_request):
|
||||
async for event in a2a_client.send_message(pb_request, context=_get_a2a_call_context(a2a_client)):
|
||||
last_event = event
|
||||
if last_event is None:
|
||||
raise RuntimeError("A2A send_message failed: no response received from agent.")
|
||||
|
|
@ -301,7 +307,7 @@ async def _stream_messages(
|
|||
)
|
||||
|
||||
pb_request: Final = _a2a_conversions.to_core_send_message_request(request)
|
||||
async for event in a2a_client.send_message(pb_request):
|
||||
async for event in a2a_client.send_message(pb_request, context=_get_a2a_call_context(a2a_client)):
|
||||
compat_chunk = _a2a_conversions.to_compat_stream_response(
|
||||
event,
|
||||
request_id=request.id,
|
||||
|
|
@ -756,26 +762,12 @@ async def create_a2a_client(
|
|||
|
||||
verbose_logger.info("Creating A2A client for %s", base_url)
|
||||
|
||||
# Use get_async_httpx_client with per-agent params so that different agents
|
||||
# (with different extra_headers) get separate cached clients. The params
|
||||
# dict is hashed into the cache key, keeping agent auth isolated while
|
||||
# still reusing connections within the same agent.
|
||||
#
|
||||
# Only pass params that AsyncHTTPHandler.__init__ accepts (e.g. timeout).
|
||||
# Use "disable_aiohttp_transport" key for cache-key-only data (it's
|
||||
# filtered out before reaching the constructor).
|
||||
_client_params: Final[dict] = {"timeout": timeout}
|
||||
if extra_headers:
|
||||
# Encode headers into a cache-key-only param so each unique header
|
||||
# set produces a distinct cache key.
|
||||
_client_params["disable_aiohttp_transport"] = str(sorted(extra_headers.items()))
|
||||
_async_handler: Final = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.A2AProvider,
|
||||
params=_client_params,
|
||||
params={"timeout": timeout},
|
||||
)
|
||||
httpx_client: Final = _async_handler.client
|
||||
if extra_headers:
|
||||
httpx_client.headers.update(extra_headers)
|
||||
verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys()))
|
||||
|
||||
a2a_client: Final = await create_client( # pyright: ignore[reportOptionalCall]
|
||||
|
|
@ -784,11 +776,17 @@ async def create_a2a_client(
|
|||
httpx_client=httpx_client,
|
||||
streaming=streaming,
|
||||
),
|
||||
resolver_http_kwargs={"headers": extra_headers} if extra_headers else None,
|
||||
)
|
||||
# Stash LiteLLM-owned handles on the client so the localhost-retry path can reuse
|
||||
# the configured httpx client (with this agent's trace-id/auth headers) without
|
||||
# excavating a2a-sdk private internals.
|
||||
# the configured httpx client and this agent's headers without excavating
|
||||
# a2a-sdk private internals.
|
||||
a2a_client._litellm_httpx_client = httpx_client
|
||||
a2a_client._litellm_call_context = ( # pyright: ignore[reportAttributeAccessIssue] # LiteLLM-owned stash
|
||||
ClientCallContext(service_parameters=extra_headers) # pyright: ignore[reportOptionalCall] # SDK checked above
|
||||
if extra_headers
|
||||
else None
|
||||
)
|
||||
agent_card: Final = getattr(a2a_client, "_card", None)
|
||||
if agent_card is not None:
|
||||
a2a_client._litellm_agent_card = agent_card
|
||||
|
|
|
|||
|
|
@ -6,16 +6,33 @@ This module provides fake streaming by converting non-streaming responses into s
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Final, cast
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Final, Protocol, cast, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
|
||||
_ANY_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[object, object])
|
||||
_STR_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_LIST_ADAPTER: Final = TypeAdapter(list[object])
|
||||
_TEXT_ADAPTER: Final = TypeAdapter(str)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SupportsModelDump(Protocol):
|
||||
def model_dump(self, *, mode: str, exclude_none: bool) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SupportsPydanticDict(Protocol):
|
||||
def dict(self, *, exclude_none: bool) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
class PydanticAITransformation:
|
||||
"""
|
||||
|
|
@ -28,7 +45,7 @@ class PydanticAITransformation:
|
|||
"""
|
||||
|
||||
@staticmethod
|
||||
def _remove_none_values(obj: Any) -> Any:
|
||||
def _remove_none_values(obj: object) -> object:
|
||||
"""
|
||||
Recursively remove None values from a dict/list structure.
|
||||
|
||||
|
|
@ -42,14 +59,18 @@ class PydanticAITransformation:
|
|||
Cleaned object with None values removed
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: PydanticAITransformation._remove_none_values(v) for k, v in obj.items() if v is not None}
|
||||
typed_dict: Final = _ANY_KEY_DICT_ADAPTER.validate_python(obj)
|
||||
return {k: PydanticAITransformation._remove_none_values(v) for k, v in typed_dict.items() if v is not None}
|
||||
elif isinstance(obj, list):
|
||||
return [PydanticAITransformation._remove_none_values(item) for item in obj if item is not None]
|
||||
typed_list: Final = _LIST_ADAPTER.validate_python(obj)
|
||||
return [PydanticAITransformation._remove_none_values(item) for item in typed_list if item is not None]
|
||||
else:
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def _params_to_dict(params: Any) -> dict[str, Any]:
|
||||
def _params_to_dict(
|
||||
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
|
||||
) -> Mapping[str, object]:
|
||||
"""
|
||||
Convert params to a dict, handling Pydantic models.
|
||||
|
||||
|
|
@ -59,10 +80,10 @@ class PydanticAITransformation:
|
|||
Returns:
|
||||
Dict representation of params
|
||||
"""
|
||||
if hasattr(params, "model_dump"):
|
||||
if isinstance(params, _SupportsModelDump):
|
||||
# Pydantic v2 model
|
||||
return params.model_dump(mode="python", exclude_none=True)
|
||||
elif hasattr(params, "dict"):
|
||||
elif isinstance(params, _SupportsPydanticDict):
|
||||
# Pydantic v1 model
|
||||
return params.dict(exclude_none=True)
|
||||
elif isinstance(params, dict):
|
||||
|
|
@ -75,12 +96,12 @@ class PydanticAITransformation:
|
|||
async def _poll_for_completion(
|
||||
client: AsyncHTTPHandler,
|
||||
endpoint: str,
|
||||
task_id: str,
|
||||
task_id: object,
|
||||
request_id: str,
|
||||
max_attempts: int = 30,
|
||||
poll_interval: float = 0.5,
|
||||
agent_extra_headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Poll for task completion using tasks/get method.
|
||||
|
||||
|
|
@ -112,10 +133,10 @@ class PydanticAITransformation:
|
|||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
poll_data = response.json()
|
||||
poll_data = _STR_KEY_DICT_ADAPTER.validate_python(response.json())
|
||||
|
||||
result = poll_data.get("result", {})
|
||||
status = result.get("status", {})
|
||||
result = _STR_KEY_DICT_ADAPTER.validate_python(poll_data.get("result", {}))
|
||||
status = _STR_KEY_DICT_ADAPTER.validate_python(result.get("status", {}))
|
||||
state = status.get("state", "")
|
||||
|
||||
verbose_logger.debug("Pydantic AI: Poll attempt %s/%s, state=%s", attempt + 1, max_attempts, state)
|
||||
|
|
@ -133,10 +154,10 @@ class PydanticAITransformation:
|
|||
async def _send_and_poll_raw(
|
||||
api_base: str,
|
||||
request_id: str,
|
||||
params: Any,
|
||||
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
|
||||
timeout: float = 60.0,
|
||||
agent_extra_headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Send a request to Pydantic AI agent and return the raw task response.
|
||||
|
||||
|
|
@ -153,14 +174,16 @@ class PydanticAITransformation:
|
|||
Raw Pydantic AI task response (with history/artifacts)
|
||||
"""
|
||||
# Convert params to dict if it's a Pydantic model
|
||||
params_dict = PydanticAITransformation._params_to_dict(params)
|
||||
|
||||
# Remove None values - FastA2A doesn't accept null for optional fields
|
||||
params_dict = PydanticAITransformation._remove_none_values(params_dict)
|
||||
params_dict: Final = _ANY_KEY_DICT_ADAPTER.validate_python(
|
||||
PydanticAITransformation._remove_none_values(PydanticAITransformation._params_to_dict(params))
|
||||
)
|
||||
|
||||
# Ensure the message has 'kind': 'message' as required by FastA2A/Pydantic AI
|
||||
if "message" in params_dict:
|
||||
params_dict["message"]["kind"] = "message"
|
||||
message_value: Final = _ANY_KEY_DICT_ADAPTER.validate_python(params_dict["message"])
|
||||
message_value["kind"] = "message"
|
||||
params_dict["message"] = message_value
|
||||
|
||||
# Build A2A JSON-RPC request using message/send method for FastA2A compatibility
|
||||
a2a_request: Final = {
|
||||
|
|
@ -189,11 +212,11 @@ class PydanticAITransformation:
|
|||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
response_data = _STR_KEY_DICT_ADAPTER.validate_python(response.json())
|
||||
|
||||
# Check if task is already completed
|
||||
result: Final = response_data.get("result", {})
|
||||
status: Final = result.get("status", {})
|
||||
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
|
||||
status: Final = _STR_KEY_DICT_ADAPTER.validate_python(result.get("status", {}))
|
||||
state: Final = status.get("state", "")
|
||||
|
||||
if state != "completed":
|
||||
|
|
@ -217,10 +240,10 @@ class PydanticAITransformation:
|
|||
async def send_non_streaming_request(
|
||||
api_base: str,
|
||||
request_id: str,
|
||||
params: Any,
|
||||
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
|
||||
timeout: float = 60.0,
|
||||
agent_extra_headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Send a non-streaming A2A request to Pydantic AI agent and wait for completion.
|
||||
|
||||
|
|
@ -253,10 +276,10 @@ class PydanticAITransformation:
|
|||
async def send_and_get_raw_response(
|
||||
api_base: str,
|
||||
request_id: str,
|
||||
params: Any,
|
||||
params: "_SupportsModelDump | _SupportsPydanticDict | Mapping[str, object]",
|
||||
timeout: float = 60.0,
|
||||
agent_extra_headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Send a request to Pydantic AI agent and return the raw task response.
|
||||
|
||||
|
|
@ -282,9 +305,9 @@ class PydanticAITransformation:
|
|||
|
||||
@staticmethod
|
||||
def _transform_to_a2a_response(
|
||||
response_data: dict[str, Any],
|
||||
response_data: Mapping[str, object],
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Transform Pydantic AI task response to standard A2A non-streaming format.
|
||||
|
||||
|
|
@ -328,7 +351,7 @@ class PydanticAITransformation:
|
|||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_text(response_data: dict[str, Any]) -> tuple[str, str, list]:
|
||||
def _extract_response_text(response_data: Mapping[str, object]) -> tuple[object, object, Sequence[object]]:
|
||||
"""
|
||||
Extract response text from completed task response.
|
||||
|
||||
|
|
@ -342,52 +365,53 @@ class PydanticAITransformation:
|
|||
Returns:
|
||||
Tuple of (full_text, message_id, parts)
|
||||
"""
|
||||
result: Final = response_data.get("result", {})
|
||||
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
|
||||
|
||||
# Try to extract from artifacts first (preferred for results)
|
||||
artifacts: Final = result.get("artifacts", [])
|
||||
if artifacts:
|
||||
for artifact in artifacts:
|
||||
parts = artifact.get("parts", [])
|
||||
for artifact in _LIST_ADAPTER.validate_python(artifacts):
|
||||
parts = _LIST_ADAPTER.validate_python(_STR_KEY_DICT_ADAPTER.validate_python(artifact).get("parts", []))
|
||||
for part in parts:
|
||||
if part.get("kind") == "text":
|
||||
text = part.get("text", "")
|
||||
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
|
||||
text = part_dict.get("text", "")
|
||||
if text:
|
||||
return text, str(uuid4()), parts
|
||||
|
||||
# Fall back to history - get the last agent message
|
||||
history: Final = result.get("history", [])
|
||||
history: Final = _LIST_ADAPTER.validate_python(result.get("history", []))
|
||||
for msg in reversed(history):
|
||||
if msg.get("role") == "agent":
|
||||
parts = msg.get("parts", [])
|
||||
message_id = msg.get("messageId", str(uuid4()))
|
||||
if (msg_dict := _STR_KEY_DICT_ADAPTER.validate_python(msg)).get("role") == "agent":
|
||||
parts = _LIST_ADAPTER.validate_python(msg_dict.get("parts", []))
|
||||
message_id = msg_dict.get("messageId", str(uuid4()))
|
||||
full_text = ""
|
||||
for part in parts:
|
||||
if part.get("kind") == "text":
|
||||
full_text += part.get("text", "")
|
||||
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
|
||||
full_text += _TEXT_ADAPTER.validate_python(part_dict.get("text", ""))
|
||||
if full_text:
|
||||
return full_text, message_id, parts
|
||||
|
||||
# Fall back to message field (original format)
|
||||
message: Final = result.get("message", {})
|
||||
if message:
|
||||
parts = message.get("parts", [])
|
||||
message_id = message.get("messageId", str(uuid4()))
|
||||
message_dict: Final = _STR_KEY_DICT_ADAPTER.validate_python(message)
|
||||
parts = _LIST_ADAPTER.validate_python(message_dict.get("parts", []))
|
||||
message_id = message_dict.get("messageId", str(uuid4()))
|
||||
full_text = ""
|
||||
for part in parts:
|
||||
if part.get("kind") == "text":
|
||||
full_text += part.get("text", "")
|
||||
if (part_dict := _STR_KEY_DICT_ADAPTER.validate_python(part)).get("kind") == "text":
|
||||
full_text += _TEXT_ADAPTER.validate_python(part_dict.get("text", ""))
|
||||
return full_text, message_id, parts
|
||||
|
||||
return "", str(uuid4()), []
|
||||
|
||||
@staticmethod
|
||||
async def fake_streaming_from_response(
|
||||
response_data: dict[str, Any],
|
||||
response_data: Mapping[str, object],
|
||||
request_id: str,
|
||||
chunk_size: int = 50,
|
||||
delay_ms: int = 10,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
) -> AsyncIterator[dict[str, object]]:
|
||||
"""
|
||||
Convert a non-streaming A2A response into fake streaming chunks.
|
||||
|
||||
|
|
@ -410,12 +434,12 @@ class PydanticAITransformation:
|
|||
full_text, message_id, parts = PydanticAITransformation._extract_response_text(response_data)
|
||||
|
||||
# Extract input message from raw response for history
|
||||
result: Final = response_data.get("result", {})
|
||||
history: Final = result.get("history", [])
|
||||
input_message = {}
|
||||
result: Final = _STR_KEY_DICT_ADAPTER.validate_python(response_data.get("result", {}))
|
||||
history: Final = _LIST_ADAPTER.validate_python(result.get("history", []))
|
||||
input_message = _STR_KEY_DICT_ADAPTER.validate_python({})
|
||||
for msg in history:
|
||||
if msg.get("role") == "user":
|
||||
input_message = msg
|
||||
if (msg_dict := _STR_KEY_DICT_ADAPTER.validate_python(msg)).get("role") == "user":
|
||||
input_message = msg_dict
|
||||
break
|
||||
|
||||
# Generate IDs for streaming events
|
||||
|
|
@ -426,45 +450,49 @@ class PydanticAITransformation:
|
|||
|
||||
# 1. Emit initial task event (kind: "task", status: "submitted")
|
||||
# Format matches A2ACompletionBridgeTransformation.create_task_event
|
||||
task_event: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"history": [
|
||||
{
|
||||
"contextId": context_id,
|
||||
"kind": "message",
|
||||
"messageId": input_message_id,
|
||||
"parts": input_message.get("parts", [{"kind": "text", "text": ""}]),
|
||||
"role": "user",
|
||||
"taskId": task_id,
|
||||
}
|
||||
],
|
||||
"id": task_id,
|
||||
"kind": "task",
|
||||
"status": {
|
||||
"state": "submitted",
|
||||
task_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"history": [
|
||||
{
|
||||
"contextId": context_id,
|
||||
"kind": "message",
|
||||
"messageId": input_message_id,
|
||||
"parts": input_message.get("parts", [{"kind": "text", "text": ""}]),
|
||||
"role": "user",
|
||||
"taskId": task_id,
|
||||
}
|
||||
],
|
||||
"id": task_id,
|
||||
"kind": "task",
|
||||
"status": {
|
||||
"state": "submitted",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
yield task_event
|
||||
|
||||
# 2. Emit status update (kind: "status-update", status: "working")
|
||||
# Format matches A2ACompletionBridgeTransformation.create_status_update_event
|
||||
working_event: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"final": False,
|
||||
"kind": "status-update",
|
||||
"status": {
|
||||
"state": "working",
|
||||
working_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"final": False,
|
||||
"kind": "status-update",
|
||||
"status": {
|
||||
"state": "working",
|
||||
},
|
||||
"taskId": task_id,
|
||||
},
|
||||
"taskId": task_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
yield working_event
|
||||
|
||||
# Small delay to simulate processing
|
||||
|
|
@ -473,29 +501,32 @@ class PydanticAITransformation:
|
|||
# 3. Emit artifact update chunks (kind: "artifact-update")
|
||||
# Format matches A2ACompletionBridgeTransformation.create_artifact_update_event
|
||||
if full_text:
|
||||
full_text_str: Final = _TEXT_ADAPTER.validate_python(full_text)
|
||||
# Split text into chunks
|
||||
for i in range(0, len(full_text), chunk_size):
|
||||
chunk_text = full_text[i : i + chunk_size]
|
||||
is_last_chunk = (i + chunk_size) >= len(full_text)
|
||||
for i in range(0, len(full_text_str), chunk_size):
|
||||
chunk_text = full_text_str[i : i + chunk_size]
|
||||
is_last_chunk = (i + chunk_size) >= len(full_text_str)
|
||||
|
||||
artifact_event = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"kind": "artifact-update",
|
||||
"taskId": task_id,
|
||||
"artifact": {
|
||||
"artifactId": artifact_id,
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": chunk_text,
|
||||
}
|
||||
],
|
||||
artifact_event = _STR_KEY_DICT_ADAPTER.validate_python(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"kind": "artifact-update",
|
||||
"taskId": task_id,
|
||||
"artifact": {
|
||||
"artifactId": artifact_id,
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": chunk_text,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
yield artifact_event
|
||||
|
||||
# Add delay between chunks (except for last chunk)
|
||||
|
|
@ -503,19 +534,21 @@ class PydanticAITransformation:
|
|||
await asyncio.sleep(delay_ms / 1000.0)
|
||||
|
||||
# 4. Emit final status update (kind: "status-update", status: "completed", final: true)
|
||||
completed_event: Final = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"final": True,
|
||||
"kind": "status-update",
|
||||
"status": {
|
||||
"state": "completed",
|
||||
completed_event: Final = _STR_KEY_DICT_ADAPTER.validate_python(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"contextId": context_id,
|
||||
"final": True,
|
||||
"kind": "status-update",
|
||||
"status": {
|
||||
"state": "completed",
|
||||
},
|
||||
"taskId": task_id,
|
||||
},
|
||||
"taskId": task_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
yield completed_event
|
||||
|
||||
verbose_logger.info("Pydantic AI: Fake streaming completed for request_id=%s", request_id)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import ast
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -47,7 +48,7 @@ class RedisSemanticCache(BaseCache):
|
|||
similarity_threshold: float | None = None,
|
||||
embedding_model: str = "text-embedding-ada-002",
|
||||
index_name: str | None = None,
|
||||
**kwargs,
|
||||
**kwargs: object,
|
||||
):
|
||||
"""
|
||||
Initialize the Redis Semantic Cache.
|
||||
|
|
@ -150,11 +151,11 @@ class RedisSemanticCache(BaseCache):
|
|||
|
||||
def _init_semantic_cache(
|
||||
self,
|
||||
semantic_cache_cls: Any,
|
||||
semantic_cache_cls: Callable[..., object],
|
||||
index_name: str,
|
||||
redis_url: str,
|
||||
cache_vectorizer: Any,
|
||||
) -> Any:
|
||||
cache_vectorizer: object,
|
||||
) -> object:
|
||||
def _is_schema_mismatch(exc: ValueError) -> bool:
|
||||
error_message: Final = str(exc).lower()
|
||||
return any(phrase in error_message for phrase in ("schema does not match", "index schema"))
|
||||
|
|
@ -206,12 +207,12 @@ class RedisSemanticCache(BaseCache):
|
|||
def _get_cache_filters(self, key: str) -> dict[str, str]:
|
||||
return {self.CACHE_KEY_FIELD_NAME: str(key)}
|
||||
|
||||
def _get_cache_key_filter_expression(self, key: str) -> Any:
|
||||
def _get_cache_key_filter_expression(self, key: str) -> object:
|
||||
from redisvl.query.filter import Tag
|
||||
|
||||
return Tag(self.CACHE_KEY_FIELD_NAME) == str(key)
|
||||
|
||||
def _cache_hit_matches_key(self, cache_hit: dict[str, Any], key: str) -> bool:
|
||||
def _cache_hit_matches_key(self, cache_hit: Mapping[str, object], key: str) -> bool:
|
||||
# Pre-isolation entries with no ``litellm_cache_key`` field cannot be
|
||||
# safely reassigned to a caller's scope and are treated as misses.
|
||||
cached_key = cache_hit.get(self.CACHE_KEY_FIELD_NAME)
|
||||
|
|
@ -297,7 +298,7 @@ class RedisSemanticCache(BaseCache):
|
|||
return
|
||||
|
||||
@staticmethod
|
||||
def _coerce_response_input_value(value: Any) -> Any:
|
||||
def _coerce_response_input_value(value: object) -> object:
|
||||
model_dump: Final = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return model_dump()
|
||||
|
|
@ -340,7 +341,7 @@ class RedisSemanticCache(BaseCache):
|
|||
)
|
||||
return embedding_response["data"][0]["embedding"]
|
||||
|
||||
def _get_cache_logic(self, cached_response: Any) -> Any:
|
||||
def _get_cache_logic(self, cached_response: Any) -> object:
|
||||
"""
|
||||
Process the cached response to prepare it for use.
|
||||
|
||||
|
|
@ -369,7 +370,7 @@ class RedisSemanticCache(BaseCache):
|
|||
|
||||
return cached_response
|
||||
|
||||
def set_cache(self, key: str, value: Any, **kwargs) -> None:
|
||||
def set_cache(self, key: str, value: object, **kwargs) -> None:
|
||||
"""
|
||||
Store a value in the semantic cache.
|
||||
|
||||
|
|
@ -405,7 +406,7 @@ class RedisSemanticCache(BaseCache):
|
|||
except Exception as e:
|
||||
print_verbose(f"Error setting {value_str or value} in the Redis semantic cache: {e}")
|
||||
|
||||
def get_cache(self, key: str, **kwargs) -> Any:
|
||||
def get_cache(self, key: str, **kwargs) -> object:
|
||||
"""
|
||||
Retrieve a semantically similar cached response.
|
||||
|
||||
|
|
@ -428,7 +429,7 @@ class RedisSemanticCache(BaseCache):
|
|||
# Check the cache for semantically similar prompts in this exact
|
||||
# LiteLLM cache-key scope.
|
||||
prompt_embedding: Final = self._get_embedding(prompt, metadata=kwargs.get("metadata"))
|
||||
check_kwargs: Final[dict[str, Any]] = {
|
||||
check_kwargs: Final[Mapping[str, object]] = {
|
||||
"prompt": prompt,
|
||||
"vector": prompt_embedding,
|
||||
"filter_expression": self._get_cache_key_filter_expression(key),
|
||||
|
|
@ -508,7 +509,7 @@ class RedisSemanticCache(BaseCache):
|
|||
print_verbose(f"Error generating async embedding: {e}")
|
||||
raise ValueError(f"Failed to generate embedding: {e}") from e
|
||||
|
||||
async def async_set_cache(self, key: str, value: Any, **kwargs) -> None:
|
||||
async def async_set_cache(self, key: str, value: object, **kwargs) -> None:
|
||||
"""
|
||||
Asynchronously store a value in the semantic cache.
|
||||
|
||||
|
|
@ -548,7 +549,7 @@ class RedisSemanticCache(BaseCache):
|
|||
except Exception as e:
|
||||
print_verbose(f"Error in async_set_cache: {e}")
|
||||
|
||||
async def async_get_cache(self, key: str, **kwargs) -> Any:
|
||||
async def async_get_cache(self, key: str, **kwargs) -> object:
|
||||
"""
|
||||
Asynchronously retrieve a semantically similar cached response.
|
||||
|
||||
|
|
@ -573,7 +574,7 @@ class RedisSemanticCache(BaseCache):
|
|||
|
||||
# Check the cache for semantically similar prompts in this exact
|
||||
# LiteLLM cache-key scope.
|
||||
check_kwargs: Final[dict[str, Any]] = {
|
||||
check_kwargs: Final[Mapping[str, object]] = {
|
||||
"prompt": prompt,
|
||||
"vector": prompt_embedding,
|
||||
"filter_expression": self._get_cache_key_filter_expression(key),
|
||||
|
|
@ -615,7 +616,7 @@ class RedisSemanticCache(BaseCache):
|
|||
print_verbose(f"Error in async_get_cache: {e}")
|
||||
kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
|
||||
|
||||
async def _index_info(self) -> dict[str, Any]:
|
||||
async def _index_info(self) -> Mapping[str, object]:
|
||||
"""
|
||||
Get information about the Redis index.
|
||||
|
||||
|
|
@ -625,7 +626,7 @@ class RedisSemanticCache(BaseCache):
|
|||
aindex: Final = await self.llmcache._get_async_index()
|
||||
return await aindex.info()
|
||||
|
||||
async def async_set_cache_pipeline(self, cache_list: list[tuple[str, Any]], **kwargs) -> None:
|
||||
async def async_set_cache_pipeline(self, cache_list: list[tuple[str, Any]], **kwargs: object) -> None:
|
||||
"""
|
||||
Asynchronously store multiple values in the semantic cache.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from litellm.litellm_core_utils.env_utils import get_env_int, get_env_int_or_non
|
|||
DEFAULT_HEALTH_CHECK_PROMPT: Final = str(os.getenv("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm"))
|
||||
AZURE_DEFAULT_RESPONSES_API_VERSION: Final = str(os.getenv("AZURE_DEFAULT_RESPONSES_API_VERSION", "preview"))
|
||||
ROUTER_MAX_FALLBACKS: Final = int(os.getenv("ROUTER_MAX_FALLBACKS", 5))
|
||||
ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS: Final = 2000
|
||||
DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512))
|
||||
DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5))
|
||||
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
|
||||
|
|
@ -279,6 +280,7 @@ TOOL_POLICY_CACHE_TTL_SECONDS: Final = int(os.getenv("TOOL_POLICY_CACHE_TTL_SECO
|
|||
GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS: Final = int(
|
||||
os.getenv("GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS", 24 * 60 * 60)
|
||||
)
|
||||
BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS: Final = 25_000
|
||||
# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger.
|
||||
# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire.
|
||||
MAX_SIZE_IN_MEMORY_QUEUE: Final = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8)))
|
||||
|
|
@ -1320,6 +1322,7 @@ X_LITELLM_DISABLE_CALLBACKS: Final = "x-litellm-disable-callbacks"
|
|||
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"
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import asyncio
|
|||
import os
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||||
|
|
@ -27,6 +30,16 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
)
|
||||
from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload
|
||||
|
||||
DEFAULT_AZURE_AUTHORITY_HOST: Final = "https://login.microsoftonline.com"
|
||||
DEFAULT_AZURE_MONITOR_SCOPE: Final = "https://monitor.azure.com/.default"
|
||||
|
||||
MONITOR_SCOPE_BY_AUTHORITY_HOST: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"login.microsoftonline.com": DEFAULT_AZURE_MONITOR_SCOPE,
|
||||
"login.microsoftonline.us": "https://monitor.azure.us/.default",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AzureSentinelLogger(CustomBatchLogger):
|
||||
"""
|
||||
|
|
@ -42,6 +55,7 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
audit_stream_name: str | None = None,
|
||||
authority_host: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -62,6 +76,10 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
If not provided, will use AZURE_SENTINEL_CLIENT_SECRET or AZURE_CLIENT_SECRET env var.
|
||||
audit_stream_name (str, optional): Stream name from DCR for audit logs.
|
||||
If not provided, will use AZURE_SENTINEL_AUDIT_STREAM_NAME env var or the standard stream name.
|
||||
authority_host (str, optional): Microsoft Entra authority host that issues the OAuth2 token,
|
||||
e.g. "https://login.microsoftonline.us" for Azure Government. If not provided, will use
|
||||
AZURE_SENTINEL_AUTHORITY_HOST or AZURE_AUTHORITY_HOST env vars, or default to the Azure
|
||||
Public Cloud authority. The Azure Monitor audience is derived from it.
|
||||
"""
|
||||
self.async_httpx_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.LoggingCallback)
|
||||
|
||||
|
|
@ -76,6 +94,12 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
resolved_client_secret: Final = (
|
||||
client_secret or os.getenv("AZURE_SENTINEL_CLIENT_SECRET") or os.getenv("AZURE_CLIENT_SECRET")
|
||||
)
|
||||
resolved_authority_host: Final = self._normalize_authority_host(
|
||||
authority_host
|
||||
or os.getenv("AZURE_SENTINEL_AUTHORITY_HOST")
|
||||
or os.getenv("AZURE_AUTHORITY_HOST")
|
||||
or DEFAULT_AZURE_AUTHORITY_HOST
|
||||
)
|
||||
|
||||
if not resolved_dcr_immutable_id:
|
||||
raise ValueError(
|
||||
|
|
@ -119,7 +143,8 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
)
|
||||
|
||||
# OAuth2 scope for Azure Monitor
|
||||
self.oauth_scope = "https://monitor.azure.com/.default"
|
||||
self.authority_host = resolved_authority_host
|
||||
self.oauth_scope = self._resolve_oauth_scope(authority_host=resolved_authority_host)
|
||||
self.oauth_token: str | None = None
|
||||
self.oauth_token_expires_at: float | None = None
|
||||
|
||||
|
|
@ -129,6 +154,26 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
self.log_queue: list[StandardLoggingPayload] = []
|
||||
self.audit_log_queue: list[StandardAuditLogPayload] = []
|
||||
|
||||
@staticmethod
|
||||
def _normalize_authority_host(authority_host: str) -> str:
|
||||
"""
|
||||
Normalize an authority host into an absolute URL with no trailing slash.
|
||||
|
||||
Accepts the scheme-qualified form litellm documents ("https://login.microsoftonline.us")
|
||||
and the bare-host form the azure-identity AzureAuthorityHosts constants use.
|
||||
"""
|
||||
stripped: Final = authority_host.strip().rstrip("/")
|
||||
return stripped if "://" in stripped else f"https://{stripped}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_oauth_scope(authority_host: str) -> str:
|
||||
"""
|
||||
Map an authority host to the Azure Monitor Logs Ingestion audience for the same cloud,
|
||||
falling back to the Azure Public Cloud audience for an unrecognized host.
|
||||
"""
|
||||
host: Final = urlparse(authority_host).hostname or ""
|
||||
return MONITOR_SCOPE_BY_AUTHORITY_HOST.get(host, DEFAULT_AZURE_MONITOR_SCOPE)
|
||||
|
||||
@staticmethod
|
||||
def _build_api_endpoint(endpoint: str, dcr_immutable_id: str, stream_name: str) -> str:
|
||||
return f"{endpoint.rstrip('/')}/dataCollectionRules/{dcr_immutable_id}/streams/{stream_name}?api-version=2023-01-01"
|
||||
|
|
@ -150,7 +195,7 @@ class AzureSentinelLogger(CustomBatchLogger):
|
|||
assert self.client_id is not None, "client_id is required"
|
||||
assert self.client_secret is not None, "client_secret is required"
|
||||
|
||||
token_url: Final = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
|
||||
token_url: Final = f"{self.authority_host}/{self.tenant_id}/oauth2/v2.0/token"
|
||||
|
||||
token_data: Final = {
|
||||
"client_id": self.client_id,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import json
|
|||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Final, cast
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone, tzinfo
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -34,6 +35,17 @@ GALILEO_CLOUD_API_BASE_URL: Final = "https://api.galileo.ai"
|
|||
GALILEO_MAX_IN_MEMORY_RECORDS: Final = 1000
|
||||
|
||||
|
||||
class GalileoStandardLoggingFields(TypedDict, total=False):
|
||||
call_type: str
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
response_cost: float
|
||||
startTime: float
|
||||
endTime: float
|
||||
|
||||
|
||||
class LLMResponse(BaseModel):
|
||||
latency_ms: int
|
||||
status_code: int
|
||||
|
|
@ -59,7 +71,7 @@ class LLMResponse(BaseModel):
|
|||
|
||||
class GalileoObserve(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
self.in_memory_records: list[dict] = []
|
||||
self.in_memory_records: list[Mapping[str, object]] = []
|
||||
self.batch_size = 1
|
||||
self.api_key = os.getenv("GALILEO_API_KEY")
|
||||
self.project_id = os.getenv("GALILEO_PROJECT_ID")
|
||||
|
|
@ -176,7 +188,7 @@ class GalileoObserve(CustomLogger):
|
|||
return False
|
||||
|
||||
@staticmethod
|
||||
def _galileo_input_messages(messages: Any | None, input_text: str) -> list[dict[str, str]]:
|
||||
def _galileo_input_messages(messages: object, input_text: str) -> list[dict[str, str]]:
|
||||
if isinstance(messages, dict):
|
||||
messages = messages.get("messages")
|
||||
if not messages:
|
||||
|
|
@ -203,11 +215,11 @@ class GalileoObserve(CustomLogger):
|
|||
return [{"role": "user", "content": input_text}]
|
||||
|
||||
@staticmethod
|
||||
def _local_timezone():
|
||||
def _local_timezone() -> tzinfo:
|
||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||
|
||||
@staticmethod
|
||||
def _format_created_at(dt: datetime | Any) -> str:
|
||||
def _format_created_at(dt: object) -> str:
|
||||
"""Serialize timestamps as UTC ISO-8601 for Galileo."""
|
||||
if not isinstance(dt, datetime):
|
||||
return str(dt)
|
||||
|
|
@ -226,7 +238,7 @@ class GalileoObserve(CustomLogger):
|
|||
return created_at
|
||||
|
||||
@staticmethod
|
||||
def _token_metrics_from_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
def _token_metrics_from_record(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
num_input_tokens: Final = int(record.get("num_input_tokens") or 0)
|
||||
num_output_tokens: Final = int(record.get("num_output_tokens") or 0)
|
||||
num_total_tokens = int(record.get("num_total_tokens") or 0)
|
||||
|
|
@ -244,7 +256,7 @@ class GalileoObserve(CustomLogger):
|
|||
|
||||
@staticmethod
|
||||
def _record_to_v2_span(
|
||||
record: dict[str, Any],
|
||||
record: Mapping[str, Any],
|
||||
*,
|
||||
trace_id: str,
|
||||
span_id: str,
|
||||
|
|
@ -275,7 +287,7 @@ class GalileoObserve(CustomLogger):
|
|||
return span
|
||||
|
||||
@staticmethod
|
||||
def _record_to_v2_trace(record: dict[str, Any]) -> dict[str, Any]:
|
||||
def _record_to_v2_trace(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
trace_id: Final = str(uuid.uuid4())
|
||||
span_id: Final = str(uuid.uuid4())
|
||||
created_at: Final = GalileoObserve._normalize_created_at(record.get("created_at", ""))
|
||||
|
|
@ -295,7 +307,7 @@ class GalileoObserve(CustomLogger):
|
|||
"spans": [GalileoObserve._record_to_v2_span(record, trace_id=trace_id, span_id=span_id)],
|
||||
}
|
||||
|
||||
def _build_traces_payload(self, records: list[dict]) -> dict[str, Any]:
|
||||
def _build_traces_payload(self, records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
payload: Final[dict[str, Any]] = {
|
||||
"traces": [self._record_to_v2_trace(record) for record in records],
|
||||
"logging_method": "api_direct",
|
||||
|
|
@ -357,7 +369,7 @@ class GalileoObserve(CustomLogger):
|
|||
@staticmethod
|
||||
def _log_v2_payload_validation(payload: dict[str, Any]) -> None:
|
||||
missing_fields: Final[list[str]] = []
|
||||
traces: Final = payload.get("traces", [])
|
||||
traces: Final[Sequence[object]] = payload.get("traces", [])
|
||||
if not traces:
|
||||
missing_fields.append("traces")
|
||||
|
||||
|
|
@ -385,7 +397,7 @@ class GalileoObserve(CustomLogger):
|
|||
)
|
||||
|
||||
def _log_flush_payload(self, url: str, payload: dict[str, Any]) -> None:
|
||||
traces: Final = payload.get("traces", [])
|
||||
traces: Final[Sequence[object]] = payload.get("traces", [])
|
||||
verbose_logger.debug(
|
||||
"Galileo Logger flush URL: %s trace_count=%s",
|
||||
url,
|
||||
|
|
@ -415,8 +427,8 @@ class GalileoObserve(CustomLogger):
|
|||
pass
|
||||
|
||||
@staticmethod
|
||||
def _build_prompt(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
optional_params: Final = kwargs.get("optional_params", {}) or {}
|
||||
def _build_prompt(kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
||||
optional_params: Final[Mapping[str, object]] = kwargs.get("optional_params", {}) or {}
|
||||
prompt: Final[dict[str, Any]] = {"messages": kwargs.get("messages")}
|
||||
if optional_params.get("functions") is not None:
|
||||
prompt["functions"] = optional_params["functions"]
|
||||
|
|
@ -425,13 +437,13 @@ class GalileoObserve(CustomLogger):
|
|||
return prompt
|
||||
|
||||
@staticmethod
|
||||
def _serialize_galileo_output(value: Any) -> str:
|
||||
def _serialize_galileo_output(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
def _json_default(obj: Any) -> object:
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump()
|
||||
return str(obj)
|
||||
|
|
@ -439,8 +451,8 @@ class GalileoObserve(CustomLogger):
|
|||
return json.dumps(value, default=_json_default)
|
||||
|
||||
@staticmethod
|
||||
def _prompt_to_input_text(prompt: dict[str, Any]) -> str:
|
||||
messages: Final = prompt.get("messages")
|
||||
def _prompt_to_input_text(prompt: Mapping[str, Any]) -> str:
|
||||
messages: Final[object] = prompt.get("messages")
|
||||
if messages is not None:
|
||||
text: Final = GalileoObserve._input_text_from_messages(messages)
|
||||
if text:
|
||||
|
|
@ -448,7 +460,7 @@ class GalileoObserve(CustomLogger):
|
|||
return json.dumps(prompt, default=str)
|
||||
|
||||
@staticmethod
|
||||
def _get_chat_content_for_galileo(response_obj: litellm.ModelResponse) -> Any:
|
||||
def _get_chat_content_for_galileo(response_obj: litellm.ModelResponse) -> object:
|
||||
if response_obj.choices and len(response_obj.choices) > 0:
|
||||
message: Final = response_obj["choices"][0]["message"]
|
||||
if hasattr(message, "json"):
|
||||
|
|
@ -470,23 +482,23 @@ class GalileoObserve(CustomLogger):
|
|||
@staticmethod
|
||||
def _get_responses_api_content_for_galileo(
|
||||
response_obj: ResponsesAPIResponse,
|
||||
) -> Any:
|
||||
) -> object:
|
||||
if hasattr(response_obj, "output") and response_obj.output:
|
||||
return response_obj.output
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _langfuse_style_rerank_prompt(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
def _langfuse_style_rerank_prompt(kwargs: Mapping[str, object]) -> dict[str, Any]:
|
||||
"""Match Langfuse rerank input: prompt = {"messages": kwargs.get("messages")}."""
|
||||
return {"messages": kwargs.get("messages")}
|
||||
|
||||
def _get_galileo_input_output_content(
|
||||
self,
|
||||
kwargs: dict[str, Any],
|
||||
response_obj: Any,
|
||||
kwargs: Mapping[str, object],
|
||||
response_obj: object,
|
||||
level: str = "DEFAULT",
|
||||
status_message: str | None = None,
|
||||
) -> tuple[str, str, Any]:
|
||||
) -> tuple[str, str, object]:
|
||||
"""
|
||||
Mirror Langfuse _get_langfuse_input_output_content for Galileo ingest.
|
||||
|
||||
|
|
@ -582,12 +594,12 @@ class GalileoObserve(CustomLogger):
|
|||
|
||||
return self._prompt_to_input_text(prompt), "", kwargs.get("messages") or []
|
||||
|
||||
def get_output_str_from_response(self, response_obj: Any, kwargs: dict[str, Any]) -> str:
|
||||
def get_output_str_from_response(self, response_obj: object, kwargs: Mapping[str, object]) -> str:
|
||||
_, output_text, _ = self._get_galileo_input_output_content(kwargs=kwargs, response_obj=response_obj)
|
||||
return output_text
|
||||
|
||||
@staticmethod
|
||||
def _input_text_from_messages(messages: Any) -> str:
|
||||
def _input_text_from_messages(messages: object) -> str:
|
||||
"""Return a plain-string summary of the input suitable for the trace-level input field."""
|
||||
if isinstance(messages, str):
|
||||
return messages
|
||||
|
|
@ -613,7 +625,13 @@ class GalileoObserve(CustomLogger):
|
|||
return str(content)
|
||||
return ""
|
||||
|
||||
async def async_log_success_event(self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any):
|
||||
async def async_log_success_event(
|
||||
self,
|
||||
kwargs: Mapping[str, object],
|
||||
response_obj: object,
|
||||
start_time: object,
|
||||
end_time: object,
|
||||
) -> None:
|
||||
verbose_logger.debug("On Async Success")
|
||||
try:
|
||||
await self._async_log_success_event_impl(
|
||||
|
|
@ -625,7 +643,13 @@ class GalileoObserve(CustomLogger):
|
|||
except Exception:
|
||||
verbose_logger.exception("Galileo Logger: unexpected error in async_log_success_event")
|
||||
|
||||
async def _async_log_success_event_impl(self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any):
|
||||
async def _async_log_success_event_impl(
|
||||
self,
|
||||
kwargs: Mapping[str, Any],
|
||||
response_obj: object,
|
||||
start_time: object,
|
||||
end_time: object,
|
||||
) -> None:
|
||||
if not self._is_configured():
|
||||
verbose_logger.debug(
|
||||
"Galileo Logger: skipping — GALILEO_PROJECT_ID=%s GALILEO_API_KEY=%s GALILEO_BASE_URL=%s",
|
||||
|
|
@ -635,7 +659,7 @@ class GalileoObserve(CustomLogger):
|
|||
)
|
||||
return
|
||||
|
||||
slo: Final[dict[str, Any] | None] = kwargs.get("standard_logging_object")
|
||||
slo: Final[GalileoStandardLoggingFields | None] = kwargs.get("standard_logging_object")
|
||||
if slo is None:
|
||||
verbose_logger.debug("Galileo Logger: no standard_logging_object in kwargs, skipping")
|
||||
return
|
||||
|
|
@ -646,8 +670,8 @@ class GalileoObserve(CustomLogger):
|
|||
kwargs=kwargs, response_obj=response_obj
|
||||
)
|
||||
|
||||
raw_start: Final = slo.get("startTime")
|
||||
raw_end: Final = slo.get("endTime")
|
||||
raw_start: Final[float | None] = slo.get("startTime")
|
||||
raw_end: Final[float | None] = slo.get("endTime")
|
||||
if raw_start is None or raw_end is None:
|
||||
verbose_logger.debug(
|
||||
"Galileo Logger: standard_logging_object missing startTime/endTime, "
|
||||
|
|
@ -710,7 +734,7 @@ class GalileoObserve(CustomLogger):
|
|||
if len(self.in_memory_records) >= self.batch_size:
|
||||
await self.flush_in_memory_records()
|
||||
|
||||
async def flush_in_memory_records(self):
|
||||
async def flush_in_memory_records(self) -> None:
|
||||
if not self.in_memory_records:
|
||||
return
|
||||
|
||||
|
|
@ -774,5 +798,11 @@ class GalileoObserve(CustomLogger):
|
|||
if not self.use_v2_api and response.status_code in (401, 403):
|
||||
self.headers = None
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
async def async_log_failure_event(
|
||||
self,
|
||||
kwargs: Mapping[str, object],
|
||||
response_obj: object,
|
||||
start_time: object,
|
||||
end_time: object,
|
||||
) -> None:
|
||||
verbose_logger.debug("On Async Failure")
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ from litellm.integrations.otel.model.semconv import (
|
|||
Metric,
|
||||
Network,
|
||||
NetworkTransport,
|
||||
RpcSystem,
|
||||
Server,
|
||||
resolve_operation,
|
||||
resolve_provider,
|
||||
|
|
@ -102,6 +103,7 @@ __all__ = [
|
|||
"ProxyRequestSpanData",
|
||||
"RequestContext",
|
||||
"RequestIdentity",
|
||||
"RpcSystem",
|
||||
"Server",
|
||||
"ServerInfo",
|
||||
"ServiceSpanData",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.integrations.otel.model.payloads import (
|
|||
is_mcp_list_tools,
|
||||
is_mcp_tool_call,
|
||||
)
|
||||
from litellm.integrations.otel.model.semconv import Error
|
||||
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
|
||||
from litellm.integrations.otel.model.utils import to_ns
|
||||
from litellm.integrations.otel.plumbing.context import (
|
||||
|
|
@ -634,18 +635,23 @@ class OpenTelemetryV2(CustomLogger):
|
|||
"""Stamp the v2 error.* attributes on the FastAPI-owned SERVER span for a
|
||||
failure that dies before any LLM-call span exists (malformed body, auth /
|
||||
validation rejection). Called from the proxy's global exception handler via
|
||||
``_close_dangling_otel_server_span``. The instrumentor still owns the span's
|
||||
status and lifecycle, so this only decorates it — never sets status, never
|
||||
ends it — and emits no exception event, matching v1's SERVER-span behavior
|
||||
and avoiding a duplicate of the event ``async_post_call_failure_hook`` or
|
||||
the ``auth`` phase span already records."""
|
||||
``_close_dangling_otel_server_span``, which swallows the exception into a
|
||||
``JSONResponse`` so the instrumentor never sees it and leaves the span
|
||||
``UNSET``; the status is set here instead (v1 did the same from the handler)
|
||||
so a failed request reads as failed and not merely as a span carrying an
|
||||
error message. The instrumentor still owns the span's lifecycle, so this
|
||||
never ends it. The exception event is recorded only when nothing stamped
|
||||
this span already — ``async_post_call_failure_hook`` and the ``auth`` phase
|
||||
span record their own, and a second event would duplicate it — while the
|
||||
attributes are always restamped so ``error.code`` stays pinned to the real
|
||||
response status."""
|
||||
if span is None or not is_recordable_span(span):
|
||||
return
|
||||
already_stamped: Final = Error.TYPE in (getattr(span, "attributes", None) or ())
|
||||
stamp_error(
|
||||
span,
|
||||
_span_error_from_exception(exception, status_code=status_code),
|
||||
record_event=False,
|
||||
set_status=False,
|
||||
record_event=not already_stamped,
|
||||
)
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ from litellm.integrations.otel.model.semconv import (
|
|||
MCP,
|
||||
Error,
|
||||
GenAI,
|
||||
JsonRpc,
|
||||
LiteLLM,
|
||||
RpcSystem,
|
||||
Server,
|
||||
)
|
||||
from litellm.integrations.otel.model.spans import db_system
|
||||
|
|
@ -94,11 +96,14 @@ class GenAIMapper:
|
|||
|
||||
_MCP_ATTRS: dict[str, Callable[[MCPToolCallSpanData], AttrValue | None]] = {
|
||||
GenAI.OPERATION_NAME: lambda d: d.operation.value,
|
||||
JsonRpc.SYSTEM: lambda d: RpcSystem.JSONRPC.value if d.server_address and d.server_port else None,
|
||||
MCP.METHOD_NAME: lambda d: d.method,
|
||||
MCP.SESSION_ID: lambda d: d.session_id,
|
||||
GenAI.TOOL_NAME: lambda d: d.tool_name or None,
|
||||
GenAI.TOOL_CALL_ARGUMENTS: lambda d: d.arguments_json,
|
||||
GenAI.TOOL_CALL_RESULT: lambda d: d.result_json,
|
||||
Server.ADDRESS: lambda d: d.server_address,
|
||||
Server.PORT: lambda d: d.server_port,
|
||||
LiteLLM.MCP_SERVER_NAME: lambda d: d.server_name,
|
||||
LiteLLM.CALL_ID: lambda d: d.identity.call_id or None,
|
||||
f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost,
|
||||
|
|
|
|||
|
|
@ -364,6 +364,34 @@ class LLMCallSpanData:
|
|||
# --- the MCP tool-call model ------------------------------------------------- #
|
||||
|
||||
|
||||
def _upstream_address_port(resource: str | None) -> tuple[str | None, int | None]:
|
||||
"""Split a redacted MCP server origin into ``server.address`` / ``server.port``.
|
||||
|
||||
``mcp_server_resource`` is a scheme + host + port origin with userinfo, path,
|
||||
query and fragment already stripped. The port falls back to the scheme default
|
||||
when the origin omits it, because a consumer that keys a downstream dependency
|
||||
off the address renders a missing port as ``0``.
|
||||
|
||||
The origin is rebuilt without its IPv6 brackets upstream, so reading the port can
|
||||
raise on an address the host check still admits: a zone-scoped ``fe80::1%25eth0``
|
||||
leaves a truthy hostname of ``fe80`` behind. Both halves are read inside the guard
|
||||
so an unparseable origin yields no address rather than propagating out of span
|
||||
construction, matching how the redactor guards the same split.
|
||||
"""
|
||||
if not resource:
|
||||
return None, None
|
||||
try:
|
||||
parsed: Final = urlsplit(resource)
|
||||
hostname: Final = parsed.hostname
|
||||
port: Final = parsed.port
|
||||
except ValueError:
|
||||
return None, None
|
||||
if not hostname:
|
||||
return None, None
|
||||
default_port: Final = 443 if parsed.scheme == "https" else 80 if parsed.scheme == "http" else None
|
||||
return hostname, port or default_port
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPToolCallSpanData:
|
||||
"""One MCP ``tools/call`` execution, parsed from a closed request's payload.
|
||||
|
|
@ -378,6 +406,8 @@ class MCPToolCallSpanData:
|
|||
method: str
|
||||
tool_name: str
|
||||
server_name: str | None
|
||||
server_address: str | None
|
||||
server_port: int | None
|
||||
session_id: str | None
|
||||
arguments_json: str | None
|
||||
result_json: str | None
|
||||
|
|
@ -390,11 +420,14 @@ class MCPToolCallSpanData:
|
|||
cls, payload: StandardLoggingPayload, capture_content: bool = False
|
||||
) -> MCPToolCallSpanData:
|
||||
meta: Final = _mcp_tool_call_metadata(cast(Mapping[str, object], payload))
|
||||
address, port = _upstream_address_port(as_str(meta.get("mcp_server_resource")) or None)
|
||||
return cls(
|
||||
operation=resolve_operation(as_str(payload.get("call_type"))),
|
||||
method=MCPMethod.TOOLS_CALL.value,
|
||||
tool_name=as_str(meta.get("name")) or "",
|
||||
server_name=as_str(meta.get("mcp_server_name")),
|
||||
server_address=address,
|
||||
server_port=port,
|
||||
session_id=as_str(meta.get("mcp_session_id")),
|
||||
arguments_json=(
|
||||
_json_or_none(meta.get("arguments")) if capture_content and meta.get("arguments") is not None else None
|
||||
|
|
|
|||
|
|
@ -130,11 +130,25 @@ class JsonRpc:
|
|||
"""JSON-RPC keys carried on MCP spans. The error/status code lives in the
|
||||
``rpc.*`` namespace per semconv, not ``jsonrpc.*``."""
|
||||
|
||||
SYSTEM: Final = "rpc.system"
|
||||
REQUEST_ID: Final = "jsonrpc.request.id"
|
||||
PROTOCOL_VERSION: Final = "jsonrpc.protocol.version"
|
||||
RESPONSE_STATUS_CODE: Final = "rpc.response.status_code"
|
||||
|
||||
|
||||
class RpcSystem(str, Enum):
|
||||
"""Well-known values for ``rpc.system``. MCP frames every message as JSON-RPC 2.0.
|
||||
|
||||
Naming the system also classifies the span: a CLIENT span carrying none of the
|
||||
``rpc.*``/``http.*``/``db.*``/``messaging.*`` families records no span type or
|
||||
subtype in backends that derive those from the attribute family. It is emitted
|
||||
only alongside ``server.address``/``server.port``, since a backend that reads it
|
||||
as a downstream dependency names that dependency from the server address.
|
||||
"""
|
||||
|
||||
JSONRPC = "jsonrpc"
|
||||
|
||||
|
||||
class NetworkTransport(str, Enum):
|
||||
"""Well-known values for ``network.transport``."""
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ server-side using litellm router's search tools.
|
|||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -37,10 +37,17 @@ from litellm.types.integrations.custom_logger import (
|
|||
AgenticLoopRequestPatch,
|
||||
)
|
||||
from litellm.types.integrations.websearch_interception import (
|
||||
AnthropicSearchQuery,
|
||||
AnthropicServerToolUseBlock,
|
||||
WebSearchInterceptionConfig,
|
||||
)
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import CallTypes, LlmProviders
|
||||
from litellm.types.utils import (
|
||||
AgenticLoopParams,
|
||||
CallTypes,
|
||||
LlmProviders,
|
||||
StandardLoggingUserAPIKeyMetadata,
|
||||
)
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -263,7 +270,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
return None
|
||||
|
||||
# Check if request has tools with native web_search
|
||||
tools: Final = kwargs.get("tools")
|
||||
tools: Final[Sequence[dict[str, object]] | None] = kwargs.get("tools")
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
|
|
@ -312,7 +319,9 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
|
||||
return kwargs
|
||||
|
||||
def _convert_responses_tools(self, kwargs: Mapping[str, object], tools: list[dict[str, object]]) -> dict | None:
|
||||
def _convert_responses_tools(
|
||||
self, kwargs: Mapping[str, object], tools: Sequence[dict[str, object]]
|
||||
) -> dict[str, object] | None:
|
||||
"""Convert Responses API web search tools to the LiteLLM standard function tool."""
|
||||
if not any(is_web_search_tool_responses(tool) for tool in tools):
|
||||
return None
|
||||
|
|
@ -377,7 +386,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(tool: dict[str, Any]) -> str | None:
|
||||
def _tool_name(tool: Mapping[str, object]) -> object:
|
||||
"""Effective tool name, handling OpenAI ``function`` wrapper shape."""
|
||||
fn: Final = tool.get("function")
|
||||
if tool.get("type") == "function" and isinstance(fn, dict):
|
||||
|
|
@ -833,22 +842,48 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
def _build_native_result_blocks(
|
||||
tool_calls: list[dict],
|
||||
structured_results: list[SearchResponse | None],
|
||||
) -> list[dict[str, object]]:
|
||||
"""Build one ``web_search_tool_result`` block per tool_call."""
|
||||
blocks: Final[list[dict[str, object]]] = []
|
||||
for i, tool_call in enumerate(tool_calls):
|
||||
tool_use_id = tool_call.get("id") or ""
|
||||
structured = structured_results[i] if i < len(structured_results) else None
|
||||
blocks.append(
|
||||
WebSearchTransformation.build_web_search_tool_result_block(
|
||||
tool_use_id=tool_use_id,
|
||||
search_response=structured,
|
||||
)
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""
|
||||
Build a ``server_tool_use`` + ``web_search_tool_result`` pair per tool_call.
|
||||
|
||||
The pair is what Anthropic's spec requires: a bare result block, or one
|
||||
keyed by the model's ``toolu_...`` id instead of a ``srvtoolu_...`` one,
|
||||
is rejected on replay ("String should match pattern '^srvtoolu_'") and
|
||||
leaves native clients without a search to attach the sources to.
|
||||
"""
|
||||
return tuple(
|
||||
block
|
||||
for i, tool_call in enumerate(tool_calls)
|
||||
for block in WebSearchInterceptionLogger._native_result_pair(
|
||||
query=WebSearchInterceptionLogger._tool_call_query(tool_call),
|
||||
search_response=structured_results[i] if i < len(structured_results) else None,
|
||||
)
|
||||
return blocks
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_native_blocks(response: Any, native_blocks: list[dict[str, object]]) -> Any:
|
||||
def _tool_call_query(tool_call: Mapping[str, object]) -> str:
|
||||
tool_input: Final = tool_call.get("input")
|
||||
if not isinstance(tool_input, Mapping):
|
||||
return ""
|
||||
query: Final = tool_input.get("query")
|
||||
return query if isinstance(query, str) else ""
|
||||
|
||||
@staticmethod
|
||||
def _native_result_pair(
|
||||
query: str,
|
||||
search_response: SearchResponse | None,
|
||||
) -> tuple[Mapping[str, object], Mapping[str, object]]:
|
||||
tool_use_id: Final = f"srvtoolu_{uuid.uuid4().hex}"
|
||||
return (
|
||||
AnthropicServerToolUseBlock(id=tool_use_id, input=AnthropicSearchQuery(query=query)).model_dump(),
|
||||
WebSearchTransformation.build_web_search_tool_result_block(
|
||||
tool_use_id=tool_use_id,
|
||||
search_response=search_response,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_native_blocks(response: Any, native_blocks: Sequence[Mapping[str, object]]) -> Any:
|
||||
"""Prepend native blocks to response content, dict or object form."""
|
||||
if not native_blocks:
|
||||
return response
|
||||
|
|
@ -1243,7 +1278,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
kwargs_for_followup: Final = self._prepare_followup_kwargs(kwargs)
|
||||
|
||||
if logging_obj is not None:
|
||||
agentic_params: Final = logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
agentic_params: Final[AgenticLoopParams] = logging_obj.model_call_details.get("agentic_loop_params", {})
|
||||
full_model_name = agentic_params.get("model", model)
|
||||
verbose_logger.debug(
|
||||
"WebSearchInterception: Built anthropic request patch [call_id=%s model=%s messages=%d searches=%d]",
|
||||
|
|
@ -1288,6 +1323,7 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
search_tool: Final = self._select_search_tool_from_router(llm_router=llm_router)
|
||||
search_provider: str | None = None
|
||||
search_litellm_params: dict[str, Any] = {}
|
||||
search_tool_name: Final = self._selected_search_tool_name(search_tool=search_tool)
|
||||
if search_tool is not None:
|
||||
await self._authorize_search_tool(search_tool=search_tool, kwargs=kwargs)
|
||||
search_litellm_params = dict(search_tool.get("litellm_params", {}) or {})
|
||||
|
|
@ -1304,12 +1340,30 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
verbose_logger.debug(
|
||||
"WebSearchInterception: Executing search for '%s' using provider '%s'", query, search_provider
|
||||
)
|
||||
user_api_key_auth: Final = self._get_user_api_key_auth_from_kwargs(kwargs)
|
||||
search_metadata: Final = (
|
||||
None
|
||||
if user_api_key_auth is None
|
||||
else self._build_search_request_metadata(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
search_tool_name=search_tool_name,
|
||||
)
|
||||
)
|
||||
search_kwargs: Final = {
|
||||
key: value
|
||||
for key, value in search_litellm_params.items()
|
||||
if key != "search_provider" and value is not None
|
||||
}
|
||||
result: Final = await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
|
||||
result: Final = (
|
||||
await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs)
|
||||
if search_metadata is None
|
||||
else await litellm.asearch(
|
||||
query=query,
|
||||
search_provider=search_provider,
|
||||
litellm_metadata=search_metadata,
|
||||
**search_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
# Format using transformation function
|
||||
search_result_text: Final = WebSearchTransformation.format_search_response(result)
|
||||
|
|
@ -1366,6 +1420,35 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
team_object=team_object,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_search_request_metadata(
|
||||
user_api_key_auth: "UserAPIKeyAuth",
|
||||
search_tool_name: str | None,
|
||||
) -> Mapping[str, object]:
|
||||
"""
|
||||
Spend-tracking metadata for the intercepted search, so its provider cost is logged
|
||||
and billed against the key/user/team that made the originating LLM request instead
|
||||
of being dropped by the proxy's spend hook for lack of an owner.
|
||||
"""
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
|
||||
user_api_key_metadata: Final[StandardLoggingUserAPIKeyMetadata] = (
|
||||
LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict=user_api_key_auth)
|
||||
)
|
||||
return { # mutable-ok: litellm's metadata channel is a plain dict its logging path reads and enriches
|
||||
**user_api_key_metadata,
|
||||
"model_group": search_tool_name,
|
||||
"user_api_key": user_api_key_auth.api_key,
|
||||
"user_api_key_auth": user_api_key_auth,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _selected_search_tool_name(search_tool: Mapping[str, object] | None) -> str | None:
|
||||
if search_tool is None:
|
||||
return None
|
||||
search_tool_name: Final = search_tool.get("search_tool_name")
|
||||
return search_tool_name if isinstance(search_tool_name, str) and search_tool_name else None
|
||||
|
||||
@staticmethod
|
||||
def _get_user_api_key_auth_from_kwargs(kwargs: Mapping[str, object] | None) -> "UserAPIKeyAuth | None":
|
||||
if not kwargs:
|
||||
|
|
|
|||
|
|
@ -412,6 +412,15 @@ class WebSearchTransformation:
|
|||
block that should accompany the model's text reply when the original
|
||||
request used a native ``web_search_*`` tool.
|
||||
|
||||
The spec'd shape carries page text only in ``encrypted_content``, an
|
||||
opaque server-issued blob that we cannot mint. Emitting the four spec
|
||||
fields alone would drop the snippet entirely, leaving the client (and
|
||||
the model, on any replayed follow-up turn) with URLs and titles but no
|
||||
evidence to answer from, forcing a fetch per result. So the snippet is
|
||||
carried in an additive ``snippet`` key alongside the spec fields.
|
||||
``encrypted_content`` stays empty rather than holding plaintext, which
|
||||
would assert encryption semantics that do not hold.
|
||||
|
||||
Spec reference:
|
||||
https://docs.anthropic.com/en/api/web-search-tool
|
||||
|
||||
|
|
@ -438,6 +447,7 @@ class WebSearchTransformation:
|
|||
"title": title,
|
||||
"page_age": page_age,
|
||||
"encrypted_content": "",
|
||||
"snippet": getattr(r, "snippet", "") or "",
|
||||
}
|
||||
)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -115,8 +115,11 @@ def get_litellm_params(
|
|||
litellm_request_debug: bool | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
_litellm_metadata_dict: Final = litellm_metadata if isinstance(litellm_metadata, dict) else None
|
||||
resolved_metadata: Final = _litellm_metadata_dict.copy() if not metadata and _litellm_metadata_dict else metadata
|
||||
|
||||
# Derive litellm_session_id / litellm_trace_id from metadata when not provided (call chaining)
|
||||
_meta: Final = metadata or {}
|
||||
_meta: Final = resolved_metadata or {}
|
||||
if litellm_session_id is None:
|
||||
litellm_session_id = _meta.get("session_id") or _meta.get("trace_id")
|
||||
if litellm_trace_id is None:
|
||||
|
|
@ -139,7 +142,7 @@ def get_litellm_params(
|
|||
"model_alias_map": model_alias_map,
|
||||
"completion_call_id": completion_call_id,
|
||||
"aembedding": aembedding,
|
||||
"metadata": metadata,
|
||||
"metadata": resolved_metadata,
|
||||
"model_info": model_info,
|
||||
"proxy_server_request": proxy_server_request,
|
||||
"preset_cache_key": preset_cache_key,
|
||||
|
|
|
|||
|
|
@ -585,8 +585,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"""
|
||||
base_litellm_params: Final[dict[str, Any]] = {}
|
||||
|
||||
if "metadata" in kwargs:
|
||||
base_litellm_params["metadata"] = kwargs["metadata"]
|
||||
if isinstance(kwargs.get("metadata"), dict):
|
||||
base_litellm_params["metadata"] = kwargs["metadata"].copy()
|
||||
if "litellm_metadata" in kwargs and isinstance(kwargs["litellm_metadata"], dict):
|
||||
base_litellm_params["litellm_metadata"] = kwargs["litellm_metadata"]
|
||||
if "metadata" not in base_litellm_params:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import io
|
|||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
|
|
@ -1775,3 +1775,24 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
|
|||
idx = end_idx
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def text_completion_prompt_to_messages(prompt: object) -> tuple[AllMessageValues, ...]:
|
||||
"""
|
||||
Wrap an OpenAI ``/v1/completions`` ``prompt`` into Chat Completion messages.
|
||||
|
||||
Mirrors what ``litellm.text_completion`` does on the real-time path: a
|
||||
string becomes a single user message, and a list of strings becomes one
|
||||
user message per element. Pre-tokenized prompts (``list[int]`` /
|
||||
``list[list[int]]``) are only meaningful for the OpenAI-family text
|
||||
endpoints, so they are rejected here rather than silently forwarded, as is
|
||||
an empty prompt, which every chat-shaped provider rejects downstream.
|
||||
"""
|
||||
prompt_type_name: Final = type(prompt).__name__
|
||||
if isinstance(prompt, str) and prompt:
|
||||
return (ChatCompletionUserMessage(role="user", content=prompt),)
|
||||
entries: Final = cast("Sequence[object]", prompt) if isinstance(prompt, Sequence) else ()
|
||||
string_entries: Final = tuple(entry for entry in entries if isinstance(entry, str) and entry)
|
||||
if string_entries and len(string_entries) == len(entries):
|
||||
return tuple(ChatCompletionUserMessage(role="user", content=entry) for entry in string_entries)
|
||||
raise ValueError(f"`prompt` must be a non-empty string or a non-empty list of strings. Got: {prompt_type_name}.")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -20,11 +21,24 @@ from .litellm_logging import Logging as LiteLLMLogging
|
|||
if TYPE_CHECKING:
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
CLIENT_CONNECTION_CLASS = ClientConnection
|
||||
else:
|
||||
CLIENT_CONNECTION_CLASS = Any
|
||||
|
||||
|
||||
class _ClientWebSocketExceptions(Protocol):
|
||||
ConnectionClosed: type[Exception]
|
||||
|
||||
|
||||
class _ClientWebSocket(Protocol):
|
||||
exceptions: _ClientWebSocketExceptions
|
||||
|
||||
async def send_text(self, data: str) -> None: ...
|
||||
async def receive_text(self) -> str: ...
|
||||
|
||||
|
||||
class RealtimeEventNormalizer(Protocol):
|
||||
def should_drop(self, event: object) -> bool: ...
|
||||
def normalize(self, event: dict) -> dict: ...
|
||||
|
|
@ -48,13 +62,13 @@ class RealTimeStreaming:
|
|||
logging_obj: LiteLLMLogging,
|
||||
provider_config: BaseRealtimeConfig | None = None,
|
||||
model: str = "",
|
||||
user_api_key_dict: Any | None = None,
|
||||
user_api_key_dict: object | None = None,
|
||||
request_data: dict | None = None,
|
||||
backend_uses_beta_protocol: bool | None = None,
|
||||
force_transcription_model: str | None = None,
|
||||
event_normalizer: RealtimeEventNormalizer | None = None,
|
||||
):
|
||||
self.websocket = websocket
|
||||
self.websocket: _ClientWebSocket = websocket
|
||||
self.backend_ws = backend_ws
|
||||
self.logging_obj = logging_obj
|
||||
self.messages: list[OpenAIRealtimeEvents] = []
|
||||
|
|
@ -127,7 +141,7 @@ class RealTimeStreaming:
|
|||
]
|
||||
)
|
||||
_CLIENT_AUDIO_BUFFER_COMMIT_TYPES = frozenset(["input_audio_buffer.commit", "input_audio_buffer.end"])
|
||||
_AUDIO_FORMAT_MAP: dict[str, dict[str, Any]] = {
|
||||
_AUDIO_FORMAT_MAP: dict[str, dict[str, str | int]] = {
|
||||
"pcm16": {"type": "audio/pcm", "rate": 24000},
|
||||
"g711_ulaw": {"type": "audio/G711-ulaw", "rate": 8000},
|
||||
"g711_alaw": {"type": "audio/G711-alaw", "rate": 8000},
|
||||
|
|
@ -281,6 +295,7 @@ class RealTimeStreaming:
|
|||
if event_obj.get("type") != "response.done":
|
||||
return
|
||||
response: Final = cast(dict[str, Any], event_obj.get("response", {}))
|
||||
item: Mapping[str, object]
|
||||
for item in response.get("output", []):
|
||||
if item.get("type") == "function_call":
|
||||
self.tool_calls.append(
|
||||
|
|
@ -384,7 +399,7 @@ class RealTimeStreaming:
|
|||
return message
|
||||
|
||||
try:
|
||||
message_obj: Final = json.loads(message)
|
||||
message_obj: Final[Mapping[str, object]] = json.loads(message)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return message
|
||||
|
||||
|
|
@ -487,7 +502,7 @@ class RealTimeStreaming:
|
|||
if self._backend_setup_complete and not self._flushing_pending_messages_until_setup:
|
||||
return False
|
||||
try:
|
||||
msg_obj: Final = json.loads(message)
|
||||
msg_obj: Final[Mapping[str, object]] = json.loads(message)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
return msg_obj.get("type") in RealTimeStreaming._CLIENT_AUDIO_BUFFER_TYPES
|
||||
|
|
@ -555,7 +570,7 @@ class RealTimeStreaming:
|
|||
def _event_to_client_json(self, event: dict) -> str:
|
||||
return json.dumps(self._normalize_event_for_ga_client(event))
|
||||
|
||||
async def _send_event_to_client(self, event: Any, event_str: str) -> bool:
|
||||
async def _send_event_to_client(self, event: object, event_str: str) -> bool:
|
||||
if self._should_drop_event_from_client(event):
|
||||
return False
|
||||
if isinstance(event, dict):
|
||||
|
|
@ -595,12 +610,12 @@ class RealTimeStreaming:
|
|||
|
||||
def _make_disable_auto_response_message(self) -> str:
|
||||
"""Return a session.update that disables VAD auto-response."""
|
||||
turn_detection: Final[dict[str, Any]] = {
|
||||
turn_detection: Final[dict[str, str | bool]] = {
|
||||
"type": "server_vad",
|
||||
"create_response": False,
|
||||
}
|
||||
if self._backend_uses_beta_protocol:
|
||||
session: dict[str, Any] = {"turn_detection": turn_detection}
|
||||
session: dict[str, object] = {"turn_detection": turn_detection}
|
||||
else:
|
||||
session = {
|
||||
"type": "realtime",
|
||||
|
|
@ -654,7 +669,7 @@ class RealTimeStreaming:
|
|||
|
||||
def _has_realtime_guardrails_for_event_hooks(
|
||||
self,
|
||||
event_hooks: list[Any],
|
||||
event_hooks: Sequence["GuardrailEventHooks"],
|
||||
) -> bool:
|
||||
"""Return True if any callback would run for one of ``event_hooks``."""
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
|
|
@ -699,7 +714,7 @@ class RealTimeStreaming:
|
|||
transcript: str,
|
||||
item_id: str | None = None,
|
||||
pre_block_backend_message: str | None = None,
|
||||
event_hooks: list[Any] | None = None,
|
||||
event_hooks: Sequence["GuardrailEventHooks"] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Run registered guardrails on realtime text (transcript, user message, tool output).
|
||||
|
|
@ -753,7 +768,7 @@ class RealTimeStreaming:
|
|||
raise
|
||||
# Extract the human-readable error from the detail dict (HTTPException)
|
||||
# or fall back to str(e) for plain ValueError.
|
||||
detail = getattr(e, "detail", None)
|
||||
detail: object | None = getattr(e, "detail", None)
|
||||
if isinstance(detail, dict):
|
||||
safe_msg = detail.get("error") or str(e)
|
||||
elif detail is not None:
|
||||
|
|
@ -826,7 +841,7 @@ class RealTimeStreaming:
|
|||
return True
|
||||
return False
|
||||
|
||||
async def _handle_provider_config_message(self, raw_response) -> None:
|
||||
async def _handle_provider_config_message(self, raw_response: str) -> None:
|
||||
"""Process a backend message when a provider_config is set (transformed path)."""
|
||||
returned_object: Final = self.provider_config.transform_realtime_response(
|
||||
raw_response,
|
||||
|
|
@ -910,7 +925,7 @@ class RealTimeStreaming:
|
|||
await self._send_event_to_client(event, event_str)
|
||||
|
||||
@staticmethod
|
||||
def _parse_backend_event(raw_response: str) -> dict | None:
|
||||
def _parse_backend_event(raw_response: str) -> dict[str, object] | None:
|
||||
"""Parse a backend frame once. Returns None for non-JSON or non-object frames."""
|
||||
try:
|
||||
event: Final = json.loads(raw_response)
|
||||
|
|
@ -1020,7 +1035,7 @@ class RealTimeStreaming:
|
|||
objects and any test doubles that expose a .scope dict.
|
||||
"""
|
||||
try:
|
||||
headers: Final = websocket.scope.get("headers", [])
|
||||
headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = websocket.scope.get("headers", [])
|
||||
for name, value in headers:
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode("latin-1")
|
||||
|
|
@ -1071,9 +1086,9 @@ class RealTimeStreaming:
|
|||
session["output_modalities"] = ["text"]
|
||||
|
||||
# 3-7. Lift flat audio fields into the nested audio object
|
||||
audio: Final[dict[str, Any]] = {}
|
||||
inp: Final[dict[str, Any]] = {}
|
||||
out: Final[dict[str, Any]] = {}
|
||||
audio: Final[dict[str, object]] = {}
|
||||
inp: Final[dict[str, object]] = {}
|
||||
out: Final[dict[str, object]] = {}
|
||||
|
||||
# voice → audio.output.voice
|
||||
if "voice" in session:
|
||||
|
|
@ -1190,7 +1205,7 @@ class RealTimeStreaming:
|
|||
# model; check them with the same guardrail used for
|
||||
# user text so an attacker cannot smuggle blocked
|
||||
# content into a function_call_output.
|
||||
output = item.get("output", "")
|
||||
output: object = item.get("output", "")
|
||||
output_text = output if isinstance(output, str) else json.dumps(output)
|
||||
if output_text:
|
||||
# Build the sanitized function_call_output up
|
||||
|
|
@ -1241,7 +1256,7 @@ class RealTimeStreaming:
|
|||
# interaction turn.
|
||||
continue
|
||||
elif item.get("role") == "user":
|
||||
content_list = item.get("content", [])
|
||||
content_list: Sequence[object] = item.get("content", [])
|
||||
texts = [
|
||||
c.get("text", "")
|
||||
for c in content_list
|
||||
|
|
@ -1280,7 +1295,7 @@ class RealTimeStreaming:
|
|||
and not self._guardrail_turn_detection_update_sent
|
||||
and self._has_audio_transcription_guardrails()
|
||||
):
|
||||
session = msg_obj.setdefault("session", {})
|
||||
session: object = msg_obj.setdefault("session", {})
|
||||
if isinstance(session, dict):
|
||||
existing_td = session.get("turn_detection")
|
||||
if not isinstance(existing_td, dict):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import time
|
|||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from itertools import groupby
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict, Union, cast
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -30,6 +30,7 @@ from litellm.types.utils import (
|
|||
from litellm.utils import print_verbose, token_counter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
|
||||
UsagePerChunk,
|
||||
)
|
||||
|
|
@ -39,6 +40,60 @@ if TYPE_CHECKING:
|
|||
)
|
||||
|
||||
|
||||
class _ThinkingBlockFragment(TypedDict, total=False):
|
||||
type: str | None
|
||||
data: str | None
|
||||
thinking: str | None
|
||||
signature: str | None
|
||||
|
||||
|
||||
class _ThinkingDelta(TypedDict, total=False):
|
||||
thinking_blocks: Sequence[_ThinkingBlockFragment]
|
||||
|
||||
|
||||
class _ThinkingChoice(TypedDict, total=False):
|
||||
delta: _ThinkingDelta
|
||||
|
||||
|
||||
class _ThinkingChunk(TypedDict):
|
||||
choices: Sequence[_ThinkingChoice]
|
||||
|
||||
|
||||
class _ContentChoice(TypedDict, total=False):
|
||||
delta: Mapping[str, str | None]
|
||||
|
||||
|
||||
class _ContentChunk(TypedDict):
|
||||
choices: Sequence[_ContentChoice]
|
||||
|
||||
|
||||
class _AudioDelta(TypedDict, total=False):
|
||||
audio: ChatCompletionAudioDelta | None
|
||||
|
||||
|
||||
class _AudioChoice(TypedDict, total=False):
|
||||
delta: _AudioDelta
|
||||
|
||||
|
||||
class _AudioChunk(TypedDict):
|
||||
choices: Sequence[_AudioChoice]
|
||||
|
||||
|
||||
class _UsageBearingChunk(TypedDict, total=False):
|
||||
usage: Usage | None
|
||||
_hidden_params: Mapping[str, str]
|
||||
|
||||
|
||||
class _UsageSummary(TypedDict):
|
||||
prompt_tokens: int | None
|
||||
completion_tokens: int | None
|
||||
cache_creation_input_tokens: int | None
|
||||
cache_read_input_tokens: int | None
|
||||
completion_tokens_details: CompletionTokensDetails | None
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None
|
||||
cost: float | None
|
||||
|
||||
|
||||
def capture_cache_creation_token_details(
|
||||
prompt_tokens_details: PromptTokensDetailsWrapper | None,
|
||||
current: CacheCreationTokenDetails | None,
|
||||
|
|
@ -78,7 +133,7 @@ class ChunkProcessor:
|
|||
return []
|
||||
|
||||
first_chunk: Final = chunks[0]
|
||||
first_hidden_params: dict[str, Any] = {}
|
||||
first_hidden_params: dict[str, object] = {}
|
||||
if isinstance(first_chunk, dict):
|
||||
candidate = first_chunk.get("_hidden_params", {})
|
||||
if isinstance(candidate, dict):
|
||||
|
|
@ -115,8 +170,8 @@ class ChunkProcessor:
|
|||
@staticmethod
|
||||
def apply_provider_assembled_streaming_metadata(
|
||||
response: ModelResponse,
|
||||
chunks: list[Any],
|
||||
logging_obj: Any | None = None,
|
||||
chunks: list[object],
|
||||
logging_obj: "Logging | None" = None,
|
||||
) -> None:
|
||||
if not chunks:
|
||||
return
|
||||
|
|
@ -456,7 +511,7 @@ class ChunkProcessor:
|
|||
)
|
||||
|
||||
def get_combined_content(
|
||||
self, chunks: list[dict[str, Any]], delta_key: str = "content"
|
||||
self, chunks: Sequence["_ContentChunk"], delta_key: str = "content"
|
||||
) -> ChatCompletionAssistantContentValue:
|
||||
content_list: Final[list[str]] = []
|
||||
for chunk in chunks:
|
||||
|
|
@ -475,7 +530,7 @@ class ChunkProcessor:
|
|||
return combined_content
|
||||
|
||||
def get_combined_thinking_content(
|
||||
self, chunks: list[dict[str, Any]]
|
||||
self, chunks: Sequence["_ThinkingChunk"]
|
||||
) -> list[Union["ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"]] | None:
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
|
|
@ -532,10 +587,10 @@ class ChunkProcessor:
|
|||
return thinking_blocks
|
||||
return None
|
||||
|
||||
def get_combined_reasoning_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAssistantContentValue:
|
||||
def get_combined_reasoning_content(self, chunks: Sequence["_ContentChunk"]) -> ChatCompletionAssistantContentValue:
|
||||
return self.get_combined_content(chunks, delta_key="reasoning_content")
|
||||
|
||||
def get_combined_audio_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAudioResponse:
|
||||
def get_combined_audio_content(self, chunks: Sequence["_AudioChunk"]) -> ChatCompletionAudioResponse:
|
||||
base64_data_list: Final[list[str]] = []
|
||||
transcript_list: Final[list[str]] = []
|
||||
expires_at: int | None = None
|
||||
|
|
@ -544,7 +599,7 @@ class ChunkProcessor:
|
|||
for chunk in chunks:
|
||||
choices = chunk["choices"]
|
||||
for choice in choices:
|
||||
delta = choice.get("delta") or {}
|
||||
delta: _AudioDelta = choice.get("delta") or {}
|
||||
audio: ChatCompletionAudioDelta | None = delta.get("audio")
|
||||
if audio is not None:
|
||||
for k, v in audio.items():
|
||||
|
|
@ -565,7 +620,7 @@ class ChunkProcessor:
|
|||
id=id,
|
||||
)
|
||||
|
||||
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
|
||||
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> "_UsageSummary":
|
||||
prompt_tokens = 0
|
||||
completion_tokens = 0
|
||||
## anthropic prompt caching information ##
|
||||
|
|
@ -623,8 +678,8 @@ class ChunkProcessor:
|
|||
return reasoning_tokens
|
||||
|
||||
@staticmethod
|
||||
def _extract_usage_chunk(chunk: dict[str, Any] | ModelResponse | ModelResponseStream) -> Usage | None:
|
||||
usage_chunk: Usage | dict[str, Any] | None = None
|
||||
def _extract_usage_chunk(chunk: "_UsageBearingChunk | ModelResponse | ModelResponseStream") -> Usage | None:
|
||||
usage_chunk: Usage | None = None
|
||||
if hasattr(chunk, "usage") and chunk.usage is not None:
|
||||
usage_chunk = chunk.usage
|
||||
elif "usage" in chunk:
|
||||
|
|
@ -640,7 +695,7 @@ class ChunkProcessor:
|
|||
|
||||
def _calculate_usage_per_chunk(
|
||||
self,
|
||||
chunks: list[dict[str, Any] | ModelResponse],
|
||||
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
|
||||
) -> "UsagePerChunk":
|
||||
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
|
||||
UsagePerChunk,
|
||||
|
|
@ -721,13 +776,7 @@ class ChunkProcessor:
|
|||
"web_search_requests",
|
||||
)
|
||||
|
||||
prompt_tokens_details = (
|
||||
cast(
|
||||
PromptTokensDetailsWrapper | None,
|
||||
usage_chunk_dict["prompt_tokens_details"],
|
||||
)
|
||||
or prompt_tokens_details
|
||||
)
|
||||
prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"] or prompt_tokens_details
|
||||
|
||||
cache_creation_token_details = capture_cache_creation_token_details(
|
||||
prompt_tokens_details, cache_creation_token_details
|
||||
|
|
@ -758,7 +807,7 @@ class ChunkProcessor:
|
|||
|
||||
@staticmethod
|
||||
def _reset_anthropic_cursor_completion_tokens(
|
||||
chunks: list[dict[str, Any] | ModelResponse],
|
||||
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
|
||||
completion_tokens: int,
|
||||
completion_usage_updates: int,
|
||||
) -> int:
|
||||
|
|
@ -797,7 +846,7 @@ class ChunkProcessor:
|
|||
|
||||
def calculate_usage(
|
||||
self,
|
||||
chunks: list[dict[str, Any] | ModelResponse],
|
||||
chunks: Sequence["_UsageBearingChunk | ModelResponse"],
|
||||
model: str,
|
||||
completion_output: str,
|
||||
messages: list | None = None,
|
||||
|
|
@ -851,8 +900,8 @@ class ChunkProcessor:
|
|||
setattr(returned_usage, "cache_read_input_tokens", cache_read_input_tokens) # for anthropic
|
||||
if completion_tokens_details is not None:
|
||||
if isinstance(completion_tokens_details, CompletionTokensDetails):
|
||||
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper(
|
||||
**completion_tokens_details.model_dump()
|
||||
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper.model_validate(
|
||||
completion_tokens_details.model_dump()
|
||||
)
|
||||
else:
|
||||
returned_usage.completion_tokens_details = completion_tokens_details
|
||||
|
|
|
|||
|
|
@ -592,7 +592,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
|
||||
# Anthropic requires additionalProperties=false for object schemas
|
||||
# See: https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs
|
||||
if result.get("type") == "object" and "additionalProperties" not in result:
|
||||
if result.get("type") == "object":
|
||||
result["additionalProperties"] = False
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ This file contains common utils for anthropic calls.
|
|||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
|
|
@ -1057,6 +1060,152 @@ def sanitize_tool_use_ids_in_anthropic_messages(messages: list[Any]) -> list[Any
|
|||
return out
|
||||
|
||||
|
||||
class _ReplayedSearchQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
query: str = ""
|
||||
|
||||
|
||||
class _ReplayedWebSearchResult(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: Literal["web_search_result"]
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
snippet: str = ""
|
||||
encrypted_content: str = ""
|
||||
|
||||
|
||||
class _ReplayedWebSearchToolResult(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: Literal["web_search_tool_result"]
|
||||
tool_use_id: str
|
||||
content: tuple[_ReplayedWebSearchResult, ...]
|
||||
|
||||
|
||||
class _ReplayedServerToolUse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: Literal["server_tool_use"]
|
||||
id: str
|
||||
input: _ReplayedSearchQuery = _ReplayedSearchQuery()
|
||||
|
||||
|
||||
class _TextBlock(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
|
||||
|
||||
_WEB_SEARCH_TOOL_RESULT_ADAPTER: Final = TypeAdapter(_ReplayedWebSearchToolResult)
|
||||
_SERVER_TOOL_USE_ADAPTER: Final = TypeAdapter(_ReplayedServerToolUse)
|
||||
|
||||
|
||||
def _flattenable_web_search_tool_result(block: object) -> _ReplayedWebSearchToolResult | None:
|
||||
"""
|
||||
The parsed block when it is a ``web_search_tool_result`` carrying no
|
||||
``encrypted_content``, else None for anything Anthropic itself issued.
|
||||
|
||||
An empty ``content`` list is flattenable too. It is what the interceptor emits
|
||||
when a search legitimately returns nothing and when a search raises, and it
|
||||
carries neither evidence to preserve nor an ``encrypted_content`` to respect,
|
||||
so leaving it in place only buys the 400 this whole function exists to avoid.
|
||||
"""
|
||||
try:
|
||||
parsed: Final = _WEB_SEARCH_TOOL_RESULT_ADAPTER.validate_python(block)
|
||||
except ValidationError:
|
||||
return None
|
||||
if any(result.encrypted_content for result in parsed.content):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _replayed_server_tool_use(block: object) -> _ReplayedServerToolUse | None:
|
||||
try:
|
||||
return _SERVER_TOOL_USE_ADAPTER.validate_python(block)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _render_web_search_results(query: str, results: tuple[_ReplayedWebSearchResult, ...]) -> str:
|
||||
header: Final = f"Web search results for '{query}':" if query else "Web search results:"
|
||||
if not results:
|
||||
return f"{header}\n\nNo results were returned."
|
||||
body: Final = "\n\n".join(
|
||||
"\n".join(
|
||||
line
|
||||
for line in (
|
||||
f"Title: {result.title}" if result.title else "",
|
||||
f"URL: {result.url}" if result.url else "",
|
||||
f"Snippet: {result.snippet}" if result.snippet else "",
|
||||
)
|
||||
if line
|
||||
)
|
||||
for result in results
|
||||
)
|
||||
return f"{header}\n\n{body}" if body else header
|
||||
|
||||
|
||||
def _rewrite_replayed_web_search_block(
|
||||
block: object,
|
||||
flattenable: Mapping[str, _ReplayedWebSearchToolResult],
|
||||
queries: Mapping[str, str],
|
||||
) -> object | None:
|
||||
parsed_result: Final = _flattenable_web_search_tool_result(block)
|
||||
if parsed_result is not None:
|
||||
return _TextBlock(
|
||||
text=_render_web_search_results(queries.get(parsed_result.tool_use_id, ""), parsed_result.content)
|
||||
).model_dump()
|
||||
parsed_use: Final = _replayed_server_tool_use(block)
|
||||
if parsed_use is not None and parsed_use.id in flattenable:
|
||||
return None
|
||||
return block
|
||||
|
||||
|
||||
def _flatten_web_search_results_in_message(message: object) -> object:
|
||||
if not isinstance(message, Mapping) or not isinstance(message.get("content"), Sequence):
|
||||
return message
|
||||
content: Final = message["content"]
|
||||
if isinstance(content, str):
|
||||
return message
|
||||
flattenable: Final = MappingProxyType(
|
||||
{
|
||||
parsed.tool_use_id: parsed
|
||||
for parsed in (_flattenable_web_search_tool_result(block) for block in content)
|
||||
if parsed is not None
|
||||
}
|
||||
)
|
||||
if not flattenable:
|
||||
return message
|
||||
queries: Final = MappingProxyType(
|
||||
{
|
||||
parsed.id: parsed.input.query
|
||||
for parsed in (_replayed_server_tool_use(block) for block in content)
|
||||
if parsed is not None
|
||||
}
|
||||
)
|
||||
rewritten: Final = tuple(_rewrite_replayed_web_search_block(block, flattenable, queries) for block in content)
|
||||
return {**message, "content": [b for b in rewritten if b is not None]} # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def flatten_unencrypted_web_search_results_in_anthropic_messages( # mutable-ok: as sibling sanitizers
|
||||
messages: list[Any],
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Return a new message list with replayed ``web_search_tool_result`` blocks that
|
||||
carry no ``encrypted_content`` rewritten into plain ``text`` blocks holding the
|
||||
same title / url / snippet evidence.
|
||||
|
||||
``encrypted_content`` is an opaque blob only Anthropic's own search backend can
|
||||
mint, so blocks synthesized by LiteLLM (websearch interception against a search
|
||||
provider) are rejected with ``Invalid encrypted_content in search_result block``
|
||||
when a native client loops them back as history. Flattening them keeps the
|
||||
evidence in the conversation instead of 400ing the follow-up turn, and leaves
|
||||
genuine Anthropic-issued blocks untouched.
|
||||
"""
|
||||
return [_flatten_web_search_results_in_message(m) for m in messages] # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
|
||||
openai_headers: Final = {}
|
||||
if "anthropic-ratelimit-requests-limit" in headers:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from collections.abc import AsyncIterator, Coroutine, Iterator
|
||||
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Final,
|
||||
TypeAlias,
|
||||
cast,
|
||||
)
|
||||
|
||||
|
|
@ -33,8 +34,12 @@ if TYPE_CHECKING:
|
|||
# Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge.
|
||||
ANTHROPIC_ONLY_REQUEST_KEYS: Final[frozenset[str]] = frozenset({"output_config"})
|
||||
|
||||
_AnthropicMessages: TypeAlias = "list[dict[str, object]]"
|
||||
_AnthropicSystem: TypeAlias = "str | list[dict[str, object]] | None"
|
||||
_ContextManagementSpec: TypeAlias = "dict[str, object] | list[dict[str, object]] | None"
|
||||
|
||||
def _messages_have_compaction_block(messages: list[dict]) -> bool:
|
||||
|
||||
def _messages_have_compaction_block(messages: _AnthropicMessages) -> bool:
|
||||
"""Return True when any message carries a ``compaction`` content block."""
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
|
|
@ -54,8 +59,10 @@ def _proxy_router_fallback() -> "Router | None":
|
|||
return _proxy_router
|
||||
|
||||
|
||||
def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return ``kwargs["litellm_metadata"]`` when it's a dict; ``None`` otherwise.
|
||||
def _extract_proxy_litellm_metadata(
|
||||
kwargs: Mapping[str, object],
|
||||
) -> "tuple[dict[str, object], UserAPIKeyAuth | None] | tuple[None, None]":
|
||||
"""Return ``(kwargs["litellm_metadata"], its user_api_key_auth)`` when it's a dict; ``(None, None)`` otherwise.
|
||||
|
||||
The proxy attaches its auth/spend-attribution fields (``user_api_key``,
|
||||
``user_api_key_team_id``, ``litellm_call_id``, the full ``UserAPIKeyAuth``
|
||||
|
|
@ -68,18 +75,19 @@ def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] |
|
|||
"""
|
||||
litellm_metadata: Final = kwargs.get("litellm_metadata")
|
||||
if not isinstance(litellm_metadata, dict):
|
||||
return None
|
||||
return litellm_metadata
|
||||
return None, None
|
||||
user_api_key_auth: Final[UserAPIKeyAuth | None] = litellm_metadata.get("user_api_key_auth")
|
||||
return litellm_metadata, user_api_key_auth
|
||||
|
||||
|
||||
async def _prepare_context_managed_request(
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
system: Any | None,
|
||||
context_management_spec: Any,
|
||||
litellm_metadata: dict | None,
|
||||
messages: _AnthropicMessages,
|
||||
tools: list[dict[str, object]] | None,
|
||||
system: _AnthropicSystem,
|
||||
context_management_spec: _ContextManagementSpec,
|
||||
litellm_metadata: dict[str, object] | None,
|
||||
additional_drop_params: list[str] | None,
|
||||
llm_router: "Router | None",
|
||||
user_api_key_auth: "UserAPIKeyAuth | None" = None,
|
||||
|
|
@ -102,11 +110,11 @@ async def _prepare_context_managed_request(
|
|||
|
||||
if polyfill_will_run:
|
||||
history_result: PolyfillResult | None = None
|
||||
working_messages: list[dict] = messages
|
||||
working_system: Any | None = system
|
||||
working_messages: _AnthropicMessages = messages
|
||||
working_system: _AnthropicSystem = system
|
||||
else:
|
||||
history_result = apply_client_compaction_block_history(
|
||||
messages=cast(list[dict[str, Any]], messages),
|
||||
messages=messages,
|
||||
system=system,
|
||||
)
|
||||
working_messages = history_result.messages if history_result is not None else messages
|
||||
|
|
@ -136,7 +144,7 @@ async def _prepare_context_managed_request(
|
|||
# to non-Anthropic backends that would reject them.
|
||||
if polyfill_will_run and history_result is None:
|
||||
history_result = apply_client_compaction_block_history(
|
||||
messages=cast(list[dict[str, Any]], messages),
|
||||
messages=messages,
|
||||
system=system,
|
||||
)
|
||||
return history_result
|
||||
|
|
@ -144,7 +152,7 @@ async def _prepare_context_managed_request(
|
|||
|
||||
def _polyfill_will_run(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
context_management_spec: _ContextManagementSpec,
|
||||
additional_drop_params: list[str] | None,
|
||||
) -> bool:
|
||||
"""Return True when ``compact_20260112`` will run via the polyfill dispatcher.
|
||||
|
|
@ -171,7 +179,7 @@ def _polyfill_will_run(
|
|||
|
||||
def _spec_has_non_compact_edits(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
context_management_spec: _ContextManagementSpec,
|
||||
additional_drop_params: list[str] | None,
|
||||
) -> bool:
|
||||
"""Return True when the spec includes edits other than ``compact_20260112``.
|
||||
|
|
@ -209,9 +217,9 @@ def _context_management_explicitly_dropped(additional_drop_params: list[str] | N
|
|||
|
||||
def _normalize_spec_edits(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
context_management_spec: _ContextManagementSpec,
|
||||
additional_drop_params: list[str] | None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Return the normalized ``edits`` list, or ``None`` if the polyfill won't run.
|
||||
|
||||
Delegates spec-shape normalization to the dispatcher's ``_normalize_spec``
|
||||
|
|
@ -236,11 +244,11 @@ def _normalize_spec_edits(
|
|||
async def _run_polyfill_if_enabled(
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
system: Any | None,
|
||||
context_management_spec: Any,
|
||||
litellm_metadata: dict | None,
|
||||
messages: _AnthropicMessages,
|
||||
tools: list[dict[str, object]] | None,
|
||||
system: _AnthropicSystem,
|
||||
context_management_spec: _ContextManagementSpec,
|
||||
litellm_metadata: dict[str, object] | None,
|
||||
additional_drop_params: list[str] | None,
|
||||
llm_router: "Router | None",
|
||||
user_api_key_auth: "UserAPIKeyAuth | None" = None,
|
||||
|
|
@ -306,7 +314,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
def _route_openai_thinking_to_responses_api_if_needed(
|
||||
completion_kwargs: dict[str, Any],
|
||||
*,
|
||||
thinking: dict[str, Any] | None,
|
||||
thinking: Mapping[str, object] | None,
|
||||
) -> None:
|
||||
"""
|
||||
When users call `litellm.anthropic.messages.*` with a non-Anthropic model and
|
||||
|
|
@ -407,12 +415,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
def _prepare_completion_kwargs(
|
||||
*,
|
||||
max_tokens: int,
|
||||
messages: list[dict],
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
stream: bool | None = False,
|
||||
system: str | list[dict[str, Any]] | None = None,
|
||||
system: _AnthropicSystem = None,
|
||||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
|
|
@ -420,7 +428,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
extra_kwargs: dict[str, Any] | None = None,
|
||||
extra_kwargs: Mapping[str, object] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""Prepare kwargs for litellm.completion/acompletion.
|
||||
|
||||
|
|
@ -433,7 +441,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
Logging as LiteLLMLoggingObject,
|
||||
)
|
||||
|
||||
request_data: Final = {
|
||||
request_data: Final[dict[str, object]] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
|
|
@ -528,7 +536,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
@staticmethod
|
||||
async def async_anthropic_messages_handler(
|
||||
max_tokens: int,
|
||||
messages: list[dict],
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
|
|
@ -537,7 +545,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
|
|
@ -551,10 +559,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
requested_router if requested_router is not None else _proxy_router_fallback()
|
||||
)
|
||||
|
||||
proxy_litellm_metadata: Final = _extract_proxy_litellm_metadata(kwargs)
|
||||
user_api_key_auth: Final[UserAPIKeyAuth | None] = (
|
||||
proxy_litellm_metadata.get("user_api_key_auth") if proxy_litellm_metadata is not None else None
|
||||
)
|
||||
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
|
||||
|
||||
polyfill_result: Final = await _prepare_context_managed_request(
|
||||
model=model,
|
||||
|
|
@ -618,7 +623,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
@staticmethod
|
||||
def anthropic_messages_handler(
|
||||
max_tokens: int,
|
||||
messages: list[dict],
|
||||
messages: _AnthropicMessages,
|
||||
model: str,
|
||||
metadata: dict | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
|
|
@ -627,7 +632,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
temperature: float | None = None,
|
||||
thinking: dict | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
top_k: int | None = None,
|
||||
top_p: float | None = None,
|
||||
output_format: dict | None = None,
|
||||
|
|
@ -688,10 +693,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
if context_management is None and not _messages_have_compaction_block(messages):
|
||||
polyfill_result: PolyfillResult | None = None
|
||||
else:
|
||||
proxy_litellm_metadata: Final = _extract_proxy_litellm_metadata(kwargs)
|
||||
user_api_key_auth: Final[UserAPIKeyAuth | None] = (
|
||||
proxy_litellm_metadata.get("user_api_key_auth") if proxy_litellm_metadata is not None else None
|
||||
)
|
||||
proxy_litellm_metadata, user_api_key_auth = _extract_proxy_litellm_metadata(kwargs)
|
||||
polyfill_result = run_async_function(
|
||||
_prepare_context_managed_request,
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -348,6 +348,26 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
merged_chunk["context_management"] = ContextManagementResponse(applied_edits=list(self.applied_edits))
|
||||
return self._augment_message_delta_usage(merged_chunk)
|
||||
|
||||
def _handle_choiceless_chunk(self, chunk: "ModelResponseStream") -> bool:
|
||||
"""Consume an OpenAI-compatible chunk that carries no ``choices``.
|
||||
|
||||
``choices`` is legitimately empty on metadata-only chunks; the final
|
||||
usage chunk emitted when ``stream_options.include_usage`` is set is the
|
||||
common case (vLLM and other OpenAI-compatible servers do this). Such a
|
||||
chunk carries no content-block information, so the caller must not run
|
||||
the content-block state machine over it.
|
||||
|
||||
Returns True when a merged ``message_delta`` was queued (usage folded
|
||||
into the held stop-reason chunk); False when the chunk should be
|
||||
skipped entirely.
|
||||
"""
|
||||
if self.holding_stop_reason_chunk is not None and _optional_attr(chunk, "usage") is not None:
|
||||
self.chunk_queue.append(self._merge_usage_into_held_stop_reason_chunk(chunk))
|
||||
self.queued_usage_chunk = True
|
||||
self.holding_stop_reason_chunk = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ensure_context_management_attached(self, message_delta_chunk: MessageBlockDelta) -> MessageBlockDelta:
|
||||
"""Attach ``context_management`` to a ``message_delta`` chunk if
|
||||
``self.applied_edits`` is non-empty and the chunk does not already
|
||||
|
|
@ -509,6 +529,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
if chunk == "None" or chunk is None:
|
||||
raise Exception
|
||||
|
||||
if not getattr(chunk, "choices", None):
|
||||
if self._handle_choiceless_chunk(chunk):
|
||||
return self.chunk_queue.popleft()
|
||||
continue
|
||||
|
||||
should_start_new_block = self._should_start_new_content_block(chunk)
|
||||
is_opening_first_block = self.sent_content_block_start is False
|
||||
if is_opening_first_block and self._is_blank_delta(chunk):
|
||||
|
|
@ -732,6 +757,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
if chunk == "None" or chunk is None:
|
||||
raise Exception
|
||||
|
||||
if not getattr(chunk, "choices", None):
|
||||
if self._handle_choiceless_chunk(chunk):
|
||||
return self.chunk_queue.popleft()
|
||||
continue
|
||||
|
||||
should_start_new_block = self._should_start_new_content_block(chunk)
|
||||
is_opening_first_block = self.sent_content_block_start is False
|
||||
if is_opening_first_block and self._is_blank_delta(chunk):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from typing import Any, Final, cast
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
flatten_unencrypted_web_search_results_in_anthropic_messages,
|
||||
sanitize_tool_use_ids_in_anthropic_messages,
|
||||
strip_empty_text_blocks_from_anthropic_messages,
|
||||
)
|
||||
|
|
@ -222,6 +223,7 @@ async def anthropic_messages(
|
|||
# Replay of cross-provider tool history (e.g. kimi -> Anthropic) may carry
|
||||
# ids like ``functions.Bash:0`` that violate Anthropic's id pattern.
|
||||
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
|
||||
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)
|
||||
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
|
|
@ -413,6 +415,7 @@ def anthropic_messages_handler(
|
|||
if not kwargs.pop("_litellm_messages_presanitized", False):
|
||||
messages = strip_empty_text_blocks_from_anthropic_messages(messages)
|
||||
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
|
||||
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)
|
||||
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from collections.abc import Coroutine, Iterable
|
||||
from typing import Any, Final, Literal
|
||||
from typing import Any, Final, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from openai import AsyncAzureOpenAI, AzureOpenAI
|
||||
from openai.types.shared_params.metadata import Metadata
|
||||
from typing_extensions import overload
|
||||
|
||||
from ...types.llms.openai import (
|
||||
|
|
@ -22,6 +23,16 @@ from ...types.llms.openai import (
|
|||
from .common_utils import BaseAzureLLM
|
||||
|
||||
|
||||
class _RunThreadStreamData(TypedDict):
|
||||
thread_id: str
|
||||
assistant_id: str
|
||||
additional_instructions: str | None
|
||||
instructions: str | None
|
||||
metadata: Metadata | None
|
||||
model: str | None
|
||||
tools: Iterable[AssistantToolParam] | None
|
||||
|
||||
|
||||
class AzureAssistantsAPI(BaseAzureLLM):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -212,9 +223,9 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
response_obj: OpenAIMessage | None = None
|
||||
if getattr(thread_message, "status", None) is None:
|
||||
thread_message.status = "completed"
|
||||
response_obj = OpenAIMessage(**thread_message.dict())
|
||||
response_obj = OpenAIMessage.model_validate(thread_message.dict())
|
||||
else:
|
||||
response_obj = OpenAIMessage(**thread_message.dict())
|
||||
response_obj = OpenAIMessage.model_validate(thread_message.dict())
|
||||
return response_obj
|
||||
|
||||
# fmt: off
|
||||
|
|
@ -301,9 +312,9 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
response_obj: OpenAIMessage | None = None
|
||||
if getattr(thread_message, "status", None) is None:
|
||||
thread_message.status = "completed"
|
||||
response_obj = OpenAIMessage(**thread_message.dict())
|
||||
response_obj = OpenAIMessage.model_validate(thread_message.dict())
|
||||
else:
|
||||
response_obj = OpenAIMessage(**thread_message.dict())
|
||||
response_obj = OpenAIMessage.model_validate(thread_message.dict())
|
||||
return response_obj
|
||||
|
||||
async def async_get_messages(
|
||||
|
|
@ -443,7 +454,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
|
||||
message_thread: Final = await openai_client.beta.threads.create(**data)
|
||||
|
||||
return Thread(**message_thread.dict())
|
||||
return Thread.model_validate(message_thread.dict())
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
|
@ -539,7 +550,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
|
||||
message_thread: Final = azure_openai_client.beta.threads.create(**data)
|
||||
|
||||
return Thread(**message_thread.dict())
|
||||
return Thread.model_validate(message_thread.dict())
|
||||
|
||||
async def async_get_thread(
|
||||
self,
|
||||
|
|
@ -566,7 +577,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
|
||||
response: Final = await openai_client.beta.threads.retrieve(thread_id=thread_id)
|
||||
|
||||
return Thread(**response.dict())
|
||||
return Thread.model_validate(response.dict())
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
|
@ -642,7 +653,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
|
||||
response: Final = openai_client.beta.threads.retrieve(thread_id=thread_id)
|
||||
|
||||
return Thread(**response.dict())
|
||||
return Thread.model_validate(response.dict())
|
||||
|
||||
# def delete_thread(self):
|
||||
# pass
|
||||
|
|
@ -730,7 +741,8 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
event_handler: AssistantEventHandler | None,
|
||||
litellm_params: dict | None = None,
|
||||
) -> AssistantStreamManager[AssistantEventHandler]:
|
||||
data: Final[dict[str, Any]] = {
|
||||
stream_fn: Final = client.beta.threads.runs.stream
|
||||
base_data: Final[_RunThreadStreamData] = {
|
||||
"thread_id": thread_id,
|
||||
"assistant_id": assistant_id,
|
||||
"additional_instructions": additional_instructions,
|
||||
|
|
@ -740,8 +752,8 @@ class AzureAssistantsAPI(BaseAzureLLM):
|
|||
"tools": tools,
|
||||
}
|
||||
if event_handler is not None:
|
||||
data["event_handler"] = event_handler
|
||||
return client.beta.threads.runs.stream(**data)
|
||||
return stream_fn(**base_data, event_handler=event_handler)
|
||||
return stream_fn(**base_data)
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from litellm.litellm_core_utils.cloud_storage_security import (
|
|||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.bedrock import (
|
||||
BedrockCreateBatchRequest,
|
||||
BedrockCreateBatchResponse,
|
||||
|
|
@ -29,7 +28,7 @@ from litellm.types.llms.openai import (
|
|||
from litellm.types.utils import LiteLLMBatch, LlmProviders
|
||||
|
||||
from ..base_aws_llm import BaseAWSLLM
|
||||
from ..common_utils import CommonBatchFilesUtils
|
||||
from ..common_utils import CommonBatchFilesUtils, resolve_s3_encryption_key_id
|
||||
|
||||
# Bedrock batch input files are uploaded as
|
||||
# s3://bucket/litellm-bedrock-files-{model, ":" -> "-"}-{uuid4}.jsonl (see
|
||||
|
|
@ -200,7 +199,10 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
|
|||
)
|
||||
|
||||
# Add optional KMS encryption key ID if provided
|
||||
s3_encryption_key_id = litellm_params.get("s3_encryption_key_id") or get_secret_str("AWS_S3_ENCRYPTION_KEY_ID")
|
||||
s3_encryption_key_id = resolve_s3_encryption_key_id(
|
||||
litellm_params=litellm_params,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
if s3_encryption_key_id:
|
||||
s3_output_config["s3EncryptionKeyId"] = s3_encryption_key_id
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
|
|||
)
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret
|
||||
from litellm.secret_managers.main import get_secret, get_secret_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
|
@ -1304,6 +1304,23 @@ def get_anthropic_beta_from_headers(headers: dict) -> list[str]:
|
|||
return []
|
||||
|
||||
|
||||
def resolve_s3_encryption_key_id(
|
||||
litellm_params: Mapping[str, Any],
|
||||
optional_params: Mapping[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Resolve the SSE-KMS key configured for Bedrock batch/file S3 objects.
|
||||
|
||||
Precedence: `s3_encryption_key_id` in litellm_params, then optional_params
|
||||
(client-side / request params), then the AWS_S3_ENCRYPTION_KEY_ID env var.
|
||||
"""
|
||||
candidates: Final = tuple(
|
||||
source.get("s3_encryption_key_id") for source in (litellm_params, optional_params) if source is not None
|
||||
)
|
||||
explicit: Final = next((value for value in candidates if isinstance(value, str) and value), None)
|
||||
return explicit or get_secret_str("AWS_S3_ENCRYPTION_KEY_ID")
|
||||
|
||||
|
||||
class CommonBatchFilesUtils:
|
||||
"""
|
||||
Common utilities for Bedrock batch and file operations.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import base64
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from collections.abc import Iterable, Mapping, MutableMapping
|
||||
from functools import cache
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
from urllib.parse import unquote
|
||||
|
|
@ -10,7 +12,7 @@ from urllib.parse import unquote
|
|||
import httpx
|
||||
from httpx import Headers, Response
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -26,12 +28,16 @@ from litellm.litellm_core_utils.cloud_storage_security import (
|
|||
split_configured_cloud_bucket_name,
|
||||
validate_managed_cloud_file_id,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
extract_file_data,
|
||||
text_completion_prompt_to_messages,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.files.transformation import (
|
||||
BaseFilesConfig,
|
||||
LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.types.llms.bedrock import BedrockBatchRecordKind
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
CreateFileRequest,
|
||||
|
|
@ -41,12 +47,14 @@ from litellm.types.llms.openai import (
|
|||
OpenAICreateFileRequestOptionalParams,
|
||||
OpenAIFileObject,
|
||||
PathLike,
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
)
|
||||
from litellm.types.utils import ExtractedFileData, LlmProviders, SpecialEnums
|
||||
from litellm.utils import get_llm_provider
|
||||
|
||||
from ..base_aws_llm import BaseAWSLLM
|
||||
from ..common_utils import BedrockError
|
||||
from ..common_utils import BedrockError, resolve_s3_encryption_key_id
|
||||
|
||||
# litellm_params key used to hand the SigV4-signed GET headers from
|
||||
# `transform_file_content_request` to `validate_environment` (the only hook
|
||||
|
|
@ -55,6 +63,26 @@ from ..common_utils import BedrockError
|
|||
S3_SIGNED_GET_HEADERS_PARAM: Final = "_s3_signed_get_headers"
|
||||
|
||||
|
||||
def _frozen_mapping(items: Iterable[tuple[str, Any]]) -> Mapping[str, Any]:
|
||||
return MappingProxyType(dict(items))
|
||||
|
||||
|
||||
# JSONL batch records are untyped json, so the `/v1/responses` fields are
|
||||
# validated into their concrete Responses API types before being handed to the
|
||||
# Responses-to-Chat bridge. Both adapters drop keys the Responses API doesn't
|
||||
# define, which is what the bridge would ignore anyway. Built on first use
|
||||
# rather than at import: `ResponseInputParam` is a deep union and only batch
|
||||
# files carrying `/v1/responses` records need it.
|
||||
@cache
|
||||
def _responses_input_adapter() -> TypeAdapter[str | ResponseInputParam]:
|
||||
return TypeAdapter(str | ResponseInputParam)
|
||||
|
||||
|
||||
@cache
|
||||
def _responses_request_adapter() -> TypeAdapter[ResponsesAPIOptionalRequestParams]:
|
||||
return TypeAdapter(ResponsesAPIOptionalRequestParams)
|
||||
|
||||
|
||||
class _BedrockS3RequestParams(BaseModel):
|
||||
"""Typed view of the credential/region params the S3 GetObject path reads."""
|
||||
|
||||
|
|
@ -303,41 +331,55 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
# example; add others here as they adopt the same schema.
|
||||
CONVERSE_INVOKE_PROVIDERS = ("nova",)
|
||||
|
||||
# OpenAI batch URL that signals an embedding request. Per OpenAI Batch API
|
||||
# spec, every JSONL record carries a `url` field; we use it as the
|
||||
# authoritative signal to route the line to the embedding code path
|
||||
# instead of inferring from the presence of `input` vs `messages`.
|
||||
# OpenAI batch URLs that select which request shape a JSONL line carries.
|
||||
# Per the OpenAI Batch API spec every record carries a `url`, so we use it
|
||||
# as the authoritative routing signal instead of inferring from the
|
||||
# presence of `input` vs `prompt` vs `messages`.
|
||||
OPENAI_EMBEDDINGS_URL = "/v1/embeddings"
|
||||
OPENAI_TEXT_COMPLETIONS_URL = "/v1/completions"
|
||||
OPENAI_RESPONSES_URL = "/v1/responses"
|
||||
|
||||
@staticmethod
|
||||
def _is_embedding_record(openai_jsonl_record: dict[str, Any]) -> bool:
|
||||
def _classify_batch_record(openai_jsonl_record: Mapping[str, Any]) -> BedrockBatchRecordKind:
|
||||
"""
|
||||
Decide whether an OpenAI batch JSONL line is an embedding request.
|
||||
Decide which OpenAI endpoint shape an OpenAI batch JSONL line carries.
|
||||
|
||||
Precedence (strict - any explicit `url` short-circuits):
|
||||
1. `url == "/v1/embeddings"` -> embedding. Authoritative per the
|
||||
OpenAI Batch API spec.
|
||||
2. Any other non-empty `url` (e.g. `/v1/chat/completions`) -> NOT
|
||||
embedding. We trust the caller's explicit signal even if the
|
||||
body would otherwise suggest embedding; misrouting a chat
|
||||
record into the embedding transformer would corrupt the
|
||||
modelInput, while a chat-shaped body sent to the chat path
|
||||
either succeeds or fails cleanly inside that transformer.
|
||||
3. `url` missing/empty -> fall back to body shape. Requires
|
||||
`input` present AND `messages` absent so a malformed record
|
||||
carrying both keys routes to the chat path (safer default:
|
||||
Anthropic transforms ignore unknown top-level keys, whereas
|
||||
the embedding transformer would silently drop the messages).
|
||||
Precedence (strict - any recognized `url` short-circuits):
|
||||
1. A `url` matching a supported endpoint wins. Authoritative per the
|
||||
OpenAI Batch API spec, which requires it on every record.
|
||||
2. Any other non-empty `url` -> chat. We trust the caller's explicit
|
||||
signal rather than re-deriving it from the body, and an
|
||||
unexpectedly-shaped body fails cleanly inside the chat
|
||||
transformer instead of being silently misrouted.
|
||||
3. `url` missing/empty -> fall back to body shape. `messages` wins
|
||||
over the other keys so a malformed record carrying several of
|
||||
them keeps its conversation instead of having it dropped, and a
|
||||
bare `input` stays an embedding for backwards compatibility
|
||||
(that ambiguity with `/v1/responses` is only resolvable from
|
||||
`url`).
|
||||
"""
|
||||
url: Final = openai_jsonl_record.get("url")
|
||||
if url == BedrockFilesConfig.OPENAI_EMBEDDINGS_URL:
|
||||
return True
|
||||
if url:
|
||||
return False
|
||||
body: Final = openai_jsonl_record.get("body", {})
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
return "input" in body and "messages" not in body
|
||||
match openai_jsonl_record.get("url"):
|
||||
case BedrockFilesConfig.OPENAI_EMBEDDINGS_URL:
|
||||
return BedrockBatchRecordKind.EMBEDDING
|
||||
case BedrockFilesConfig.OPENAI_TEXT_COMPLETIONS_URL:
|
||||
return BedrockBatchRecordKind.TEXT_COMPLETION
|
||||
case BedrockFilesConfig.OPENAI_RESPONSES_URL:
|
||||
return BedrockBatchRecordKind.RESPONSES
|
||||
case None | "":
|
||||
pass
|
||||
case _:
|
||||
return BedrockBatchRecordKind.CHAT
|
||||
|
||||
body: Final = openai_jsonl_record.get("body")
|
||||
if not isinstance(body, Mapping):
|
||||
return BedrockBatchRecordKind.CHAT
|
||||
if "messages" in body:
|
||||
return BedrockBatchRecordKind.CHAT
|
||||
if "prompt" in body:
|
||||
return BedrockBatchRecordKind.TEXT_COMPLETION
|
||||
if "input" in body:
|
||||
return BedrockBatchRecordKind.EMBEDDING
|
||||
return BedrockBatchRecordKind.CHAT
|
||||
|
||||
# Identifier for the Bedrock Titan v2 InvokeModel body schema as stored
|
||||
# in `model_prices_and_context_window.json`. Centralized so future
|
||||
|
|
@ -544,9 +586,84 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
)
|
||||
return dict(titan_config._transform_request(input=input_text, inference_params=inference_params))
|
||||
|
||||
@staticmethod
|
||||
def _transform_text_completion_body_to_chat_body(openai_request_body: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""
|
||||
Rewrite an OpenAI `/v1/completions` batch body as a Chat Completions body.
|
||||
|
||||
Bedrock batch `modelInput` is the model's InvokeModel/Converse body, and
|
||||
no Bedrock batch model takes a bare `prompt`, so the wrapping that
|
||||
`litellm.text_completion` does in real time has to happen here too.
|
||||
"""
|
||||
prompt: Final = openai_request_body.get("prompt")
|
||||
if prompt is None:
|
||||
raise ValueError(
|
||||
"Batch record for /v1/completions is missing required `prompt` field: "
|
||||
f"model={openai_request_body.get('model', '')}"
|
||||
)
|
||||
return _frozen_mapping(
|
||||
chain(
|
||||
((key, value) for key, value in openai_request_body.items() if key != "prompt"),
|
||||
(("messages", text_completion_prompt_to_messages(prompt)),),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _transform_responses_body_to_chat_body(openai_request_body: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
"""
|
||||
Rewrite an OpenAI `/v1/responses` batch body as a Chat Completions body.
|
||||
|
||||
Delegates to the same Responses-to-Chat bridge the real-time path uses
|
||||
for providers without a native Responses API (which is every Bedrock
|
||||
model), so `input`, `instructions`, `max_output_tokens` and the tool
|
||||
params translate identically in batch and real time. The bridge always
|
||||
emits a `tools` key; an empty one is dropped rather than shipped as an
|
||||
empty array inside `modelInput`.
|
||||
"""
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
responses_input: Final = openai_request_body.get("input")
|
||||
if responses_input is None:
|
||||
raise ValueError(
|
||||
"Batch record for /v1/responses is missing required `input` field: "
|
||||
f"model={openai_request_body.get('model', '')}"
|
||||
)
|
||||
chat_body: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
|
||||
model=openai_request_body.get("model", ""),
|
||||
input=_responses_input_adapter().validate_python(responses_input),
|
||||
responses_api_request=_responses_request_adapter().validate_python(
|
||||
_frozen_mapping(
|
||||
(key, value) for key, value in openai_request_body.items() if key not in ("model", "input")
|
||||
)
|
||||
),
|
||||
metadata=openai_request_body.get("metadata"),
|
||||
)
|
||||
return _frozen_mapping((key, value) for key, value in chat_body.items() if key != "tools" or value)
|
||||
|
||||
@staticmethod
|
||||
def _transform_batch_body_to_chat_body(
|
||||
openai_request_body: Mapping[str, Any],
|
||||
record_kind: BedrockBatchRecordKind,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Normalize a non-embedding batch body to the Chat Completions shape the
|
||||
per-provider Bedrock transformations expect.
|
||||
"""
|
||||
match record_kind:
|
||||
case BedrockBatchRecordKind.TEXT_COMPLETION:
|
||||
return BedrockFilesConfig._transform_text_completion_body_to_chat_body(openai_request_body)
|
||||
case BedrockBatchRecordKind.RESPONSES:
|
||||
return BedrockFilesConfig._transform_responses_body_to_chat_body(openai_request_body)
|
||||
case BedrockBatchRecordKind.CHAT:
|
||||
return openai_request_body
|
||||
case BedrockBatchRecordKind.EMBEDDING:
|
||||
raise ValueError("Embedding batch records do not have a chat-completion equivalent")
|
||||
|
||||
def _map_openai_to_bedrock_params(
|
||||
self,
|
||||
openai_request_body: dict[str, Any],
|
||||
openai_request_body: Mapping[str, Any],
|
||||
provider: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -658,14 +775,18 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
provider = self.get_bedrock_invoke_provider(model)
|
||||
|
||||
# Route to the embedding transformer when the OpenAI batch line
|
||||
# targets /v1/embeddings; otherwise fall back to the existing
|
||||
# chat-completion path. We branch here (rather than inside
|
||||
# targets /v1/embeddings; every other endpoint shape is normalized
|
||||
# to chat completions first. We branch here (rather than inside
|
||||
# `_map_openai_to_bedrock_params`) so the chat helper keeps its
|
||||
# narrow contract and the embedding helper can evolve independently.
|
||||
if self._is_embedding_record(_openai_jsonl_content):
|
||||
record_kind = self._classify_batch_record(_openai_jsonl_content)
|
||||
if record_kind is BedrockBatchRecordKind.EMBEDDING:
|
||||
model_input = self._map_openai_embedding_to_bedrock_params(openai_request_body=openai_body)
|
||||
else:
|
||||
model_input = self._map_openai_to_bedrock_params(openai_request_body=openai_body, provider=provider)
|
||||
model_input = self._map_openai_to_bedrock_params(
|
||||
openai_request_body=self._transform_batch_body_to_chat_body(openai_body, record_kind),
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
# Create Bedrock batch record
|
||||
record_id = _openai_jsonl_content.get("custom_id", f"CALL{str(idx).zfill(7)}")
|
||||
|
|
@ -733,6 +854,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
content=file_content,
|
||||
api_base=api_base,
|
||||
optional_params=optional_params,
|
||||
s3_encryption_key_id=resolve_s3_encryption_key_id(
|
||||
litellm_params=litellm_params,
|
||||
optional_params=optional_params,
|
||||
),
|
||||
)
|
||||
|
||||
litellm_params["upload_url"] = api_base
|
||||
|
|
@ -750,6 +875,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
content: str,
|
||||
api_base: str,
|
||||
optional_params: dict,
|
||||
s3_encryption_key_id: str | None = None,
|
||||
) -> tuple[dict, str]:
|
||||
"""
|
||||
Sign S3 PUT request using the same proven logic as S3Logger.
|
||||
|
|
@ -782,12 +908,25 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
|
|||
content_hash: Final = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Prepare headers with required S3 headers (same as s3_v2.py)
|
||||
request_headers: Final = {
|
||||
"Content-Type": "application/json", # JSONL files are JSON content
|
||||
"x-amz-content-sha256": content_hash, # REQUIRED by S3
|
||||
"Content-Language": "en",
|
||||
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
|
||||
}
|
||||
sse_headers: Final = (
|
||||
MappingProxyType(
|
||||
{
|
||||
"x-amz-server-side-encryption": "aws:kms",
|
||||
"x-amz-server-side-encryption-aws-kms-key-id": s3_encryption_key_id,
|
||||
}
|
||||
)
|
||||
if s3_encryption_key_id
|
||||
else MappingProxyType({})
|
||||
)
|
||||
request_headers: Final = MappingProxyType(
|
||||
{
|
||||
"Content-Type": "application/json", # JSONL files are JSON content
|
||||
"x-amz-content-sha256": content_hash, # REQUIRED by S3
|
||||
"Content-Language": "en",
|
||||
"Cache-Control": "private, immutable, max-age=31536000, s-maxage=0",
|
||||
**sse_headers,
|
||||
}
|
||||
)
|
||||
|
||||
# Use requests.Request to prepare the request (same pattern as s3_v2.py)
|
||||
req: Final = requests.Request("PUT", api_base, data=content, headers=request_headers)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
from aiohttp import ClientSession, TCPConnector
|
||||
from aiohttp import ClientSession, DummyCookieJar, TCPConnector
|
||||
from httpx import USE_CLIENT_DEFAULT, AsyncHTTPTransport, HTTPTransport
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
|
|
@ -144,6 +145,15 @@ def _handler_may_close_client(client_refcount: int, owns_client: bool) -> bool:
|
|||
return owns_client and client_refcount <= _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER
|
||||
|
||||
|
||||
def blocked_cookie_jar() -> CookieJar:
|
||||
"""A jar that stores no response cookie and sends none, for httpx clients.
|
||||
|
||||
LiteLLM's outbound clients are pooled and shared by every caller, so a cookie one
|
||||
upstream sets would be replayed to every other upstream on a matching domain.
|
||||
"""
|
||||
return CookieJar(policy=DefaultCookiePolicy(allowed_domains=()))
|
||||
|
||||
|
||||
_STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS: Final = 5.0
|
||||
_STREAMING_ERROR_BODY_READ_EXECUTOR: Final = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=50,
|
||||
|
|
@ -587,6 +597,7 @@ class AsyncHTTPHandler:
|
|||
verify=ssl_config,
|
||||
cert=cert,
|
||||
headers=default_headers,
|
||||
cookies=blocked_cookie_jar(),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
|
@ -1063,6 +1074,7 @@ class AsyncHTTPHandler:
|
|||
def session_factory() -> ClientSession:
|
||||
return ClientSession(
|
||||
connector=TCPConnector(**transport_connector_kwargs),
|
||||
cookie_jar=DummyCookieJar(),
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
|
@ -1132,6 +1144,7 @@ class HTTPHandler:
|
|||
verify=ssl_config,
|
||||
cert=cert,
|
||||
headers=default_headers,
|
||||
cookies=blocked_cookie_jar(),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
_get_httpx_client,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
|
||||
from litellm.llms.vertex_ai.common_utils import VertexAIError, get_vertex_base_url
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
|
||||
from litellm.types.llms.openai import CreateBatchRequest
|
||||
from litellm.types.llms.vertex_ai import (
|
||||
|
|
@ -98,9 +98,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
data=json.dumps(vertex_batch_request),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
response=_json_response
|
||||
|
|
@ -130,8 +127,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
error_body[:1000],
|
||||
)
|
||||
raise
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -243,7 +238,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -293,7 +290,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
headers=headers,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -366,7 +365,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response: Final = (
|
||||
|
|
@ -391,7 +392,9 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
params=params,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=response.status_code, message=f"Error: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
_json_response: Final = response.json()
|
||||
vertex_batch_response: Final = (
|
||||
|
|
@ -461,7 +464,7 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
|
||||
sync_handler: Final = _get_httpx_client()
|
||||
try:
|
||||
response: Final = sync_handler.post(
|
||||
sync_handler.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=json.dumps({}),
|
||||
|
|
@ -475,9 +478,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
)
|
||||
raise
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
# HTTPHandler.get() does not accept a timeout parameter
|
||||
retrieve_response: Final = sync_handler.get(
|
||||
url=retrieve_api_base,
|
||||
|
|
@ -489,7 +489,10 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
retrieve_response.status_code,
|
||||
retrieve_response.text[:1000],
|
||||
)
|
||||
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=retrieve_response.status_code,
|
||||
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
|
||||
)
|
||||
|
||||
_json_response: Final = retrieve_response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
@ -508,7 +511,7 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
llm_provider=litellm.LlmProviders.VERTEX_AI,
|
||||
)
|
||||
try:
|
||||
response: Final = await client.post(
|
||||
await client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=json.dumps({}),
|
||||
|
|
@ -521,8 +524,6 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
e.response.text[:1000],
|
||||
)
|
||||
raise
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Error: {response.status_code} {response.text}")
|
||||
|
||||
# AsyncHTTPHandler.get() does not accept a timeout parameter
|
||||
retrieve_response: Final = await client.get(
|
||||
|
|
@ -535,7 +536,10 @@ class VertexAIBatchPrediction(VertexLLM):
|
|||
retrieve_response.status_code,
|
||||
retrieve_response.text[:1000],
|
||||
)
|
||||
raise Exception(f"Error: {retrieve_response.status_code} {retrieve_response.text}")
|
||||
raise VertexAIError(
|
||||
status_code=retrieve_response.status_code,
|
||||
message=f"Error: {retrieve_response.status_code} {retrieve_response.text}",
|
||||
)
|
||||
|
||||
_json_response: Final = retrieve_response.json()
|
||||
vertex_batch_response = VertexAIBatchTransformation.transform_vertex_ai_batch_response_to_openai_batch_response(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from typing import Any, Final
|
||||
from urllib.parse import unquote
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
VertexAIError,
|
||||
_convert_vertex_datetime_to_openai_datetime,
|
||||
)
|
||||
from litellm.types.llms.openai import BatchJobStatus, CreateBatchRequest
|
||||
|
|
@ -199,16 +201,40 @@ class VertexAIBatchTransformation:
|
|||
|
||||
gcs_file_uri format: gs://litellm-testing-bucket/litellm-vertex-files/publishers/google/models/gemini-1.5-flash-001/e9412502-2c91-42a6-8e61-f5c294cc0fc8
|
||||
returns: "publishers/google/models/gemini-1.5-flash-001"
|
||||
|
||||
Raises a 400 `VertexAIError` when the uri carries no parseable model path.
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
|
||||
decoded_uri: Final = unquote(gcs_file_uri)
|
||||
|
||||
model_path: Final = decoded_uri.split("publishers/")[1]
|
||||
parts: Final = model_path.split("/")
|
||||
model: Final = f"publishers/{'/'.join(parts[:3])}"
|
||||
model: Final = cls._parse_model_from_gcs_file(gcs_file_uri)
|
||||
if model is None:
|
||||
raise VertexAIError(
|
||||
status_code=400,
|
||||
message=(
|
||||
"Vertex AI batch creation requires the model to be part of `input_file_id`, but "
|
||||
f"'{gcs_file_uri}' contains no 'publishers/<publisher>/models/<model>' path segment. "
|
||||
"Either upload the input file through LiteLLM (POST /v1/files with "
|
||||
"custom_llm_provider=vertex_ai), which encodes the model into the returned file id, or "
|
||||
"pass a uri of the form "
|
||||
"gs://<bucket>/<prefix>/publishers/<publisher>/models/<model>/<file>"
|
||||
),
|
||||
)
|
||||
return model
|
||||
|
||||
@classmethod
|
||||
def _parse_model_from_gcs_file(cls, gcs_file_uri: str) -> str | None:
|
||||
"""
|
||||
Returns the `publishers/<publisher>/models/<model>` path from a gcs uri, or None if the uri
|
||||
does not contain one.
|
||||
"""
|
||||
_, separator, model_path = unquote(gcs_file_uri).partition("publishers/")
|
||||
if not separator:
|
||||
return None
|
||||
|
||||
parts: Final = model_path.split("/")
|
||||
if len(parts) < 3 or parts[1] != "models" or not parts[2]:
|
||||
return None
|
||||
|
||||
return f"publishers/{'/'.join(parts[:3])}"
|
||||
|
||||
@classmethod
|
||||
def is_unmanaged_gcs_batch_input_file_id(cls, input_file_id: str | None) -> bool:
|
||||
"""
|
||||
|
|
@ -216,7 +242,11 @@ class VertexAIBatchTransformation:
|
|||
LiteLLM-managed unified file id) with a `publishers/` model path that
|
||||
`_get_model_from_gcs_file` can parse.
|
||||
"""
|
||||
return input_file_id is not None and input_file_id.startswith("gs://") and "publishers/" in input_file_id
|
||||
return (
|
||||
input_file_id is not None
|
||||
and input_file_id.startswith("gs://")
|
||||
and cls._parse_model_from_gcs_file(input_file_id) is not None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_bare_model_name_from_gcs_file(cls, gcs_file_uri: str) -> str:
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import contextvars
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, TypeAlias, TypedDict
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
# Tool names emitted from OpenAPI specs must work across all major LLM providers.
|
||||
# OpenAI/Anthropic/Bedrock all enforce a character class roughly equivalent to
|
||||
# ^[a-zA-Z0-9_-]+$ on tool names. Many specs (notably GitHub's REST API) use
|
||||
|
|
@ -44,6 +47,41 @@ from litellm.proxy._experimental.mcp_server.tool_registry import (
|
|||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
_OpenAPIParameter: TypeAlias = Mapping[str, Any]
|
||||
|
||||
|
||||
class _OpenAPIJSONSchema(TypedDict, total=False):
|
||||
properties: Mapping[str, object]
|
||||
|
||||
|
||||
class _OpenAPIMediaType(TypedDict, total=False):
|
||||
schema: _OpenAPIJSONSchema
|
||||
|
||||
|
||||
class _OpenAPIRequestBody(TypedDict, total=False):
|
||||
description: str
|
||||
required: bool
|
||||
content: Mapping[str, _OpenAPIMediaType]
|
||||
|
||||
|
||||
class _OpenAPIOperation(TypedDict, total=False):
|
||||
operationId: str
|
||||
summary: str
|
||||
description: str
|
||||
parameters: Sequence[_OpenAPIParameter]
|
||||
requestBody: _OpenAPIRequestBody
|
||||
|
||||
|
||||
class _OpenAPIPathItem(TypedDict, total=False):
|
||||
summary: str
|
||||
description: str
|
||||
parameters: Sequence[_OpenAPIParameter]
|
||||
|
||||
|
||||
class _OpenAPIComponents(TypedDict, total=False):
|
||||
parameters: Mapping[str, _OpenAPIParameter]
|
||||
|
||||
|
||||
# Store the base URL and headers globally
|
||||
BASE_URL: Final = ""
|
||||
HEADERS: Final[dict[str, str]] = {}
|
||||
|
|
@ -69,7 +107,7 @@ _request_resolved_auth_headers: Final[contextvars.ContextVar[dict[str, str] | No
|
|||
)
|
||||
|
||||
|
||||
def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str:
|
||||
def _sanitize_path_parameter_value(param_value: object, param_name: str) -> str:
|
||||
"""Ensure path params cannot introduce directory traversal."""
|
||||
if param_value is None:
|
||||
return ""
|
||||
|
|
@ -109,7 +147,7 @@ def load_openapi_spec(filepath: str) -> dict[str, Any]:
|
|||
async def load_openapi_spec_async(filepath: str) -> dict[str, Any]:
|
||||
if filepath.startswith("http://") or filepath.startswith("https://"):
|
||||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
|
||||
r: Final = await async_safe_get(client, filepath)
|
||||
r: Final[httpx.Response] = await async_safe_get(client, filepath)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
|
@ -121,11 +159,11 @@ async def load_openapi_spec_async(filepath: str) -> dict[str, Any]:
|
|||
return json.load(f)
|
||||
|
||||
|
||||
def get_base_url(spec: dict[str, Any], spec_path: str | None = None) -> str:
|
||||
def get_base_url(spec: Mapping[str, Any], spec_path: str | None = None) -> str:
|
||||
"""Extract base URL from OpenAPI spec."""
|
||||
# OpenAPI 3.x
|
||||
if "servers" in spec and spec["servers"]:
|
||||
server_url: Final = spec["servers"][0]["url"]
|
||||
server_url: Final[str] = spec["servers"][0]["url"]
|
||||
|
||||
# If the server URL is relative (starts with /), derive base from spec_path
|
||||
if server_url.startswith("/") and spec_path:
|
||||
|
|
@ -147,8 +185,8 @@ def get_base_url(spec: dict[str, Any], spec_path: str | None = None) -> str:
|
|||
return server_url
|
||||
# OpenAPI 2.x (Swagger)
|
||||
elif "host" in spec:
|
||||
scheme: Final = spec.get("schemes", ["https"])[0]
|
||||
base_path: Final = spec.get("basePath", "")
|
||||
scheme: Final[str] = spec.get("schemes", ["https"])[0]
|
||||
base_path: Final[str] = spec.get("basePath", "")
|
||||
return f"{scheme}://{spec['host']}{base_path}"
|
||||
|
||||
# Fallback: derive base URL from spec_path if it's a URL
|
||||
|
|
@ -172,20 +210,24 @@ def get_base_url(spec: dict[str, Any], spec_path: str | None = None) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _resolve_ref(param: dict[str, Any], component_params: dict[str, Any]) -> dict[str, Any] | None:
|
||||
def _resolve_ref(
|
||||
param: _OpenAPIParameter, component_params: Mapping[str, _OpenAPIParameter]
|
||||
) -> _OpenAPIParameter | None:
|
||||
"""Resolve a single parameter, following a $ref if present.
|
||||
|
||||
Returns the resolved param dict, or None if the $ref target is absent from
|
||||
components (so callers can skip/filter it rather than propagating a stub
|
||||
with name=None that would corrupt deduplication).
|
||||
"""
|
||||
ref: Final = param.get("$ref", "")
|
||||
ref: Final[str] = param.get("$ref", "")
|
||||
if not ref.startswith("#/components/parameters/"):
|
||||
return param
|
||||
return component_params.get(ref.split("/")[-1])
|
||||
|
||||
|
||||
def _resolve_param_list(raw: list[dict[str, Any]], component_params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _resolve_param_list(
|
||||
raw: Sequence[_OpenAPIParameter], component_params: Mapping[str, _OpenAPIParameter]
|
||||
) -> list[_OpenAPIParameter]:
|
||||
"""Resolve $refs in a parameter list, dropping any unresolvable entries."""
|
||||
result: Final = []
|
||||
for p in raw:
|
||||
|
|
@ -196,9 +238,9 @@ def _resolve_param_list(raw: list[dict[str, Any]], component_params: dict[str, A
|
|||
|
||||
|
||||
def resolve_operation_params(
|
||||
operation: dict[str, Any],
|
||||
path_item: dict[str, Any],
|
||||
components: dict[str, Any],
|
||||
operation: _OpenAPIOperation,
|
||||
path_item: _OpenAPIPathItem,
|
||||
components: _OpenAPIComponents,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a copy of *operation* with fully-resolved, merged parameters.
|
||||
|
||||
|
|
@ -214,7 +256,7 @@ def resolve_operation_params(
|
|||
merged with the operation-level params; operation-level wins when the
|
||||
same ``name`` + ``in`` combination appears in both.
|
||||
"""
|
||||
component_params: Final = components.get("parameters", {})
|
||||
component_params: Final[Mapping[str, _OpenAPIParameter]] = components.get("parameters", {})
|
||||
path_level: Final = _resolve_param_list(path_item.get("parameters", []), component_params)
|
||||
op_level: Final = _resolve_param_list(operation.get("parameters", []), component_params)
|
||||
op_keys: Final = {(p["name"], p.get("in")) for p in op_level}
|
||||
|
|
@ -224,7 +266,7 @@ def resolve_operation_params(
|
|||
return result
|
||||
|
||||
|
||||
def extract_parameters(operation: dict[str, Any]) -> tuple:
|
||||
def extract_parameters(operation: Mapping[str, Any]) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
|
||||
"""Extract parameter names from OpenAPI operation."""
|
||||
path_params: Final = []
|
||||
query_params: Final = []
|
||||
|
|
@ -250,7 +292,7 @@ def extract_parameters(operation: dict[str, Any]) -> tuple:
|
|||
return path_params, query_params, body_params
|
||||
|
||||
|
||||
def build_input_schema(operation: dict[str, Any]) -> dict[str, Any]:
|
||||
def build_input_schema(operation: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Build MCP input schema from OpenAPI operation."""
|
||||
properties: Final = {}
|
||||
required: Final = []
|
||||
|
|
@ -274,12 +316,12 @@ def build_input_schema(operation: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
# Process requestBody (OpenAPI 3.x)
|
||||
if "requestBody" in operation:
|
||||
request_body: Final = operation["requestBody"]
|
||||
content: Final = request_body.get("content", {})
|
||||
request_body: Final[_OpenAPIRequestBody] = operation["requestBody"]
|
||||
content: Final[Mapping[str, _OpenAPIMediaType]] = request_body.get("content", {})
|
||||
|
||||
# Try to get JSON schema
|
||||
if "application/json" in content:
|
||||
schema: Final = content["application/json"].get("schema", {})
|
||||
schema: Final[_OpenAPIJSONSchema] = content["application/json"].get("schema", {})
|
||||
properties["body"] = {
|
||||
"type": "object",
|
||||
"description": request_body.get("description", "Request body"),
|
||||
|
|
@ -347,7 +389,7 @@ def _merge_openapi_tool_request_headers(
|
|||
def create_tool_function(
|
||||
path: str,
|
||||
method: str,
|
||||
operation: dict[str, Any],
|
||||
operation: Mapping[str, Any],
|
||||
base_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
):
|
||||
|
|
@ -373,7 +415,7 @@ def create_tool_function(
|
|||
path_params, query_params, body_params = extract_parameters(operation)
|
||||
original_method: Final = method.lower()
|
||||
|
||||
async def tool_function(**kwargs: Any) -> str:
|
||||
async def tool_function(**kwargs: object) -> str:
|
||||
"""
|
||||
Dynamically generated tool function.
|
||||
|
||||
|
|
@ -448,10 +490,10 @@ def create_tool_function(
|
|||
return tool_function
|
||||
|
||||
|
||||
def register_tools_from_openapi(spec: dict[str, Any], base_url: str):
|
||||
def register_tools_from_openapi(spec: Mapping[str, Any], base_url: str) -> None:
|
||||
"""Register MCP tools from OpenAPI specification."""
|
||||
paths: Final = spec.get("paths", {})
|
||||
used_names: Final[set] = set()
|
||||
paths: Final[Mapping[str, Mapping[str, Any]]] = spec.get("paths", {})
|
||||
used_names: Final = set()
|
||||
|
||||
for path, path_item in paths.items():
|
||||
for method in ["get", "post", "put", "delete", "patch"]:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ if MCP_AVAILABLE:
|
|||
############ MCP Server REST API Routes #################
|
||||
async def _safe_fire_mcp_tool_call_logging(
|
||||
logging_obj: Any | None,
|
||||
result: Any,
|
||||
result: "CallToolResult",
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import time
|
|||
import traceback
|
||||
import types
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
|
@ -145,7 +145,7 @@ try:
|
|||
)
|
||||
|
||||
# Robust auth lookup keyed by session_object.
|
||||
_session_obj_auth_storage: "weakref.WeakKeyDictionary[Any, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary()
|
||||
_session_obj_auth_storage: "weakref.WeakKeyDictionary[object, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary()
|
||||
except ImportError as e:
|
||||
verbose_logger.debug("MCP module not found: %s", e)
|
||||
MCP_AVAILABLE = False
|
||||
|
|
@ -493,14 +493,14 @@ if MCP_AVAILABLE:
|
|||
def _gateway_create_initialization_options(
|
||||
self,
|
||||
notification_options: NotificationOptions | None = None,
|
||||
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
experimental_capabilities: dict[str, dict[str, object]] | None = None,
|
||||
) -> InitializationOptions:
|
||||
opts: Final = Server.create_initialization_options(
|
||||
self,
|
||||
notification_options=notification_options,
|
||||
experimental_capabilities=experimental_capabilities or {},
|
||||
)
|
||||
updates: Final[dict[str, Any]] = {}
|
||||
updates: Final[dict[str, str]] = {}
|
||||
merged: Final = _mcp_gateway_initialize_instructions.get()
|
||||
if merged is not None:
|
||||
updates["instructions"] = merged
|
||||
|
|
@ -549,6 +549,17 @@ if MCP_AVAILABLE:
|
|||
_stateful_session_locks: Final[dict[str, asyncio.Lock]] = {}
|
||||
_stateful_session_active_request_counts: Final[dict[str, int]] = {}
|
||||
|
||||
class _TerminableTransport(Protocol):
|
||||
async def terminate(self) -> None: ...
|
||||
|
||||
class _TransportRegistry(Protocol):
|
||||
def __contains__(self, session_id: object, /) -> bool: ...
|
||||
|
||||
def pop(self, session_id: str, default: None, /) -> "_TerminableTransport | None": ...
|
||||
|
||||
def _stateful_server_instances() -> _TransportRegistry:
|
||||
return getattr(session_manager_stateful, "_server_instances", {})
|
||||
|
||||
def _remove_stateful_session_tracking(session_id: str) -> None:
|
||||
_stateful_session_auth_contexts.pop(session_id, None)
|
||||
_stateful_session_auth_context_last_seen.pop(session_id, None)
|
||||
|
|
@ -578,8 +589,8 @@ if MCP_AVAILABLE:
|
|||
) -> None:
|
||||
"""Terminate expired stateful sessions and drop their auth contexts."""
|
||||
now = time.monotonic() if now is None else now
|
||||
server_instances: Final = getattr(session_manager_stateful, "_server_instances", {})
|
||||
expired_session_ids: Final = []
|
||||
server_instances: Final = _stateful_server_instances()
|
||||
expired_session_ids: Final[list[str]] = []
|
||||
for session_id, last_seen in _stateful_session_auth_context_last_seen.items():
|
||||
if _stateful_session_active_request_counts.get(session_id, 0) > 0:
|
||||
continue
|
||||
|
|
@ -619,7 +630,7 @@ if MCP_AVAILABLE:
|
|||
session may proceed, or ``False`` when the caller is already at the cap
|
||||
with every session in flight (the new ``initialize`` should be rejected).
|
||||
"""
|
||||
server_instances: Final = getattr(session_manager_stateful, "_server_instances", {})
|
||||
server_instances: Final = _stateful_server_instances()
|
||||
|
||||
def _owned_live_session_ids() -> list[str]:
|
||||
return [
|
||||
|
|
@ -778,7 +789,7 @@ if MCP_AVAILABLE:
|
|||
get_virtual_tool_definitions,
|
||||
)
|
||||
|
||||
return [Tool(**d) for d in get_virtual_tool_definitions()]
|
||||
return [Tool.model_validate(d) for d in get_virtual_tool_definitions()]
|
||||
|
||||
# Get mcp_servers from context variable
|
||||
verbose_logger.debug("MCP list_tools - Calling _list_mcp_tools")
|
||||
|
|
@ -847,7 +858,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _build_virtual_call_logging_obj(
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: dict[str, object],
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
) -> LiteLLMLoggingObj | None:
|
||||
"""Run the pre-call pipeline (guardrails + logging setup) for a virtual
|
||||
|
|
@ -885,7 +896,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _dispatch_virtual_mcp_tool(
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None,
|
||||
arguments: dict[str, object] | None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
client_ip: str | None,
|
||||
mcp_servers: list[str] | None = None,
|
||||
|
|
@ -957,7 +968,7 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
@server.call_tool()
|
||||
async def mcp_server_tool_call(name: str, arguments: dict[str, Any] | None) -> CallToolResult:
|
||||
async def mcp_server_tool_call(name: str, arguments: dict[str, object] | None) -> CallToolResult:
|
||||
"""
|
||||
Call a specific tool with the provided arguments
|
||||
Args:
|
||||
|
|
@ -1621,7 +1632,7 @@ if MCP_AVAILABLE:
|
|||
async def _get_user_oauth_extra_headers_from_db(
|
||||
server: MCPServer,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
prefetched_creds: dict[str, dict[str, Any]] | None = None,
|
||||
prefetched_creds: 'Mapping[str, "OAuthCredentialPayload"] | None' = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Stored OAuth2 token for (user, server) as an ``Authorization: Bearer`` header, or None.
|
||||
|
||||
|
|
@ -1646,7 +1657,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
Returns a dict keyed by server_id to avoid N+1 queries in asyncio.gather loops.
|
||||
"""
|
||||
user_id: Final = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
|
||||
user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None) if user_api_key_auth else None
|
||||
if not user_id:
|
||||
return {}
|
||||
try:
|
||||
|
|
@ -1871,7 +1882,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
list_tools_start_time: Final = datetime.now()
|
||||
litellm_logging_obj: LiteLLMLoggingObj | None = None
|
||||
list_tools_request_data: dict[str, Any] = {}
|
||||
list_tools_request_data: dict[str, object] = {}
|
||||
|
||||
if log_list_tools_to_spendlogs:
|
||||
# This is intentionally minimal: only async_success_handler / post_call_failure_hook
|
||||
|
|
@ -1879,7 +1890,7 @@ if MCP_AVAILABLE:
|
|||
list_tools_call_id: Final = str(uuid.uuid4())
|
||||
# Derive trace_id from raw_headers when not explicitly passed (same as A2A / MCP call_tool)
|
||||
effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)
|
||||
spend_logs_metadata: Final[dict[str, Any]] = {
|
||||
spend_logs_metadata: Final[dict[str, object]] = {
|
||||
"mcp_operation": "list_tools",
|
||||
}
|
||||
if isinstance(list_tools_log_source, str):
|
||||
|
|
@ -2615,7 +2626,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def execute_mcp_tool(
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: dict[str, object],
|
||||
allowed_mcp_servers: list[MCPServer],
|
||||
start_time: datetime,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
@ -2882,7 +2893,7 @@ if MCP_AVAILABLE:
|
|||
_request_auth_header.reset(_auth_token)
|
||||
_request_extra_headers.reset(_extra_token)
|
||||
_request_resolved_auth_headers.reset(_resolved_token)
|
||||
response = CallToolResult(content=cast(Any, local_content), isError=False)
|
||||
response = CallToolResult(content=local_content, isError=False)
|
||||
|
||||
# Try managed MCP server tool (the name is bare; the prefix boundary was
|
||||
# already resolved above against this server's registered prefixes)
|
||||
|
|
@ -2956,7 +2967,7 @@ if MCP_AVAILABLE:
|
|||
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
|
||||
|
||||
local_content = await _handle_local_mcp_tool(original_tool_name, arguments)
|
||||
response = CallToolResult(content=cast(Any, local_content), isError=False)
|
||||
response = CallToolResult(content=local_content, isError=False)
|
||||
|
||||
return await _run_post_mcp_call_guardrails(
|
||||
result=response,
|
||||
|
|
@ -3003,7 +3014,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _fire_mcp_tool_call_logging(
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
result: Any,
|
||||
result: CallToolResult,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
@ -3070,7 +3081,7 @@ if MCP_AVAILABLE:
|
|||
@client
|
||||
async def call_mcp_tool(
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
arguments: dict[str, object] | None = None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
mcp_auth_header: str | None = None,
|
||||
mcp_servers: list[str] | None = None,
|
||||
|
|
@ -3161,7 +3172,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def mcp_get_prompt(
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
arguments: dict[str, object] | None = None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
mcp_auth_header: str | None = None,
|
||||
mcp_servers: list[str] | None = None,
|
||||
|
|
@ -3262,7 +3273,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
def _get_standard_logging_mcp_tool_call(
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: dict[str, object],
|
||||
server_name: str | None,
|
||||
session_id: str | None = None,
|
||||
) -> StandardLoggingMCPToolCall:
|
||||
|
|
@ -3291,13 +3302,13 @@ if MCP_AVAILABLE:
|
|||
async def _handle_managed_mcp_tool(
|
||||
server_name: str,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
arguments: dict[str, object],
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
mcp_auth_header: str | None = None,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
|
||||
oauth2_headers: dict[str, str] | None = None,
|
||||
raw_headers: dict[str, str] | None = None,
|
||||
litellm_logging_obj: Any | None = None,
|
||||
litellm_logging_obj: LiteLLMLoggingObj | None = None,
|
||||
host_progress_callback: Callable | None = None,
|
||||
) -> CallToolResult:
|
||||
"""Handle tool execution for managed server tools"""
|
||||
|
|
@ -3320,7 +3331,7 @@ if MCP_AVAILABLE:
|
|||
return call_tool_result
|
||||
|
||||
async def _handle_local_mcp_tool(
|
||||
name: str, arguments: dict[str, Any]
|
||||
name: str, arguments: dict[str, object]
|
||||
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
||||
"""
|
||||
Handle tool execution for local registry tools
|
||||
|
|
@ -3426,7 +3437,8 @@ if MCP_AVAILABLE:
|
|||
Extract mcp-session-id from ASGI scope headers.
|
||||
Returns None if not present.
|
||||
"""
|
||||
for header_name, header_value in scope.get("headers", []):
|
||||
scope_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = scope.get("headers", [])
|
||||
for header_name, header_value in scope_headers:
|
||||
name = header_name if isinstance(header_name, bytes) else header_name.encode()
|
||||
if name.lower() == b"mcp-session-id":
|
||||
return header_value.decode() if isinstance(header_value, bytes) else str(header_value)
|
||||
|
|
@ -3528,7 +3540,7 @@ if MCP_AVAILABLE:
|
|||
if message.get("type") != "http.request":
|
||||
break
|
||||
|
||||
body = message.get("body", b"") or b""
|
||||
body: bytes = message.get("body", b"") or b""
|
||||
if body:
|
||||
# Only retain up to the remaining peek budget for sniffing.
|
||||
# The full ``message`` is already in memory (delivered by
|
||||
|
|
@ -3571,9 +3583,9 @@ if MCP_AVAILABLE:
|
|||
Fixes https://github.com/BerriAI/litellm/issues/20992
|
||||
"""
|
||||
_mcp_session_header: Final = b"mcp-session-id"
|
||||
_headers: Final = scope.get("headers", [])
|
||||
_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = scope.get("headers", [])
|
||||
|
||||
def _normalize_header_name(header_name: Any) -> bytes | None:
|
||||
def _normalize_header_name(header_name: object) -> bytes | None:
|
||||
if isinstance(header_name, bytes):
|
||||
return header_name.lower()
|
||||
if isinstance(header_name, str):
|
||||
|
|
@ -3902,7 +3914,8 @@ if MCP_AVAILABLE:
|
|||
|
||||
def _get_authorization_header_from_scope(scope: Scope) -> str | None:
|
||||
"""First ``Authorization`` header value in the ASGI scope, or None."""
|
||||
for key, value in scope.get("headers", []):
|
||||
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
|
||||
for key, value in scope_headers:
|
||||
if key.lower() == b"authorization":
|
||||
return value.decode("latin-1")
|
||||
return None
|
||||
|
|
@ -3921,7 +3934,8 @@ if MCP_AVAILABLE:
|
|||
``MCPRequestHandler.process_mcp_request``), and forwarding it upstream
|
||||
would leak the proxy key to a third-party MCP server.
|
||||
"""
|
||||
has_litellm_key_header: Final = any(key.lower() == b"x-litellm-api-key" for key, _ in scope.get("headers", []))
|
||||
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
|
||||
has_litellm_key_header: Final = any(key.lower() == b"x-litellm-api-key" for key, _ in scope_headers)
|
||||
if not has_litellm_key_header:
|
||||
return None
|
||||
return _get_authorization_header_from_scope(scope)
|
||||
|
|
@ -4115,7 +4129,7 @@ if MCP_AVAILABLE:
|
|||
async def handle_streamable_http_mcp(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Handle MCP requests through StreamableHTTP."""
|
||||
try:
|
||||
path: Final = scope.get("path", "")
|
||||
path: Final[str] = scope.get("path", "")
|
||||
(
|
||||
user_api_key_auth,
|
||||
mcp_auth_header,
|
||||
|
|
@ -4135,7 +4149,8 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
# Strip any client-supplied x-mcp-toolset-id to prevent forgery.
|
||||
scope["headers"] = [(k, v) for k, v in scope.get("headers", []) if k.lower() != b"x-mcp-toolset-id"]
|
||||
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
|
||||
scope["headers"] = [(k, v) for k, v in scope_headers if k.lower() != b"x-mcp-toolset-id"]
|
||||
|
||||
# Apply toolset scope if set server-side via ContextVar (set by
|
||||
# /toolset/{name}/mcp and /{name}/mcp route handlers in proxy_server.py).
|
||||
|
|
@ -4436,7 +4451,7 @@ if MCP_AVAILABLE:
|
|||
async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Handle MCP requests through SSE."""
|
||||
try:
|
||||
path: Final = scope.get("path", "")
|
||||
path: Final[str] = scope.get("path", "")
|
||||
(
|
||||
user_api_key_auth,
|
||||
mcp_auth_header,
|
||||
|
|
@ -4456,7 +4471,8 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
# Strip any client-supplied x-mcp-toolset-id to prevent forgery.
|
||||
scope["headers"] = [(k, v) for k, v in scope.get("headers", []) if k.lower() != b"x-mcp-toolset-id"]
|
||||
scope_headers: Final[Sequence[tuple[bytes, bytes]]] = scope.get("headers", [])
|
||||
scope["headers"] = [(k, v) for k, v in scope_headers if k.lower() != b"x-mcp-toolset-id"]
|
||||
|
||||
# Apply toolset scope if set server-side via ContextVar so the
|
||||
# downstream probe list matches the fully-authorized server set
|
||||
|
|
@ -4680,7 +4696,8 @@ if MCP_AVAILABLE:
|
|||
) -> Send:
|
||||
async def wrapped_send(message: Message) -> None:
|
||||
if message.get("type") == "http.response.start":
|
||||
for key, value in message.get("headers", []):
|
||||
response_headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = message.get("headers", [])
|
||||
for key, value in response_headers:
|
||||
header_name = key if isinstance(key, bytes) else str(key).encode()
|
||||
if header_name.lower() == b"mcp-session-id":
|
||||
session_id = value.decode() if isinstance(value, bytes) else str(value)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +1,9 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ClientPageRoot"]
|
||||
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/28md7sjkucknx.js","/litellm-asset-prefix/_next/static/chunks/2x96scis66zmk.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/1azbeyb626rh5.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/17gy9d71tfqhd.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","/litellm-asset-prefix/_next/static/chunks/0kx52ovlpa34x.js","/litellm-asset-prefix/_next/static/chunks/3drq2_k-jeio2.js","/litellm-asset-prefix/_next/static/chunks/2vnpyhxoamx0f.js","/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","/litellm-asset-prefix/_next/static/chunks/3ib18qm2ox61z.js","/litellm-asset-prefix/_next/static/chunks/111jj26rg98nb.js","/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","/litellm-asset-prefix/_next/static/chunks/1abwfud5uqxxq.js","/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","/litellm-asset-prefix/_next/static/chunks/3bhv2o_oast51.js","/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","/litellm-asset-prefix/_next/static/chunks/31cwj7vkk3gfz.js","/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","/litellm-asset-prefix/_next/static/chunks/3pu9plov1btip.js","/litellm-asset-prefix/_next/static/chunks/1lndy6n7cwvrq.js","/litellm-asset-prefix/_next/static/chunks/2kt_m68ln2fyr.js","/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js","/litellm-asset-prefix/_next/static/chunks/08eaumdx0krrt.js"],"default"]
|
||||
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"OutletBoundary"]
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"ClientPageRoot"]
|
||||
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js","/litellm-asset-prefix/_next/static/chunks/0catil7su1yp5.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/34canl9e2fj8w.js","/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","/litellm-asset-prefix/_next/static/chunks/3vu26x-a1_slz.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/0ex44ljfg9dp9.js","/litellm-asset-prefix/_next/static/chunks/0xcneptpo0s76.js","/litellm-asset-prefix/_next/static/chunks/08uoywqkfbbbt.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","/litellm-asset-prefix/_next/static/chunks/2v54hze4wuham.js","/litellm-asset-prefix/_next/static/chunks/1axupaiywv5s2.js","/litellm-asset-prefix/_next/static/chunks/0zy8o1br4cxj_.js","/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","/litellm-asset-prefix/_next/static/chunks/2bwy4wke9jrlh.js","/litellm-asset-prefix/_next/static/chunks/2cz4e0-p1l3hf.js","/litellm-asset-prefix/_next/static/chunks/2tkpj7d49kuht.js","/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","/litellm-asset-prefix/_next/static/chunks/29l3pao1xfkc3.js","/litellm-asset-prefix/_next/static/chunks/111jj26rg98nb.js","/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","/litellm-asset-prefix/_next/static/chunks/31azy9hywrzm7.js","/litellm-asset-prefix/_next/static/chunks/3pu9plov1btip.js","/litellm-asset-prefix/_next/static/chunks/3mz07lvvrbciz.js","/litellm-asset-prefix/_next/static/chunks/1lndy6n7cwvrq.js","/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","/litellm-asset-prefix/_next/static/chunks/0ikhgrs0xvkyu.js","/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js"],"default"]
|
||||
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"OutletBoundary"]
|
||||
7:"$Sreact.suspense"
|
||||
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0kx52ovlpa34x.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3drq2_k-jeio2.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/2vnpyhxoamx0f.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/3ib18qm2ox61z.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/111jj26rg98nb.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/1abwfud5uqxxq.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/3bhv2o_oast51.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/31cwj7vkk3gfz.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/3pu9plov1btip.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/1lndy6n7cwvrq.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2kt_m68ln2fyr.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/08eaumdx0krrt.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"VCvPhLLOUp92Yx-E-CVlV"}
|
||||
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/2v54hze4wuham.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/1axupaiywv5s2.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0zy8o1br4cxj_.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/1uz3jt-tj9lkf.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/2bwy4wke9jrlh.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/2cz4e0-p1l3hf.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/2tkpj7d49kuht.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/323l6h8s7ahat.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/1cea03gg5a_c7.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/2hu1vyy-5pv13.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/199uwr871eene.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/105643dvf00hu.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/29l3pao1xfkc3.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/111jj26rg98nb.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/12wsfsljxg4xv.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/112n0hv3cc2rg.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/31azy9hywrzm7.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/3pu9plov1btip.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/3mz07lvvrbciz.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/1lndy6n7cwvrq.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/09l_m9l1emin2.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/2c90xukbd3il6.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/0ikhgrs0xvkyu.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/23-g73xaw3kap.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"HynDchE8aLeEewsZVNDO8"}
|
||||
4:{}
|
||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||
8:null
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[92825,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ClientSegmentRoot"]
|
||||
3:I[216370,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/28md7sjkucknx.js","/litellm-asset-prefix/_next/static/chunks/2x96scis66zmk.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/1azbeyb626rh5.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/17gy9d71tfqhd.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js"],"default"]
|
||||
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/28md7sjkucknx.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/2x96scis66zmk.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/1azbeyb626rh5.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0g8wwba6umbim.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/2up3bks93iqds.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/17gy9d71tfqhd.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"VCvPhLLOUp92Yx-E-CVlV"}
|
||||
2:I[92825,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"ClientSegmentRoot"]
|
||||
3:I[216370,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js","/litellm-asset-prefix/_next/static/chunks/0catil7su1yp5.js","/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","/litellm-asset-prefix/_next/static/chunks/34canl9e2fj8w.js","/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","/litellm-asset-prefix/_next/static/chunks/3vu26x-a1_slz.js","/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","/litellm-asset-prefix/_next/static/chunks/0ex44ljfg9dp9.js","/litellm-asset-prefix/_next/static/chunks/0xcneptpo0s76.js","/litellm-asset-prefix/_next/static/chunks/08uoywqkfbbbt.js","/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js"],"default"]
|
||||
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0catil7su1yp5.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/2cngn5bal3278.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/34canl9e2fj8w.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0vvnul8uez9kj.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/3vu26x-a1_slz.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/1vquuz09jxl5_.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/3_3dj4vdy-3xy.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0ex44ljfg9dp9.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0xcneptpo0s76.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/08uoywqkfbbbt.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0g_w4tf2inv3i.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/1iakmimqrlpn0.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/17-6zku8f68gf.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0f5fel02jwglw.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0fk0i3e2aixp7.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0dbvgsc7ha049.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"HynDchE8aLeEewsZVNDO8"}
|
||||
6:"$0:rsc:props:children:1:props:serverProvidedParams:params"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"ViewportBoundary"]
|
||||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"MetadataBoundary"]
|
||||
2:I[897367,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"ViewportBoundary"]
|
||||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.3arlap5n8tyzg.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"VCvPhLLOUp92Yx-E-CVlV"}
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"IconMark"]
|
||||
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.3arlap5n8tyzg.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"HynDchE8aLeEewsZVNDO8"}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[867271,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
3:I[71195,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
4:I[557951,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"AuthProvider"]
|
||||
5:I[339756,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
6:I[837457,["/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js"],"default"]
|
||||
2:I[12985,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"NuqsAdapter"]
|
||||
3:I[867271,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
4:I[71195,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
5:I[557951,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"AuthProvider"]
|
||||
6:I[339756,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
7:I[837457,["/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js"],"default"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/169bqf_mz3j8m.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/169bqf_mz3j8m.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0vggytdohwe7o.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/32_ulchi2_aad.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"VCvPhLLOUp92Yx-E-CVlV"}
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1u9cxkx771jnb.css","style"]
|
||||
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1u9cxkx771jnb.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/19frz_r2jewoi.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/20mvgyvrlrdla.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1jfookxfajkeo.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/3fzya67r1y1fl.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"children":["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"HynDchE8aLeEewsZVNDO8"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/169bqf_mz3j8m.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1u9cxkx771jnb.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"VCvPhLLOUp92Yx-E-CVlV"}
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"HynDchE8aLeEewsZVNDO8"}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,618566,(e,t,r)=>{t.exports=e.r(976562)},434166,e=>{"use strict";e.s(["getSecureItem",0,function(e){try{let t=window.sessionStorage.getItem(e);if(null===t)return null;return decodeURIComponent(atob(t).split("").map(e=>"%"+e.charCodeAt(0).toString(16).padStart(2,"0")).join(""))}catch{return null}},"setSecureItem",0,function(e,t){window.sessionStorage.setItem(e,btoa(encodeURIComponent(t).replace(/%([0-9A-F]{2})/g,(e,t)=>String.fromCharCode(parseInt(t,16)))))}])},346328,e=>{"use strict";var t=e.i(843476),r=e.i(271645),l=e.i(618566),s=e.i(434166);let i=()=>{let e=(0,l.useSearchParams)(),i=(0,r.useMemo)(()=>e?{type:"litellm-mcp-oauth",code:e.get("code"),state:e.get("state"),error:e.get("error"),error_description:e.get("error_description")}:null,[e]);return(0,r.useEffect)(()=>{if(!i)return;try{let e=JSON.stringify(i);(0,s.setSecureItem)("litellm-mcp-oauth-result",e),(0,s.setSecureItem)("litellm-user-mcp-oauth-result",e),(0,s.setSecureItem)("litellm-tools-mcp-oauth-result",e)}catch(e){}let e=(0,s.getSecureItem)("litellm-mcp-oauth-return-url"),t=(()=>{let e=window.location.pathname||"",t=e.indexOf("/ui");if(t>=0){let r=e.slice(0,t+3);return r.endsWith("/")?r:`${r}`}return"/"})();if(e)try{let r=new URL(e,window.location.origin);r.origin===window.location.origin&&(t=r.href)}catch{}window.location.replace(t)},[i]),(0,t.jsx)("div",{className:"min-h-screen flex items-center justify-center bg-slate-50 p-6",children:(0,t.jsxs)("div",{className:"max-w-lg w-full rounded-lg bg-white shadow-md p-8 text-center space-y-4",children:[(0,t.jsx)("h1",{className:"text-xl font-semibold text-slate-900",children:"LiteLLM MCP OAuth"}),(0,t.jsx)("p",{className:"text-sm text-slate-700",children:"Authorization complete. You may close this window and return to the LiteLLM dashboard."}),(0,t.jsx)("p",{className:"text-xs text-slate-500",children:"If the window does not close automatically, everything is still saved—you can close it manually."})]})})};e.s(["default",0,()=>(0,t.jsx)(r.Suspense,{fallback:(0,t.jsx)("div",{className:"min-h-screen flex items-center justify-center",children:"Loading..."}),children:(0,t.jsx)(i,{})})])}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue