mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
chore: merge main into litellm_gemini_tool_call_id_length
This commit is contained in:
commit
c64759a17c
351 changed files with 24379 additions and 2601 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -1,5 +1,6 @@
|
|||
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
||||
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
||||
/ci_cd/cost_map_pins.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
||||
/helm/ @yassin-berriai
|
||||
/terraform/ @yassin-berriai
|
||||
/.github/workflows/zizmor.yml @yuneng-berri
|
||||
|
|
|
|||
7
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
7
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -84,9 +84,10 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
|
|||
## Screenshots / Proof of Fix
|
||||
|
||||
<!-- Include screenshots, screen recordings, or command (e.g., curl) + output demonstrating that your changes work as expected
|
||||
The proof must be completely e2e with no mocks, using actual LLM calls costing real $$$ if applicable. `pytest` commands are not enough
|
||||
Show ONLY the latest run: capture Before at the merge base and After at the PR's current tip, and when new commits change behavior, replace this whole section with the fresh run instead of stacking it on top of older ones. The run must be up to date. As soon as a new commit is made and it makes this PR description's after sha stale (it's no longer tip of PR), you must re-run the QA
|
||||
Structure the section exactly as below: Before and After one heading level below this section, each naming the commit hash it was captured at, one lower-level heading per case inside each, the same case names in the same order on both sides, and numbered steps (command, observed output) under every case, never loose prose; shared setup (config, payloads) goes above Before, and with a single case, drop the case headings and number the steps directly
|
||||
Simple PR exception (2026-10-28, #49030). A mechanical change fully proven by focused checks may skip before/after e2e. Follow AGENTS.md's eligibility rule, trace callers, and verify bug-fix regression sensitivity. Record E2E exception at <tip SHA>, scope, rationale, commands and results here. Tests/static checks suffice only for this exception. Keep CI and bot gates, reassess changed diffs, and honor explicit e2e requests. Omit the Before/After scaffold for an exception
|
||||
Otherwise proof must be fully e2e, no mocks, with real provider calls where applicable. Pytest alone is insufficient. The following format applies to live proof
|
||||
Show only the latest proof. Before uses the merge base and After the tested tip. Rerun affected QA after behavior changes. Verified neutral diffs may carry proof with the tested SHA, current tip, equivalence reason and focused checks, following AGENTS.md and /qa
|
||||
Use matching Before/After case headings with numbered commands and observed output. Put shared setup above them. For one case, omit case headings
|
||||
|
||||
### Before (<hash>)
|
||||
|
||||
|
|
|
|||
12
.github/workflows/cost-map-guard.yml
vendored
12
.github/workflows/cost-map-guard.yml
vendored
|
|
@ -21,16 +21,18 @@ jobs:
|
|||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Fetch the pull request head and its merge base
|
||||
- name: Fetch the pull request head, its merge base, and the base branch tip
|
||||
id: revisions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
target="$(gh api "repos/${GITHUB_REPOSITORY}/branches/${BASE_REF}" --jq '.commit.sha')"
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${target}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA" "$target"
|
||||
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
|
||||
echo "target=$target" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
|
|
@ -38,6 +40,8 @@ jobs:
|
|||
- name: Run the guard
|
||||
env:
|
||||
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
|
||||
COST_MAP_GUARD_TARGET: ${{ steps.revisions.outputs.target }}
|
||||
COST_MAP_GUARD_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
|
|
|
|||
91
.github/workflows/cost-map-pins-recheck.yml
vendored
Normal file
91
.github/workflows/cost-map-pins-recheck.yml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
name: Cost map pins recheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "litellm_**"
|
||||
paths:
|
||||
- model_prices_and_context_window.json
|
||||
- litellm/model_prices_and_context_window_backup.json
|
||||
- ci_cd/cost_map_pins.json
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
rerun-cost-map-guard:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Re-run the cost map guard on open pull requests the push could break
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_REF: ${{ github.ref_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
AFTER_SHA: ${{ github.event.after }}
|
||||
FORCED: ${{ github.event.forced }}
|
||||
COST_MAP_PATHS: '["model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json", "ci_cd/cost_map_pins.json"]'
|
||||
PINS_PATHS: '["ci_cd/cost_map_pins.json"]'
|
||||
run: |
|
||||
if [[ "$BEFORE_SHA" =~ ^0+$ || "$FORCED" == true ]] \
|
||||
|| ! gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${AFTER_SHA}?per_page=1" \
|
||||
--jq '(env.PINS_PATHS | fromjson) as $pins | (.files | length) < 300 and all(.files[]; [.filename, .previous_filename] | any(IN($pins[])) | not)' \
|
||||
| grep -qx true; then
|
||||
export RECHECK_PATHS="$COST_MAP_PATHS"
|
||||
else
|
||||
export RECHECK_PATHS="$PINS_PATHS"
|
||||
fi
|
||||
rerun_cutoff="$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
candidates="$(gh api graphql --paginate -F owner="${GITHUB_REPOSITORY_OWNER}" -F name="${GITHUB_REPOSITORY#*/}" -F base="$BASE_REF" \
|
||||
-f query='query($owner: String!, $name: String!, $base: String!, $endCursor: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(states: OPEN, baseRefName: $base, first: 25, after: $endCursor) {
|
||||
nodes { number headRefOid changedFiles files(first: 100) { nodes { path } } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '(env.RECHECK_PATHS | fromjson) as $paths | .data.repository.pullRequests.nodes[]
|
||||
| {number, headRefOid, listed: any(.files.nodes[].path; IN($paths[])), unlisted: (.changedFiles > 100)}
|
||||
| select(.listed or .unlisted) | "\(.number) \(.headRefOid) \(.listed)"')"
|
||||
failed=0
|
||||
declare -A rerun_heads
|
||||
while read -r number head_sha listed; do
|
||||
[ -n "$number" ] || continue
|
||||
if [ "$listed" = false ]; then
|
||||
if ! changed="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${number}/files?per_page=100" \
|
||||
--jq '(env.RECHECK_PATHS | fromjson) as $paths | .[].filename | select(IN($paths[]))')"; then
|
||||
echo "::error::could not list the files of #$number"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
[ -n "$changed" ] || continue
|
||||
fi
|
||||
[ -z "${rerun_heads[$head_sha]:-}" ] || continue
|
||||
rerun_heads[$head_sha]=1
|
||||
if ! runs="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow cost-map-guard.yml --commit "$head_sha" --limit 100 --json databaseId,createdAt --jq '.[] | "\(.databaseId) \(.createdAt)"')"; then
|
||||
echo "::error::could not list the cost map guard runs of #$number"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
if [ -z "$runs" ]; then
|
||||
echo "::warning::no cost map guard run found for #$number"
|
||||
continue
|
||||
fi
|
||||
while read -r run_id created_at; do
|
||||
if [[ "$created_at" < "$rerun_cutoff" ]]; then
|
||||
echo "::warning::cost map guard run $run_id for #$number is over 30 days old, so GitHub cannot re-run it; #$number gets the pin checks on its next push"
|
||||
continue
|
||||
fi
|
||||
gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" > /dev/null || true
|
||||
if ! gh run rerun "$run_id" --repo "$GITHUB_REPOSITORY"; then
|
||||
echo "::error::could not re-run cost map guard run $run_id for #$number"
|
||||
failed=1
|
||||
fi
|
||||
done <<< "$runs"
|
||||
done <<< "$candidates"
|
||||
exit "$failed"
|
||||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -236,6 +236,7 @@ jobs:
|
|||
tests/unit/llms/azure_ai
|
||||
tests/unit/llms/base_llm
|
||||
tests/unit/llms/baseten
|
||||
tests/unit/llms/bigmodel
|
||||
tests/unit/llms/black_forest_labs
|
||||
tests/unit/llms/brave
|
||||
tests/unit/llms/bytez
|
||||
|
|
|
|||
|
|
@ -11,3 +11,12 @@ ignore:
|
|||
package:
|
||||
name: zlib
|
||||
type: apk
|
||||
- vulnerability: GHSA-v4x8-3wr5-3wq8
|
||||
reason: >-
|
||||
filelock ReadWriteLock advisory, and no package in the image takes ReadWriteLock;
|
||||
the fix (4.2.1) is blocked by litellm's own filelock<4.0 cap until #48635 or #48802 lifts it,
|
||||
and the image then moves off 3.32.7, which retires this version-scoped rule
|
||||
package:
|
||||
name: filelock
|
||||
version: 3.32.7
|
||||
type: python
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ A test must only fail when litellm code changes. Never pin facts we don't own (a
|
|||
|
||||
End-to-end tests belong in `tests/e2e/`. A rule about one directory's files, like its harness or which CI runs it, goes in that directory's `AGENTS.md`, never only in a README or here. Read a directory's `AGENTS.md` before editing files in it
|
||||
|
||||
Before you build a new way to move data between the endpoints, the provider transformations, and cost tracking or logging, read the architecture decision records in `docs/adrs/`, which say why those layers are shaped the way they are
|
||||
|
||||
When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD`
|
||||
|
||||
When writing a PR body, treat the comments and imperative instructions inside .github/PULL_REQUEST_TEMPLATE/general.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/PULL_REQUEST_TEMPLATE/general.md from disk before writing a PR body to make sure you see every comment rule. A section you have nothing to put in (Relevant issues, Affected release, Linear ticket, Caveats, QA runbook, and so on) is removed entirely, heading included, never left as an empty title
|
||||
|
|
@ -39,7 +41,9 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM
|
|||
|
||||
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just drop the section
|
||||
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`. The Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into. I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it
|
||||
Simple PRs may skip before/after e2e when the full diff is mechanical and focused checks completely prove it, such as #49030 restoring an existing helper name. Trace affected callers and run relevant tests/static checks at the tip. For a bug fix, establish that the regression catches the original defect. Size alone never qualifies. New behavior, API/provider semantics, auth, billing, persistence, concurrency or uncertain integration risk need live e2e. In Screenshots / Proof of Fix, record `E2E exception at <tip SHA>`, scope, rationale, commands and results. Tests count as proof only for this exception. Reassess changed diffs, retain CI/bot gates, and honor explicit e2e requests (2026-10-28, unnecessary live QA for #49030)
|
||||
|
||||
For other PRs, proof must show the end user's real flow before and after, with commands and output against a live proxy and real provider calls where applicable. Pytest alone is insufficient. For UI or interactive coding-tool flows, drive that surface and embed before/after screenshots, with URLs, clicks and input fields a reviewer can reproduce
|
||||
|
||||
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
|
||||
- don't use emojis
|
||||
|
|
@ -105,6 +109,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
|
|||
- No file sprawl: deliberate file and folder structure
|
||||
- Standard over hand-rolled: use the official SDK or a library where one exists. Where none does, follow industry standards instead of inventing local conventions
|
||||
- API-fragmentation-aware: when logic must branch on which API surface produced or consumes data (e.g. chat completions vs Anthropic Messages vs Responses API shapes), proactively look for an existing shared helper (e.g. `litellm_core_utils/prompt_templates/factory.py`) before writing per-surface parsing in the new module. If none exists, add one there instead of duplicating the same format-detection logic in every new guardrail/integration
|
||||
- Current default for new model names: logic that picks behavior from a model-name table (tokenizer, message framing, supported params) gives a provider's model the table does not list yet that provider's current behavior, never its oldest, since providers ship names faster than any table is updated
|
||||
|
||||
Follow conventional commits for commit names and PR titles
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
This document helps contributors understand where to make changes in LiteLLM.
|
||||
|
||||
For why a layer is shaped the way it is, read the architecture decision records in [`docs/adrs/`](docs/adrs/AGENTS.md) (Python SDK and proxy) and [`litellm-rust/docs/adrs/`](litellm-rust/docs/adrs/AGENTS.md) (Rust core)
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Here are the core requirements for any PR submitted to LiteLLM:
|
|||
|
||||
- [ ] **Sign the Contributor License Agreement (CLA)** - [see details](#contributor-license-agreement-cla)
|
||||
- [ ] **Keep scope isolated** - Your changes should address 1 specific problem at a time
|
||||
- [ ] **Prove it works end to end** - If your change affects what the proxy or SDK does, paste the commands you ran against a live proxy and their output in the PR - [see details](#proving-your-change-works-end-to-end)
|
||||
|
||||
#### Proxy (Backend) PRs
|
||||
|
||||
|
|
@ -248,6 +249,41 @@ If `make test-unit` fails:
|
|||
- **Test edge cases**: Don't just test the happy path
|
||||
- **Update documentation**: If you change APIs, update docs
|
||||
|
||||
## Proving Your Change Works End to End
|
||||
|
||||
Tests are required, but they do not prove that your change works for a user. A PR that changes what the proxy or SDK does shows a real run in its "Screenshots / Proof of Fix" section, meaning the exact commands you sent to a live proxy and the output that came back, against real provider APIs and not mocks. `pytest` output does not count as proof, because a passing test only shows that the code does what its mocks expect. A PR that changes only docs or CI shows the rendered page or the CI run instead
|
||||
|
||||
A good proof looks like this:
|
||||
|
||||
1. Install the proxy dependencies with `make install-proxy-dev`, then start the proxy from your branch with a config that has the model you changed, `uv run litellm --config your_config.yaml --detailed_debug`
|
||||
2. Send the request a user would send with `curl`, and paste both the command and the response. When cost or routing is involved, include the response headers, since `x-litellm-response-cost` is what the user sees
|
||||
3. For a bug fix, do this twice, once at the merge base with BerriAI/litellm's main (`git merge-base origin/main HEAD`, with your remote for BerriAI/litellm in place of `origin` in a fork clone) to show the bug and once at your branch's latest commit to show the fix. Name both commit hashes
|
||||
4. If your change is reachable from more than one endpoint (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`), show each one
|
||||
5. If the request can stream, show a `"stream": true` run too, because streaming builds usage and cost on a different path. A stream sends its headers before its usage exists, so show the `usage.cost` on the last chunk, which `/v1/chat/completions` sends when the request has `"stream_options": {"include_usage": true}` and other endpoints send with `include_cost_in_streaming_usage: true` under `litellm_settings`, or, when the proxy has a database, the request's row on the Admin UI's Logs page or `GET /spend/logs`
|
||||
6. For UI changes, add before and after screenshots and name the page
|
||||
7. If users reach your change through a coding tool such as Claude Code or Codex, drive that tool the way a user does and add before and after screenshots of it, because a `curl` or a headless run alone does not show what the user sees
|
||||
|
||||
```bash
|
||||
curl -sD - http://localhost:4000/v1/chat/completions \
|
||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "your-model", "messages": [{"role": "user", "content": "hi"}]}'
|
||||
```
|
||||
|
||||
When the provider reports its own usage or spend numbers, compare the proxy's numbers against them in the proof
|
||||
|
||||
## Contributing an LLM Provider Integration
|
||||
|
||||
Provider work (a new provider, a new parameter, a new usage or cost field) is the most common kind of contribution, and it is where PRs get reworked most often. Follow these rules
|
||||
|
||||
**Find the existing machinery before you add plumbing.** LiteLLM already has generic paths that move data between the endpoints, the provider transformations, and cost tracking. A second, hand-rolled path for the same data is a common reason a provider PR gets rewritten before merge. [`ARCHITECTURE.md`](ARCHITECTURE.md) shows where the layers live, and the decision records in [`docs/adrs/`](docs/adrs/AGENTS.md) explain why they are shaped that way. For example, [the ADR on provider usage fields](docs/adrs/adr_001_provider_usage_fields_ride_as_usage_extras.md) covers how provider-specific usage fields reach cost tracking. If you cannot find a mechanism for what you need, say so in the PR or ask in [#pr-review on Slack](https://join.slack.com/t/litellmossslack/shared_invite/zt-3o7nkuyfr-p_kbNJj8taRfXGgQI1~YyA) before you build one
|
||||
|
||||
**Bill a new provider usage field from the usage object.** Leave the field on the usage object as an extra and read it in that provider's `cost_calculator.py`. A provider without one gets a new `cost_calculator.py` and an arm for it in `cost_per_token` (`litellm/cost_calculator.py`), which picks the calculator by provider. If the provider's transform builds `Usage` from named fields, as the Anthropic, Gemini, and Bedrock Converse ones do, pass the new field there too. Streamed requests rebuild usage in `ChunkProcessor.calculate_usage` (`litellm/litellm_core_utils/streaming_chunk_builder_utils.py`), which does not copy extras yet the way the Responses-to-chat bridge does (minus a short exclusion list), so until it does, bill the field on streams by naming it there, as Anthropic's `inference_geo` is. A provider that reports its own charge for the request, as OpenRouter does in `usage.cost`, is billed at that charge from `_hidden_params` before any calculator runs, unless the deployment sets its own prices. When the field counts a built-in tool such as web search, add the provider to `get_cost_for_web_search_request` in `litellm/llms/__init__.py`, and add a gate to `litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py` only when no existing gate fires. Then show the field's cost in your proof on every endpoint it reaches, streaming included
|
||||
|
||||
**Do not change what a caller sees to make internals easier.** Each endpoint keeps the schema of the API it emulates. A `/v1/responses` caller gets `input_tokens` and a `/v1/chat/completions` caller gets `prompt_tokens`, whatever the provider sent and whatever cost tracking needs. A provider usage field that shows up as an extra key is fine, since it only adds a key, but renaming, removing, or reshaping a key the endpoint defines is not. Reshaping a public response to feed an internal consumer breaks every user of that endpoint
|
||||
|
||||
If your change makes an architecture decision, such as a new cross-cutting mechanism or a deliberate difference from a provider's API shape, add an ADR to [`docs/adrs/`](docs/adrs/AGENTS.md) in the same PR
|
||||
|
||||
## Building and Running Locally
|
||||
|
||||
### LiteLLM Proxy Server
|
||||
|
|
|
|||
|
|
@ -1,37 +1,67 @@
|
|||
"""Guard the cost map on pull requests.
|
||||
|
||||
Every pull request whose diff against its merge base touches one of the two cost map files gets the file
|
||||
checks: the files parse and the backup copy matches the root file. A pull request that leaves both untouched
|
||||
skips them, since merging it keeps the base branch's copies and its head tree only carries whatever state the
|
||||
branch was cut from. Pull requests from the cost map sync bot (branches named litellm_cost_map_sync_*) always
|
||||
get the file checks and additionally may only touch those two files and may only add or update models.
|
||||
Every pull request whose diff against its merge base touches one of the two cost map files or the pins file gets the
|
||||
file checks: the files parse, the backup copy matches the root file, and every capability pinned in
|
||||
ci_cd/cost_map_pins.json (a value verified against a live provider call where the provider's model listing says
|
||||
otherwise) still holds in the tree that merging the pull request into its base branch produces, so a branch cut before
|
||||
a pin was added cannot lower it, and a pull request that deletes the pins file fails too. A pull request that leaves
|
||||
all three untouched skips them, since merging it keeps the base branch's copies and its head tree only carries
|
||||
whatever state the branch was cut from. Pull requests from the cost map sync bot (branches named
|
||||
litellm_cost_map_sync_*) always get the file checks and additionally may only touch the two cost map files and may
|
||||
only add or update models, so a sync can never lower a pinned capability or edit the pins. Price sync pull requests
|
||||
(branches named litellm-providers/price-sync*) and pull requests a bot account opened (COST_MAP_GUARD_AUTHOR_TYPE is
|
||||
Bot) get the human checks and may not touch the pins file either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
COST_MAP_PATH: Final = "model_prices_and_context_window.json"
|
||||
BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
|
||||
PINS_PATH: Final = "ci_cd/cost_map_pins.json"
|
||||
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH)
|
||||
CHECKED_PATHS: Final = (*GUARDED_PATHS, PINS_PATH)
|
||||
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
|
||||
PRICE_SYNC_BRANCH_PREFIX: Final = "litellm-providers/price-sync"
|
||||
TARGET_ENV: Final = "COST_MAP_GUARD_TARGET"
|
||||
AUTHOR_TYPE_ENV: Final = "COST_MAP_GUARD_AUTHOR_TYPE"
|
||||
BOT_AUTHOR_TYPE: Final = "Bot"
|
||||
MERGE_CONFLICT_EXIT_CODE: Final = 1
|
||||
SPECIAL_ROOT_KEYS: Final = frozenset({"sample_spec", "fallback_generalizations"})
|
||||
EVALUATION_OUTPUT_PRICE_SOURCES: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
CostMap = dict[str, object]
|
||||
_MISSING: Final = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Snapshot:
|
||||
cost_map: str
|
||||
backup: str
|
||||
pins: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MergeFailed:
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pin:
|
||||
key: str
|
||||
field: str
|
||||
value: object
|
||||
checked: str
|
||||
source: str
|
||||
|
||||
|
||||
def _parse_object(text: str, path: str) -> CostMap | str:
|
||||
|
|
@ -91,6 +121,48 @@ def _entries(cost_map: CostMap) -> dict[str, dict[str, object]]:
|
|||
return {key: entry for key, entry in cost_map.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def _pin(key: str, field: str, spec: object) -> Pin | str:
|
||||
malformed: Final = f"{PINS_PATH}: {key}.{field} needs value, checked, and source"
|
||||
if not isinstance(spec, dict) or "value" not in spec:
|
||||
return malformed
|
||||
checked: Final = spec.get("checked")
|
||||
source: Final = spec.get("source")
|
||||
if not isinstance(checked, str) or not isinstance(source, str) or not checked or not source:
|
||||
return malformed
|
||||
return Pin(key, field, spec["value"], checked, source)
|
||||
|
||||
|
||||
def _pins(parsed: CostMap) -> Iterator[Pin | str]:
|
||||
for key, fields in parsed.items():
|
||||
if not isinstance(fields, dict):
|
||||
yield f"{PINS_PATH}: {key} must map field names to pins"
|
||||
continue
|
||||
for field, spec in fields.items():
|
||||
yield _pin(key, field, spec)
|
||||
|
||||
|
||||
def _pin_violations(parsed: CostMap, entries: Mapping[str, Mapping[str, object]]) -> Iterator[str]:
|
||||
for pin in _pins(parsed):
|
||||
if isinstance(pin, str):
|
||||
yield pin
|
||||
elif pin.key not in entries:
|
||||
yield f"{PINS_PATH} pins {pin.key}.{pin.field} but {COST_MAP_PATH} has no {pin.key} entry"
|
||||
elif entries[pin.key].get(pin.field, _MISSING) != pin.value:
|
||||
yield (
|
||||
f"{COST_MAP_PATH}: {pin.key}.{pin.field} must stay {json.dumps(pin.value)} "
|
||||
f"(verified {pin.checked}: {pin.source}); change {PINS_PATH} with new evidence first"
|
||||
)
|
||||
|
||||
|
||||
def pin_failures(pins_text: str, head_map: CostMap) -> tuple[str, ...]:
|
||||
if not pins_text:
|
||||
return (f"{PINS_PATH} is missing; restore it, its pins were verified against live provider calls",)
|
||||
parsed: Final = _parse_object(pins_text, PINS_PATH)
|
||||
if isinstance(parsed, str):
|
||||
return (parsed,)
|
||||
return tuple(_pin_violations(parsed, _entries(head_map)))
|
||||
|
||||
|
||||
def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
base_map: Final = _parse_object(base.cost_map, COST_MAP_PATH)
|
||||
if isinstance(base_map, str):
|
||||
|
|
@ -120,8 +192,18 @@ def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str
|
|||
)
|
||||
|
||||
|
||||
def pin_author_failures(head_ref: str, author_type: str, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
if PINS_PATH not in changed_files:
|
||||
return ()
|
||||
if head_ref.startswith(PRICE_SYNC_BRANCH_PREFIX):
|
||||
return (f"price sync PRs may not change {PINS_PATH}; a pin moves only in a human PR with new evidence",)
|
||||
if author_type == BOT_AUTHOR_TYPE:
|
||||
return (f"PRs opened by a bot may not change {PINS_PATH}; a pin moves only in a human PR with new evidence",)
|
||||
return ()
|
||||
|
||||
|
||||
def touches_cost_map(changed_files: Sequence[str]) -> bool:
|
||||
return any(path in GUARDED_PATHS for path in changed_files)
|
||||
return any(path in CHECKED_PATHS for path in changed_files)
|
||||
|
||||
|
||||
def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
||||
|
|
@ -130,9 +212,21 @@ def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
|||
return "human PR, file checks only" if touches_cost_map(changed_files) else "human PR, cost map untouched"
|
||||
|
||||
|
||||
def _merged_pin_failures(base: Snapshot, merged: Snapshot | None) -> tuple[str, ...]:
|
||||
if merged is None:
|
||||
return ("the pull request does not merge cleanly into its base branch; merge the base branch in first",)
|
||||
if not merged.pins and not base.pins:
|
||||
return ()
|
||||
merged_map: Final = _parse_object(merged.cost_map, COST_MAP_PATH)
|
||||
if isinstance(merged_map, str):
|
||||
return (f"after merging into the base branch, {merged_map}",)
|
||||
return pin_failures(merged.pins, merged_map)
|
||||
|
||||
|
||||
def guard_failures(
|
||||
base: Snapshot,
|
||||
head: Snapshot,
|
||||
merged: Snapshot | None,
|
||||
changed_files: Sequence[str],
|
||||
bot: bool,
|
||||
*,
|
||||
|
|
@ -147,6 +241,7 @@ def guard_failures(
|
|||
*_file_failures(
|
||||
head, head_map, evaluation_output_price_sources=evaluation_output_price_sources
|
||||
),
|
||||
*_merged_pin_failures(base, merged),
|
||||
*(_bot_failures(base, head_map, changed_files) if bot else ()),
|
||||
)
|
||||
|
||||
|
|
@ -157,7 +252,22 @@ def _git(*args: str) -> str | None:
|
|||
|
||||
|
||||
def snapshot(revision: str) -> Snapshot:
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in GUARDED_PATHS))
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in CHECKED_PATHS))
|
||||
|
||||
|
||||
def merged_tree(merge_base: str, target: str, head: str) -> str | MergeFailed | None:
|
||||
result: Final = subprocess.run(
|
||||
("git", "merge-tree", "--write-tree", f"--merge-base={merge_base}", target, head),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tree: Final = result.stdout.split("\n", 1)[0]
|
||||
if result.returncode == 0:
|
||||
return tree
|
||||
if result.returncode == MERGE_CONFLICT_EXIT_CODE and tree:
|
||||
return None
|
||||
return MergeFailed(result.stderr.strip())
|
||||
|
||||
|
||||
def changed_files(base: str, head: str) -> tuple[str, ...] | None:
|
||||
|
|
@ -169,6 +279,13 @@ def main(argv: Sequence[str]) -> int:
|
|||
parser: Final = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", required=True, help="merge base of the pull request")
|
||||
parser.add_argument("--head", required=True, help="head commit of the pull request")
|
||||
env_target: Final = os.environ.get(TARGET_ENV)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default=env_target,
|
||||
required=env_target is None,
|
||||
help=f"tip of the base branch the pull request merges into, defaulting to ${TARGET_ENV}",
|
||||
)
|
||||
parser.add_argument("--head-ref", required=True, help="head branch name of the pull request")
|
||||
args: Final = parser.parse_args(argv)
|
||||
bot: Final = args.head_ref.startswith(BOT_BRANCH_PREFIX)
|
||||
|
|
@ -176,7 +293,19 @@ def main(argv: Sequence[str]) -> int:
|
|||
if changed is None:
|
||||
print(f"cost map guard failed: git diff {args.base} {args.head} failed, so the changed files are unknown")
|
||||
return 1
|
||||
failures: Final = guard_failures(snapshot(args.base), snapshot(args.head), changed, bot)
|
||||
if not bot and not touches_cost_map(changed):
|
||||
print(f"cost map guard passed ({contract_for(bot, changed)})")
|
||||
return 0
|
||||
merged: Final = merged_tree(args.base, args.target, args.head)
|
||||
if isinstance(merged, MergeFailed):
|
||||
print(f"cost map guard failed: git merge-tree into {args.target} failed: {merged.stderr}")
|
||||
return 1
|
||||
failures: Final = (
|
||||
*guard_failures(
|
||||
snapshot(args.base), snapshot(args.head), None if merged is None else snapshot(merged), changed, bot
|
||||
),
|
||||
*pin_author_failures(args.head_ref, os.environ.get(AUTHOR_TYPE_ENV, ""), changed),
|
||||
)
|
||||
contract: Final = contract_for(bot, changed)
|
||||
if failures:
|
||||
print(f"cost map guard failed ({contract}):")
|
||||
|
|
|
|||
16
ci_cd/cost_map_pins.json
Normal file
16
ci_cd/cost_map_pins.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"fireworks_ai/accounts/fireworks/models/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
},
|
||||
"fireworks_ai/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
docs/adrs/AGENTS.md
Normal file
5
docs/adrs/AGENTS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Architecture decision records for the Python SDK and proxy
|
||||
|
||||
This folder records decisions about the Python SDK and proxy the way [`litellm-rust/docs/adrs/`](../../litellm-rust/docs/adrs/AGENTS.md) records them for the Rust core, and that file's process applies here unchanged: one short `adr_NNN_short_title.md` per decision that is hard to reverse or that a new contributor would reasonably question, with Context, Decision, Alternatives Considered, and Consequences, superseded by a new ADR instead of edited once accepted. Each folder numbers its own ADRs, so a reference to an ADR in the other folder names its path
|
||||
|
||||
Read the ADRs here before you build a new way to move data between the endpoints, the provider transformations, and cost tracking or logging. A PR that makes a decision of that kind adds its ADR in the same PR
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ADR 001: Provider usage fields ride as extras on the normalized usage object
|
||||
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Providers report usage fields that no other provider has, such as xAI's `usage.server_side_tool_usage_details.web_search_calls` or Anthropic's `usage.inference_geo`, and cost tracking has to read them or that spend never reaches `LiteLLM_SpendLogs`. One provider response can be served on `/v1/chat/completions`, `/v1/responses`, and `/v1/messages`, streaming or not, so a field that only one of those paths carries is billed wrong on the others
|
||||
|
||||
[#30817](https://github.com/BerriAI/litellm/pull/30817) fixed unbilled xAI web searches. One draft did the copy in the xAI Responses transform to keep provider code out of shared files, and that swapped `/v1/responses` usage to the chat shape, so callers of that endpoint lost the Responses schema. The merged version keeps the schema, copies unknown usage fields in the shared Responses-to-chat bridge for every provider, and gates on the reported field without naming the provider
|
||||
|
||||
In October 2026, `Usage` and `ResponseAPIUsage` kept unknown usage keys as pydantic extras, and that bridge copied them. Paths that build usage from named fields kept only the fields they name: the Anthropic, Gemini, and Bedrock Converse transforms, the stream builder, and the bridges from chat usage to `/v1/responses` and `/v1/messages`
|
||||
|
||||
## Decision
|
||||
|
||||
Provider-only usage fields stay on the usage object as pydantic extras, and cost tracking reads them there, in that provider's cost calculator or in a shared gate that checks the field instead of the provider. An extra reaches callers as an added usage key. No transform renames, removes, or reshapes a usage field the endpoint's schema defines to feed cost tracking, though it may fill one LiteLLM's usage object already defines, such as `server_tool_use`
|
||||
|
||||
A shared path that rebuilds usage, such as the stream builder or a bridge between endpoint shapes, copies the extras instead of naming provider fields. A provider transform that builds usage from named fields names the new field. Neither is bypassed with a side channel. Older code that differs, such as the Azure branches in the shared tracker and the Gemini pricing tier read from `_hidden_params`, predates this ADR
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Reshape usage in the provider transform**, as a draft of #30817 did. It changes the schema that callers of that endpoint rely on, and every provider and endpoint needs its own copy
|
||||
|
||||
**A typed field on `ResponseAPIUsage` and `Usage` for each provider's quirk**. The types grow without bound and every bridge needs an update for each one. A field several providers report, like `server_tool_use`, does get one
|
||||
|
||||
**A side channel such as `_hidden_params`**. Cost calculators receive `Usage`, so a side channel makes a second source of truth for the same number
|
||||
|
||||
**Provider branches or provider field names in shared code**. They put one provider's logic in a file every provider shares, which is why #30817 replaced its xAI-only gate with one that checks the reported field
|
||||
|
||||
## Consequences
|
||||
|
||||
Benefits:
|
||||
|
||||
- Billing a new provider usage field touches that provider's calculator (plus a `cost_per_token` arm when the calculator is new), its transform when that names fields, and for a built-in tool at most one gate and one `get_cost_for_web_search_request` arm
|
||||
|
||||
Costs:
|
||||
|
||||
- Extras are untyped, so a reader validates them before use, as `_usage_reports_server_side_web_search_calls` does
|
||||
- The stream builder and the bridges from chat usage did not copy extras when this was written, so until they do, a field billed on those paths is named in them, and tests and proof runs cover each endpoint and `"stream": true`
|
||||
- This covers usage fields LiteLLM prices, not request-side params or a provider's own charge for the request, which OpenRouter and opencode Go report and `response_cost_calculator` reads from `_hidden_params` first
|
||||
|
|
@ -15,7 +15,10 @@ import litellm
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.proxy.guardrails._content_utils import (
|
||||
is_non_conversational_request,
|
||||
iter_message_text,
|
||||
)
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
_THRESHOLD_VALUE: Final = TypeAdapter(object)
|
||||
|
|
@ -94,7 +97,7 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger):
|
|||
|
||||
async def async_moderation_hook(
|
||||
self,
|
||||
data: dict,
|
||||
data: dict[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
call_type: CallTypesLiteral,
|
||||
):
|
||||
|
|
@ -102,6 +105,9 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger):
|
|||
- Calls Google's Text Moderation API
|
||||
- Rejects request if it fails safety check
|
||||
"""
|
||||
if is_non_conversational_request(call_type, data):
|
||||
verbose_proxy_logger.debug("Google text moderation: skipping non-conversational call type %s", call_type)
|
||||
return
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
text = "".join(iter_message_text(data))
|
||||
if text:
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ class EnterpriseCallbackControls:
|
|||
|
||||
try:
|
||||
disabled_callbacks = EnterpriseCallbackControls.get_disabled_callbacks(litellm_params, standard_callback_dynamic_params)
|
||||
verbose_logger.debug(f"Dynamically disabled callbacks from {X_LITELLM_DISABLE_CALLBACKS}: {disabled_callbacks}")
|
||||
verbose_logger.debug(f"Checking if {callback} is disabled via headers. Disable callbacks from headers: {disabled_callbacks}")
|
||||
if disabled_callbacks is not None:
|
||||
verbose_logger.debug(f"Checking if {callback} is in dynamically disabled callbacks: {disabled_callbacks}")
|
||||
#########################################################
|
||||
# premium user check
|
||||
#########################################################
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from collections.abc import Awaitable, Callable, Mapping
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
|
|
@ -290,12 +290,14 @@ class CheckResponsesCost:
|
|||
sees provider env vars, so it fails for every deployment whose credentials
|
||||
live in the config; the row then never leaves ``queued``.
|
||||
"""
|
||||
if not via_router:
|
||||
return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
router_response = await self.llm_router.aget_responses(
|
||||
response_id=response_id, litellm_metadata=litellm_metadata
|
||||
retrieved: Final[object] = (
|
||||
await self.llm_router.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
if via_router
|
||||
else await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
)
|
||||
return cast(ResponsesAPIResponse, router_response)
|
||||
if not isinstance(retrieved, ResponsesAPIResponse):
|
||||
raise TypeError(f"Retrieving {response_id} did not return a response object")
|
||||
return retrieved
|
||||
|
||||
async def _fetch_unbilled(self, target: _PollTarget) -> ResponsesAPIResponse:
|
||||
return await self._get_response(
|
||||
|
|
|
|||
63
examples/bigmodel.md
Normal file
63
examples/bigmodel.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# BigModel (Zhipu AI)
|
||||
|
||||
Use `bigmodel/` for the domestic BigModel platform and `zai/` for international Z.AI. Each platform uses its own credentials and default URL
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: glm
|
||||
litellm_params:
|
||||
model: bigmodel/glm-5.3
|
||||
api_key: os.environ/BIGMODEL_API_KEY
|
||||
api_base: https://open.bigmodel.cn
|
||||
```
|
||||
|
||||
One model configuration supports all three native APIs. The provider selects the upstream endpoint from the requested API
|
||||
|
||||
| Proxy endpoint | BigModel endpoint |
|
||||
| --- | --- |
|
||||
| `/v1/chat/completions` | `/api/paas/v4/chat/completions` |
|
||||
| `/v1/responses` | `/api/v1/responses` |
|
||||
| `/v1/messages` | `/api/anthropic/v1/messages` |
|
||||
|
||||
`api_base` is optional and defaults to `https://open.bigmodel.cn`. Set `BIGMODEL_API_BASE` to override the default, or pass an explicit `api_base` to override the environment value. Existing API-specific base URLs and complete endpoint URLs are normalized, preserving a custom proxy's path prefix
|
||||
|
||||
A GLM Coding Plan key uses `https://open.bigmodel.cn/api/coding/paas/v4` as its `api_base`, since Coding Plan and standard keys do not work on each other's endpoints. Chat Completions then calls the Coding Plan endpoint, and Responses and Messages keep the endpoints in the table above
|
||||
|
||||
The Admin UI lists BigModel (Zhipu AI), displays its official logo, and provides a required password API Key field and an optional API Base field with the default URL. Authentication uses an explicit `api_key` or `BIGMODEL_API_KEY`
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
chat = litellm.completion(
|
||||
model="bigmodel/glm-5.3",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
thinking={"type": "enabled"},
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
response = litellm.responses(
|
||||
model="bigmodel/glm-5.3",
|
||||
input="Hello",
|
||||
reasoning={"effort": "low"},
|
||||
store=True,
|
||||
)
|
||||
|
||||
async def messages():
|
||||
return await litellm.anthropic.messages.acreate(
|
||||
model="bigmodel/glm-5.3",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
thinking={"type": "adaptive"},
|
||||
output_config={"effort": "low"},
|
||||
)
|
||||
```
|
||||
|
||||
Each API retains its native response format and streaming events. The native Messages SDK entry point is asynchronous, as is the proxy's `/v1/messages` route
|
||||
|
||||
GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX use always-on thinking. GLM-5.3 supports `low`, `high`, and `max` reasoning effort. Messages preserves native `thinking`, `output_config`, and `reasoning_effort` parameters
|
||||
|
||||
Set `store=True` when creating a Responses request if you need continuation, retrieval, or deletion. Use the response ID returned by LiteLLM. BigModel documents a seven-day retention period and does not support cancel, compact, native WebSocket, or file_search
|
||||
|
||||
The model catalog and packaged backup include GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX. Prices are expressed in USD/token using a static 7.0 CNY/USD conversion, with the source and verification date in each entry's metadata. Use existing `litellm_params` cost fields for contract-specific deployment prices
|
||||
|
||||
API references: [Chat Completions](https://docs.bigmodel.cn/cn/guide/develop/openai/introduction), [Responses](https://docs.bigmodel.cn/cn/guide/develop/responses/introduction), [Messages](https://docs.bigmodel.cn/cn/guide/develop/claude/introduction), and [pricing](https://docs.bigmodel.cn/cn/guide/start/pricing), checked on 2026-10-27
|
||||
|
|
@ -12,6 +12,7 @@ use rstest::rstest;
|
|||
use super::*;
|
||||
|
||||
const MODEL: &str = "azure_ai/doc-intelligence/prebuilt-read";
|
||||
const LAYOUT_MODEL: &str = "azure_ai/doc-intelligence/prebuilt-layout";
|
||||
|
||||
fn read_request(base: &str, options: Value) -> LiteLLMOcrRequest {
|
||||
ocr_request(MODEL, base, options)
|
||||
|
|
@ -70,6 +71,7 @@ async fn pages_features_and_extra_options_map_to_the_analyze_call() {
|
|||
#[case(json!({"pages": "1&&features=bad"}), Error::Pages(PagesFault::Range))]
|
||||
#[case(json!({"features": "languages&pages=1"}), Error::Features)]
|
||||
#[case(json!({"req_format": "azure"}), Error::RequestFormat)]
|
||||
#[case(json!({"outputContentFormat": "html"}), Error::OutputContentFormat)]
|
||||
#[tokio::test]
|
||||
async fn invalid_pages_features_and_format_are_rejected_before_sending(
|
||||
#[case] options: Value,
|
||||
|
|
@ -130,6 +132,137 @@ async fn an_inline_document_is_sent_as_base64_and_only_page_text_is_kept(#[case]
|
|||
assert_eq!(sent.json(), json!({"base64Source": "YWJj"}));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::lower_case("markdown")]
|
||||
#[case::capitalized("Markdown")]
|
||||
#[tokio::test]
|
||||
async fn markdown_output_is_requested_on_the_analyze_call_and_split_into_pages(
|
||||
#[case] output_content_format: &str,
|
||||
) {
|
||||
let upstream = upstream([json_response(json!({
|
||||
"status": "succeeded",
|
||||
"analyzeResult": {
|
||||
"content": "# Tïtle\n\n<!-- PageBreak -->\n\n| a |",
|
||||
"contentFormat": "markdown",
|
||||
"stringIndexType": "unicodeCodePoint",
|
||||
"pages": [
|
||||
{"pageNumber": 1, "spans": [{"offset": 0, "length": 29}], "lines": [{"content": "Tïtle"}]},
|
||||
{"pageNumber": 2, "spans": [{"offset": 29, "length": 5}], "lines": [{"content": "a"}]}
|
||||
]
|
||||
}
|
||||
}))])
|
||||
.await;
|
||||
|
||||
let response = perform(ocr_request(
|
||||
LAYOUT_MODEL,
|
||||
&upstream.uri(),
|
||||
json!({"outputContentFormat": output_content_format, "pages": [0, 1]}),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(
|
||||
sent.query("outputContentFormat").as_deref(),
|
||||
Some("markdown")
|
||||
);
|
||||
assert_eq!(
|
||||
sent.query("stringIndexType").as_deref(),
|
||||
Some("unicodeCodePoint")
|
||||
);
|
||||
assert_eq!(sent.query("pages").as_deref(), Some("1,2"));
|
||||
assert_eq!(sent.json(), json!({"base64Source": "YWJj"}));
|
||||
assert_eq!(
|
||||
response.content.as_deref(),
|
||||
Some("# Tïtle\n\n<!-- PageBreak -->\n\n| a |")
|
||||
);
|
||||
let pages = response
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| page.markdown.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pages, ["# Tïtle\n\n<!-- PageBreak -->\n\n", "| a |"]);
|
||||
assert_eq!(
|
||||
response.extra_fields.get("stringIndexType"),
|
||||
Some(&json!("unicodeCodePoint"))
|
||||
);
|
||||
assert_eq!(
|
||||
response.usage_info.as_ref().unwrap().pages_processed,
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::native_markdown(LAYOUT_MODEL, json!({"outputContentFormat": "markdown", "req_format": "native"}), Some("markdown"))]
|
||||
#[case::markdown_on_read(MODEL, json!({"outputContentFormat": "markdown"}), Some("markdown"))]
|
||||
#[case::text(LAYOUT_MODEL, json!({"outputContentFormat": "text"}), Some("text"))]
|
||||
#[case::omitted(LAYOUT_MODEL, json!({}), None)]
|
||||
#[tokio::test]
|
||||
async fn only_layout_markdown_in_litellm_format_asks_for_code_point_offsets(
|
||||
#[case] model: &str,
|
||||
#[case] options: Value,
|
||||
#[case] output_content_format: Option<&str>,
|
||||
) {
|
||||
let upstream = upstream([json_response(json!({
|
||||
"status": "succeeded",
|
||||
"analyzeResult": {
|
||||
"contentFormat": "text",
|
||||
"stringIndexType": "textElements",
|
||||
"pages": [{"pageNumber": 1, "lines": [{"content": "hello"}]}]
|
||||
}
|
||||
}))])
|
||||
.await;
|
||||
|
||||
let response = perform(ocr_request(model, &upstream.uri(), options))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(
|
||||
sent.query("outputContentFormat").as_deref(),
|
||||
output_content_format
|
||||
);
|
||||
assert_eq!(sent.query("stringIndexType"), None);
|
||||
assert_eq!(sent.json(), json!({"base64Source": "YWJj"}));
|
||||
assert_eq!(response.pages[0].markdown, "hello");
|
||||
assert_eq!(response.extra_fields.get("stringIndexType"), None);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::snake_case(json!({"output_content_format": "markdown"}), Some("markdown"))]
|
||||
#[case::padded(json!({"outputContentFormat": " markdown "}), Some("markdown"))]
|
||||
#[case::empty(json!({"outputContentFormat": ""}), None)]
|
||||
#[case::empty_camel_case_falls_back_to_snake_case(
|
||||
json!({"outputContentFormat": "", "output_content_format": "markdown"}),
|
||||
Some("markdown")
|
||||
)]
|
||||
#[case::dropped(json!({"outputContentFormat": "html", "drop_params": true}), None)]
|
||||
#[tokio::test]
|
||||
async fn the_requested_output_content_format_reaches_azure_in_its_query_spelling(
|
||||
#[case] options: Value,
|
||||
#[case] output_content_format: Option<&str>,
|
||||
) {
|
||||
let upstream = upstream([json_response(json!({
|
||||
"status": "succeeded",
|
||||
"analyzeResult": {"pages": [{"pageNumber": 1, "lines": [{"content": "hello"}]}]}
|
||||
}))])
|
||||
.await;
|
||||
|
||||
let response = perform(ocr_request(MODEL, &upstream.uri(), options))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(
|
||||
sent.query("outputContentFormat").as_deref(),
|
||||
output_content_format
|
||||
);
|
||||
assert_eq!(sent.query("output_content_format"), None);
|
||||
assert_eq!(sent.query("drop_params"), None);
|
||||
assert_eq!(sent.json(), json!({"base64Source": "YWJj"}));
|
||||
assert_eq!(response.pages[0].markdown, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_format_normalizes_pages_and_keeps_the_provider_response() {
|
||||
let operation = json!({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
use serde_json::Value;
|
||||
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
pub struct AudioTranscriptionUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
pub struct AudioTranscriptionResponseData {
|
||||
pub text: String,
|
||||
pub usage: Option<AudioTranscriptionUsage>,
|
||||
}
|
||||
|
||||
impl AudioTranscriptionResponseData {
|
||||
pub fn into_json(self) -> Value {
|
||||
serde_json::json!({
|
||||
"text": self.text,
|
||||
})
|
||||
match self.usage {
|
||||
None => serde_json::json!({"text": self.text}),
|
||||
Some(usage) => serde_json::json!({
|
||||
"text": self.text,
|
||||
"usage": {
|
||||
"type": "tokens",
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use std::{collections::BTreeSet, time::Duration};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use litellm_auth::{InputSource, Sourced};
|
||||
|
|
@ -8,7 +11,7 @@ use litellm_llms_types::serde_compat::{FiniteF64, LaxI64};
|
|||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_with::serde_as;
|
||||
use serde_with::{DefaultOnError, serde_as};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::base_llm::ocr::{
|
||||
|
|
@ -17,9 +20,10 @@ use crate::base_llm::ocr::{
|
|||
handler::{CallHooks, OcrClient, read_json_response},
|
||||
settings::OcrSettings,
|
||||
transformation::{
|
||||
BaseOcrConfig, DecodedOcrResponse, OCR_INLINE_MAX_BYTES, OCR_POLL_RETRY_SECS,
|
||||
OcrConnection, OcrCredentialInputs, OcrResponseContext, PreparedOcrRequest,
|
||||
ResolvedOcrCredentials, decode_and_normalize_response, decode_response,
|
||||
BaseOcrConfig, DROP_PARAMS_PARAM, DecodedOcrResponse, OCR_INLINE_MAX_BYTES,
|
||||
OCR_POLL_RETRY_SECS, OcrConnection, OcrCredentialInputs, OcrResponseContext,
|
||||
PreparedOcrRequest, ResolvedOcrCredentials, decode_and_normalize_response, decode_response,
|
||||
drops_unsupported_params, response_format,
|
||||
},
|
||||
};
|
||||
use litellm_llms_types::formats::ocr::{
|
||||
|
|
@ -32,6 +36,30 @@ const AZURE_DI_DEFAULT_HEIGHT: f64 = 11.0;
|
|||
|
||||
const AZURE_DI_API_KEY_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_API_KEY";
|
||||
const AZURE_DI_ENDPOINT_ENV: &str = "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT";
|
||||
const OUTPUT_CONTENT_FORMAT_PARAM: &str = "outputContentFormat";
|
||||
const OUTPUT_CONTENT_FORMAT_ALIAS: &str = "output_content_format";
|
||||
const CODE_POINT_STRING_INDEX: &str = "unicodeCodePoint";
|
||||
const STRING_INDEX_TYPE_FIELD: &str = "stringIndexType";
|
||||
const MARKDOWN_MODEL_ID: &str = "prebuilt-layout";
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
PartialEq,
|
||||
strum::EnumString,
|
||||
strum::IntoStaticStr,
|
||||
strum::Display,
|
||||
strum::VariantArray,
|
||||
serde_with::SerializeDisplay,
|
||||
)]
|
||||
#[strum(ascii_case_insensitive)]
|
||||
pub enum OutputContentFormat {
|
||||
#[strum(serialize = "text")]
|
||||
Text,
|
||||
#[strum(serialize = "markdown")]
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct DocumentIntelligenceParams {
|
||||
|
|
@ -39,6 +67,13 @@ pub struct DocumentIntelligenceParams {
|
|||
pub pages: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub features: Option<String>,
|
||||
#[serde(
|
||||
rename = "outputContentFormat",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub output_content_format: Option<OutputContentFormat>,
|
||||
#[serde(rename = "stringIndexType", skip_serializing_if = "Option::is_none")]
|
||||
pub string_index_type: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
@ -80,6 +115,10 @@ pub struct AzureDocumentIntelligenceOperation {
|
|||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
struct AzureDocumentIntelligenceAnalyzeResult {
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "contentFormat")]
|
||||
pub content_format: Option<String>,
|
||||
#[serde(rename = "stringIndexType")]
|
||||
pub string_index_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pages: Vec<AzureDocumentIntelligencePage>,
|
||||
pub tables: Option<Vec<Map<String, Value>>>,
|
||||
|
|
@ -100,6 +139,9 @@ struct AzureDocumentIntelligencePage {
|
|||
pub unit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub lines: Vec<AzureDocumentIntelligenceLine>,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub spans: Option<Vec<AzureDocumentIntelligenceSpan>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -107,6 +149,18 @@ struct AzureDocumentIntelligenceLine {
|
|||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct AzureDocumentIntelligenceSpan {
|
||||
pub offset: usize,
|
||||
pub length: usize,
|
||||
}
|
||||
|
||||
impl AzureDocumentIntelligenceSpan {
|
||||
fn end(&self) -> usize {
|
||||
self.offset.saturating_add(self.length)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AzureDocumentIntelligenceOcrConfig;
|
||||
|
||||
|
|
@ -116,7 +170,14 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
type Environment = Vec<(String, String)>;
|
||||
|
||||
fn get_supported_ocr_params(&self, _model: &str) -> &'static [&'static str] {
|
||||
&["pages", "features", "req_format"]
|
||||
&[
|
||||
"pages",
|
||||
"features",
|
||||
OUTPUT_CONTENT_FORMAT_PARAM,
|
||||
OUTPUT_CONTENT_FORMAT_ALIAS,
|
||||
"req_format",
|
||||
DROP_PARAMS_PARAM,
|
||||
]
|
||||
}
|
||||
|
||||
fn get_api_key_env_var(&self) -> Option<&'static str> {
|
||||
|
|
@ -154,11 +215,17 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
fn map_ocr_params(
|
||||
&self,
|
||||
non_default_params: &CallArguments,
|
||||
_model: &str,
|
||||
model: &str,
|
||||
) -> Result<DocumentIntelligenceParams, Error> {
|
||||
let output_content_format = requested_output_content_format(non_default_params)?;
|
||||
let slices_markdown_pages = output_content_format == Some(OutputContentFormat::Markdown)
|
||||
&& response_format(non_default_params)? != OcrResponseFormat::Native
|
||||
&& model_id(model).is_ok_and(|id| id == MARKDOWN_MODEL_ID);
|
||||
Ok(DocumentIntelligenceParams {
|
||||
pages: normalize_pages_param(non_default_params.get("pages"))?,
|
||||
features: normalize_features_param(non_default_params.get("features"))?,
|
||||
output_content_format,
|
||||
string_index_type: slices_markdown_pages.then_some(CODE_POINT_STRING_INDEX),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +404,36 @@ fn normalize_features_param(features: Option<&Value>) -> Result<Option<String>,
|
|||
Ok(Some(normalized.join(",")))
|
||||
}
|
||||
|
||||
fn requested_output_content_format(
|
||||
params: &CallArguments,
|
||||
) -> Result<Option<OutputContentFormat>, Error> {
|
||||
let requested = [OUTPUT_CONTENT_FORMAT_PARAM, OUTPUT_CONTENT_FORMAT_ALIAS]
|
||||
.into_iter()
|
||||
.filter_map(|name| params.get(name))
|
||||
.find(|format| !is_unset(format));
|
||||
match requested.map(parse_output_content_format).transpose() {
|
||||
Err(Error::OutputContentFormat) if drops_unsupported_params(params) => Ok(None),
|
||||
parsed => parsed,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unset(format: &Value) -> bool {
|
||||
match format {
|
||||
Value::Null => true,
|
||||
Value::String(format) => format.trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_output_content_format(format: &Value) -> Result<OutputContentFormat, Error> {
|
||||
format
|
||||
.as_str()
|
||||
.ok_or(Error::OutputContentFormat)?
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| Error::OutputContentFormat)
|
||||
}
|
||||
|
||||
fn build_request(document: OcrDocument) -> Result<DocumentIntelligenceRequest, Error> {
|
||||
let source = document.source();
|
||||
if source.is_empty() {
|
||||
|
|
@ -367,10 +464,27 @@ fn transform_completed_response(
|
|||
));
|
||||
}
|
||||
let result = response.analyze_result.unwrap_or_default();
|
||||
let markdown_content = result
|
||||
.content
|
||||
.as_deref()
|
||||
.filter(|_| {
|
||||
result.content_format.as_deref() == Some(<&str>::from(OutputContentFormat::Markdown))
|
||||
&& result.string_index_type.as_deref() == Some(CODE_POINT_STRING_INDEX)
|
||||
&& result.pages.iter().all(|page| page.spans.is_some())
|
||||
})
|
||||
.map(|content| {
|
||||
CodePointText::new(
|
||||
content,
|
||||
result
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|page| page.spans.iter().flatten()),
|
||||
)
|
||||
});
|
||||
let pages = result
|
||||
.pages
|
||||
.into_iter()
|
||||
.map(|page| transform_azure_page(page, dpi))
|
||||
.map(|page| transform_azure_page(page, markdown_content.as_ref(), dpi))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let pages_processed = i64::try_from(pages.len()).map_err(|_| Error::NumericRange("pages"))?;
|
||||
Ok(LiteLLMOcrResponse {
|
||||
|
|
@ -381,11 +495,20 @@ fn transform_completed_response(
|
|||
pages_processed: Some(pages_processed),
|
||||
..Default::default()
|
||||
}),
|
||||
extra_fields: result
|
||||
.string_index_type
|
||||
.filter(|unit| unit == CODE_POINT_STRING_INDEX)
|
||||
.map(|unit| Map::from_iter([(STRING_INDEX_TYPE_FIELD.to_owned(), Value::String(unit))]))
|
||||
.unwrap_or_default(),
|
||||
..LiteLLMOcrResponse::new(model, pages)
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_azure_page(page: AzureDocumentIntelligencePage, dpi: i64) -> Result<OcrPage, Error> {
|
||||
fn transform_azure_page(
|
||||
page: AzureDocumentIntelligencePage,
|
||||
markdown_content: Option<&CodePointText<'_>>,
|
||||
dpi: i64,
|
||||
) -> Result<OcrPage, Error> {
|
||||
let index = page
|
||||
.page_number
|
||||
.unwrap_or(1)
|
||||
|
|
@ -397,12 +520,20 @@ fn transform_azure_page(page: AzureDocumentIntelligencePage, dpi: i64) -> Result
|
|||
page.unit.as_deref().unwrap_or("inch"),
|
||||
dpi,
|
||||
)?;
|
||||
let markdown = page
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.content.as_deref().unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let markdown = match markdown_content {
|
||||
Some(content) => page
|
||||
.spans
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|span| content.slice(span))
|
||||
.collect::<String>(),
|
||||
None => page
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.content.as_deref().unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
};
|
||||
Ok(OcrPage {
|
||||
index,
|
||||
markdown,
|
||||
|
|
@ -411,6 +542,43 @@ fn transform_azure_page(page: AzureDocumentIntelligencePage, dpi: i64) -> Result
|
|||
})
|
||||
}
|
||||
|
||||
struct CodePointText<'a> {
|
||||
text: &'a str,
|
||||
byte_offsets: BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
impl<'a> CodePointText<'a> {
|
||||
fn new<'s>(
|
||||
text: &'a str,
|
||||
spans: impl Iterator<Item = &'s AzureDocumentIntelligenceSpan>,
|
||||
) -> Self {
|
||||
let span_bounds = spans
|
||||
.flat_map(|span| [span.offset, span.end()])
|
||||
.collect::<BTreeSet<_>>();
|
||||
Self {
|
||||
text,
|
||||
byte_offsets: text
|
||||
.char_indices()
|
||||
.map(|(byte_offset, _)| byte_offset)
|
||||
.enumerate()
|
||||
.filter(|(code_point, _)| span_bounds.contains(code_point))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slice(&self, span: &AzureDocumentIntelligenceSpan) -> &'a str {
|
||||
let byte_offset = |code_point: usize| {
|
||||
self.byte_offsets
|
||||
.get(&code_point)
|
||||
.copied()
|
||||
.unwrap_or(self.text.len())
|
||||
};
|
||||
self.text
|
||||
.get(byte_offset(span.offset)..byte_offset(span.end()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_dimensions(
|
||||
width: f64,
|
||||
height: f64,
|
||||
|
|
@ -578,6 +746,16 @@ impl AzureDocumentIntelligenceOcrConfig {
|
|||
.features
|
||||
.iter()
|
||||
.map(|features| ("features", features.as_str())),
|
||||
)
|
||||
.chain(
|
||||
params
|
||||
.output_content_format
|
||||
.map(|format| (OUTPUT_CONTENT_FORMAT_PARAM, format.into())),
|
||||
)
|
||||
.chain(
|
||||
params
|
||||
.string_index_type
|
||||
.map(|index_type| ("stringIndexType", index_type)),
|
||||
),
|
||||
)
|
||||
.into_string()
|
||||
|
|
@ -697,8 +875,12 @@ mod tests {
|
|||
}
|
||||
|
||||
fn map(value: Value) -> Result<DocumentIntelligenceParams, Error> {
|
||||
map_for_model(value, "azure_ai/doc-intelligence/prebuilt-layout")
|
||||
}
|
||||
|
||||
fn map_for_model(value: Value, model: &str) -> Result<DocumentIntelligenceParams, Error> {
|
||||
let arguments = serde_json::from_value(value).unwrap();
|
||||
AzureDocumentIntelligenceOcrConfig.map_ocr_params(&arguments, "model")
|
||||
AzureDocumentIntelligenceOcrConfig.map_ocr_params(&arguments, model)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -828,6 +1010,218 @@ mod tests {
|
|||
assert_eq!(map(json!({"features": []})).unwrap().features, None);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::markdown(
|
||||
json!({"outputContentFormat": "markdown"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::markdown_litellm_format(
|
||||
json!({"outputContentFormat": "markdown", "req_format": "litellm"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::markdown_native_format(
|
||||
json!({"outputContentFormat": "markdown", "req_format": "native"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
None
|
||||
)]
|
||||
#[case::capitalized_markdown(
|
||||
json!({"outputContentFormat": "Markdown"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::text(json!({"outputContentFormat": "text"}), Some(OutputContentFormat::Text), None)]
|
||||
#[case::upper_case_text(json!({"outputContentFormat": "TEXT"}), Some(OutputContentFormat::Text), None)]
|
||||
#[case::padded_markdown(
|
||||
json!({"outputContentFormat": " markdown\n"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::snake_case_markdown(
|
||||
json!({"output_content_format": "markdown"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::camel_case_wins_over_snake_case(
|
||||
json!({"outputContentFormat": "text", "output_content_format": "markdown"}),
|
||||
Some(OutputContentFormat::Text),
|
||||
None
|
||||
)]
|
||||
#[case::empty_camel_case_falls_back_to_snake_case(
|
||||
json!({"outputContentFormat": "", "output_content_format": "markdown"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::null_camel_case_falls_back_to_snake_case(
|
||||
json!({"outputContentFormat": null, "output_content_format": "markdown"}),
|
||||
Some(OutputContentFormat::Markdown),
|
||||
Some("unicodeCodePoint")
|
||||
)]
|
||||
#[case::empty(json!({"outputContentFormat": ""}), None, None)]
|
||||
#[case::blank(json!({"outputContentFormat": " "}), None, None)]
|
||||
#[case::null(json!({"outputContentFormat": null}), None, None)]
|
||||
#[case::omitted(json!({}), None, None)]
|
||||
fn output_content_format_maps_to_query_fields(
|
||||
#[case] options: Value,
|
||||
#[case] format: Option<OutputContentFormat>,
|
||||
#[case] string_index_type: Option<&str>,
|
||||
) {
|
||||
let mapped = map(options).unwrap();
|
||||
|
||||
assert_eq!(mapped.output_content_format, format);
|
||||
assert_eq!(mapped.string_index_type, string_index_type);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::layout("azure_ai/doc-intelligence/prebuilt-layout", Some("unicodeCodePoint"))]
|
||||
#[case::bare_layout("prebuilt-layout", Some("unicodeCodePoint"))]
|
||||
#[case::read("azure_ai/doc-intelligence/prebuilt-read", None)]
|
||||
#[case::invoice("azure_ai/doc-intelligence/prebuilt-invoice", None)]
|
||||
#[case::custom("azure_ai/doc-intelligence/my-extraction-model", None)]
|
||||
fn only_the_layout_model_asks_for_code_point_offsets(
|
||||
#[case] model: &str,
|
||||
#[case] string_index_type: Option<&str>,
|
||||
) {
|
||||
let mapped = map_for_model(json!({"outputContentFormat": "markdown"}), model).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mapped.output_content_format,
|
||||
Some(OutputContentFormat::Markdown)
|
||||
);
|
||||
assert_eq!(mapped.string_index_type, string_index_type);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::unknown(json!({"outputContentFormat": "html"}))]
|
||||
#[case::number(json!({"outputContentFormat": 1}))]
|
||||
#[case::boolean(json!({"outputContentFormat": true}))]
|
||||
#[case::list(json!({"outputContentFormat": ["markdown"]}))]
|
||||
#[case::snake_case_unknown(json!({"output_content_format": "html"}))]
|
||||
#[case::drop_params_off(json!({"outputContentFormat": "html", "drop_params": false}))]
|
||||
fn invalid_output_content_format_is_a_request_error(#[case] options: Value) {
|
||||
let error = map(options).unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::OutputContentFormat), "{error:?}");
|
||||
assert_eq!(error.http_status_code(), Some(400));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::unknown(json!({"outputContentFormat": "html", "drop_params": true, "pages": [0]}))]
|
||||
#[case::number(json!({"outputContentFormat": 1, "drop_params": true, "pages": [0]}))]
|
||||
#[case::snake_case_unknown(
|
||||
json!({"output_content_format": "html", "drop_params": true, "pages": [0]})
|
||||
)]
|
||||
fn drop_params_drops_an_invalid_output_content_format(#[case] options: Value) {
|
||||
let mapped = map(options).unwrap();
|
||||
|
||||
assert_eq!(serde_json::to_value(mapped).unwrap(), json!({"pages": "1"}));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::camel_case(json!({"outputContentFormat": "markdown", "drop_params": true}))]
|
||||
#[case::snake_case(json!({"output_content_format": "markdown", "drop_params": true}))]
|
||||
fn drop_params_keeps_a_valid_output_content_format(#[case] options: Value) {
|
||||
let mapped = map(options).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mapped.output_content_format,
|
||||
Some(OutputContentFormat::Markdown)
|
||||
);
|
||||
}
|
||||
|
||||
fn markdown_pages(analyze_result: Value) -> Vec<String> {
|
||||
let raw =
|
||||
serde_json::to_vec(&json!({"status": "succeeded", "analyzeResult": analyze_result}))
|
||||
.unwrap();
|
||||
AzureDocumentIntelligenceOcrConfig
|
||||
.transform_ocr_response("model", &raw, OcrResponseFormat::Litellm)
|
||||
.unwrap()
|
||||
.pages
|
||||
.into_iter()
|
||||
.map(|page| page.markdown)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn markdown_pages_are_code_point_slices_of_the_content() {
|
||||
let content = "# Ünïcode क्ष\n\n<!-- PageBreak -->\n\n## Två 🙂\n\n|a|\n\n> tail";
|
||||
let second_page_start = "# Ünïcode क्ष\n\n<!-- PageBreak -->\n\n".chars().count();
|
||||
let pages = markdown_pages(json!({
|
||||
"content": content,
|
||||
"contentFormat": "markdown",
|
||||
"stringIndexType": "unicodeCodePoint",
|
||||
"pages": [
|
||||
{"pageNumber": 1, "spans": [{"offset": 0, "length": second_page_start}],
|
||||
"lines": [{"content": "Ünïcode क्ष"}]},
|
||||
{"pageNumber": 2, "spans": [
|
||||
{"offset": second_page_start, "length": 8},
|
||||
{"offset": second_page_start + 10, "length": 3}
|
||||
], "lines": [{"content": "Två"}]}
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
pages,
|
||||
["# Ünïcode क्ष\n\n<!-- PageBreak -->\n\n", "## Två 🙂|a|"]
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn spans_past_the_content_end_are_clamped() {
|
||||
let pages = markdown_pages(json!({
|
||||
"content": "ab",
|
||||
"contentFormat": "markdown",
|
||||
"stringIndexType": "unicodeCodePoint",
|
||||
"pages": [{"spans": [{"offset": 1, "length": 9}, {"offset": 7, "length": 1}]}]
|
||||
}));
|
||||
|
||||
assert_eq!(pages, ["b"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::text_content(json!("text"), json!("unicodeCodePoint"))]
|
||||
#[case::grapheme_offsets(json!("markdown"), json!("textElements"))]
|
||||
#[case::no_index_type(json!("markdown"), Value::Null)]
|
||||
#[case::no_content_format(Value::Null, json!("unicodeCodePoint"))]
|
||||
fn other_responses_keep_line_joined_pages(
|
||||
#[case] content_format: Value,
|
||||
#[case] string_index_type: Value,
|
||||
) {
|
||||
let pages = markdown_pages(json!({
|
||||
"content": "# A\n\nB",
|
||||
"contentFormat": content_format,
|
||||
"stringIndexType": string_index_type,
|
||||
"pages": [{"spans": [{"offset": 0, "length": 6}],
|
||||
"lines": [{"content": "A"}, {"content": "B"}]}]
|
||||
}));
|
||||
|
||||
assert_eq!(pages, ["A\nB"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::null_spans(json!("markdown"), json!(null))]
|
||||
#[case::fractional_offset(json!("markdown"), json!([{"offset": 0.5, "length": 1}]))]
|
||||
#[case::missing_length(json!("markdown"), json!([{"offset": 0}]))]
|
||||
#[case::negative_offset(json!("markdown"), json!([{"offset": -1, "length": 1}]))]
|
||||
#[case::text_content(json!("text"), json!([{"offset": "0", "length": 1}]))]
|
||||
fn unusable_page_spans_keep_line_joined_pages(
|
||||
#[case] content_format: Value,
|
||||
#[case] broken_spans: Value,
|
||||
) {
|
||||
let pages = markdown_pages(json!({
|
||||
"content": "# A\n\nB",
|
||||
"contentFormat": content_format,
|
||||
"stringIndexType": "unicodeCodePoint",
|
||||
"pages": [
|
||||
{"spans": [{"offset": 0, "length": 5}], "lines": [{"content": "A"}]},
|
||||
{"spans": broken_spans, "lines": [{"content": "B"}]}
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(pages, ["A", "B"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_endpoint_cannot_receive_environment_key() {
|
||||
let connection = OcrConnection {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ pub enum Error {
|
|||
Pages(PagesFault),
|
||||
#[error("invalid OCR features")]
|
||||
Features,
|
||||
#[error(
|
||||
"invalid OCR outputContentFormat, expected text or markdown. To drop unsupported params, set `litellm.drop_params = True`."
|
||||
)]
|
||||
OutputContentFormat,
|
||||
#[error("OCR model cannot be a dot segment")]
|
||||
DotModel,
|
||||
#[error("OCR response exceeds the size limit of {limit} bytes")]
|
||||
|
|
@ -200,6 +204,7 @@ impl Error {
|
|||
| Self::InlineDocumentTooLarge
|
||||
| Self::Pages(_)
|
||||
| Self::Features
|
||||
| Self::OutputContentFormat
|
||||
| Self::DotModel
|
||||
| Self::TooLarge { .. }
|
||||
| Self::ResponseField { .. }
|
||||
|
|
@ -242,6 +247,7 @@ impl Error {
|
|||
| Self::InlineDocumentTooLarge
|
||||
| Self::Pages(_)
|
||||
| Self::Features
|
||||
| Self::OutputContentFormat
|
||||
| Self::DotModel
|
||||
| Self::TooLarge { .. }
|
||||
| Self::ResponseField { .. }
|
||||
|
|
@ -291,6 +297,7 @@ impl Error {
|
|||
| Self::InlineDocumentTooLarge
|
||||
| Self::Pages(_)
|
||||
| Self::Features
|
||||
| Self::OutputContentFormat
|
||||
| Self::DotModel
|
||||
| Self::InvalidProvider(_)
|
||||
| Self::InvalidModel { .. }
|
||||
|
|
@ -345,6 +352,7 @@ impl Error {
|
|||
| Self::InlineDocumentTooLarge
|
||||
| Self::Pages(_)
|
||||
| Self::Features
|
||||
| Self::OutputContentFormat
|
||||
| Self::DotModel
|
||||
| Self::InvalidProvider(_)
|
||||
| Self::InvalidModel { .. }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub const OCR_RESPONSE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
|||
pub const OCR_INLINE_MAX_BYTES: usize = 50 * 1024 * 1024;
|
||||
pub const OCR_MAX_FETCH_REDIRECTS: usize = 10;
|
||||
pub const OCR_POLL_RETRY_SECS: u64 = 2;
|
||||
pub const DROP_PARAMS_PARAM: &str = "drop_params";
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OcrCredentialInputs {
|
||||
|
|
@ -185,6 +186,10 @@ pub fn response_format(optional_params: &CallArguments) -> Result<OcrResponseFor
|
|||
.map(|format| format.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn drops_unsupported_params(optional_params: &CallArguments) -> bool {
|
||||
optional_params.get(DROP_PARAMS_PARAM) == Some(&Value::Bool(true))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DecodedOcrResponse<T> {
|
||||
pub data: T,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use litellm_auth_aws::{
|
|||
};
|
||||
use litellm_core_utils::core_helpers::json_type_name;
|
||||
use litellm_llms_types::{
|
||||
formats::audio_transcription::AudioTranscriptionResponseData,
|
||||
providers::bedrock::ConverseResponse,
|
||||
formats::audio_transcription::{AudioTranscriptionResponseData, AudioTranscriptionUsage},
|
||||
providers::bedrock::{ConverseResponse, ConverseUsage},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
|
@ -101,10 +101,10 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
if let Some(prompt) = optional_string(&optional_params, "prompt") {
|
||||
instruction.push_str(&format!(" Additional context: {prompt}"));
|
||||
}
|
||||
let mut inference_config = Map::from_iter([("maxTokens".to_string(), json!(4096))]);
|
||||
if let Some(temperature) = optional_params.get("temperature") {
|
||||
inference_config.insert("temperature".to_string(), temperature.clone());
|
||||
}
|
||||
let temperature = optional_params
|
||||
.get("temperature")
|
||||
.cloned()
|
||||
.unwrap_or(json!(0));
|
||||
Ok(AudioTranscriptionRequestData {
|
||||
body: json!({
|
||||
"messages": [{
|
||||
|
|
@ -114,8 +114,7 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
{"text": instruction}
|
||||
]
|
||||
}],
|
||||
"system": [{"text": "You are a transcription assistant."}],
|
||||
"inferenceConfig": inference_config,
|
||||
"inferenceConfig": {"maxTokens": 4096, "temperature": temperature},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -125,6 +124,7 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
_model: &str,
|
||||
response_json: Value,
|
||||
) -> Result<AudioTranscriptionResponseData, Error> {
|
||||
let reports_usage = response_json.get("usage").is_some();
|
||||
let response: ConverseResponse =
|
||||
serde_json::from_value(response_json).map_err(|error| {
|
||||
Error::InvalidResponse(ErrorDetail::invalid("Converse response", error))
|
||||
|
|
@ -137,6 +137,7 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
}
|
||||
Ok(AudioTranscriptionResponseData {
|
||||
text: response.content_text(),
|
||||
usage: reports_usage.then(|| transcription_usage(&response.usage)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -191,22 +192,41 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
}
|
||||
}
|
||||
|
||||
fn transcription_usage(usage: &ConverseUsage) -> AudioTranscriptionUsage {
|
||||
AudioTranscriptionUsage {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: usage
|
||||
.total_tokens
|
||||
.unwrap_or(usage.input_tokens + usage.output_tokens),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_matches_python_shape() {
|
||||
let params = Map::from_iter([
|
||||
("language".to_string(), json!("en")),
|
||||
("prompt".to_string(), json!("Speaker names")),
|
||||
("temperature".to_string(), json!(0)),
|
||||
("timestamp_granularities".to_string(), json!(["word"])),
|
||||
]);
|
||||
#[rstest]
|
||||
#[case::caller_temperature(Some(json!(0.4)), json!(0.4))]
|
||||
#[case::default_temperature(None, json!(0))]
|
||||
fn request_sends_audio_and_instruction_without_system_block(
|
||||
#[case] temperature: Option<Value>,
|
||||
#[case] expected_temperature: Value,
|
||||
) {
|
||||
let params = Map::from_iter(
|
||||
[
|
||||
("language".to_string(), json!("en")),
|
||||
("prompt".to_string(), json!("Speaker names")),
|
||||
("timestamp_granularities".to_string(), json!(["word"])),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(temperature.map(|value| ("temperature".to_string(), value))),
|
||||
);
|
||||
let params = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG.map_transcription_params(¶ms);
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.transform_audio_transcription_request(
|
||||
|
|
@ -225,22 +245,39 @@ mod tests {
|
|||
{"text": "Transcribe the audio. Respond with only the transcript. The audio language is en. Additional context: Speaker names"}
|
||||
]
|
||||
}],
|
||||
"system": [{"text": "You are a transcription assistant."}],
|
||||
"inferenceConfig": {"maxTokens": 4096, "temperature": 0}
|
||||
"inferenceConfig": {"maxTokens": 4096, "temperature": expected_temperature}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_concatenates_content_blocks() {
|
||||
#[rstest]
|
||||
#[case::reported_total(
|
||||
json!({"inputTokens": 390, "outputTokens": 60, "totalTokens": 450}),
|
||||
json!({"text": "hello world", "usage": {"type": "tokens", "input_tokens": 390, "output_tokens": 60, "total_tokens": 450}})
|
||||
)]
|
||||
#[case::derived_total(
|
||||
json!({"inputTokens": 1, "outputTokens": 2}),
|
||||
json!({"text": "hello world", "usage": {"type": "tokens", "input_tokens": 1, "output_tokens": 2, "total_tokens": 3}})
|
||||
)]
|
||||
fn response_carries_text_and_converse_usage(#[case] usage: Value, #[case] expected: Value) {
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.transform_audio_transcription_response(
|
||||
"model",
|
||||
json!({"output": {"message": {"content": [{"text": "hello "}, {"text": "world"}]}}, "usage": {"inputTokens": 1, "outputTokens": 2}}),
|
||||
json!({"output": {"message": {"content": [{"text": "hello "}, {"text": "world"}]}}, "usage": usage}),
|
||||
)
|
||||
.expect("response");
|
||||
assert_eq!(result.text, "hello world");
|
||||
assert_eq!(result.into_json(), json!({"text": "hello world"}));
|
||||
assert_eq!(result.into_json(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn response_without_usage_reports_none() {
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.transform_audio_transcription_response(
|
||||
"model",
|
||||
json!({"output": {"message": {"content": [{"text": "hello"}]}}}),
|
||||
)
|
||||
.expect("response");
|
||||
assert_eq!(result.into_json(), json!({"text": "hello"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use pyo3::{
|
|||
|
||||
use super::{
|
||||
errors::route_failure,
|
||||
project::{OcrHostHandles, SETTINGS_ERROR_MARKER, project_request},
|
||||
project::{OcrHostHandles, ROUTE_HOST_MODULE, SETTINGS_ERROR_MARKER, project_request},
|
||||
};
|
||||
use crate::{marshal::public_response, routes::RequestView};
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ impl OcrPythonHost {
|
|||
_ => "",
|
||||
};
|
||||
let mapped = py
|
||||
.import("litellm.rust_bridge.host.routes.ocr")
|
||||
.import(ROUTE_HOST_MODULE)
|
||||
.and_then(|module| module.getattr("map_failure"))
|
||||
.and_then(|map| map.call1((error.value(py), self.view.request(py)?, provider)))
|
||||
.and_then(|mapped| mapped.extract::<Py<PyBaseException>>().map_err(PyErr::from));
|
||||
|
|
@ -115,7 +115,7 @@ impl PythonBinding for OcrPythonHost {
|
|||
py: Python<'_>,
|
||||
response: LiteLLMOcrResponse,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
public_response(py, "litellm.rust_bridge.host.routes.ocr", &response)
|
||||
public_response(py, ROUTE_HOST_MODULE, &response)
|
||||
}
|
||||
|
||||
fn encode_stream_head(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use litellm_inference_ocr::{
|
|||
types::{LiteLLMOcrRequest, OcrDocumentInput},
|
||||
wire::{OcrWireRequest, consumed_optional_params, decode_document, decode_request_input},
|
||||
};
|
||||
use litellm_llms::base_llm::ocr::error::Error;
|
||||
use litellm_llms::base_llm::ocr::{error::Error, transformation::DROP_PARAMS_PARAM};
|
||||
use pyo3::{prelude::*, types::PyDict};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ use crate::{
|
|||
python_settings::{PythonSettings, Snapshot},
|
||||
};
|
||||
|
||||
pub(super) const ROUTE_HOST_MODULE: &str = "litellm.rust_bridge.host.routes.ocr";
|
||||
|
||||
/// What the host keeps after projection: the caller's token callable that answers the
|
||||
/// token operation, and the provider name the failure mapping reports.
|
||||
pub(super) struct OcrHostHandles {
|
||||
|
|
@ -139,6 +141,15 @@ pub(super) fn project_request(
|
|||
Some(defaults) => fold_module_globals(bound.py(), optional_params, &globals, &defaults)?,
|
||||
None => optional_params,
|
||||
};
|
||||
let optional_params = if names.contains(&DROP_PARAMS_PARAM) {
|
||||
fold_drop_params(
|
||||
bound.py(),
|
||||
optional_params,
|
||||
present(kwargs, bound, DROP_PARAMS_PARAM)?,
|
||||
)?
|
||||
} else {
|
||||
optional_params
|
||||
};
|
||||
let input_sources = request_input_sources(
|
||||
kwargs,
|
||||
names
|
||||
|
|
@ -216,6 +227,23 @@ fn fold_module_globals(
|
|||
.collect())
|
||||
}
|
||||
|
||||
fn fold_drop_params(
|
||||
py: Python<'_>,
|
||||
optional_params: Map<String, Value>,
|
||||
requested: Option<Bound<'_, PyAny>>,
|
||||
) -> PyResult<Map<String, Value>> {
|
||||
let drops: bool = py
|
||||
.import(ROUTE_HOST_MODULE)?
|
||||
.getattr("drops_unsupported_params")?
|
||||
.call1((requested,))?
|
||||
.extract()?;
|
||||
Ok(optional_params
|
||||
.into_iter()
|
||||
.filter(|(name, _)| name != DROP_PARAMS_PARAM)
|
||||
.chain(drops.then(|| (DROP_PARAMS_PARAM.to_string(), Value::Bool(true))))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A misconfigured litellm setting is the operator's error, not the request's or the
|
||||
/// provider's, so the host raises it as is instead of mapping it onto a provider failure.
|
||||
pub(super) const SETTINGS_ERROR_MARKER: &str = "native_settings_error";
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from litellm._logging import (
|
|||
turn_on_json,
|
||||
log_level,
|
||||
)
|
||||
from litellm._logging import suppress_startup_output as suppress_startup_output
|
||||
import re
|
||||
from litellm.constants import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
|
|
@ -591,7 +592,7 @@ vertex_ai_safety_settings: Optional[dict] = None
|
|||
####### COMPLETION MODELS ###################
|
||||
from typing import Set
|
||||
|
||||
open_ai_chat_completion_models: Set = set()
|
||||
open_ai_chat_completion_models: Set[str] = set()
|
||||
open_ai_text_completion_models: Set = set()
|
||||
cohere_models: Set = set()
|
||||
cohere_chat_models: Set = set()
|
||||
|
|
@ -619,6 +620,8 @@ vertex_openai_models: Set = set()
|
|||
vertex_minimax_models: Set = set()
|
||||
vertex_moonshot_models: Set = set()
|
||||
vertex_zai_models: Set = set()
|
||||
vertex_qwen_models: Final[Set[str]] = set()
|
||||
vertex_ai_models: Final[Set[str]] = set()
|
||||
ai21_models: Set = set()
|
||||
ai21_chat_models: Set = set()
|
||||
nlp_cloud_models: Set = set()
|
||||
|
|
@ -635,6 +638,7 @@ watsonx_models: Set = set()
|
|||
gemini_models: Set = set()
|
||||
xai_models: Set = set()
|
||||
zai_models: Set = set()
|
||||
bigmodel_models: Final[set[str]] = set() # mutable-ok: provider registry is populated and extended at runtime
|
||||
deepseek_models: Set = set()
|
||||
tencent_models: Set = set()
|
||||
runwayml_models: Set = set()
|
||||
|
|
@ -681,6 +685,7 @@ moonshot_models: Set = set()
|
|||
publicai_models: Set = set()
|
||||
darkbloom_models: Set = set()
|
||||
openinfer_models: Final[Set[str]] = set()
|
||||
streamlake_models: Final[Set[str]] = set()
|
||||
zerogpu_models: Final[Set[str]] = set()
|
||||
v0_models: Set = set()
|
||||
morph_models: Set = set()
|
||||
|
|
@ -832,6 +837,10 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
|
|||
elif value.get("litellm_provider") == "vertex_ai-zai_models":
|
||||
key = key.replace("vertex_ai/", "")
|
||||
vertex_zai_models.add(key)
|
||||
elif value.get("litellm_provider") == "vertex_ai-qwen_models":
|
||||
vertex_qwen_models.add(key.removeprefix("vertex_ai/"))
|
||||
elif value.get("litellm_provider") == "vertex_ai":
|
||||
vertex_ai_models.add(key.removeprefix("vertex_ai/"))
|
||||
elif value.get("litellm_provider") == "ai21":
|
||||
if value.get("mode") == "chat":
|
||||
ai21_chat_models.add(key)
|
||||
|
|
@ -876,6 +885,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
|
|||
xai_models.add(key)
|
||||
elif value.get("litellm_provider") == "zai":
|
||||
zai_models.add(key)
|
||||
elif value.get("litellm_provider") == "bigmodel":
|
||||
bigmodel_models.add(key)
|
||||
elif value.get("litellm_provider") == "fal_ai":
|
||||
fal_ai_models.add(key)
|
||||
elif value.get("litellm_provider") == "deepseek":
|
||||
|
|
@ -970,6 +981,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
|
|||
darkbloom_models.add(key)
|
||||
elif value.get("litellm_provider") == "openinfer":
|
||||
openinfer_models.add(key)
|
||||
elif value.get("litellm_provider") == "streamlake":
|
||||
streamlake_models.add(key)
|
||||
elif value.get("litellm_provider") == "zerogpu":
|
||||
zerogpu_models.add(key)
|
||||
elif value.get("litellm_provider") == "v0":
|
||||
|
|
@ -1104,6 +1117,7 @@ model_list = list(
|
|||
| text_completion_inception_models
|
||||
| xai_models
|
||||
| zai_models
|
||||
| bigmodel_models
|
||||
| fal_ai_models
|
||||
| deepseek_models
|
||||
| modelscope_models
|
||||
|
|
@ -1145,6 +1159,7 @@ model_list = list(
|
|||
| publicai_models
|
||||
| darkbloom_models
|
||||
| openinfer_models
|
||||
| streamlake_models
|
||||
| zerogpu_models
|
||||
| v0_models
|
||||
| morph_models
|
||||
|
|
@ -1200,7 +1215,16 @@ def _build_models_by_provider() -> dict:
|
|||
| vertex_deepseek_models
|
||||
| vertex_minimax_models
|
||||
| vertex_moonshot_models
|
||||
| vertex_zai_models,
|
||||
| vertex_zai_models
|
||||
| vertex_qwen_models
|
||||
| vertex_mistral_models
|
||||
| vertex_llama3_models
|
||||
| vertex_ai_ai21_models
|
||||
| vertex_openai_models
|
||||
| vertex_ai_image_models
|
||||
| vertex_ai_video_models
|
||||
| vertex_embedding_models
|
||||
| vertex_ai_models,
|
||||
"ai21": ai21_models | ai21_chat_models,
|
||||
"bedrock": bedrock_models | bedrock_converse_models,
|
||||
"petals": petals_models,
|
||||
|
|
@ -1217,6 +1241,7 @@ def _build_models_by_provider() -> dict:
|
|||
"text-completion-inception": text_completion_inception_models,
|
||||
"xai": xai_models,
|
||||
"zai": zai_models,
|
||||
"bigmodel": bigmodel_models,
|
||||
"fal_ai": fal_ai_models,
|
||||
"deepseek": deepseek_models,
|
||||
"tencent": tencent_models,
|
||||
|
|
@ -1265,6 +1290,7 @@ def _build_models_by_provider() -> dict:
|
|||
"publicai": publicai_models,
|
||||
"darkbloom": darkbloom_models,
|
||||
"openinfer": openinfer_models,
|
||||
"streamlake": streamlake_models,
|
||||
"zerogpu": zerogpu_models,
|
||||
"v0": v0_models,
|
||||
"morph": morph_models,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import sys
|
|||
from collections.abc import Iterator, Mapping
|
||||
from datetime import datetime
|
||||
from logging import Formatter
|
||||
from types import FrameType
|
||||
from typing import Final, TextIO
|
||||
from urllib.parse import unquote
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
|
|
@ -598,6 +600,7 @@ _LOG_FORMAT_PREFIX: Final = "%(asctime)s - %(name)s:%(levelname)s"
|
|||
_LOG_FORMAT_SUFFIX: Final = ": %(filename)s:%(lineno)s - %(message)s"
|
||||
_PLAIN_LOG_FORMAT: Final = _LOG_FORMAT_PREFIX + _LOG_FORMAT_SUFFIX
|
||||
_COLOR_LOG_FORMAT: Final = f"\033[92m{_LOG_FORMAT_PREFIX}\033[0m{_LOG_FORMAT_SUFFIX}"
|
||||
_ANSI_ESCAPE: Final = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _stream_is_tty(stream: TextIO | None) -> bool:
|
||||
|
|
@ -612,15 +615,28 @@ def _stream_is_tty(stream: TextIO | None) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _plain_log_format(stdout: TextIO | None, stderr: TextIO | None) -> str:
|
||||
"""The plain-text log format, colorized only when both streams are an interactive terminal.
|
||||
def _color_enabled(stream: TextIO | None) -> bool:
|
||||
"""True when ANSI colors written to the stream reach an interactive terminal.
|
||||
|
||||
Honors the NO_COLOR convention from no-color.org: color is disabled when
|
||||
NO_COLOR is present with a non-empty value.
|
||||
"""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return _PLAIN_LOG_FORMAT
|
||||
return _COLOR_LOG_FORMAT if _stream_is_tty(stdout) and _stream_is_tty(stderr) else _PLAIN_LOG_FORMAT
|
||||
return not os.environ.get("NO_COLOR") and _stream_is_tty(stream)
|
||||
|
||||
|
||||
def _terminal_colors(stdout: TextIO | None, stderr: TextIO | None) -> bool:
|
||||
"""True when both streams are an interactive color terminal, the one rule for log lines and startup prints."""
|
||||
return _color_enabled(stdout) and _color_enabled(stderr)
|
||||
|
||||
|
||||
def terminal_text(message: str) -> str:
|
||||
"""The message with its ANSI colors kept only on a color terminal, by the rule the plain log format uses."""
|
||||
return message if _terminal_colors(sys.stdout, sys.stderr) else _ANSI_ESCAPE.sub("", message)
|
||||
|
||||
|
||||
def _plain_log_format(stdout: TextIO | None, stderr: TextIO | None) -> str:
|
||||
"""The plain-text log format, colorized only when both streams are an interactive terminal."""
|
||||
return _COLOR_LOG_FORMAT if _terminal_colors(stdout, stderr) else _PLAIN_LOG_FORMAT
|
||||
|
||||
|
||||
class LevelRoutingStreamHandler(logging.StreamHandler):
|
||||
|
|
@ -642,8 +658,8 @@ class LevelRoutingStreamHandler(logging.StreamHandler):
|
|||
super().emit(record)
|
||||
|
||||
|
||||
def _parse_json_logs_env(value: str | None) -> bool:
|
||||
"""Strict opt-in parse for the JSON_LOGS env var: only "true" (any case) enables JSON logs.
|
||||
def _parse_env_flag(value: str | None) -> bool:
|
||||
"""Strict opt-in parse for a boolean env var such as JSON_LOGS: only "true" (any case) enables it.
|
||||
|
||||
Matches the reader in litellm-proxy-extras/_logging.py. The previous
|
||||
bool(os.getenv(...)) treated any non-empty value, including "false" and "0",
|
||||
|
|
@ -656,7 +672,18 @@ def resolve_log_level(log_level: str) -> int:
|
|||
return getattr(logging, log_level.upper())
|
||||
|
||||
|
||||
json_logs: Final = _parse_json_logs_env(os.getenv("JSON_LOGS"))
|
||||
json_logs: Final = _parse_env_flag(os.getenv("JSON_LOGS"))
|
||||
suppress_startup_output: Final = _parse_env_flag(os.getenv("LITELLM_SUPPRESS_STARTUP_OUTPUT"))
|
||||
|
||||
|
||||
class StartupOutputSettings(TypedDict, total=False):
|
||||
json_logs: ReadOnly[object]
|
||||
suppress_startup_output: ReadOnly[object]
|
||||
|
||||
|
||||
STARTUP_OUTPUT_SETTINGS: Final = TypeAdapter(StartupOutputSettings)
|
||||
|
||||
|
||||
# Create a handler for the logger (you may need to adapt this based on your needs)
|
||||
log_level: Final = os.getenv("LITELLM_LOG", "DEBUG")
|
||||
numeric_level: Final[int] = resolve_log_level(log_level)
|
||||
|
|
@ -743,7 +770,7 @@ class JsonFormatter(Formatter):
|
|||
return dt.isoformat()
|
||||
|
||||
def format(self, record):
|
||||
message_str: Final = record.getMessage()
|
||||
message_str: Final = _ANSI_ESCAPE.sub("", record.getMessage())
|
||||
json_record: Final[dict[str, object]] = {
|
||||
"message": message_str,
|
||||
"level": record.levelname,
|
||||
|
|
@ -799,12 +826,14 @@ class CorrelationPlainFormatter(logging.Formatter):
|
|||
"""Appends trace_id/session_id to plain-text log lines stamped by CorrelationContextFilter.
|
||||
|
||||
Mirrors JsonFormatter's handling of these two fields so request_correlation_in_logs
|
||||
behaves the same whether or not json_logs is enabled.
|
||||
behaves the same whether or not json_logs is enabled. Unless it was built with the color format,
|
||||
it also drops the ANSI color codes a message carries, which a piped log would store as text.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
rendered: Final = super().format(record)
|
||||
formatted: Final = rendered if _is_redacted(record) else redact_string(rendered)
|
||||
uncolored: Final = rendered if self._fmt == _COLOR_LOG_FORMAT else _ANSI_ESCAPE.sub("", rendered)
|
||||
formatted: Final = uncolored if _is_redacted(record) else redact_string(uncolored)
|
||||
trace_id: Final = getattr(record, "trace_id", None)
|
||||
session_id: Final = getattr(record, "session_id", None)
|
||||
if not trace_id and not session_id:
|
||||
|
|
@ -898,29 +927,46 @@ verbose_proxy_logger.addFilter(_diagnostic_filter)
|
|||
verbose_proxy_stdout_logger.addFilter(_diagnostic_filter)
|
||||
verbose_logger.addFilter(_diagnostic_filter)
|
||||
|
||||
_ANSI_ESCAPE: Final = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def print_startup_message(message: str) -> None:
|
||||
"""Write a boot-time message meant for the operator's terminal.
|
||||
|
||||
Plain mode prints it unchanged, colors and spacing included. With JSON logs on, every line of
|
||||
stdout is read by a log pipeline, so the message goes out as one INFO record of the proxy logger
|
||||
instead, on stdout like the print whatever handlers the logging config gave that logger, with the
|
||||
ANSI color codes and surrounding blank lines removed, located at the caller and emitted at any log
|
||||
level, whole and unredacted as the print showed it; a blank spacer produces no record.
|
||||
LITELLM_SUPPRESS_STARTUP_OUTPUT or litellm_settings.suppress_startup_output turns every one of them
|
||||
off; a warning goes through print_startup_warning instead. Plain mode prints it with its spacing,
|
||||
keeping the ANSI colors only on a color terminal by the same check as the plain log format, so a
|
||||
container log never stores escape codes.
|
||||
With JSON logs on, every line of stdout is read by a log pipeline, so the message goes out as one
|
||||
INFO record of the proxy logger instead, on stdout like the print whatever handlers the logging
|
||||
config gave that logger, with the ANSI color codes and surrounding blank lines removed, located at
|
||||
the caller and emitted at any log level, whole and unredacted as the print showed it; a blank
|
||||
spacer produces no record.
|
||||
"""
|
||||
if litellm.suppress_startup_output is True:
|
||||
return
|
||||
frame: Final = inspect.currentframe()
|
||||
_write_startup_message(message, frame.f_back if frame is not None else None, logging.INFO)
|
||||
|
||||
|
||||
def print_startup_warning(message: str) -> None:
|
||||
"""Write a boot-time warning as print_startup_message writes a message, even with suppress_startup_output
|
||||
on, since it names something the operator has to fix. With JSON logs on it is a WARNING record, on
|
||||
stdout where the print put it, so a collector that filters on the level field keeps it."""
|
||||
frame: Final = inspect.currentframe()
|
||||
_write_startup_message(message, frame.f_back if frame is not None else None, logging.WARNING)
|
||||
|
||||
|
||||
def _write_startup_message(message: str, caller: FrameType | None, level: int) -> None:
|
||||
if not litellm.json_logs:
|
||||
print(message) # noqa: T201 # plain mode: this is the operator-terminal print it replaces
|
||||
import click
|
||||
|
||||
click.echo(terminal_text(message))
|
||||
return
|
||||
text: Final = _ANSI_ESCAPE.sub("", message).strip("\n")
|
||||
if not text:
|
||||
return
|
||||
frame: Final = inspect.currentframe()
|
||||
caller: Final = frame.f_back if frame is not None else None
|
||||
record: Final = verbose_proxy_logger.makeRecord(
|
||||
verbose_proxy_logger.name,
|
||||
logging.INFO,
|
||||
level,
|
||||
caller.f_code.co_filename if caller is not None else __file__,
|
||||
caller.f_lineno if caller is not None else 0,
|
||||
text,
|
||||
|
|
@ -934,8 +980,8 @@ def print_startup_message(message: str) -> None:
|
|||
|
||||
|
||||
def _startup_json_handler() -> logging.Handler:
|
||||
"""The proxy's JSON handler on stdout, filters included, for one startup message."""
|
||||
handler: Final = LevelRoutingStreamHandler()
|
||||
"""The proxy's JSON handler on stdout, filters included, for one startup message or warning."""
|
||||
handler: Final = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.addFilter(_secret_filter)
|
||||
handler.addFilter(_correlation_filter)
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ class ResponsesToCompletionBridgeHandler:
|
|||
structured_output=is_structured_output_request(
|
||||
cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict
|
||||
),
|
||||
replays_openai_history=custom_llm_provider == "openai",
|
||||
)
|
||||
streamwrapper: Final = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
|
|
@ -363,6 +364,7 @@ class ResponsesToCompletionBridgeHandler:
|
|||
structured_output=is_structured_output_request(
|
||||
cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict
|
||||
),
|
||||
replays_openai_history=custom_llm_provider == "openai",
|
||||
)
|
||||
streamwrapper: Final = CustomStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
import hashlib
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import accumulate, chain, pairwise
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||
|
||||
REPLAY_FIELD: Final = "openai_responses_replay"
|
||||
PHASE_FIELD: Final = "phase"
|
||||
|
||||
Phase: TypeAlias = Literal["commentary", "final_answer"]
|
||||
|
||||
|
||||
class _Frozen(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class _OutputText(_Frozen):
|
||||
type: Literal["output_text"]
|
||||
text: str
|
||||
|
||||
|
||||
class _OutputMessage(_Frozen):
|
||||
type: Literal["message"]
|
||||
role: Literal["assistant"]
|
||||
content: tuple[_OutputText, ...]
|
||||
phase: Phase | None = None
|
||||
|
||||
|
||||
class _OutputReasoning(_Frozen):
|
||||
type: Literal["reasoning"]
|
||||
id: str
|
||||
|
||||
|
||||
class _OutputToolCall(_Frozen):
|
||||
type: Literal["function_call", "custom_tool_call"]
|
||||
call_id: str
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class _OutputToolSearch(_Frozen):
|
||||
type: Literal["tool_search_call", "tool_search_output"]
|
||||
|
||||
|
||||
_OutputItem: TypeAlias = Annotated[
|
||||
_OutputMessage | _OutputReasoning | _OutputToolCall | _OutputToolSearch, Field(discriminator="type")
|
||||
]
|
||||
_OUTPUT_ITEM: Final[TypeAdapter[_OutputItem]] = TypeAdapter(_OutputItem)
|
||||
|
||||
|
||||
class _AnyMessage(_Frozen):
|
||||
type: Literal["message"]
|
||||
|
||||
|
||||
class _MessageEntry(_Frozen):
|
||||
type: Literal["message"]
|
||||
parts: tuple[Annotated[int, Field(ge=0)], ...]
|
||||
phase: Phase | None = None
|
||||
|
||||
|
||||
class _IndexedEntry(_Frozen):
|
||||
type: Literal["reasoning", "function_call", "custom_tool_call", "tool_search_call", "tool_search_output"]
|
||||
index: Annotated[int, Field(ge=0)]
|
||||
|
||||
|
||||
_Entry: TypeAlias = Annotated[_MessageEntry | _IndexedEntry, Field(discriminator="type")]
|
||||
|
||||
|
||||
class _Replay(_Frozen):
|
||||
version: Literal[1]
|
||||
items: tuple[_Entry, ...]
|
||||
checksum: str
|
||||
|
||||
|
||||
class _Namespaced(_Frozen):
|
||||
namespace: str | None = None
|
||||
|
||||
|
||||
class _ToolFunction(_Frozen):
|
||||
name: str = ""
|
||||
arguments: str = ""
|
||||
provider_specific_fields: _Namespaced | None = None
|
||||
|
||||
|
||||
class _ToolCall(_Frozen):
|
||||
id: str
|
||||
function: _ToolFunction
|
||||
provider_specific_fields: _Namespaced | None = None
|
||||
|
||||
|
||||
class _SummaryText(_Frozen):
|
||||
type: str = "summary_text"
|
||||
text: str = ""
|
||||
|
||||
|
||||
class _Reasoning(_Frozen):
|
||||
id: str | None = None
|
||||
encrypted_content: str | None = None
|
||||
summary: tuple[_SummaryText, ...] | None = None
|
||||
|
||||
|
||||
class _ProviderFields(_Frozen):
|
||||
phase: Phase | None = None
|
||||
tool_search_items: tuple[Mapping[str, object], ...] | None = None
|
||||
openai_responses_replay: object = None
|
||||
|
||||
|
||||
class _ChatMessage(_Frozen):
|
||||
role: str | None = None
|
||||
content: str | None = None
|
||||
tool_calls: tuple[_ToolCall, ...] | None = None
|
||||
reasoning_items: tuple[_Reasoning, ...] | None = None
|
||||
phase: Phase | None = None
|
||||
provider_specific_fields: _ProviderFields | None = None
|
||||
|
||||
|
||||
class _PhaseFields(_Frozen):
|
||||
phase: Phase | None = None
|
||||
|
||||
|
||||
class _RequestedPhaseFields(_Frozen):
|
||||
phase: object = None
|
||||
|
||||
|
||||
class _RequestedPhase(_Frozen):
|
||||
phase: object = None
|
||||
provider_specific_fields: _RequestedPhaseFields | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnknownPhase:
|
||||
value: object
|
||||
|
||||
|
||||
_PHASE: Final[TypeAdapter[Phase]] = TypeAdapter(Phase)
|
||||
|
||||
|
||||
class _RetainedFields(_PhaseFields):
|
||||
openai_responses_replay: object = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayedTurn:
|
||||
items: tuple[Mapping[str, object], ...]
|
||||
custom_call_ids: frozenset[str]
|
||||
|
||||
|
||||
def _as_json(value: object) -> object:
|
||||
return value.model_dump() if isinstance(value, BaseModel) else value
|
||||
|
||||
|
||||
def _parsed_output_item(item: object) -> _OutputItem | None:
|
||||
try:
|
||||
return _OUTPUT_ITEM.validate_python(_as_json(item))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_message(item: object) -> bool:
|
||||
try:
|
||||
_AnyMessage.model_validate(_as_json(item))
|
||||
except ValidationError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _chat_message(message: object) -> _ChatMessage | None:
|
||||
try:
|
||||
return _ChatMessage.model_validate(_as_json(message))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def requested_phase(message: object) -> Phase | UnknownPhase | None:
|
||||
try:
|
||||
requested: Final = _RequestedPhase.model_validate(_as_json(message))
|
||||
except ValidationError:
|
||||
return None
|
||||
fields: Final = requested.provider_specific_fields
|
||||
value: Final = requested.phase or (fields.phase if fields else None)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return _PHASE.validate_python(value)
|
||||
except ValidationError:
|
||||
return UnknownPhase(value)
|
||||
|
||||
|
||||
def reasoning_input_item(
|
||||
item_id: str | None, summary: Sequence[object] | None, encrypted_content: str | None
|
||||
) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": "reasoning",
|
||||
**({"id": item_id} if item_id else {}),
|
||||
"summary": list(summary or ()),
|
||||
**({"encrypted_content": encrypted_content} if encrypted_content else {}),
|
||||
}
|
||||
|
||||
|
||||
def retained_replay_fields(provider_specific_fields: object) -> Mapping[str, object] | None:
|
||||
try:
|
||||
retained: Final = _RetainedFields.model_validate(provider_specific_fields)
|
||||
except ValidationError:
|
||||
return None
|
||||
return retained.model_dump(exclude_none=True) or None
|
||||
|
||||
|
||||
def output_message_text(output_items: Sequence[object], *, first_message_only: bool) -> str:
|
||||
parsed: Final = map(_parsed_output_item, output_items)
|
||||
messages: Final = tuple(item for item in parsed if isinstance(item, _OutputMessage))
|
||||
return "".join(_texts(messages[:1] if first_message_only else messages))
|
||||
|
||||
|
||||
def _tool_search_entries(message: _ChatMessage) -> tuple[Mapping[str, object], ...]:
|
||||
fields: Final = message.provider_specific_fields
|
||||
items: Final = (fields.tool_search_items if fields else None) or ()
|
||||
return tuple(item for item in items if item.get("type") in ("tool_search_call", "tool_search_output"))
|
||||
|
||||
|
||||
def _checksum(message: _ChatMessage, entries: Sequence[_MessageEntry | _IndexedEntry]) -> str:
|
||||
fields: Final = message.provider_specific_fields
|
||||
bound: Final = {
|
||||
"content": message.content or "",
|
||||
"tool_calls": [[call.id, call.function.name, call.function.arguments] for call in message.tool_calls or ()],
|
||||
"reasoning": [
|
||||
[item.id, item.encrypted_content, [part.text for part in item.summary or ()]]
|
||||
for item in message.reasoning_items or ()
|
||||
],
|
||||
"tool_search_items": list((fields.tool_search_items if fields else None) or ()),
|
||||
"phase": message.phase or (fields.phase if fields else None),
|
||||
"items": [entry.model_dump(mode="json", exclude_none=True) for entry in entries],
|
||||
}
|
||||
encoded: Final = json.dumps(bound, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
return hashlib.sha256(encoded.encode("utf-8", "surrogatepass")).hexdigest()
|
||||
|
||||
|
||||
def _kind(item: _OutputItem) -> str:
|
||||
match item:
|
||||
case _OutputToolCall():
|
||||
return "tool_call"
|
||||
case _OutputToolSearch():
|
||||
return "tool_search"
|
||||
case _OutputMessage() | _OutputReasoning():
|
||||
return item.type
|
||||
|
||||
|
||||
def _entry(items: Sequence[_OutputItem], position: int) -> _MessageEntry | _IndexedEntry:
|
||||
item: Final = items[position]
|
||||
if isinstance(item, _OutputMessage):
|
||||
return _MessageEntry(type="message", parts=tuple(len(part.text) for part in item.content), phase=item.phase)
|
||||
ordinal: Final = sum(1 for earlier in items[:position] if _kind(earlier) == _kind(item))
|
||||
return _IndexedEntry(type=item.type, index=ordinal)
|
||||
|
||||
|
||||
def _texts(items: Sequence[_OutputItem]) -> Iterator[str]:
|
||||
for item in items:
|
||||
if isinstance(item, _OutputMessage):
|
||||
yield from (part.text for part in item.content)
|
||||
|
||||
|
||||
def _matches(items: Sequence[_OutputItem], message: _ChatMessage) -> bool:
|
||||
reasoning_ids: Final = tuple(item.id for item in items if isinstance(item, _OutputReasoning))
|
||||
calls: Final = tuple(item for item in items if isinstance(item, _OutputToolCall))
|
||||
tool_calls: Final = message.tool_calls or ()
|
||||
searches: Final = sum(1 for item in items if isinstance(item, _OutputToolSearch))
|
||||
return (
|
||||
"".join(_texts(items)) == (message.content or "")
|
||||
and reasoning_ids == tuple(item.id for item in message.reasoning_items or ())
|
||||
and len(calls) == len(tool_calls)
|
||||
and all(chat.id in (call.call_id, call.id) for call, chat in zip(calls, tool_calls))
|
||||
and searches == len(_tool_search_entries(message))
|
||||
)
|
||||
|
||||
|
||||
def _content_phase(
|
||||
output_items: Sequence[object], parsed: Sequence[_OutputItem | None], *, first_message_only: bool
|
||||
) -> Phase | None:
|
||||
messages: Final = tuple(item for raw, item in zip(output_items, parsed) if _is_message(raw))
|
||||
if not messages:
|
||||
return None
|
||||
content_message: Final = messages[0] if first_message_only else messages[-1]
|
||||
return content_message.phase if isinstance(content_message, _OutputMessage) else None
|
||||
|
||||
|
||||
def _with_phase(message: _ChatMessage, phase: Phase | None) -> _ChatMessage:
|
||||
if phase is None:
|
||||
return message
|
||||
fields: Final = (message.provider_specific_fields or _ProviderFields()).model_copy(update={PHASE_FIELD: phase})
|
||||
return message.model_copy(update={"provider_specific_fields": fields})
|
||||
|
||||
|
||||
def replay_fields(
|
||||
output_items: Sequence[object], message: object, *, completed: bool, first_message_only: bool
|
||||
) -> Mapping[str, object]:
|
||||
parsed: Final = tuple(map(_parsed_output_item, output_items))
|
||||
phase: Final = _content_phase(output_items, parsed, first_message_only=first_message_only)
|
||||
phase_fields: Final[Mapping[str, object]] = MappingProxyType({PHASE_FIELD: phase} if phase else {})
|
||||
items: Final = tuple(item for item in parsed if item is not None)
|
||||
parsed_message: Final = _chat_message(message)
|
||||
chat: Final = _with_phase(parsed_message, phase) if parsed_message is not None else None
|
||||
if not completed or not items or len(items) != len(parsed) or chat is None or not _matches(items, chat):
|
||||
return phase_fields
|
||||
entries: Final = tuple(_entry(items, position) for position in range(len(items)))
|
||||
replay: Final = _Replay(version=1, items=entries, checksum=_checksum(chat, entries))
|
||||
return MappingProxyType({**phase_fields, REPLAY_FIELD: replay.model_dump(mode="json", exclude_none=True)})
|
||||
|
||||
|
||||
def _stored_replay(message: _ChatMessage) -> _Replay | None:
|
||||
fields: Final = message.provider_specific_fields
|
||||
if fields is None or fields.openai_responses_replay is None:
|
||||
return None
|
||||
try:
|
||||
return _Replay.model_validate(fields.openai_responses_replay)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _message_texts(entries: Sequence[_MessageEntry], content: str) -> tuple[tuple[str, ...], ...]:
|
||||
lengths: Final = tuple(chain.from_iterable(entry.parts for entry in entries))
|
||||
bounds: Final = tuple(accumulate(lengths, initial=0))
|
||||
parts: Final = tuple(content[start:end] for start, end in pairwise(bounds))
|
||||
groups: Final = tuple(accumulate((len(entry.parts) for entry in entries), initial=0))
|
||||
return tuple(parts[start:end] for start, end in pairwise(groups))
|
||||
|
||||
|
||||
def _message_item(texts: Sequence[str], phase: Phase | None) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text} for text in texts],
|
||||
**({PHASE_FIELD: phase} if phase else {}),
|
||||
}
|
||||
|
||||
|
||||
def _function_call_item(call: _ToolCall) -> Mapping[str, object]:
|
||||
namespaced: Final = call.provider_specific_fields or call.function.provider_specific_fields
|
||||
namespace: Final = namespaced.namespace if namespaced else None
|
||||
return {
|
||||
"type": "function_call",
|
||||
"call_id": call.id,
|
||||
"name": call.function.name,
|
||||
"arguments": call.function.arguments,
|
||||
**({"namespace": namespace} if namespace else {}),
|
||||
}
|
||||
|
||||
|
||||
def _custom_tool_call_item(call: _ToolCall) -> Mapping[str, object]:
|
||||
return {
|
||||
"type": "custom_tool_call",
|
||||
"call_id": call.id,
|
||||
"name": call.function.name,
|
||||
"input": call.function.arguments,
|
||||
}
|
||||
|
||||
|
||||
def _reasoning_item(item: _Reasoning) -> Mapping[str, object]:
|
||||
summary: Final = tuple(part.model_dump() for part in item.summary or ())
|
||||
return reasoning_input_item(item.id, summary, item.encrypted_content)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Resolved:
|
||||
message: _ChatMessage
|
||||
texts: Mapping[int, tuple[str, ...]]
|
||||
tool_searches: tuple[Mapping[str, object], ...]
|
||||
|
||||
|
||||
def _input_item(resolved: _Resolved, position: int, entry: _MessageEntry | _IndexedEntry) -> Mapping[str, object]:
|
||||
if isinstance(entry, _MessageEntry):
|
||||
return _message_item(resolved.texts[position], entry.phase)
|
||||
tool_calls: Final = resolved.message.tool_calls or ()
|
||||
match entry.type:
|
||||
case "reasoning":
|
||||
return _reasoning_item((resolved.message.reasoning_items or ())[entry.index])
|
||||
case "function_call":
|
||||
return _function_call_item(tool_calls[entry.index])
|
||||
case "custom_tool_call":
|
||||
return _custom_tool_call_item(tool_calls[entry.index])
|
||||
case "tool_search_call" | "tool_search_output":
|
||||
return dict(resolved.tool_searches[entry.index])
|
||||
|
||||
|
||||
def _fits(message: _ChatMessage, entries: Sequence[_MessageEntry | _IndexedEntry]) -> bool:
|
||||
tool_calls: Final = len(message.tool_calls or ())
|
||||
tool_searches: Final = len(_tool_search_entries(message))
|
||||
sizes: Final = MappingProxyType(
|
||||
{
|
||||
"reasoning": len(message.reasoning_items or ()),
|
||||
"function_call": tool_calls,
|
||||
"custom_tool_call": tool_calls,
|
||||
"tool_search_call": tool_searches,
|
||||
"tool_search_output": tool_searches,
|
||||
}
|
||||
)
|
||||
parts: Final = chain.from_iterable(entry.parts for entry in entries if isinstance(entry, _MessageEntry))
|
||||
indexed: Final = (entry for entry in entries if isinstance(entry, _IndexedEntry))
|
||||
return sum(parts) == len(message.content or "") and all(entry.index < sizes[entry.type] for entry in indexed)
|
||||
|
||||
|
||||
def _resolve(
|
||||
message: _ChatMessage, entries: Sequence[_MessageEntry | _IndexedEntry], replay_tool_search: bool
|
||||
) -> ReplayedTurn | None:
|
||||
indexed: Final = tuple(entry for entry in entries if isinstance(entry, _IndexedEntry))
|
||||
if not replay_tool_search and any(entry.type in ("tool_search_call", "tool_search_output") for entry in indexed):
|
||||
return None
|
||||
messages: Final = tuple(entry for entry in entries if isinstance(entry, _MessageEntry))
|
||||
calls: Final = tuple(entry for entry in indexed if entry.type in ("function_call", "custom_tool_call"))
|
||||
texts: Final = _message_texts(messages, message.content or "")
|
||||
positions: Final = tuple(position for position, entry in enumerate(entries) if isinstance(entry, _MessageEntry))
|
||||
resolved: Final = _Resolved(
|
||||
message=message,
|
||||
texts=MappingProxyType(dict(zip(positions, texts, strict=True))),
|
||||
tool_searches=_tool_search_entries(message),
|
||||
)
|
||||
tool_calls: Final = message.tool_calls or ()
|
||||
return ReplayedTurn(
|
||||
items=tuple(_input_item(resolved, position, entry) for position, entry in enumerate(entries)),
|
||||
custom_call_ids=frozenset(tool_calls[entry.index].id for entry in calls if entry.type == "custom_tool_call"),
|
||||
)
|
||||
|
||||
|
||||
def replay_items(message: object, *, replay_tool_search: bool) -> ReplayedTurn | None:
|
||||
chat: Final = _chat_message(message)
|
||||
stored: Final = _stored_replay(chat) if chat is not None and chat.role == "assistant" else None
|
||||
if chat is None or stored is None or stored.checksum != _checksum(chat, stored.items):
|
||||
return None
|
||||
if not _fits(chat, stored.items):
|
||||
return None
|
||||
return _resolve(chat, stored.items, replay_tool_search)
|
||||
|
|
@ -24,9 +24,19 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|||
import litellm
|
||||
from litellm import ModelResponse
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.completion_extras.litellm_responses_transformation.history import (
|
||||
PHASE_FIELD,
|
||||
UnknownPhase,
|
||||
output_message_text,
|
||||
reasoning_input_item,
|
||||
replay_fields,
|
||||
replay_items,
|
||||
requested_phase,
|
||||
)
|
||||
from litellm.integrations.anthropic_cache_control_hook import supports_openai_prompt_cache_breakpoint
|
||||
from litellm.litellm_core_utils.hidden_params import get_hidden_params, get_or_create_hidden_params
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
prompt_cache_breakpoint_for_wire,
|
||||
responses_reasoning_items_from_thinking_blocks,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
|
|
@ -52,7 +62,6 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionToolCallChunk,
|
||||
ChatCompletionToolCallFunctionChunk,
|
||||
ChatCompletionToolParamFunctionChunk,
|
||||
PromptCacheBreakpoint,
|
||||
Reasoning,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
|
|
@ -490,7 +499,7 @@ def _fold_choices_into_one(
|
|||
choices: Sequence["Choices"],
|
||||
tool_calls: Sequence[Mapping[str, object]],
|
||||
trailing_reasoning_content: str | None,
|
||||
trailing_reasoning_item: _BuiltReasoningItem | None,
|
||||
trailing_reasoning_items: Sequence[_BuiltReasoningItem],
|
||||
tool_search_items: Sequence[Mapping[str, object]],
|
||||
structured_output: bool = False,
|
||||
) -> "Choices":
|
||||
|
|
@ -509,7 +518,7 @@ def _fold_choices_into_one(
|
|||
reasoning_contents: Final = (*(_message_reasoning_content(m) for m in messages), trailing_reasoning_content)
|
||||
reasoning_items: Final = (
|
||||
*chain.from_iterable(_message_reasoning_items(m) for m in messages),
|
||||
*(() if trailing_reasoning_item is None else (trailing_reasoning_item,)),
|
||||
*trailing_reasoning_items,
|
||||
)
|
||||
return Choices(
|
||||
message=Message(
|
||||
|
|
@ -526,6 +535,61 @@ def _fold_choices_into_one(
|
|||
)
|
||||
|
||||
|
||||
def _with_replay_fields(
|
||||
choice: "Choices", output_items: Sequence[object], *, completed: bool, structured_output: bool
|
||||
) -> "Choices":
|
||||
fields: Final = {
|
||||
**(choice.message.provider_specific_fields or {}),
|
||||
**replay_fields(output_items, choice.message, completed=completed, first_message_only=structured_output),
|
||||
}
|
||||
message: Final = choice.message.model_copy(update={"provider_specific_fields": fields or None})
|
||||
return choice.model_copy(update={"message": message})
|
||||
|
||||
|
||||
_STREAMED_OUTPUT_ITEMS: Final[TypeAdapter[tuple[Mapping[str, object], ...]]] = TypeAdapter(
|
||||
tuple[Mapping[str, object], ...]
|
||||
)
|
||||
|
||||
|
||||
def completed_stream_history(
|
||||
output_payload: object,
|
||||
tool_search_fields: Mapping[str, object] | None,
|
||||
*,
|
||||
completed: bool,
|
||||
structured_output: bool,
|
||||
) -> dict[str, object] | None: # mutable-ok: Delta.provider_specific_fields is typed as a dict
|
||||
items: Final = _STREAMED_OUTPUT_ITEMS.validate_python(output_payload)
|
||||
calls: Final = tuple(item for item in items if item.get("type") in ("function_call", "custom_tool_call"))
|
||||
streamed_message: Final = {
|
||||
"role": "assistant",
|
||||
"content": output_message_text(items, first_message_only=structured_output),
|
||||
"tool_calls": [tool_call_dict_from_output_item(call, index) for index, call in enumerate(calls)],
|
||||
"reasoning_items": list(_reasoning_items_from_output_items(items)),
|
||||
"provider_specific_fields": tool_search_fields,
|
||||
}
|
||||
fields: Final = {
|
||||
**(tool_search_fields or {}),
|
||||
**replay_fields(items, streamed_message, completed=completed, first_message_only=structured_output),
|
||||
}
|
||||
return fields or None
|
||||
|
||||
|
||||
def _assistant_phase_field(msg: object, replay_openai_history: bool, drop_params: bool) -> Mapping[str, object]:
|
||||
match requested_phase(msg) if replay_openai_history else None:
|
||||
case None:
|
||||
return {}
|
||||
case UnknownPhase(value=value) if drop_params:
|
||||
verbose_logger.debug("Chat provider: dropping assistant phase=%r under drop_params", value)
|
||||
return {}
|
||||
case UnknownPhase(value=value):
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message=f"phase must be 'commentary' or 'final_answer', got {value!r}. "
|
||||
"To drop it, set `litellm.drop_params=True` or pass `drop_params=True`."
|
||||
)
|
||||
case phase:
|
||||
return {PHASE_FIELD: phase}
|
||||
|
||||
|
||||
def _responses_event_type(chunk: Mapping[str, object]) -> object:
|
||||
event_type: Final = chunk.get("type")
|
||||
return event_type.value if isinstance(event_type, ResponsesAPIStreamEvents) else event_type
|
||||
|
|
@ -567,19 +631,6 @@ def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Li
|
|||
return "length"
|
||||
|
||||
|
||||
_PROMPT_CACHE_BREAKPOINT: Final = TypeAdapter(PromptCacheBreakpoint)
|
||||
|
||||
|
||||
def _prompt_cache_breakpoint_for_wire(marker: object, drop_params: bool) -> object:
|
||||
if marker is None or not drop_params:
|
||||
return marker
|
||||
try:
|
||||
return _PROMPT_CACHE_BREAKPOINT.validate_python(marker)
|
||||
except ValidationError:
|
||||
verbose_logger.debug("Chat provider: dropping malformed prompt_cache_breakpoint %r under drop_params", marker)
|
||||
return None
|
||||
|
||||
|
||||
def _input_file_from_file_value(file_value: object) -> dict[str, object]:
|
||||
if not isinstance(file_value, dict):
|
||||
return {"type": "input_file"}
|
||||
|
|
@ -667,15 +718,7 @@ def _reasoning_item_to_response_input(
|
|||
An item without an id is sent without one: the Responses API accepts that and
|
||||
verifies the encrypted content on its own, while it rejects any id it did not mint.
|
||||
"""
|
||||
item_id: Final = r_item.get("id")
|
||||
encrypted_content: Final = r_item.get("encrypted_content")
|
||||
return {
|
||||
"type": "reasoning",
|
||||
**({"id": item_id} if item_id else {}),
|
||||
# summary is always required by the Responses API, even when empty
|
||||
"summary": r_item.get("summary") or [],
|
||||
**({"encrypted_content": encrypted_content} if encrypted_content else {}),
|
||||
}
|
||||
return dict(reasoning_input_item(r_item.get("id"), r_item.get("summary"), r_item.get("encrypted_content")))
|
||||
|
||||
|
||||
def _requested_defer_loading(tool: Mapping[str, object], function_tool: Mapping[str, object]) -> object:
|
||||
|
|
@ -804,12 +847,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
keep_prompt_cache_breakpoints: bool = False,
|
||||
replay_tool_search: bool = False,
|
||||
include_namespace: bool = False,
|
||||
replay_openai_history: bool = False,
|
||||
) -> tuple[list[object], str | None]:
|
||||
converted_input_items, instructions = self._convert_chat_completion_messages_to_responses_input(
|
||||
messages,
|
||||
drop_params=drop_params,
|
||||
replay_tool_search=replay_tool_search,
|
||||
include_namespace=include_namespace,
|
||||
replay_openai_history=replay_openai_history,
|
||||
)
|
||||
return (
|
||||
converted_input_items
|
||||
|
|
@ -825,10 +870,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
drop_params: bool = False,
|
||||
replay_tool_search: bool = False,
|
||||
include_namespace: bool = False,
|
||||
replay_openai_history: bool = False,
|
||||
) -> tuple[list[object], str | None]:
|
||||
input_items: Final[list[object]] = []
|
||||
instructions: str | None = None
|
||||
custom_tool_call_ids: Final = frozenset(
|
||||
replayed_turns: Final = tuple(
|
||||
replay_items(msg, replay_tool_search=replay_tool_search) if replay_openai_history else None
|
||||
for msg in messages
|
||||
)
|
||||
replayed_custom_call_ids: Final = frozenset(
|
||||
chain.from_iterable(turn.custom_call_ids for turn in replayed_turns if turn is not None)
|
||||
)
|
||||
custom_tool_call_ids: Final = replayed_custom_call_ids | frozenset(
|
||||
tool_call["id"]
|
||||
for msg in messages
|
||||
if msg.get("role") == "assistant" and isinstance(msg.get("tool_calls"), list)
|
||||
|
|
@ -849,7 +902,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
tool_calls = msg.get("tool_calls")
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
|
||||
if role == "system":
|
||||
if (replayed_turn := replayed_turns[index]) is not None:
|
||||
input_items.extend(replayed_turn.items)
|
||||
elif role == "system":
|
||||
# Extract system message as instructions
|
||||
if isinstance(content, str) and index < leading_system_count:
|
||||
if instructions:
|
||||
|
|
@ -915,6 +970,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
"content": self._convert_content_to_responses_format(
|
||||
content, "assistant", drop_params=drop_params
|
||||
),
|
||||
**_assistant_phase_field(msg, replay_openai_history, drop_params),
|
||||
}
|
||||
)
|
||||
for tool_call in tool_calls:
|
||||
|
|
@ -955,6 +1011,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
"content": self._convert_content_to_responses_format(
|
||||
content, cast(str, role), drop_params=drop_params
|
||||
),
|
||||
**_assistant_phase_field(msg, replay_openai_history and role == "assistant", drop_params),
|
||||
}
|
||||
)
|
||||
elif role == "assistant":
|
||||
|
|
@ -1081,6 +1138,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
keep_prompt_cache_breakpoints=supports_prompt_cache_breakpoint,
|
||||
replay_tool_search=_requests_tool_search(cast(object, optional_params)), # cast-ok: upcast
|
||||
include_namespace=True,
|
||||
replay_openai_history=provider == "openai",
|
||||
)
|
||||
# OpenAI's Responses API rejects an empty input. For a system-only
|
||||
# request, carry the system message as a system-role input item instead
|
||||
|
|
@ -1165,6 +1223,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
output_items: Sequence[object],
|
||||
handle_raw_dict_callback: Callable[..., tuple["Choices | None", int]] | None = None,
|
||||
structured_output: bool = False,
|
||||
replay_openai_history: bool = False,
|
||||
completed: bool = False,
|
||||
) -> list["Choices"]:
|
||||
"""
|
||||
Convert Responses API output items to chat completion choices.
|
||||
|
|
@ -1174,6 +1234,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
handle_raw_dict_callback: Optional callback for handling raw dict items
|
||||
structured_output: The chat request carried a json response_format, so only the
|
||||
first message item becomes content
|
||||
replay_openai_history: Record the output's item sequence for the next turn
|
||||
completed: The response finished, so its item sequence is whole
|
||||
|
||||
Returns:
|
||||
List of Choices objects
|
||||
|
|
@ -1196,7 +1258,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
choices: Final[list[Choices]] = []
|
||||
index = 0
|
||||
reasoning_content: str | None = None
|
||||
pending_reasoning_item: _BuiltReasoningItem | None = None
|
||||
pending_reasoning_items: tuple[_BuiltReasoningItem, ...] = ()
|
||||
|
||||
# Collect all tool calls to put them in a single choice
|
||||
# (Chat Completions API expects all tool calls in one message)
|
||||
|
|
@ -1205,12 +1267,19 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
|
||||
for item in output_items:
|
||||
if isinstance(item, ResponseReasoningItem):
|
||||
pending_reasoning_item = _build_reasoning_item(
|
||||
item_id=item.id,
|
||||
encrypted_content=getattr(item, "encrypted_content", None),
|
||||
summary_raw=item.summary,
|
||||
pending_reasoning_items = (
|
||||
*(pending_reasoning_items if replay_openai_history else ()),
|
||||
_build_reasoning_item(
|
||||
item_id=item.id,
|
||||
encrypted_content=getattr(item, "encrypted_content", None),
|
||||
summary_raw=item.summary,
|
||||
),
|
||||
)
|
||||
reasoning_content = " ".join(
|
||||
s["text"]
|
||||
for s in chain.from_iterable(built["summary"] for built in pending_reasoning_items)
|
||||
if s.get("text")
|
||||
)
|
||||
reasoning_content = " ".join(s["text"] for s in pending_reasoning_item["summary"] if s.get("text"))
|
||||
|
||||
elif isinstance(item, ResponseOutputMessage):
|
||||
for content in item.content:
|
||||
|
|
@ -1227,7 +1296,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
annotations=annotations,
|
||||
reasoning_items=cast(
|
||||
list[ChatCompletionReasoningItem] | None,
|
||||
([pending_reasoning_item] if pending_reasoning_item is not None else None),
|
||||
(list(pending_reasoning_items) or None),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1240,7 +1309,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
)
|
||||
|
||||
reasoning_content = None # flush
|
||||
pending_reasoning_item = None # flush
|
||||
pending_reasoning_items = () # flush
|
||||
index += 1
|
||||
|
||||
elif isinstance(item, ResponseFunctionToolCall):
|
||||
|
|
@ -1300,16 +1369,17 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
output_items, "stop", tool_search_items=tool_search_items
|
||||
)
|
||||
]
|
||||
return [
|
||||
_fold_choices_into_one(
|
||||
choices=choices,
|
||||
tool_calls=accumulated_tool_calls,
|
||||
trailing_reasoning_content=reasoning_content,
|
||||
trailing_reasoning_item=pending_reasoning_item,
|
||||
tool_search_items=tool_search_items,
|
||||
structured_output=structured_output,
|
||||
)
|
||||
]
|
||||
folded: Final = _fold_choices_into_one(
|
||||
choices=choices,
|
||||
tool_calls=accumulated_tool_calls,
|
||||
trailing_reasoning_content=reasoning_content,
|
||||
trailing_reasoning_items=pending_reasoning_items,
|
||||
tool_search_items=tool_search_items,
|
||||
structured_output=structured_output,
|
||||
)
|
||||
if not replay_openai_history:
|
||||
return [folded]
|
||||
return [_with_replay_fields(folded, output_items, completed=completed, structured_output=structured_output)]
|
||||
|
||||
@staticmethod
|
||||
def _build_empty_choice(
|
||||
|
|
@ -1443,6 +1513,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
structured_output=is_structured_output_request(
|
||||
cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict
|
||||
),
|
||||
replay_openai_history=_served_provider(
|
||||
cast("Mapping[str, object]", litellm_params) # cast-ok: litellm_params is an untyped dict
|
||||
)
|
||||
== "openai",
|
||||
completed=raw_response.status == "completed",
|
||||
)
|
||||
|
||||
response_is_incomplete: Final = raw_response.status == "incomplete" or (
|
||||
|
|
@ -1506,9 +1581,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
sync_stream: bool,
|
||||
json_mode: bool | None = False,
|
||||
structured_output: bool = False,
|
||||
replays_openai_history: bool = False,
|
||||
) -> BaseModelResponseIterator:
|
||||
return OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response, sync_stream, json_mode, structured_output=structured_output
|
||||
streaming_response,
|
||||
sync_stream,
|
||||
json_mode,
|
||||
structured_output=structured_output,
|
||||
replays_openai_history=replays_openai_history,
|
||||
)
|
||||
|
||||
def _convert_content_str_to_input_text(self, content: str, role: str) -> dict[str, object]:
|
||||
|
|
@ -1586,7 +1666,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
if original_type == "text":
|
||||
converted = with_prompt_cache_breakpoint(
|
||||
self._convert_content_str_to_input_text(item.get("text", ""), role),
|
||||
_prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: text -> %s", converted)
|
||||
|
|
@ -1599,14 +1679,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
cast(ChatCompletionImageObject, item), role
|
||||
),
|
||||
),
|
||||
_prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: image_url -> %s", converted)
|
||||
elif original_type == "input_audio":
|
||||
converted = with_prompt_cache_breakpoint(
|
||||
{"type": "input_audio", "input_audio": item.get("input_audio")},
|
||||
_prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: input_audio -> %s", converted)
|
||||
|
|
@ -1622,7 +1702,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
_input_file_from_file_value(
|
||||
cast("ChatCompletionFileObject", item).get("file"), # cast-ok: type tag checked
|
||||
),
|
||||
_prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: file -> %s", converted)
|
||||
|
|
@ -1646,7 +1726,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
# Default to input_text for unknown types
|
||||
converted = with_prompt_cache_breakpoint(
|
||||
self._convert_content_str_to_input_text(str(item.get("text", item)), role),
|
||||
_prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
prompt_cache_breakpoint_for_wire(item.get("prompt_cache_breakpoint"), drop_params),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: unknown(%s) -> %s", original_type, converted)
|
||||
|
|
@ -1890,8 +1970,10 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
sync_stream: bool,
|
||||
json_mode: bool | None = False,
|
||||
structured_output: bool = False,
|
||||
replays_openai_history: bool = False,
|
||||
):
|
||||
super().__init__(streaming_response, sync_stream, json_mode)
|
||||
self._replays_openai_history: Final = replays_openai_history
|
||||
self._chat_completion_id: str | None = None
|
||||
self._served_service_tier: str | None = None
|
||||
self._tool_call_index_map: dict[int, int] = {} # mutable-ok: per-stream accumulator state
|
||||
|
|
@ -1937,6 +2019,8 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
def translate_responses_chunk_to_openai_stream(
|
||||
parsed_chunk: dict | BaseModel,
|
||||
tool_call_index_map: dict[int, int] | None = None, # mutable-ok: per-stream state, remapped in place
|
||||
replays_openai_history: bool = False,
|
||||
structured_output: bool = False,
|
||||
) -> "ModelResponseStream":
|
||||
"""
|
||||
Translate a Responses API streaming chunk to OpenAI chat completion streaming format.
|
||||
|
|
@ -1944,6 +2028,8 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
Args:
|
||||
parsed_chunk: Dict containing the Responses API event chunk
|
||||
tool_call_index_map: Per-stream output_index -> sequential tool_call index map
|
||||
replays_openai_history: Record the completed output's item sequence for the next turn
|
||||
structured_output: Only the first message item became content
|
||||
|
||||
Returns:
|
||||
ModelResponseStream: OpenAI-formatted streaming chunk
|
||||
|
|
@ -2154,6 +2240,10 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
terminal_reasoning_items_typed: Final = _as_chat_reasoning_items(
|
||||
_reasoning_items_from_output_items(output_items)
|
||||
)
|
||||
output_payload: Final = cast(object, output_items) # cast-ok: untyped
|
||||
tool_search_fields: Final = _tool_search_provider_fields(
|
||||
_tool_search_items_from_output_items(output_payload)
|
||||
)
|
||||
|
||||
usage = None
|
||||
if response_data.get("usage"):
|
||||
|
|
@ -2167,10 +2257,17 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(
|
||||
content="",
|
||||
content=None if replays_openai_history else "",
|
||||
reasoning_items=terminal_reasoning_items_typed,
|
||||
provider_specific_fields=_tool_search_provider_fields(
|
||||
_tool_search_items_from_output_items(cast(object, output_items)) # cast-ok: untyped
|
||||
provider_specific_fields=(
|
||||
completed_stream_history(
|
||||
output_payload,
|
||||
tool_search_fields,
|
||||
completed=event_type == "response.completed",
|
||||
structured_output=structured_output,
|
||||
)
|
||||
if replays_openai_history
|
||||
else tool_search_fields
|
||||
),
|
||||
),
|
||||
finish_reason=finish_reason,
|
||||
|
|
@ -2216,7 +2313,10 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
self._empty_content_chunk()
|
||||
if self._is_text_delta_after_structured_answer(chunk)
|
||||
else OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
|
||||
chunk, tool_call_index_map=self._tool_call_index_map
|
||||
chunk,
|
||||
tool_call_index_map=self._tool_call_index_map,
|
||||
replays_openai_history=self._replays_openai_history,
|
||||
structured_output=self._structured_output,
|
||||
)
|
||||
)
|
||||
if self._structured_output and _is_message_item_done(chunk):
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ DEFAULT_REPLICATE_POLLING_DELAY_SECONDS: Final = get_env_int("DEFAULT_REPLICATE_
|
|||
DEFAULT_IMAGE_TOKEN_COUNT: Final = get_env_int("DEFAULT_IMAGE_TOKEN_COUNT", 250)
|
||||
HF_CONFIG_FETCH_TIMEOUT_SECONDS: Final = 10.0
|
||||
HUGGINGFACE_TOKENIZER_LOAD_RETRY_SECONDS: Final = get_env_int_in_range(
|
||||
"HUGGINGFACE_TOKENIZER_LOAD_RETRY_SECONDS", 300, 1, 86400
|
||||
"HUGGINGFACE_TOKENIZER_LOAD_RETRY_SECONDS", 60, 1, 86400
|
||||
)
|
||||
|
||||
# Maximum wall-clock seconds a streaming response is allowed to run.
|
||||
|
|
@ -1064,6 +1064,7 @@ openai_compatible_endpoints: Final[list[str]] = [
|
|||
"https://api.reka.ai/v1",
|
||||
"https://gigachat.devices.sberbank.ru/api/v1",
|
||||
"https://api.openinfer.ai/v1",
|
||||
"https://vanchin.streamlake.ai/api/gateway/v1/endpoints",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1145,6 +1146,7 @@ openai_compatible_providers: Final[list[str]] = [
|
|||
"prism",
|
||||
"sail",
|
||||
"reka",
|
||||
"streamlake",
|
||||
]
|
||||
|
||||
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset({"openai"} | frozenset(openai_compatible_providers))
|
||||
|
|
|
|||
|
|
@ -688,7 +688,7 @@ def cost_per_token(
|
|||
elif call_type == "atranscription" or call_type == "transcription":
|
||||
if _transcription_usage_has_token_details(usage_block):
|
||||
return generic_cost_per_token(
|
||||
model=model_without_prefix,
|
||||
model=model,
|
||||
usage=usage_block,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
service_tier=service_tier,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ def _genai_message_part(part: object) -> _GenAIMessagePart | None:
|
|||
return ChatCompletionTextObject(type="text", text=text) if isinstance(text, str) else _genai_media_part(fields)
|
||||
|
||||
|
||||
def _genai_content_parts(value: object) -> Sequence[object]:
|
||||
single_part: Final = _validated(_CONFIG_FIELDS, value)
|
||||
return (single_part,) if single_part is not None else _validated(_GENAI_CONTENT_PARTS, value) or ()
|
||||
|
||||
|
||||
def _genai_message_parts(parts: object) -> tuple[_GenAIMessagePart, ...]:
|
||||
items: Final = _validated(_GENAI_CONTENT_PARTS, parts) or ()
|
||||
return tuple(converted for part in items if (converted := _genai_message_part(part)) is not None)
|
||||
|
|
@ -248,7 +253,7 @@ def _genai_content_messages(content: object) -> tuple[AllMessageValues, ...]:
|
|||
fields: Final = _validated(_CONFIG_FIELDS, content)
|
||||
if fields is None:
|
||||
return ()
|
||||
parts: Final = _validated(_GENAI_CONTENT_PARTS, fields.get("parts", ())) or ()
|
||||
parts: Final = _genai_content_parts(fields.get("parts", ()))
|
||||
converted: Final = _genai_message_parts(parts)
|
||||
role: Final = fields.get("role", "user")
|
||||
if role == "user":
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ MAX_CACHE_CONTROL_BLOCKS: Final = 4
|
|||
CACHE_BREAKPOINT_KEYS: Final = ("cache_control", "prompt_cache_breakpoint")
|
||||
OPENAI_PROMPT_CACHE_BREAKPOINT_MIN_GPT_VERSION: Final = (5, 6)
|
||||
_GPT_VERSION_PATTERN: Final = re.compile(r"^gpt-(\d+)(?:\.(\d+))?")
|
||||
_FINE_TUNED_BASE_PATTERN: Final = re.compile(r"^ft:([^:]+)")
|
||||
_O_SERIES_PATTERN: Final = re.compile(r"^o\d+(?:-|$)")
|
||||
OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES: Final = frozenset(
|
||||
{"text", "image", "image_url", "file", "input_audio", "input_text", "input_image", "input_file"}
|
||||
)
|
||||
|
|
@ -125,6 +127,10 @@ def _served_provider_for_model(model: str) -> str | None:
|
|||
|
||||
|
||||
def supports_openai_prompt_cache_breakpoint(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
return openai_prompt_cache_breakpoint_support(model, custom_llm_provider) is True
|
||||
|
||||
|
||||
def openai_prompt_cache_breakpoint_support(model: str, custom_llm_provider: str | None = None) -> bool | None:
|
||||
hosted_flag: Final = (
|
||||
None
|
||||
if custom_llm_provider is None
|
||||
|
|
@ -135,9 +141,19 @@ def supports_openai_prompt_cache_breakpoint(model: str, custom_llm_provider: str
|
|||
model_map_flag: Final = _model_map_prompt_cache_breakpoint_flag(model)
|
||||
if model_map_flag is not None:
|
||||
return model_map_flag
|
||||
version_match: Final = _GPT_VERSION_PATTERN.match(model.rsplit("/", 1)[-1].lower())
|
||||
if version_match is None:
|
||||
fine_tuned_base: Final = _FINE_TUNED_BASE_PATTERN.match(model.rsplit("/", 1)[-1])
|
||||
if fine_tuned_base is not None:
|
||||
return False if _named_model_breakpoint_support(fine_tuned_base.group(1)) is False else None
|
||||
return _named_model_breakpoint_support(model)
|
||||
|
||||
|
||||
def _named_model_breakpoint_support(model: str) -> bool | None:
|
||||
bare_model: Final = model.rsplit("/", 1)[-1].lower()
|
||||
if _O_SERIES_PATTERN.match(bare_model) is not None:
|
||||
return False
|
||||
version_match: Final = _GPT_VERSION_PATTERN.match(bare_model)
|
||||
if version_match is None:
|
||||
return None
|
||||
version: Final = (int(version_match.group(1)), int(version_match.group(2) or 0))
|
||||
return version >= OPENAI_PROMPT_CACHE_BREAKPOINT_MIN_GPT_VERSION
|
||||
|
||||
|
|
|
|||
|
|
@ -1173,8 +1173,8 @@ class PrometheusLogger(CustomLogger):
|
|||
# Build label filters from valid configurations
|
||||
label_filters: Final = self._build_label_filters(parsed_configs)
|
||||
|
||||
# Pretty print the processed configuration
|
||||
self._pretty_print_prometheus_config(label_filters)
|
||||
if litellm.suppress_startup_output is not True:
|
||||
self._pretty_print_prometheus_config(label_filters)
|
||||
return label_filters
|
||||
|
||||
def _parse_exclude_config(self) -> tuple[frozenset[str], frozenset[str]]:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.litellm_core_utils.bug_report import (
|
|||
)
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none
|
||||
from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string
|
||||
from litellm.llms.bigmodel.common_utils import raise_bigmodel_business_error
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
from ..exceptions import (
|
||||
|
|
@ -2762,6 +2763,8 @@ def exception_type(
|
|||
exception_provider=exception_provider,
|
||||
extra_information=extra_information,
|
||||
)
|
||||
if custom_llm_provider == "bigmodel":
|
||||
raise_bigmodel_business_error(model, f"{exception_provider} - {error_str}", original_exception) # pyright: ignore[reportUnknownArgumentType] # exception_type takes untyped parameters
|
||||
if custom_llm_provider == "openrouter":
|
||||
_map_openrouter_exception(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ OPTIONAL_KWARGS_KEYS: Final = (
|
|||
"otpm",
|
||||
"tpd",
|
||||
"rpd",
|
||||
"daily_reset_time",
|
||||
"use_xai_oauth",
|
||||
"chatgpt_auth_file",
|
||||
"github_copilot_auth_type",
|
||||
|
|
|
|||
|
|
@ -794,6 +794,13 @@ def _get_openai_compatible_provider_info(
|
|||
api_base,
|
||||
dynamic_api_key,
|
||||
) = litellm.XAIChatConfig().get_openai_compatible_provider_info(api_base, api_key)
|
||||
elif custom_llm_provider == "bigmodel":
|
||||
from litellm.llms.bigmodel.common_utils import get_bigmodel_api_key, get_bigmodel_url
|
||||
|
||||
(
|
||||
api_base, # rebind-ok: existing provider-resolution pipeline resolves the API base in place
|
||||
dynamic_api_key, # rebind-ok: existing provider-resolution pipeline resolves the API key in place
|
||||
) = get_bigmodel_url(api_base, "chat"), get_bigmodel_api_key(api_key)
|
||||
elif custom_llm_provider == "zai":
|
||||
(
|
||||
api_base,
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ def is_unbilled_non_inference_call(
|
|||
origin: Final = metadata.get(INTERNAL_CALL_ORIGIN_METADATA_KEY) if metadata is not None else None
|
||||
if origin == BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN:
|
||||
return False
|
||||
if not is_background_response(response):
|
||||
if not is_background_response(_finished_response(response)):
|
||||
return True
|
||||
return origin == BACKGROUND_RESPONSE_STATUS_POLL_CALL_ORIGIN or _is_managed_background_call(metadata)
|
||||
|
||||
|
|
|
|||
|
|
@ -1130,9 +1130,6 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
}
|
||||
self.litellm_request_debug = litellm_params.get("litellm_request_debug", False)
|
||||
self.logger_fn = litellm_params.get("logger_fn", None)
|
||||
if is_debugging_on() or self.litellm_request_debug:
|
||||
verbose_logger.debug("self.optional_params: %s", self.optional_params)
|
||||
|
||||
self.model_call_details.update(
|
||||
{
|
||||
"model": self.model,
|
||||
|
|
@ -5055,6 +5052,23 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
return result_copy
|
||||
|
||||
|
||||
_SENSITIVE_KEYWORDS: Final = (
|
||||
"authorization",
|
||||
"token",
|
||||
"key",
|
||||
"secret",
|
||||
"vertex_credentials",
|
||||
"credentials",
|
||||
"password",
|
||||
"passwd",
|
||||
)
|
||||
|
||||
|
||||
def has_sensitive_keyword(key: str) -> bool:
|
||||
lowered: Final = key.lower()
|
||||
return any(keyword in lowered for keyword in _SENSITIVE_KEYWORDS)
|
||||
|
||||
|
||||
def get_masked_values(
|
||||
sensitive_object: Mapping[str, object],
|
||||
ignore_sensitive_values: bool = False,
|
||||
|
|
@ -5073,16 +5087,6 @@ def get_masked_values(
|
|||
masked_length: Optional length for the masked portion (number of *). If set, will use exactly this many *
|
||||
regardless of original string length. The total length will be unmasked_length + masked_length.
|
||||
"""
|
||||
sensitive_keywords: Final = (
|
||||
"authorization",
|
||||
"token",
|
||||
"key",
|
||||
"secret",
|
||||
"vertex_credentials",
|
||||
"credentials",
|
||||
"password",
|
||||
"passwd",
|
||||
)
|
||||
|
||||
def _mask_value(v: object) -> object:
|
||||
if isinstance(v, dict):
|
||||
|
|
@ -5109,12 +5113,7 @@ def get_masked_values(
|
|||
return v[: unmasked_length // 2] + "*" * (len(v) - unmasked_length) + v[-unmasked_length // 2 :]
|
||||
|
||||
return {
|
||||
k: (
|
||||
v
|
||||
if ignore_sensitive_values
|
||||
or not any(sensitive_keyword in k.lower() for sensitive_keyword in sensitive_keywords)
|
||||
else _mask_value(v)
|
||||
)
|
||||
k: (v if ignore_sensitive_values or not has_sensitive_keyword(k) else _mask_value(v))
|
||||
for k, v in sensitive_object.items()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ class TranscriptionUsageObjectTransformation:
|
|||
if isinstance(usage_object, TranscriptionUsageDurationObject):
|
||||
return None
|
||||
elif isinstance(usage_object, TranscriptionUsageTokensObject):
|
||||
input_token_details: Final = usage_object.input_token_details
|
||||
return Usage(
|
||||
prompt_tokens=usage_object.input_tokens,
|
||||
completion_tokens=usage_object.output_tokens,
|
||||
total_tokens=usage_object.total_tokens,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=usage_object.input_token_details.text_tokens,
|
||||
audio_tokens=usage_object.input_token_details.audio_tokens,
|
||||
prompt_tokens_details=(
|
||||
None
|
||||
if input_token_details is None
|
||||
else PromptTokensDetailsWrapper(
|
||||
text_tokens=input_token_details.text_tokens,
|
||||
audio_tokens=input_token_details.audio_tokens,
|
||||
)
|
||||
),
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from typing import Final, cast
|
||||
|
||||
import litellm
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider
|
||||
|
||||
|
||||
def _form_field_value(value: object) -> str:
|
||||
|
|
@ -153,24 +154,33 @@ def pick_cheapest_chat_models_from_llm_provider(custom_llm_provider: str, n=1):
|
|||
if custom_llm_provider not in litellm.models_by_provider:
|
||||
return []
|
||||
|
||||
known_models: Final = litellm.models_by_provider.get(custom_llm_provider, [])
|
||||
model_costs: Final = []
|
||||
known_models: Final[Iterable[str]] = litellm.models_by_provider.get(custom_llm_provider, ())
|
||||
ranked_rows: Final = (
|
||||
(route_rank, cost, model)
|
||||
for model, cost in _chat_row_costs(known_models, custom_llm_provider)
|
||||
if (route_rank := _route_rank(model, custom_llm_provider)) is not None
|
||||
)
|
||||
return [model for _, _, model in sorted(ranked_rows, key=lambda row: row[:2])[:n]]
|
||||
|
||||
for model in known_models:
|
||||
|
||||
def _chat_row_costs(models: Iterable[str], custom_llm_provider: str) -> Iterator[tuple[str, float]]:
|
||||
for model in models:
|
||||
try:
|
||||
model_info = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception:
|
||||
continue
|
||||
if model_info.get("mode") != "chat":
|
||||
continue
|
||||
_cost = (model_info.get("input_cost_per_token") or 0.0) + (model_info.get("output_cost_per_token") or 0.0)
|
||||
model_costs.append((model, _cost))
|
||||
yield model, (model_info.get("input_cost_per_token") or 0.0) + (model_info.get("output_cost_per_token") or 0.0)
|
||||
|
||||
# Sort by cost (ascending)
|
||||
model_costs.sort(key=lambda x: x[1])
|
||||
|
||||
# Return the top n cheapest models
|
||||
return [model for model, _ in model_costs[:n]]
|
||||
def _route_rank(model: str, custom_llm_provider: str) -> int | None:
|
||||
routed_provider: Final = inferred_provider(model)
|
||||
if routed_provider is None:
|
||||
return 1
|
||||
if routed_provider == inferred_provider(f"{custom_llm_provider}/{model}"):
|
||||
return 0
|
||||
return None
|
||||
|
||||
|
||||
def get_proxy_server_request_headers(litellm_params: dict | None) -> dict:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from pathlib import Path
|
|||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm import verbose_logger
|
||||
|
|
@ -2073,6 +2073,19 @@ def with_prompt_cache_breakpoint(target: _MarkedT, marker: object) -> _MarkedT:
|
|||
return cast(_MarkedT, marked) # cast-ok: same block shape as the input plus the marker key
|
||||
|
||||
|
||||
_PROMPT_CACHE_BREAKPOINT: Final = TypeAdapter(PromptCacheBreakpoint)
|
||||
|
||||
|
||||
def prompt_cache_breakpoint_for_wire(marker: object, drop_params: bool) -> object:
|
||||
if marker is None or not drop_params:
|
||||
return marker
|
||||
try:
|
||||
return _PROMPT_CACHE_BREAKPOINT.validate_python(marker)
|
||||
except ValidationError:
|
||||
verbose_logger.debug("Dropping malformed prompt_cache_breakpoint %r under drop_params", marker)
|
||||
return None
|
||||
|
||||
|
||||
def cache_control_as_prompt_cache_breakpoint(block: _MarkedT) -> _MarkedT:
|
||||
"""Anthropic's ``cache_control`` mark rewritten as OpenAI's ``prompt_cache_breakpoint`` on the same block."""
|
||||
if block.get("cache_control") is None:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRAC
|
|||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
|
||||
)
|
||||
from litellm.litellm_core_utils.token_counter import usage_tokenizer_model
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionAssistantContentValue,
|
||||
|
|
@ -51,7 +52,7 @@ from litellm.types.utils import (
|
|||
UnreportedUsage,
|
||||
Usage,
|
||||
)
|
||||
from litellm.utils import print_verbose, token_counter
|
||||
from litellm.utils import get_base_model_from_metadata, print_verbose, token_counter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
|
@ -1552,6 +1553,9 @@ def stream_chunk_builder(
|
|||
return stream_chunk_builder_text_completion(chunks=chunks, messages=messages)
|
||||
|
||||
model: Final = chunks[0]["model"]
|
||||
usage_model: Final = usage_tokenizer_model(
|
||||
model, get_base_model_from_metadata(logging_obj.model_call_details) if logging_obj is not None else None
|
||||
)
|
||||
# Initialize the response dictionary
|
||||
response: Final = processor.build_base_response(chunks)
|
||||
|
||||
|
|
@ -1575,7 +1579,7 @@ def stream_chunk_builder(
|
|||
completion_output = get_content_from_model_response(response)
|
||||
usage = processor.calculate_usage(
|
||||
chunks=chunks,
|
||||
model=model,
|
||||
model=usage_model,
|
||||
completion_output=completion_output,
|
||||
messages=messages,
|
||||
reasoning_tokens=0,
|
||||
|
|
@ -1766,7 +1770,7 @@ def stream_chunk_builder(
|
|||
|
||||
usage = processor.calculate_usage(
|
||||
chunks=chunks,
|
||||
model=model,
|
||||
model=usage_model,
|
||||
completion_output=completion_output,
|
||||
messages=messages,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,18 @@ def _next_sync_or_exhausted(it: Iterator[object]) -> object:
|
|||
return _SYNC_ITER_EXHAUSTED
|
||||
|
||||
|
||||
def _stamp_usage_cost(
|
||||
usage_chunk: ModelResponse | TextCompletionResponse | ModelResponseStream,
|
||||
complete_response: ModelResponse | TextCompletionResponse | ModelResponseStream,
|
||||
) -> None:
|
||||
bill: Final = (get_hidden_params(complete_response) or {}).get("response_cost")
|
||||
usage: Final[object] = getattr(usage_chunk, "usage", None)
|
||||
if usage is None or isinstance(bill, bool) or not isinstance(bill, (int, float)):
|
||||
return
|
||||
if bill > 0 or isinstance(getattr(usage, "cost", None), (int, float)):
|
||||
setattr(usage, "cost", float(bill)) # noqa: B010 # cost is an optional extra on the usage model
|
||||
|
||||
|
||||
def _stamp_served_service_tier(response: ModelResponseStream, complete_streaming_response: ModelResponse) -> None:
|
||||
served_tier: Final = complete_streaming_response.model_dump().get("service_tier")
|
||||
if isinstance(served_tier, str) and served_tier:
|
||||
|
|
@ -1926,6 +1938,16 @@ class CustomStreamWrapper:
|
|||
model_response: Final = self.model_response_creator(
|
||||
hidden_params=None if self.received_model_id is None else {"received_model_id": self.received_model_id}
|
||||
)
|
||||
served_tier: Final = next(
|
||||
(
|
||||
tier
|
||||
for chunk in reversed(self.chunks)
|
||||
if isinstance(tier := getattr(chunk, "service_tier", None), str) and tier
|
||||
),
|
||||
None,
|
||||
)
|
||||
if served_tier is not None:
|
||||
setattr(model_response, "service_tier", served_tier) # noqa: B010 # pydantic extra, not a declared field
|
||||
_finish_reason: Final = self.received_finish_reason or self.intermittent_finish_reason
|
||||
if _finish_reason is not None:
|
||||
model_response.choices[0].finish_reason = _finish_reason
|
||||
|
|
@ -2141,6 +2163,7 @@ class CustomStreamWrapper:
|
|||
getattr(complete_streaming_response, "usage"),
|
||||
)
|
||||
_stamp_served_service_tier(response, complete_streaming_response)
|
||||
_stamp_usage_cost(response, complete_streaming_response)
|
||||
try:
|
||||
_cache_copy = complete_streaming_response.model_copy(deep=True)
|
||||
_log_copy = complete_streaming_response.model_copy(deep=True)
|
||||
|
|
@ -2361,6 +2384,7 @@ class CustomStreamWrapper:
|
|||
getattr(complete_streaming_response, "usage"),
|
||||
)
|
||||
_stamp_served_service_tier(response, complete_streaming_response)
|
||||
_stamp_usage_cost(response, complete_streaming_response)
|
||||
try:
|
||||
_copy = complete_streaming_response.model_copy(deep=True)
|
||||
except RuntimeError:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import anyio
|
|||
import anyio.lowlevel
|
||||
import httpx
|
||||
import tiktoken
|
||||
from tiktoken.model import MODEL_TO_ENCODING
|
||||
from typing_extensions import ParamSpec, TypeVar
|
||||
|
||||
import litellm
|
||||
|
|
@ -49,6 +50,7 @@ if TYPE_CHECKING:
|
|||
from litellm.litellm_core_utils.tokenizer import Encoding, HuggingFace
|
||||
from litellm.litellm_core_utils.url_utils import safe_get
|
||||
from litellm.llms.custom_httpx.http_handler import get_httpx_client, header_value
|
||||
from litellm.router_utils.deployment_pricing import deployment_pricing_models
|
||||
from litellm.rust_bridge.tokenizer import get_encoding
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthropicContentParamSource,
|
||||
|
|
@ -609,7 +611,7 @@ T_ParamSpec: Final = ParamSpec("T_ParamSpec")
|
|||
T_Retval = TypeVar("T_Retval")
|
||||
_COUNT_OFFLOAD_LIMITER: Final = anyio.lowlevel.RunVar[anyio.CapacityLimiter]("litellm_count_offload_limiter")
|
||||
_ANTHROPIC_TOOL_KEYS_MAPPED_BY_BRIDGE: Final = frozenset(
|
||||
("name", "input_schema", "description", "cache_control", "strict", "type", "eager_input_streaming")
|
||||
("name", "input_schema", "description", "cache_control", "strict", "type", "eager_input_streaming", "custom")
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1005,17 +1007,144 @@ def openai_tokenizer_encoding(model: str) -> "Encoding":
|
|||
|
||||
|
||||
def openai_tokenizer_encoding_name(model: str) -> str:
|
||||
"""The tiktoken encoding name for `model`, without loading the encoding."""
|
||||
from litellm.utils import print_verbose
|
||||
"""The tiktoken encoding name for `model`, without loading the encoding.
|
||||
|
||||
model_to_use: Final = _fix_model_name(model)
|
||||
A listed OpenAI or Azure model that tiktoken does not know is either a suffixed variant of a model it does
|
||||
know (`text-embedding-ada-002-v2`) or a newer release than its table, which gets o200k_base, the encoding every
|
||||
OpenAI model since gpt-4o uses. Provider and region prefixes (`azure/eu/`, `azure_ai/`) are dropped, a name
|
||||
listed only under Azure (`gpt-41`, `openai/o1-mini`) counts as listed through its `azure/` cost-map key, and an
|
||||
unlisted name tiktoken counts with o200k_base (`openai/gpt-5.9`) or named like a gpt-5 or o-series release
|
||||
(`openai/gpt-6.2`) gets it."""
|
||||
model_to_use: Final = _openai_model_name(model) or _fix_model_name(model)
|
||||
if "gpt-4o" in model_to_use:
|
||||
return "o200k_base"
|
||||
try:
|
||||
return tiktoken.encoding_name_for_model(model_to_use)
|
||||
except KeyError:
|
||||
print_verbose("Warning: model not found. Using cl100k_base encoding.")
|
||||
return "cl100k_base"
|
||||
return _suffixed_variant_encoding_name(model_to_use) or "o200k_base"
|
||||
|
||||
|
||||
def unresolved_tokenizer_model(model: str) -> str:
|
||||
"""`model`, or `gpt-3.5-turbo` when resolving the OpenAI model `model` names moves it off cl100k_base, the encoding
|
||||
tiktoken gives the name as written, so a count with the result never drops below that one."""
|
||||
as_written: Final = _fix_model_name(model)
|
||||
as_written_encoding: Final = (
|
||||
"o200k_base" if "gpt-4o" in as_written else (_tiktoken_encoding_name(as_written) or "cl100k_base")
|
||||
)
|
||||
moved_off_cl100k: Final = (
|
||||
as_written_encoding == "cl100k_base" and openai_tokenizer_encoding_name(model) != "cl100k_base"
|
||||
)
|
||||
return "gpt-3.5-turbo" if moved_off_cl100k else model
|
||||
|
||||
|
||||
def usage_tokenizer_model(model: str, base_model: str | None) -> str:
|
||||
"""The model to estimate missing usage with: a deployment's `base_model` when it names an OpenAI model, since a
|
||||
custom deployment name says nothing about its tokenizer, else `model`."""
|
||||
return base_model if base_model is not None and is_openai_model(base_model) else model
|
||||
|
||||
|
||||
def is_openai_model(model: str) -> bool:
|
||||
"""Whether `model` names an OpenAI or Azure OpenAI model, listed in the cost map or counted with o200k_base by
|
||||
tiktoken, with or without its prefixes."""
|
||||
return _openai_model_name(model) is not None
|
||||
|
||||
|
||||
def deployment_openai_model(deployment: object) -> str | None:
|
||||
"""The OpenAI model a router deployment serves, read from its base model first, since a custom deployment name
|
||||
(`azure/my-gpt-5`) says nothing about its tokenizer, or None when it serves no OpenAI model."""
|
||||
return _as_openai_model(_served_model(deployment))
|
||||
|
||||
|
||||
def served_openai_models(deployments: Iterable[object]) -> tuple[str | None, ...]:
|
||||
"""The distinct OpenAI models `deployments` serve, with None standing for any deployment serving another model."""
|
||||
served: Final = dict.fromkeys(_served_model(deployment) for deployment in deployments)
|
||||
return tuple(dict.fromkeys(_as_openai_model(model) for model in served))
|
||||
|
||||
|
||||
def _served_model(deployment: object) -> str | None:
|
||||
return next(iter(deployment_pricing_models(deployment)), None)
|
||||
|
||||
|
||||
def _as_openai_model(model: str | None) -> str | None:
|
||||
return model if model is not None and is_openai_model(model) else None
|
||||
|
||||
|
||||
def single_encoding_openai_model(served: Sequence[str | None]) -> str | None:
|
||||
"""An OpenAI model to count a request with, when every `served_openai_models` entry is an OpenAI model on the same
|
||||
encoding, else None."""
|
||||
if not served or None in served:
|
||||
return None
|
||||
encodings: Final = frozenset(openai_tokenizer_encoding_name(model) for model in served if model is not None)
|
||||
return served[0] if len(encodings) == 1 else None
|
||||
|
||||
|
||||
def _openai_model_name(model: str) -> str | None:
|
||||
return _listed_openai_model_name(model) or _tiktoken_o200k_model_name(model) or _versioned_openai_model_name(model)
|
||||
|
||||
|
||||
_VERSIONED_OPENAI_MODEL: Final = re.compile(r"(?:gpt-(?:[5-9]|[1-9]\d+)|o[1-9]\d*)(?:[.-]|$)")
|
||||
|
||||
|
||||
def _versioned_openai_model_name(model: str) -> str | None:
|
||||
name: Final = model.rsplit("/", 1)[-1]
|
||||
return name if _VERSIONED_OPENAI_MODEL.match(name) else None
|
||||
|
||||
|
||||
def _tiktoken_o200k_model_name(model: str) -> str | None:
|
||||
name: Final = model.rsplit("/", 1)[-1]
|
||||
return name if _tiktoken_encoding_name(name) == "o200k_base" else None
|
||||
|
||||
|
||||
def _listed_openai_model_name(model: str) -> str | None:
|
||||
segments: Final = model.split("/")
|
||||
suffixes: Final = ("/".join(segments[start:]) for start in range(len(segments)))
|
||||
listed: Final = any(
|
||||
_is_openai_cost_map_model(suffix) or _is_openai_cost_map_model(f"azure/{suffix}") for suffix in suffixes
|
||||
)
|
||||
return segments[-1] if listed else None
|
||||
|
||||
|
||||
def _is_openai_cost_map_model(model: str) -> bool:
|
||||
return (
|
||||
model in litellm.azure_llms
|
||||
or model in litellm.open_ai_chat_completion_models
|
||||
or _is_azure_openai_text_model(model)
|
||||
)
|
||||
|
||||
|
||||
_TEXT_MODEL_MODES: Final = frozenset({"chat", "completion", "responses", "realtime"})
|
||||
|
||||
|
||||
def _is_azure_openai_text_model(model: str) -> bool:
|
||||
cost_info: Final[Mapping[str, object]] = litellm.model_cost.get(model) or {}
|
||||
return (
|
||||
cost_info.get("litellm_provider") == "azure"
|
||||
and cost_info.get("mode") in _TEXT_MODEL_MODES
|
||||
and _is_openai_model_name(model.rsplit("/", 1)[-1])
|
||||
)
|
||||
|
||||
|
||||
def _is_openai_model_name(name: str) -> bool:
|
||||
return (
|
||||
name in litellm.open_ai_chat_completion_models
|
||||
or _tiktoken_encoding_name(name) is not None
|
||||
or any(
|
||||
name.startswith(f"{listed}-") or listed.startswith(f"{name}-")
|
||||
for listed in tuple(litellm.open_ai_chat_completion_models)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _tiktoken_encoding_name(model: str) -> str | None:
|
||||
try:
|
||||
return tiktoken.encoding_name_for_model(model)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _suffixed_variant_encoding_name(model: str) -> str | None:
|
||||
known: Final = tuple(name for name in MODEL_TO_ENCODING if model.startswith(f"{name}-"))
|
||||
return MODEL_TO_ENCODING[max(known, key=len)] if known else None
|
||||
|
||||
|
||||
def uses_legacy_message_accounting(model: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -121,6 +121,54 @@ _DECODED_JSON: Final = TypeAdapter(object)
|
|||
_CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/")
|
||||
|
||||
|
||||
def is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
|
||||
if custom_llm_provider == "anthropic":
|
||||
return True
|
||||
model_lower: Final = model.lower()
|
||||
if custom_llm_provider == "bedrock":
|
||||
return "claude" in model_lower or ("arn:" in model_lower and ":bedrock:" in model_lower)
|
||||
if custom_llm_provider == "azure_ai" or custom_llm_provider == "vertex_ai":
|
||||
return "claude" in model_lower
|
||||
return False
|
||||
|
||||
|
||||
def is_claude_model_target(custom_llm_provider: str | None, model: str) -> bool:
|
||||
return "claude" in model.lower() and is_claude_tool_target(custom_llm_provider, model)
|
||||
|
||||
|
||||
def _hoistable_defer_loading(tool_dict: Mapping[object, object], custom: object) -> bool | None:
|
||||
if not isinstance(custom, dict) or "defer_loading" in tool_dict:
|
||||
return None
|
||||
defer_loading: Final = OBJECT_MAPPING_ADAPTER.validate_python(custom).get("defer_loading")
|
||||
return defer_loading if isinstance(defer_loading, bool) else None
|
||||
|
||||
|
||||
def _tool_without_custom_field(tool: object, hoist_defer_loading: bool, drop_custom: bool) -> object:
|
||||
if not isinstance(tool, dict):
|
||||
return tool
|
||||
|
||||
if "custom" not in tool:
|
||||
return tool
|
||||
|
||||
tool_dict: Final = OBJECT_MAPPING_ADAPTER.validate_python(tool)
|
||||
|
||||
custom: Final = tool_dict["custom"]
|
||||
without_custom: Final = {key: value for key, value in tool_dict.items() if key != "custom"}
|
||||
defer_loading: Final = _hoistable_defer_loading(tool_dict, custom) if hoist_defer_loading else None
|
||||
if defer_loading is None:
|
||||
return without_custom if drop_custom else tool_dict
|
||||
|
||||
unhoisted: Final = {
|
||||
key: value for key, value in OBJECT_MAPPING_ADAPTER.validate_python(custom).items() if key != "defer_loading"
|
||||
}
|
||||
kept_custom: Final = {"custom": unhoisted} if unhoisted and not drop_custom else {}
|
||||
return {**without_custom, "defer_loading": defer_loading, **kept_custom}
|
||||
|
||||
|
||||
def tools_without_custom_field(tools: Sequence[object], hoist_defer_loading: bool, drop_custom: bool) -> list[object]:
|
||||
return [_tool_without_custom_field(tool, hoist_defer_loading, drop_custom) for tool in tools]
|
||||
|
||||
|
||||
def is_anthropic_messages_url(url: str) -> bool:
|
||||
"""Check whether a URL addresses Anthropic's Messages API."""
|
||||
parsed_url: Final = urlparse(url)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
tools: list[dict[str, JsonValue]] | None = None,
|
||||
system: JsonValue = None,
|
||||
optional_params: Mapping[str, JsonValue] | None = None,
|
||||
drop_params: bool = False,
|
||||
) -> dict[str, JsonValue]:
|
||||
"""
|
||||
Handle a CountTokens request using httpx.
|
||||
|
|
@ -69,6 +70,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
tools=tools,
|
||||
system=system,
|
||||
optional_params=optional_params,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
|
||||
verbose_logger.debug("Transformed request: %s", request_body)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pydantic import JsonValue
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler
|
||||
from litellm.llms.anthropic.count_tokens.transformation import (
|
||||
AnthropicCountFields,
|
||||
|
|
@ -131,6 +132,7 @@ class AnthropicTokenCounter(BaseTokenCounter):
|
|||
tools=tools,
|
||||
system=system,
|
||||
optional_params=count_options,
|
||||
drop_params=drops_unsupported_params(litellm_params.get("drop_params")),
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
|
|||
split_leading_system_run,
|
||||
)
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.anthropic.common_utils import merge_anthropic_beta_headers
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
is_claude_model_target,
|
||||
merge_anthropic_beta_headers,
|
||||
tools_without_custom_field,
|
||||
)
|
||||
from litellm.llms.anthropic.wif import resolve_anthropic_base
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionImageObject, ValidUserMessageContentTypes
|
||||
from litellm.types.utils import TokenCountMessagesFormat, TokenCountResponse
|
||||
|
|
@ -372,6 +376,7 @@ class AnthropicCountTokensConfig:
|
|||
tools: list[dict[str, JsonValue]] | None = None,
|
||||
system: JsonValue = None,
|
||||
optional_params: Mapping[str, JsonValue] | None = None,
|
||||
drop_params: bool = False,
|
||||
) -> dict[str, JsonValue]: # mutable-ok: provider transport requires JSON dictionaries
|
||||
"""
|
||||
Transform request to Anthropic CountTokens format.
|
||||
|
|
@ -391,7 +396,19 @@ class AnthropicCountTokensConfig:
|
|||
**MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in (("system", counted_system), ("tools", tools))
|
||||
for key, value in (
|
||||
("system", counted_system),
|
||||
(
|
||||
"tools",
|
||||
None
|
||||
if tools is None
|
||||
else tools_without_custom_field(
|
||||
tools,
|
||||
hoist_defer_loading=is_claude_model_target("anthropic", model),
|
||||
drop_custom=drop_params,
|
||||
),
|
||||
),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -821,6 +821,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
"name",
|
||||
"input_schema",
|
||||
"description",
|
||||
"custom",
|
||||
"cache_control",
|
||||
"strict",
|
||||
"type",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from ..constants import (
|
|||
)
|
||||
from ..errors import AnthropicContextManagementError
|
||||
from ..placeholders import build_cleared_tool_result_content
|
||||
from .compact import count_system_tokens, history_after_client_compaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.llms.openai import ChatCompletionToolParam
|
||||
|
|
@ -203,9 +204,10 @@ def _trigger_met(
|
|||
model: str,
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, object]] | None,
|
||||
system: str | Sequence[Mapping[str, object]] | None,
|
||||
counted_tool_uses: int,
|
||||
) -> tuple[bool, int | None]:
|
||||
"""Return (trigger_met, input_tokens if counted for reuse)."""
|
||||
"""Return (trigger_met, message and tool tokens for reuse); like the API, input_tokens adds the system prompt."""
|
||||
trigger_type: Final = trigger.get("type", "input_tokens")
|
||||
threshold = trigger.get("value")
|
||||
|
||||
|
|
@ -216,14 +218,17 @@ def _trigger_met(
|
|||
|
||||
if not isinstance(threshold, int):
|
||||
threshold = DEFAULT_INPUT_TOKENS_TRIGGER
|
||||
current_tokens: Final = litellm.token_counter(
|
||||
message_tokens: Final = litellm.token_counter(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=cast("list[ChatCompletionToolParam] | None", tools),
|
||||
)
|
||||
if message_tokens > threshold:
|
||||
return True, message_tokens
|
||||
current_tokens: Final = message_tokens + count_system_tokens(model, system)
|
||||
verbose_logger.debug("context_management polyfill: current_tokens: %s", current_tokens)
|
||||
verbose_logger.debug("context_management polyfill: threshold: %s", threshold)
|
||||
return current_tokens > threshold, current_tokens
|
||||
return current_tokens > threshold, message_tokens
|
||||
|
||||
|
||||
def _resolve_keep_count(keep: dict[str, object]) -> int:
|
||||
|
|
@ -237,7 +242,7 @@ def _resolve_keep_count(keep: dict[str, object]) -> int:
|
|||
|
||||
|
||||
def _last_completed_tool_use_id(
|
||||
messages: list[dict[str, object]],
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
) -> str | None:
|
||||
"""Latest completed tool_result id; never cleared."""
|
||||
last_id: str | None = None
|
||||
|
|
@ -253,7 +258,7 @@ def _last_completed_tool_use_id(
|
|||
|
||||
|
||||
def _clear_tool_results(
|
||||
messages: list[dict[str, object]], ids_to_clear: set[str]
|
||||
messages: list[dict[str, object]], ids_to_clear: Set[str]
|
||||
) -> tuple[list[dict[str, object]], int]:
|
||||
"""Clear matching tool_result content; return (messages, cleared_count)."""
|
||||
cleared = 0
|
||||
|
|
@ -311,7 +316,11 @@ def apply_clear_tool_uses_20250919(
|
|||
system: str | list[dict[str, object]] | None,
|
||||
edit_spec: ClearToolUsesEditSpec,
|
||||
) -> tuple[list[dict[str, object]], AppliedEdit | None]:
|
||||
"""Apply clear_tool_uses to an edit ``check_clear_tool_uses_spec`` returned; return (messages, AppliedEdit or None)."""
|
||||
"""Apply clear_tool_uses to an edit ``check_clear_tool_uses_spec`` returned; return (messages, AppliedEdit or None).
|
||||
|
||||
Like the API, it reads only the history after a client compaction block and counts the block's summary as system.
|
||||
"""
|
||||
kept, counted_system = history_after_client_compaction(messages, system)
|
||||
fields: Final = _ClearToolUsesFields.model_validate(edit_spec)
|
||||
clear_at_least: Final = 0 if fields.clear_at_least is None else fields.clear_at_least.value
|
||||
exclude_tools: Final = frozenset(fields.exclude_tools or ())
|
||||
|
|
@ -326,7 +335,7 @@ def apply_clear_tool_uses_20250919(
|
|||
"value": DEFAULT_KEEP_TOOL_USES,
|
||||
}
|
||||
|
||||
blocks: Final = _all_blocks(messages)
|
||||
blocks: Final = _all_blocks(kept)
|
||||
results: Final = _blocks_by_id(blocks, "tool_result", "tool_use_id")
|
||||
uses: Final = _blocks_by_id(blocks, "tool_use", "id")
|
||||
tiny: Final = _tiny_result_ids(results)
|
||||
|
|
@ -335,15 +344,15 @@ def apply_clear_tool_uses_20250919(
|
|||
for tool_use_id, name in _tool_uses_in_order(blocks)
|
||||
if name not in exclude_tools and (tool_use_id not in tiny or _clears_input(name, input_tools))
|
||||
)
|
||||
met, counted_tokens = _trigger_met(trigger, model, messages, tools, len(clearable))
|
||||
if not met:
|
||||
return messages, None
|
||||
|
||||
keep_count: Final = _resolve_keep_count(keep)
|
||||
if len(clearable) <= keep_count:
|
||||
return messages, None
|
||||
|
||||
last_completed_id: Final = _last_completed_tool_use_id(messages)
|
||||
met, counted_tokens = _trigger_met(trigger, model, kept, tools, counted_system, len(clearable))
|
||||
if not met:
|
||||
return messages, None
|
||||
|
||||
last_completed_id: Final = _last_completed_tool_use_id(kept)
|
||||
candidates: Final = tuple(
|
||||
(tool_use_id, name)
|
||||
for tool_use_id, name in clearable[: len(clearable) - keep_count]
|
||||
|
|
@ -358,7 +367,7 @@ def apply_clear_tool_uses_20250919(
|
|||
_select_in_batches(candidate_ids, tokens, clear_at_least) if clear_at_least > 0 else candidate_ids
|
||||
)
|
||||
|
||||
edited, cleared_count = _clear_tool_results(messages, set(ids_to_clear))
|
||||
edited, cleared_count = _clear_tool_results(messages, ids_to_clear)
|
||||
verbose_logger.debug("context_management polyfill: edited: %s", edited)
|
||||
if cleared_count == 0:
|
||||
return messages, None
|
||||
|
|
@ -370,11 +379,12 @@ def apply_clear_tool_uses_20250919(
|
|||
counted_tokens
|
||||
if counted_tokens is not None
|
||||
else litellm.token_counter(
|
||||
model=model, messages=messages, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
model=model, messages=kept, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
)
|
||||
)
|
||||
kept_after, _ = history_after_client_compaction(final_messages, system)
|
||||
tokens_after: Final = litellm.token_counter(
|
||||
model=model, messages=final_messages, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
model=model, messages=kept_after, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
)
|
||||
cleared_input_tokens: Final = max(tokens_before - tokens_after, 0)
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,21 @@ def _strip_compaction_blocks(
|
|||
return cleaned
|
||||
|
||||
|
||||
def history_after_client_compaction(
|
||||
messages: Sequence[dict[str, object]],
|
||||
system: str | Sequence[Mapping[str, object]] | None,
|
||||
) -> tuple[list[dict[str, object]], str | Sequence[Mapping[str, object]] | None]:
|
||||
"""Return the history the API keeps after the latest compaction block, without it, and the system with its summary."""
|
||||
sliced, block = _slice_around_compaction_block(messages)
|
||||
if block is None:
|
||||
return list(messages), system
|
||||
tail: Final = _strip_compaction_blocks(sliced)
|
||||
summary: Final = block.get("content")
|
||||
if not isinstance(summary, str) or not summary:
|
||||
return tail, system
|
||||
return tail, _augment_system_with_summary(_system_to_text(system) or None, summary)
|
||||
|
||||
|
||||
def _augment_system_with_summary(
|
||||
system: str | list[dict[str, object]] | None,
|
||||
summary_text: str,
|
||||
|
|
@ -320,14 +335,16 @@ def _count_effective_tokens(
|
|||
content: Final = compaction_block.get("content") or ""
|
||||
if content:
|
||||
total += litellm.token_counter(model=model, text=content)
|
||||
return total + count_system_tokens(model, system)
|
||||
|
||||
|
||||
def count_system_tokens(model: str, system: str | Sequence[Mapping[str, object]] | None) -> int:
|
||||
system_text: Final = _system_to_text(system)
|
||||
if system_text:
|
||||
total += litellm.token_counter(model=model, text=system_text)
|
||||
return total
|
||||
return litellm.token_counter(model=model, text=system_text) if system_text else 0
|
||||
|
||||
|
||||
def _system_to_text(
|
||||
system: str | list[dict[str, object]] | None,
|
||||
system: str | Sequence[Mapping[str, object]] | None,
|
||||
) -> str:
|
||||
"""Flatten an Anthropic-style ``system`` value into a single string for
|
||||
token counting. Returns ``""`` when ``system`` carries no text."""
|
||||
|
|
@ -338,7 +355,7 @@ def _system_to_text(
|
|||
return "\n".join(
|
||||
text
|
||||
for block in system
|
||||
if isinstance(block, dict)
|
||||
if isinstance(block, Mapping)
|
||||
and block.get("type") == "text"
|
||||
and isinstance(text := block.get("text"), str)
|
||||
and text
|
||||
|
|
|
|||
|
|
@ -29,13 +29,16 @@ from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
from ...common_utils import (
|
||||
OBJECT_LIST_ADAPTER,
|
||||
AnthropicError,
|
||||
AnthropicModelInfo,
|
||||
is_claude_model_target,
|
||||
merge_anthropic_beta_headers,
|
||||
optionally_handle_anthropic_oauth,
|
||||
requires_native_compaction_beta,
|
||||
strip_advisor_blocks_from_messages,
|
||||
strip_encrypted_reasoning_blocks_from_anthropic_messages,
|
||||
tools_without_custom_field,
|
||||
)
|
||||
from .mid_conversation_system import (
|
||||
as_system_content_blocks,
|
||||
|
|
@ -181,6 +184,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
"""
|
||||
return False
|
||||
|
||||
def hoists_wrapped_defer_loading(self, model: str) -> bool:
|
||||
return is_claude_model_target(self._resolved_provider, model)
|
||||
|
||||
def tools_for_upstream(self, tools: Sequence[object], model: str, drop_params: object) -> Sequence[object]:
|
||||
return tools_without_custom_field(
|
||||
tools,
|
||||
hoist_defer_loading=self.hoists_wrapped_defer_loading(model),
|
||||
drop_custom=drops_unsupported_params(drop_params),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_billing_headers_from_system(system_param):
|
||||
"""
|
||||
|
|
@ -700,7 +713,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
|
||||
# Auto-strip advisor blocks from history if advisor tool is absent.
|
||||
# Prevents Anthropic 400: advisor_tool_result in history requires advisor tool.
|
||||
_tools: Final = anthropic_messages_optional_request_params.get("tools") or []
|
||||
tools: Final = anthropic_messages_optional_request_params.get("tools")
|
||||
_tools: Final = tools or []
|
||||
_has_advisor: Final = any(isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for t in _tools)
|
||||
if not _has_advisor:
|
||||
messages = strip_advisor_blocks_from_messages(messages)
|
||||
|
|
@ -713,7 +727,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
model=model,
|
||||
**anthropic_messages_optional_request_params,
|
||||
)
|
||||
return dict(anthropic_messages_request)
|
||||
if not isinstance(tools, list):
|
||||
return dict(anthropic_messages_request)
|
||||
return {
|
||||
**anthropic_messages_request,
|
||||
"tools": self.tools_for_upstream(
|
||||
OBJECT_LIST_ADAPTER.validate_python(tools), model, litellm_params.drop_params
|
||||
),
|
||||
}
|
||||
|
||||
def transform_anthropic_messages_response(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ from litellm.integrations.anthropic_cache_control_hook import (
|
|||
supports_openai_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import cache_control_as_prompt_cache_breakpoint
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
cache_control_as_prompt_cache_breakpoint,
|
||||
prompt_cache_breakpoint_for_wire,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.responses.utils import auto_reasoning_summary
|
||||
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
|
||||
from litellm.types.llms.anthropic import (
|
||||
|
|
@ -90,14 +94,28 @@ def _without_prompt_cache_breakpoint(block: Mapping[str, object]) -> Mapping[str
|
|||
return {key: value for key, value in block.items() if key != "prompt_cache_breakpoint"}
|
||||
|
||||
|
||||
def _cache_control_as_wire_safe_prompt_cache_breakpoint(block: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if "prompt_cache_breakpoint" not in block:
|
||||
return cache_control_as_prompt_cache_breakpoint(block)
|
||||
wire_marker: Final = prompt_cache_breakpoint_for_wire(block["prompt_cache_breakpoint"], drop_params=True)
|
||||
return cache_control_as_prompt_cache_breakpoint(
|
||||
with_prompt_cache_breakpoint(_without_prompt_cache_breakpoint(block), wire_marker)
|
||||
)
|
||||
|
||||
|
||||
def _prompt_cache_block_rewrite(model: str, forwarded_kwargs: Mapping[str, object]) -> BlockRewrite | None:
|
||||
"""Client cache marks become breakpoints for a deployment that reads them, and a model that takes no breakpoints
|
||||
drops raw ones under drop_params."""
|
||||
drops_params: Final = drops_unsupported_params(forwarded_kwargs.get("drop_params"))
|
||||
if _reads_prompt_cache_breakpoint(model, forwarded_kwargs):
|
||||
return cache_control_as_prompt_cache_breakpoint
|
||||
return (
|
||||
_cache_control_as_wire_safe_prompt_cache_breakpoint
|
||||
if drops_params
|
||||
else cache_control_as_prompt_cache_breakpoint
|
||||
)
|
||||
if _model_takes_prompt_cache_breakpoint(model, forwarded_kwargs):
|
||||
return None
|
||||
if drops_unsupported_params(forwarded_kwargs.get("drop_params")):
|
||||
if drops_params:
|
||||
return _without_prompt_cache_breakpoint
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.types.utils import (
|
|||
from litellm.utils import (
|
||||
CustomStreamWrapper,
|
||||
convert_to_model_response_object,
|
||||
get_base_model_from_metadata,
|
||||
)
|
||||
|
||||
from ...types.llms.openai import HttpxBinaryResponseContent
|
||||
|
|
@ -145,6 +146,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
azure_client: AzureOpenAI | OpenAI,
|
||||
data: dict,
|
||||
timeout: float | httpx.Timeout,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
):
|
||||
"""
|
||||
Helper to:
|
||||
|
|
@ -160,7 +162,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
except BadRequestError as e:
|
||||
if not is_output_token_limit_error(e):
|
||||
raise
|
||||
return build_output_token_limit_response(e=e, data=data, is_async=False)
|
||||
return build_output_token_limit_response(
|
||||
e=e,
|
||||
data=data,
|
||||
is_async=False,
|
||||
base_model=get_base_model_from_metadata(logging_obj.model_call_details),
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
|
@ -192,7 +199,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
except BadRequestError as e:
|
||||
if not is_output_token_limit_error(e):
|
||||
raise
|
||||
return build_output_token_limit_response(e=e, data=data, is_async=True)
|
||||
return build_output_token_limit_response(
|
||||
e=e,
|
||||
data=data,
|
||||
is_async=True,
|
||||
base_model=get_base_model_from_metadata(logging_obj.model_call_details),
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
|
@ -350,7 +362,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
)
|
||||
|
||||
headers, response = self.make_sync_azure_openai_chat_completion_request(
|
||||
azure_client=azure_client, data=data, timeout=timeout
|
||||
azure_client=azure_client, data=data, timeout=timeout, logging_obj=logging_obj
|
||||
)
|
||||
if not isinstance(response, BaseModel):
|
||||
raise AzureOpenAIError(
|
||||
|
|
@ -564,7 +576,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
|
|||
},
|
||||
)
|
||||
headers, response = self.make_sync_azure_openai_chat_completion_request(
|
||||
azure_client=azure_client, data=data, timeout=timeout
|
||||
azure_client=azure_client, data=data, timeout=timeout, logging_obj=logging_obj
|
||||
)
|
||||
logging_obj.model_call_details["response_headers"] = headers
|
||||
streamwrapper: Final = CustomStreamWrapper(
|
||||
|
|
|
|||
|
|
@ -621,7 +621,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
|
||||
# For Azure v1 API, use standard OpenAI client instead of AzureOpenAI
|
||||
# See: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
|
||||
if self._is_azure_v1_api_version(api_version):
|
||||
if self.is_azure_v1_api_version(api_version):
|
||||
# Extract only params that OpenAI client accepts
|
||||
# Always use /openai/v1/ regardless of whether user passed "v1", "latest", or "preview"
|
||||
# The OpenAI client accepts a callable for `api_key` and re-invokes it
|
||||
|
|
@ -922,7 +922,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
else:
|
||||
new_url = api_base
|
||||
|
||||
if BaseAzureLLM._is_azure_v1_api_version(api_version):
|
||||
if BaseAzureLLM.is_azure_v1_api_version(api_version):
|
||||
# ensure the request go to /openai/v1 and not just /openai
|
||||
if "/openai/v1" not in new_url:
|
||||
parsed_url: Final = httpx.URL(new_url)
|
||||
|
|
@ -952,7 +952,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
|
||||
Returns None when ``api_version`` is a dated one, which still uses the deployment route.
|
||||
"""
|
||||
if not BaseAzureLLM._is_azure_v1_api_version(api_version):
|
||||
if not BaseAzureLLM.is_azure_v1_api_version(api_version):
|
||||
return None
|
||||
|
||||
base_url: Final = httpx.URL(api_base)
|
||||
|
|
@ -970,7 +970,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_azure_v1_api_version(api_version: str | None) -> bool:
|
||||
def is_azure_v1_api_version(api_version: str | None) -> bool:
|
||||
if api_version is None:
|
||||
return False
|
||||
return api_version in {"preview", "latest", "v1"}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ def _resolve_responses_api_version(api_base: str | None, litellm_params: Mapping
|
|||
return api_version
|
||||
|
||||
|
||||
def _v1_responses_url(api_base: str) -> httpx.URL:
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
path: Final = parsed_url.path.rstrip("/")
|
||||
root: Final = next(
|
||||
(path.removesuffix(route) for route in ("/openai/v1/responses", "/openai/responses") if path.endswith(route)),
|
||||
path,
|
||||
)
|
||||
return parsed_url.copy_with(path=f"{root}/openai/v1/responses", query=None)
|
||||
|
||||
|
||||
def _with_responses_api_version(api_base: str | None, litellm_params: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType({**litellm_params, "api_version": _resolve_responses_api_version(api_base, litellm_params)})
|
||||
|
||||
|
|
@ -132,7 +142,9 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
return filtered_item
|
||||
return item
|
||||
|
||||
def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam:
|
||||
def _validate_input_param(
|
||||
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
|
||||
) -> str | ResponseInputParam:
|
||||
"""
|
||||
Override parent method to also filter out 'status' field from message items.
|
||||
Azure OpenAI API does not accept 'status' field in input messages.
|
||||
|
|
@ -140,7 +152,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
from typing import cast
|
||||
|
||||
# First call parent's validation
|
||||
validated_input: Final = super()._validate_input_param(input)
|
||||
validated_input: Final = super()._validate_input_param(input, drops_set_created_by=drops_set_created_by)
|
||||
|
||||
# Then filter out status from message items
|
||||
if isinstance(validated_input, list):
|
||||
|
|
@ -247,20 +259,20 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
if api_base is None:
|
||||
raise ValueError("api_base is required for Azure WebSocket")
|
||||
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
path = parsed_url.path.rstrip("/")
|
||||
# Strip existing /openai/responses path if the api_base already contains it
|
||||
for suffix in ("/openai/v1/responses", "/openai/responses"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)]
|
||||
break
|
||||
scheme: Final = "wss" if parsed_url.scheme == "https" else "ws"
|
||||
return str(parsed_url.copy_with(scheme=scheme, path=f"{path}/openai/v1/responses", query=None))
|
||||
v1_url: Final = _v1_responses_url(api_base)
|
||||
return str(v1_url.copy_with(scheme="wss" if v1_url.scheme == "https" else "ws"))
|
||||
|
||||
def model_in_websocket_url(self) -> bool:
|
||||
# Azure sends the model in the response.create body, not the URL
|
||||
return False
|
||||
|
||||
def compact_url(self, api_base: str) -> str:
|
||||
params: Final = httpx.URL(api_base).params
|
||||
v1_params: Final = (
|
||||
params if BaseAzureLLM.is_azure_v1_api_version(params.get("api-version")) else params.remove("api-version")
|
||||
)
|
||||
return super().compact_url(str(_v1_responses_url(api_base).copy_with(params=v1_params)))
|
||||
|
||||
#########################################################
|
||||
########## DELETE RESPONSE API TRANSFORMATION ##############
|
||||
#########################################################
|
||||
|
|
@ -323,7 +335,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> tuple[str, dict]:
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""
|
||||
Transform the get response API request into a URL and data
|
||||
|
||||
|
|
@ -331,7 +343,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
- GET /v1/responses/{response_id}
|
||||
"""
|
||||
get_url: Final = self._construct_url_for_response_id_in_path(api_base=api_base, response_id=response_id)
|
||||
data: Final[dict] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
verbose_logger.debug("get response url=%s", get_url)
|
||||
return get_url, data
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from pydantic import JsonValue
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.asyncify import asyncify
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.llms.anthropic.common_utils import AnthropicError
|
||||
from litellm.llms.azure_ai.anthropic.count_tokens.transformation import (
|
||||
AzureAIAnthropicCountTokensConfig,
|
||||
|
|
@ -69,6 +70,7 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
|
|||
tools=tools,
|
||||
system=system,
|
||||
optional_params=optional_params,
|
||||
drop_params=drops_unsupported_params((litellm_params or {}).get("drop_params")),
|
||||
)
|
||||
|
||||
verbose_logger.debug("Transformed request: %s", request_body)
|
||||
|
|
|
|||
|
|
@ -140,6 +140,15 @@ def get_azure_ai_auth_headers(
|
|||
AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: Final = "azure_model_router_selected_model"
|
||||
|
||||
|
||||
FOUNDRY_CLASSIC_AGENT_ID_PREFIX: Final = "asst_"
|
||||
|
||||
|
||||
def is_foundry_project_agent(model: str | None) -> bool:
|
||||
if model is None or AzureFoundryModelInfo.get_azure_ai_route(model) != "agents":
|
||||
return False
|
||||
return not model.split("agents/", 1)[1].startswith(FOUNDRY_CLASSIC_AGENT_ID_PREFIX)
|
||||
|
||||
|
||||
def azure_ai_supports_native_responses(model: str | None, api_base: str | None) -> bool:
|
||||
resolved_base: Final = AzureFoundryModelInfo.get_api_base(api_base)
|
||||
if resolved_base is not None and not is_azure_openai_v1_host(resolved_base):
|
||||
|
|
|
|||
|
|
@ -1,26 +1,64 @@
|
|||
from typing import Final
|
||||
import os
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final, NoReturn
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
|
||||
from litellm.llms.azure_ai.chat.transformation import AzureAIGPT5Config
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AZURE_AI_AGENTS_SCOPE,
|
||||
AzureFoundryModelInfo,
|
||||
api_key_header_for_base,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.types.llms.azure_ai import FoundryAgentReference
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
_PROJECT_PATH_PREFIX: Final = ("api", "projects")
|
||||
_RESPONSES_PATH: Final = ("openai", "v1", "responses")
|
||||
_AGENT_OWNED_PARAMS: Final = frozenset({"temperature", "top_p", "tools", "text", "reasoning"})
|
||||
_CALLER_AGENT_KEYS: Final = ("agent", "agent_reference")
|
||||
_RESPONSE_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _project_root(base_url: httpx.URL) -> tuple[str, ...]:
|
||||
segments: Final = tuple(segment for segment in base_url.path.split("/") if segment)
|
||||
return segments[:3] if segments[:2] == _PROJECT_PATH_PREFIX else ()
|
||||
|
||||
|
||||
def _responses_url(api_base: str) -> str:
|
||||
base_url: Final = httpx.URL(api_base)
|
||||
segments: Final = tuple(segment for segment in base_url.path.split("/") if segment)
|
||||
project_root: Final = segments[:3] if segments[:2] == _PROJECT_PATH_PREFIX else ()
|
||||
return str(base_url.copy_with(path="/" + "/".join((*project_root, *_RESPONSES_PATH)), query=None))
|
||||
return str(base_url.copy_with(path="/" + "/".join((*_project_root(base_url), *_RESPONSES_PATH)), query=None))
|
||||
|
||||
|
||||
def _without_agent_instructions(response: ResponsesAPIResponse) -> ResponsesAPIResponse:
|
||||
if (response.model_extra or {}).get("agent_reference") is None:
|
||||
return response
|
||||
return response.model_copy(update={"instructions": None})
|
||||
|
||||
|
||||
def _event_without_agent_instructions(event: Mapping[str, object]) -> Mapping[str, object]:
|
||||
response: Final = event.get("response")
|
||||
if not isinstance(response, Mapping):
|
||||
return event
|
||||
fields: Final = _RESPONSE_FIELDS.validate_python(response)
|
||||
if fields.get("agent_reference") is None:
|
||||
return event
|
||||
return {**event, "response": {**fields, "instructions": None}}
|
||||
|
||||
|
||||
class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
|
||||
|
|
@ -42,10 +80,19 @@ class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
|
|||
|
||||
def validate_environment(self, headers: dict, model: str, litellm_params: GenericLiteLLMParams | None) -> dict:
|
||||
params: Final = litellm_params or GenericLiteLLMParams()
|
||||
api_base: Final = AzureFoundryModelInfo.get_api_base(params.api_base)
|
||||
on_project: Final = api_base is not None and bool(_project_root(httpx.URL(api_base)))
|
||||
auth_params: Final = (
|
||||
params.model_copy(
|
||||
update={"azure_scope": params.azure_scope or os.getenv("AZURE_SCOPE") or AZURE_AI_AGENTS_SCOPE}
|
||||
)
|
||||
if on_project
|
||||
else params
|
||||
)
|
||||
auth_headers: Final = get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(params.api_key),
|
||||
litellm_params=params.model_dump(),
|
||||
api_key_header=api_key_header_for_base(AzureFoundryModelInfo.get_api_base(params.api_base)),
|
||||
litellm_params=auth_params.model_dump(),
|
||||
api_key_header="Authorization" if on_project else api_key_header_for_base(api_base),
|
||||
)
|
||||
return {
|
||||
**headers,
|
||||
|
|
@ -64,3 +111,207 @@ class AzureAIResponsesAPIConfig(AzureOpenAIResponsesAPIConfig):
|
|||
"Set the api_base parameter or the AZURE_AI_API_BASE environment variable."
|
||||
)
|
||||
return _responses_url(resolved_base)
|
||||
|
||||
def transform_response_api_response(
|
||||
self, model: str, raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj"
|
||||
) -> ResponsesAPIResponse:
|
||||
return _without_agent_instructions(super().transform_response_api_response(model, raw_response, logging_obj))
|
||||
|
||||
def transform_streaming_response(
|
||||
self,
|
||||
model: str,
|
||||
parsed_chunk: Mapping[str, object],
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
) -> ResponsesAPIStreamingResponse:
|
||||
return super().transform_streaming_response( # pyright: ignore[reportUnknownMemberType] # parent types parsed_chunk as a bare dict
|
||||
model=model, parsed_chunk=dict(_event_without_agent_instructions(parsed_chunk)), logging_obj=logging_obj
|
||||
)
|
||||
|
||||
def transform_get_response_api_response(
|
||||
self, raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj"
|
||||
) -> ResponsesAPIResponse:
|
||||
return _without_agent_instructions(super().transform_get_response_api_response(raw_response, logging_obj))
|
||||
|
||||
def transform_cancel_response_api_response(
|
||||
self, raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj"
|
||||
) -> ResponsesAPIResponse:
|
||||
return _without_agent_instructions(super().transform_cancel_response_api_response(raw_response, logging_obj))
|
||||
|
||||
|
||||
def _agent_reference(model: str) -> FoundryAgentReference:
|
||||
name, _, version = model.split("agents/", 1)[1].partition(":")
|
||||
if version:
|
||||
return FoundryAgentReference(type="agent_reference", name=name, version=version)
|
||||
return FoundryAgentReference(type="agent_reference", name=name)
|
||||
|
||||
|
||||
class _CallerAgentReference(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, coerce_numbers_to_str=True)
|
||||
|
||||
name: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
def _caller_agent_reference(value: object) -> _CallerAgentReference | None:
|
||||
try:
|
||||
return _CallerAgentReference.model_validate(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _requested_agent(
|
||||
deployment_agent: FoundryAgentReference, caller_agents: Sequence[_CallerAgentReference | None]
|
||||
) -> FoundryAgentReference:
|
||||
requested_versions: Final = frozenset(
|
||||
agent.version
|
||||
for agent in caller_agents
|
||||
if agent is not None and agent.name == deployment_agent["name"] and agent.version is not None
|
||||
)
|
||||
if "version" in deployment_agent or len(requested_versions) != 1:
|
||||
return deployment_agent
|
||||
return FoundryAgentReference(
|
||||
type="agent_reference", name=deployment_agent["name"], version=next(iter(requested_versions))
|
||||
)
|
||||
|
||||
|
||||
def _caller_agent_fields(fields: Mapping[str, object]) -> tuple[tuple[str, object], ...]:
|
||||
return tuple((key, fields[key]) for key in _CALLER_AGENT_KEYS if key in fields)
|
||||
|
||||
|
||||
def _deployment_agent(
|
||||
model: str, caller_fields: Sequence[tuple[str, object]], litellm_params: GenericLiteLLMParams
|
||||
) -> FoundryAgentReference:
|
||||
caller_keys: Final = tuple(key for key, _ in caller_fields)
|
||||
caller_agents: Final = tuple(_caller_agent_reference(value) for _, value in caller_fields)
|
||||
agent: Final = _requested_agent(_agent_reference(model), caller_agents)
|
||||
other_agents: Final = tuple(
|
||||
key
|
||||
for key, caller_agent in zip(caller_keys, caller_agents)
|
||||
if caller_agent is None
|
||||
or caller_agent.name != agent["name"]
|
||||
or caller_agent.version not in (None, agent.get("version"))
|
||||
)
|
||||
if other_agents and not (litellm.drop_params is True or litellm_params.drop_params is True):
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
f"This model entry always calls Foundry agent {agent['name']!r}, so `{other_agents[0]}` "
|
||||
"in the request cannot pick another agent or version. Remove it, call the model entry for that "
|
||||
"agent, or set `drop_params: true` to drop it"
|
||||
),
|
||||
llm_provider=LlmProviders.AZURE_AI.value,
|
||||
model=model,
|
||||
)
|
||||
return agent
|
||||
|
||||
|
||||
_INPUT_ITEMS: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def _with_system_prompt(instructions: str, request_input: object) -> Sequence[object]:
|
||||
conversation: Final = (
|
||||
[{"type": "message", "role": "user", "content": request_input}]
|
||||
if isinstance(request_input, str)
|
||||
else _INPUT_ITEMS.validate_python(request_input)
|
||||
)
|
||||
return [{"type": "message", "role": "system", "content": instructions}, *conversation]
|
||||
|
||||
|
||||
class AzureAIAgentResponsesAPIConfig(AzureAIResponsesAPIConfig):
|
||||
def should_fake_stream(
|
||||
self, model: str | None, stream: bool | None, custom_llm_provider: str | None = None
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: override contract
|
||||
inherited: Final[list[str]] = super().get_supported_openai_params(model) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # parent returns a bare list # mutable-ok: same
|
||||
return [param for param in inherited if param not in _AGENT_OWNED_PARAMS]
|
||||
|
||||
def map_openai_params_for_deployment(
|
||||
self,
|
||||
response_api_optional_params: ResponsesAPIOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
*,
|
||||
declared_reasoning_efforts: Collection[str],
|
||||
) -> Mapping[str, object]:
|
||||
mapped: Final = super().map_openai_params_for_deployment(
|
||||
response_api_optional_params, model, drop_params, declared_reasoning_efforts=declared_reasoning_efforts
|
||||
)
|
||||
resends_instructions: Final = bool(mapped.get("instructions")) and bool(mapped.get("previous_response_id"))
|
||||
if resends_instructions and not drop_params:
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
"azure_ai Foundry agents store a turn's system message with the conversation, so `instructions` "
|
||||
"with `previous_response_id` would add another copy each turn. Send `instructions` on the first "
|
||||
"turn only, or set `drop_params: true` to drop it"
|
||||
),
|
||||
llm_provider=LlmProviders.AZURE_AI.value,
|
||||
model=model,
|
||||
)
|
||||
dropped: Final = _AGENT_OWNED_PARAMS | (
|
||||
frozenset({"instructions"}) if resends_instructions else frozenset[str]()
|
||||
)
|
||||
return {key: value for key, value in mapped.items() if key not in dropped}
|
||||
|
||||
def transform_responses_api_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str | ResponseInputParam,
|
||||
response_api_optional_request_params: dict[str, object], # mutable-ok: signature fixed by the override contract
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: same
|
||||
) -> dict[str, object]: # mutable-ok: same
|
||||
request: Final[dict[str, object]] = super().transform_responses_api_request( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # parent returns a bare dict # mutable-ok: same
|
||||
model=model,
|
||||
input=input,
|
||||
response_api_optional_request_params=response_api_optional_request_params,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
instructions: Final = request.get("instructions")
|
||||
return {
|
||||
**{key: value for key, value in request.items() if key not in ("model", "instructions")},
|
||||
**(
|
||||
{"input": _with_system_prompt(instructions, request.get("input"))}
|
||||
if isinstance(instructions, str) and instructions
|
||||
else {}
|
||||
),
|
||||
"agent_reference": _deployment_agent(
|
||||
model, _caller_agent_fields(litellm_params.model_extra or {}), litellm_params
|
||||
),
|
||||
}
|
||||
|
||||
def transform_extra_body(
|
||||
self,
|
||||
extra_body: Mapping[str, object],
|
||||
request: Mapping[str, object],
|
||||
model: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
) -> Mapping[str, object]:
|
||||
passthrough: Final = {key: value for key, value in extra_body.items() if key not in _CALLER_AGENT_KEYS}
|
||||
if len(passthrough) == len(extra_body):
|
||||
return passthrough
|
||||
return {
|
||||
**passthrough,
|
||||
"agent_reference": _deployment_agent(
|
||||
model, (*_caller_agent_fields(request), *_caller_agent_fields(extra_body)), litellm_params
|
||||
),
|
||||
}
|
||||
|
||||
def transform_compact_response_api_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str | ResponseInputParam,
|
||||
response_api_optional_request_params: dict[str, object], # mutable-ok: signature fixed by the override contract
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: same
|
||||
) -> NoReturn:
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
"azure_ai Foundry agents cannot be compacted, because the project's /responses/compact endpoint "
|
||||
"takes a model deployment, not an agent. Call compact on a model entry for that deployment instead"
|
||||
),
|
||||
llm_provider=LlmProviders.AZURE_AI.value,
|
||||
model=model,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ class BaseResponsesAPIConfig(ABC):
|
|||
def supports_encrypted_agent_messages(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_streamed_retrieval(self) -> bool:
|
||||
return self.custom_llm_provider in (LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI)
|
||||
|
||||
def sign_request(
|
||||
self,
|
||||
headers: dict,
|
||||
|
|
@ -209,7 +212,7 @@ class BaseResponsesAPIConfig(ABC):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> tuple[str, dict]:
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -268,6 +271,9 @@ class BaseResponsesAPIConfig(ABC):
|
|||
"""Returns True if litellm should fake a stream for the given model and stream value"""
|
||||
return False
|
||||
|
||||
def may_answer_stream_with_json(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_native_websocket(self) -> bool:
|
||||
"""
|
||||
Returns True if the provider has a native WebSocket endpoint for Responses API.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import base64
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
|
||||
from litellm.rust_bridge import runtime
|
||||
|
|
@ -14,7 +18,72 @@ from litellm.rust_bridge.transcription.native import (
|
|||
RustAtranscription,
|
||||
RustTranscription,
|
||||
)
|
||||
from litellm.types.utils import FileTypes, TranscriptionResponse
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.utils import FileTypes, TranscriptionResponse, TranscriptionUsageTokensObject
|
||||
|
||||
SUPPORTED_OPENAI_TRANSCRIPTION_PARAMS: Final = ("language", "prompt", "temperature", "response_format")
|
||||
TEXT_RESPONSE_FORMATS: Final = frozenset({"json", "text"})
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
return None
|
||||
try:
|
||||
number: Final = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _rejection(key: str, value: object) -> str | None:
|
||||
if key not in SUPPORTED_OPENAI_TRANSCRIPTION_PARAMS:
|
||||
return f"bedrock transcription does not support {key}."
|
||||
if key == "response_format" and value not in TEXT_RESPONSE_FORMATS:
|
||||
return f"bedrock transcription returns only the transcript text, so response_format={value!r} is not supported."
|
||||
if key == "temperature" and _as_float(value) is None:
|
||||
return f"bedrock transcription needs a number for temperature, got {value!r}."
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnsupportedTranscriptionParams:
|
||||
message: str
|
||||
|
||||
|
||||
def map_openai_transcription_params(
|
||||
non_default_params: Mapping[str, object], drop_params: bool
|
||||
) -> Mapping[str, object] | UnsupportedTranscriptionParams:
|
||||
rejections: Final = {
|
||||
key: reason for key, value in non_default_params.items() if (reason := _rejection(key, value)) is not None
|
||||
}
|
||||
if rejections and not drop_params:
|
||||
return UnsupportedTranscriptionParams(
|
||||
message=f"{' '.join(rejections.values())} To drop it from the call, set `litellm.drop_params = True`."
|
||||
)
|
||||
return {
|
||||
key: _as_float(value) if key == "temperature" else value
|
||||
for key, value in non_default_params.items()
|
||||
if key not in rejections
|
||||
}
|
||||
|
||||
|
||||
class _RustTranscriptionResult(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
text: str
|
||||
usage: TranscriptionUsageTokensObject | None = None
|
||||
|
||||
|
||||
def _transcription_response(
|
||||
result: Mapping[str, object], optional_params: Mapping[str, object]
|
||||
) -> TranscriptionResponse:
|
||||
parsed: Final = _RustTranscriptionResult.model_validate(result)
|
||||
response: Final = TranscriptionResponse(text=parsed.text).model_copy(update={"usage": parsed.usage})
|
||||
region_name: Final = (
|
||||
optional_params.get("aws_region_name") or get_secret_str("AWS_REGION_NAME") or get_secret_str("AWS_REGION")
|
||||
)
|
||||
response.hidden_params = {**response.hidden_params, "region_name": region_name}
|
||||
return response
|
||||
|
||||
|
||||
class BedrockAudioTranscriptionRustDispatch:
|
||||
|
|
@ -64,7 +133,7 @@ class BedrockAudioTranscriptionRustDispatch:
|
|||
"timeout_seconds": timeout_to_seconds(timeout),
|
||||
}
|
||||
call: Final = NativeCall(args=(), kwargs=fields, base={})
|
||||
return TranscriptionResponse(**rust(call))
|
||||
return _transcription_response(rust(call), optional_params)
|
||||
|
||||
return runtime.run(
|
||||
RouteContext(Route.TRANSCRIPTION, provider=custom_llm_provider, model=model),
|
||||
|
|
@ -97,7 +166,7 @@ class BedrockAudioTranscriptionRustDispatch:
|
|||
"timeout_seconds": timeout_to_seconds(timeout),
|
||||
}
|
||||
call: Final = NativeCall(args=(), kwargs=fields, base={})
|
||||
return TranscriptionResponse(**await rust(call))
|
||||
return _transcription_response(await rust(call), optional_params)
|
||||
|
||||
return await runtime.arun(
|
||||
RouteContext(Route.TRANSCRIPTION, provider=custom_llm_provider, model=model),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ from dataclasses import dataclass
|
|||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
from typing_extensions import (
|
||||
ReadOnly,
|
||||
TypedDict,
|
||||
TypeIs, # noqa: TID251 # narrows the caller's tools without copying them
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from botocore.eventstream import EventStreamMessage
|
||||
|
|
@ -573,6 +577,26 @@ def strip_unsupported_bedrock_invoke_output_config_keys(
|
|||
request_body["output_config"] = {"format": preserved_format} # rebind-ok: out-param
|
||||
|
||||
|
||||
_TOOL_ITEMS_ADAPTER: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
|
||||
def _is_str_keyed_dict(
|
||||
value: object,
|
||||
) -> TypeIs[dict[str, object]]: # guard-ok: JSON tool objects # mutable-ok: fallbacks reuse tools edited in place
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def _normalize_custom_field_on_tool(tool: object) -> None:
|
||||
if not _is_str_keyed_dict(tool):
|
||||
return
|
||||
custom: Final = tool.pop("custom", None)
|
||||
if not _is_str_keyed_dict(custom) or "defer_loading" in tool:
|
||||
return
|
||||
deferred: Final = custom.get("defer_loading")
|
||||
if isinstance(deferred, bool):
|
||||
tool["defer_loading"] = deferred # rebind-ok: a fallback reuses the caller's tool, as on main
|
||||
|
||||
|
||||
def normalize_custom_field_on_tools(request_body: dict) -> None:
|
||||
"""
|
||||
Drop the ``custom`` field from each tool, first hoisting a boolean
|
||||
|
|
@ -590,15 +614,8 @@ def normalize_custom_field_on_tools(request_body: dict) -> None:
|
|||
tools: Final = request_body.get("tools")
|
||||
if not tools or not isinstance(tools, list):
|
||||
return
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
custom: dict[str, object] | None = tool.pop("custom", None)
|
||||
if not isinstance(custom, dict) or "defer_loading" in tool:
|
||||
continue
|
||||
deferred: object = custom.get("defer_loading")
|
||||
if isinstance(deferred, bool):
|
||||
tool["defer_loading"] = deferred
|
||||
for tool in _TOOL_ITEMS_ADAPTER.validate_python(tools):
|
||||
_normalize_custom_field_on_tool(tool)
|
||||
|
||||
|
||||
_TOOL_DICTS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.anthropic.common_utils import tools_without_custom_field
|
||||
from litellm.llms.anthropic.count_tokens.transformation import COUNT_TOKEN_OPTION_NAMES, AnthropicCountTokensConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, run_aws_signing
|
||||
from litellm.llms.bedrock.common_utils import BedrockError, build_mantle_messages_url
|
||||
|
|
@ -26,6 +27,7 @@ class MantleCountTokensRequest(TypedDict):
|
|||
_COUNT_REQUEST: Final = TypeAdapter(MantleCountTokensRequest)
|
||||
_COUNT_RESPONSE: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_COUNT_OPTIONS: Final[TypeAdapter[Mapping[str, JsonValue]]] = TypeAdapter(Mapping[str, JsonValue])
|
||||
_TOOLS: Final = TypeAdapter(list[dict[str, JsonValue]])
|
||||
|
||||
|
||||
class BedrockMantleCountTokensHandler(BaseAWSLLM):
|
||||
|
|
@ -59,10 +61,15 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM):
|
|||
self.get_aws_region_name, optional_params=litellm_params, model=resolved_model, model_id=None
|
||||
)
|
||||
endpoint_url: Final = await run_aws_signing(self.get_mantle_count_tokens_endpoint, aws_region_name)
|
||||
tools: Final = request.get("tools")
|
||||
body: Final = self._anthropic_config.transform_request_to_count_tokens(
|
||||
model=resolved_model,
|
||||
messages=request["messages"],
|
||||
tools=request.get("tools"),
|
||||
tools=None
|
||||
if tools is None
|
||||
else _TOOLS.validate_python(
|
||||
tools_without_custom_field(tools, hoist_defer_loading=True, drop_custom=True)
|
||||
),
|
||||
system=request.get("system"),
|
||||
optional_params=_COUNT_OPTIONS.validate_python(
|
||||
{name: value for name, value in request_data.items() if name in COUNT_TOKEN_OPTION_NAMES}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Final, Literal
|
|||
from pydantic import JsonValue
|
||||
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.anthropic.common_utils import tools_without_custom_field
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
|
||||
|
||||
|
|
@ -194,7 +195,15 @@ class BedrockCountTokensConfig(BaseAWSLLM):
|
|||
|
||||
# For InvokeModel, we need to provide the raw body that would be sent to the model
|
||||
# Remove the 'model' field from the body as it's not part of the model input
|
||||
body_data: Final = {k: v for k, v in request_data.items() if k != "model"}
|
||||
tools: Final = request_data.get("tools")
|
||||
body_data: Final = {
|
||||
**{k: v for k, v in request_data.items() if k != "model"},
|
||||
**(
|
||||
{"tools": tools_without_custom_field(tools, hoist_defer_loading=True, drop_custom=True)}
|
||||
if isinstance(tools, list)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if "messages" in body_data:
|
||||
# Bedrock validates the body against the model's InvokeModel schema;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
def should_strip_billing_metadata(self) -> bool:
|
||||
return True
|
||||
|
||||
def tools_for_upstream(self, tools: Sequence[object], model: str, drop_params: object) -> Sequence[object]:
|
||||
return tools
|
||||
|
||||
def drops_dangerous_tool_use_beta_without_safeguards(self) -> bool:
|
||||
return True
|
||||
|
||||
|
|
|
|||
0
litellm/llms/bigmodel/__init__.py
Normal file
0
litellm/llms/bigmodel/__init__.py
Normal file
0
litellm/llms/bigmodel/chat/__init__.py
Normal file
0
litellm/llms/bigmodel/chat/__init__.py
Normal file
59
litellm/llms/bigmodel/chat/transformation.py
Normal file
59
litellm/llms/bigmodel/chat/transformation.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
from litellm.llms.zai.chat.transformation import ZAIChatConfig
|
||||
|
||||
from ..common_utils import get_bigmodel_api_key, get_bigmodel_url, is_unsupported_disabled_thinking
|
||||
|
||||
_SUPPORTED_PARAMS: Final = (
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"stop",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"response_format",
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
)
|
||||
|
||||
|
||||
class BigModelChatConfig(ZAIChatConfig):
|
||||
@property
|
||||
def custom_llm_provider(self) -> str:
|
||||
return "bigmodel"
|
||||
|
||||
def get_openai_compatible_provider_info(self, api_base: str | None, api_key: str | None) -> tuple[str, str | None]:
|
||||
return get_bigmodel_url(api_base, "chat"), get_bigmodel_api_key(api_key)
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited interface requires a list
|
||||
return list(_SUPPORTED_PARAMS)
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: Mapping[str, object],
|
||||
optional_params: Mapping[str, object],
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: request pipeline adds parameters to the returned body
|
||||
mapped: Final = {
|
||||
**optional_params,
|
||||
**{
|
||||
("max_tokens" if key == "max_completion_tokens" else key): value
|
||||
for key, value in non_default_params.items()
|
||||
if key in _SUPPORTED_PARAMS and key != "thinking"
|
||||
},
|
||||
}
|
||||
if "thinking" not in non_default_params:
|
||||
return mapped
|
||||
thinking: Final = non_default_params["thinking"]
|
||||
if is_unsupported_disabled_thinking(model, thinking):
|
||||
return {
|
||||
**mapped,
|
||||
"reasoning_effort": mapped.get("reasoning_effort", "low"),
|
||||
"extra_body": {"thinking": {"type": "enabled"}},
|
||||
}
|
||||
return {**mapped, "extra_body": {"thinking": thinking}}
|
||||
92
litellm/llms/bigmodel/common_utils.py
Normal file
92
litellm/llms/bigmodel/common_utils.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import re
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import ContentPolicyViolationError, ContextWindowExceededError
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
BIGMODEL_API_BASE: Final = "https://open.bigmodel.cn"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_BUSINESS_CODE_IN_ERROR: Final = re.compile(r"""['"]code['"]\s*:\s*['"]?(\d{4})\b""")
|
||||
_CODING_CHAT_PATH: Final = "/api/coding/paas/v4"
|
||||
_API_PATHS: Final = (
|
||||
"/api/coding/paas/v4/chat/completions",
|
||||
_CODING_CHAT_PATH,
|
||||
"/api/paas/v4/chat/completions",
|
||||
"/api/anthropic/v1/messages",
|
||||
"/api/anthropic/v1",
|
||||
"/api/v1/responses",
|
||||
"/api/paas/v4",
|
||||
"/api/anthropic",
|
||||
"/api/v1",
|
||||
"/api",
|
||||
)
|
||||
_ENDPOINT_PATHS: Final = MappingProxyType(
|
||||
{
|
||||
"chat": "/api/paas/v4",
|
||||
"responses": "/api/v1/responses",
|
||||
"messages": "/api/anthropic/v1/messages",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_bigmodel_api_key(api_key: str | None) -> str | None:
|
||||
return api_key or get_secret_str("BIGMODEL_API_KEY")
|
||||
|
||||
|
||||
def get_bigmodel_url(api_base: str | None, endpoint: Literal["chat", "responses", "messages"]) -> str:
|
||||
resolved_base: Final = api_base or get_secret_str("BIGMODEL_API_BASE") or BIGMODEL_API_BASE
|
||||
parsed: Final = urlsplit(resolved_base)
|
||||
path: Final = parsed.path.rstrip("/")
|
||||
suffix: Final = next((candidate for candidate in _API_PATHS if path.endswith(candidate)), "")
|
||||
endpoint_path: Final = (
|
||||
_CODING_CHAT_PATH if endpoint == "chat" and suffix.startswith(_CODING_CHAT_PATH) else _ENDPOINT_PATHS[endpoint]
|
||||
)
|
||||
return urlunsplit(
|
||||
(parsed.scheme, parsed.netloc, f"{path.removesuffix(suffix)}{endpoint_path}", parsed.query, parsed.fragment)
|
||||
)
|
||||
|
||||
|
||||
def bigmodel_json_body(response: httpx.Response) -> Mapping[str, JsonValue]:
|
||||
try:
|
||||
return _JSON_OBJECT.validate_json(response.content)
|
||||
except ValidationError:
|
||||
raise BaseLLMException(message=response.text, status_code=response.status_code, headers=response.headers)
|
||||
|
||||
|
||||
def is_unsupported_disabled_thinking(model: str, thinking: object) -> bool:
|
||||
return thinking == {"type": "disabled"} and litellm.utils.supports_factory(
|
||||
model=model, custom_llm_provider="bigmodel", key="thinking_always_on"
|
||||
)
|
||||
|
||||
|
||||
def bigmodel_business_error(
|
||||
code: JsonValue, message: str, model: str, response: httpx.Response | None
|
||||
) -> ContextWindowExceededError | ContentPolicyViolationError | None:
|
||||
match code:
|
||||
case 1261 | "1261":
|
||||
return ContextWindowExceededError(message=message, model=model, llm_provider="bigmodel", response=response)
|
||||
case 1301 | "1301":
|
||||
return ContentPolicyViolationError(message=message, model=model, llm_provider="bigmodel", response=response)
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def raise_bigmodel_business_error(model: object, error_str: str, original_exception: object) -> None:
|
||||
found: Final = _BUSINESS_CODE_IN_ERROR.search(error_str)
|
||||
response: Final = getattr(original_exception, "response", None)
|
||||
error: Final = bigmodel_business_error(
|
||||
found.group(1) if found else None,
|
||||
error_str,
|
||||
str(model),
|
||||
response if isinstance(response, httpx.Response) else None,
|
||||
)
|
||||
if error is not None:
|
||||
raise error
|
||||
0
litellm/llms/bigmodel/messages/__init__.py
Normal file
0
litellm/llms/bigmodel/messages/__init__.py
Normal file
206
litellm/llms/bigmodel/messages/transformation.py
Normal file
206
litellm/llms/bigmodel/messages/transformation.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import json
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from openai._streaming import ServerSentEvent, SSEDecoder
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.openai_like.messages.transformation import (
|
||||
DEFAULT_ANTHROPIC_API_VERSION,
|
||||
OpenAILikeAnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
from ..common_utils import (
|
||||
bigmodel_json_body,
|
||||
get_bigmodel_api_key,
|
||||
get_bigmodel_url,
|
||||
is_unsupported_disabled_thinking,
|
||||
)
|
||||
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_OUTPUT_CONFIG: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _with_lowest_effort_thinking(
|
||||
params: Mapping[str, object],
|
||||
) -> dict[str, object]: # mutable-ok: the parent transform takes a request dict
|
||||
requested_output_config: Final = params.get("output_config")
|
||||
output_config: Final = {
|
||||
**({} if "reasoning_effort" in params else {"effort": "low"}),
|
||||
**(
|
||||
_OUTPUT_CONFIG.validate_python(requested_output_config) if isinstance(requested_output_config, dict) else {}
|
||||
),
|
||||
}
|
||||
return {**params, "thinking": {"type": "adaptive"}, **({"output_config": output_config} if output_config else {})}
|
||||
|
||||
|
||||
def _normalize_message(body: Mapping[str, JsonValue]) -> dict[str, JsonValue]: # mutable-ok: native JSON response
|
||||
usage: Final = body.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
return dict(body)
|
||||
server_tools: Final = usage.get("server_tool_use")
|
||||
if not isinstance(server_tools, dict) or "web_fetch_requests" in server_tools:
|
||||
return dict(body)
|
||||
normalized: Final[JsonValue] = {
|
||||
**body,
|
||||
"usage": {**usage, "server_tool_use": {"web_fetch_requests": 0, **server_tools}},
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
class _BigModelMessagesStream(httpx.AsyncByteStream):
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self._response = response
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
async for event in SSEDecoder().aiter_bytes(self._response.aiter_bytes()):
|
||||
yield _normalize_event(event)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._response.aclose()
|
||||
|
||||
|
||||
def _json_object(data: str) -> Mapping[str, JsonValue] | None:
|
||||
try:
|
||||
return _JSON_OBJECT.validate_json(data)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalized_data(data: str) -> str:
|
||||
body: Final = _json_object(data)
|
||||
if body is None:
|
||||
return data
|
||||
message: Final = body.get("message")
|
||||
normalized: Final[JsonValue] = (
|
||||
{**body, "message": _normalize_message(message)}
|
||||
if body.get("type") == "message_start" and isinstance(message, dict)
|
||||
else _normalize_message(body)
|
||||
)
|
||||
return data if normalized == body else json.dumps(normalized, ensure_ascii=False)
|
||||
|
||||
|
||||
def _normalize_event(event: ServerSentEvent) -> bytes:
|
||||
metadata: Final = "".join(
|
||||
f"{name}: {value}\n"
|
||||
for name, value in (("event", event.event), ("id", event.id), ("retry", event.retry))
|
||||
if value is not None
|
||||
)
|
||||
data_lines: Final = "".join(f"data: {line}\n" for line in _normalized_data(event.data).split("\n"))
|
||||
return f"{metadata}{data_lines}\n".encode()
|
||||
|
||||
|
||||
class BigModelMessagesConfig(OpenAILikeAnthropicMessagesConfig):
|
||||
@property
|
||||
def custom_llm_provider(self) -> str:
|
||||
return "bigmodel"
|
||||
|
||||
def is_native_messages_route(self) -> bool:
|
||||
return True
|
||||
|
||||
def should_strip_billing_metadata(self) -> bool:
|
||||
return True
|
||||
|
||||
def transform_anthropic_messages_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, object]], # mutable-ok: matches dict-typed base signature
|
||||
anthropic_messages_optional_request_params: dict[str, object], # mutable-ok: matches dict-typed base signature
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: matches dict-typed base signature
|
||||
) -> dict[str, object]: # mutable-ok: matches dict-typed base signature
|
||||
request_params: Final = (
|
||||
_with_lowest_effort_thinking(anthropic_messages_optional_request_params)
|
||||
if is_unsupported_disabled_thinking(model, anthropic_messages_optional_request_params.get("thinking"))
|
||||
else anthropic_messages_optional_request_params
|
||||
)
|
||||
return super().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the parent transform returns a bare dict
|
||||
model, messages, request_params, litellm_params, headers
|
||||
)
|
||||
|
||||
def transform_anthropic_messages_response(
|
||||
self, model: str, raw_response: httpx.Response, logging_obj: Logging
|
||||
) -> AnthropicMessagesResponse:
|
||||
normalized: Final = httpx.Response(
|
||||
raw_response.status_code,
|
||||
json=_normalize_message(bigmodel_json_body(raw_response)),
|
||||
request=raw_response.request,
|
||||
)
|
||||
return super().transform_anthropic_messages_response(model, normalized, logging_obj)
|
||||
|
||||
def get_async_streaming_response_iterator(
|
||||
self,
|
||||
model: str,
|
||||
httpx_response: httpx.Response,
|
||||
request_body: dict[str, object], # mutable-ok: inherited native stream API takes a request dict
|
||||
litellm_logging_obj: Logging,
|
||||
) -> AsyncIterator[bytes]:
|
||||
normalized: Final = httpx.Response(
|
||||
httpx_response.status_code,
|
||||
headers={
|
||||
key: value
|
||||
for key, value in httpx_response.headers.items()
|
||||
if key.lower() not in ("content-encoding", "content-length")
|
||||
},
|
||||
stream=_BigModelMessagesStream(httpx_response),
|
||||
request=httpx_response.request,
|
||||
)
|
||||
return super().get_async_streaming_response_iterator( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # native parent yields bytes but its iterator annotation omits the element type
|
||||
model, normalized, request_body, litellm_logging_obj
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _translate_reasoning_effort_to_anthropic(
|
||||
model: str, optional_params: Mapping[str, object], max_tokens: int | None, custom_llm_provider: str
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _translate_adaptive_effort_for_non_adaptive_model(
|
||||
model: str, optional_params: Mapping[str, object], max_tokens: int | None, custom_llm_provider: str
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _drop_incompatible_temperature_for_thinking(
|
||||
model: str, optional_params: Mapping[str, object], custom_llm_provider: str
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
model: str,
|
||||
messages: Sequence[object],
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> tuple[dict[str, str], str | None]: # mutable-ok: HTTP handler merges forwarded headers
|
||||
resolved_key: Final = get_bigmodel_api_key(api_key)
|
||||
if resolved_key is None:
|
||||
raise AuthenticationError(
|
||||
message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model
|
||||
)
|
||||
return {
|
||||
**{key: value for key, value in headers.items() if key.lower() not in ("x-api-key", "authorization")},
|
||||
"x-api-key": resolved_key,
|
||||
"anthropic-version": DEFAULT_ANTHROPIC_API_VERSION,
|
||||
"content-type": "application/json",
|
||||
}, api_base
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
return get_bigmodel_url(api_base, "messages")
|
||||
0
litellm/llms/bigmodel/responses/__init__.py
Normal file
0
litellm/llms/bigmodel/responses/__init__.py
Normal file
263
litellm/llms/bigmodel/responses/transformation.py
Normal file
263
litellm/llms/bigmodel/responses/transformation.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import Never
|
||||
|
||||
from litellm.exceptions import AuthenticationError, UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseInputParam,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
from ..common_utils import bigmodel_business_error, bigmodel_json_body, get_bigmodel_api_key, get_bigmodel_url
|
||||
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_SUPPORTED_PARAMS: Final = (
|
||||
"input",
|
||||
"model",
|
||||
"instructions",
|
||||
"stream",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_output_tokens",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"include",
|
||||
"reasoning",
|
||||
"text",
|
||||
"prompt_cache_key",
|
||||
"previous_response_id",
|
||||
"store",
|
||||
"extra_headers",
|
||||
"extra_query",
|
||||
"extra_body",
|
||||
"timeout",
|
||||
)
|
||||
|
||||
|
||||
_STATUS_BY_BUSINESS_CODE: Final = MappingProxyType(
|
||||
{
|
||||
**dict.fromkeys((1000, 1001, 1003, 1005), 401),
|
||||
**dict.fromkeys((1210, 1211, 1212, 1213, 1214, 1215, 1221, 1222, 1261, 1301), 400),
|
||||
1220: 403,
|
||||
**dict.fromkeys(
|
||||
(1113, 1302, 1305, 1308, 1309, 1310, 1311, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321),
|
||||
429,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _RequestedTools(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
|
||||
tools: JsonValue = None
|
||||
tool_choice: JsonValue = None
|
||||
parallel_tool_calls: JsonValue = None
|
||||
|
||||
|
||||
def _requested_tools(logging_obj: Logging) -> _RequestedTools:
|
||||
try:
|
||||
return _RequestedTools.model_validate(logging_obj.model_call_details.get("optional_params") or {})
|
||||
except ValidationError:
|
||||
return _RequestedTools()
|
||||
|
||||
|
||||
def _envelope_status_code(code: JsonValue) -> int:
|
||||
if not isinstance(code, int) or isinstance(code, bool):
|
||||
return 500
|
||||
if 400 <= code <= 599:
|
||||
return code
|
||||
return _STATUS_BY_BUSINESS_CODE.get(code, 500)
|
||||
|
||||
|
||||
def _normalize_output_item(item: JsonValue) -> JsonValue:
|
||||
if not isinstance(item, dict) or item.get("type") not in ("message", "reasoning"):
|
||||
return item
|
||||
content: Final = item.get("content")
|
||||
normalized: Final[JsonValue] = {"summary": [], **item} if item["type"] == "reasoning" else item
|
||||
if not isinstance(content, dict):
|
||||
return normalized
|
||||
return {**normalized, "content": [content]}
|
||||
|
||||
|
||||
def _normalize_response(
|
||||
body: Mapping[str, JsonValue],
|
||||
requested: _RequestedTools,
|
||||
) -> dict[str, JsonValue]: # mutable-ok: JSON serialization boundary
|
||||
usage: Final = body.get("usage")
|
||||
output: Final = body.get("output")
|
||||
tools: Final = body.get("tools")
|
||||
tool_choice: Final = body.get("tool_choice")
|
||||
parallel_tool_calls: Final = body.get("parallel_tool_calls")
|
||||
input_tokens: Final = usage.get("input_tokens") if isinstance(usage, dict) else None
|
||||
output_tokens: Final = usage.get("output_tokens") if isinstance(usage, dict) else None
|
||||
normalized_usage: Final = (
|
||||
{
|
||||
"total_tokens": input_tokens + output_tokens,
|
||||
**usage,
|
||||
}
|
||||
if isinstance(usage, dict) and isinstance(input_tokens, int) and isinstance(output_tokens, int)
|
||||
else usage
|
||||
)
|
||||
normalized: Final[JsonValue] = {
|
||||
**body,
|
||||
"parallel_tool_calls": (
|
||||
requested.parallel_tool_calls is not False if parallel_tool_calls is None else parallel_tool_calls
|
||||
),
|
||||
"tools": (requested.tools or []) if tools is None else tools,
|
||||
"tool_choice": (requested.tool_choice or "auto") if tool_choice is None else tool_choice,
|
||||
**({"usage": normalized_usage} if "usage" in body else {}),
|
||||
**({"output": [_normalize_output_item(item) for item in output]} if isinstance(output, list) else {}),
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_stream_part(part: JsonValue) -> JsonValue:
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
if part.get("type") == "output_text":
|
||||
return {
|
||||
"annotations": [],
|
||||
"logprobs": None,
|
||||
**part,
|
||||
}
|
||||
if part.get("type") == "reasoning_text":
|
||||
return {"reasoning": part.get("text", ""), **part}
|
||||
return part
|
||||
|
||||
|
||||
class BigModelResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
@property
|
||||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.BIGMODEL
|
||||
|
||||
def supports_native_file_search(self) -> bool:
|
||||
return False
|
||||
|
||||
def supports_native_websocket(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_fake_stream(
|
||||
self, model: str | None, stream: bool | None, custom_llm_provider: str | None = None
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
def may_answer_stream_with_json(self) -> bool:
|
||||
return True
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited API requires a list
|
||||
return list(_SUPPORTED_PARAMS)
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
response_api_optional_params: ResponsesAPIOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: request pipeline adds parameters to the returned body
|
||||
return {key: value for key, value in response_api_optional_params.items() if key in _SUPPORTED_PARAMS}
|
||||
|
||||
def validate_environment(
|
||||
self, headers: Mapping[str, str], model: str, litellm_params: GenericLiteLLMParams | None
|
||||
) -> dict[str, str]: # mutable-ok: HTTP handler merges forwarded headers
|
||||
resolved_key: Final = get_bigmodel_api_key(litellm_params.api_key if litellm_params else None)
|
||||
if resolved_key is None:
|
||||
raise AuthenticationError(
|
||||
message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model
|
||||
)
|
||||
return {
|
||||
**headers,
|
||||
"Authorization": f"Bearer {resolved_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_complete_url(self, api_base: str | None, litellm_params: Mapping[str, object]) -> str:
|
||||
return get_bigmodel_url(api_base, "responses")
|
||||
|
||||
def transform_response_api_response(
|
||||
self, model: str, raw_response: httpx.Response, logging_obj: Logging
|
||||
) -> ResponsesAPIResponse:
|
||||
body: Final = bigmodel_json_body(raw_response)
|
||||
if body.get("success") is False:
|
||||
message: Final = str(body.get("msg") or raw_response.text)
|
||||
raise bigmodel_business_error(body.get("code"), message, model, None) or BaseLLMException(
|
||||
message=message,
|
||||
status_code=_envelope_status_code(body.get("code")),
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
normalized: Final = _normalize_response(body, _requested_tools(logging_obj))
|
||||
normalized_response: Final = httpx.Response(
|
||||
status_code=raw_response.status_code,
|
||||
headers={
|
||||
key: value
|
||||
for key, value in raw_response.headers.items()
|
||||
if key.lower() not in ("content-encoding", "content-length")
|
||||
},
|
||||
json=normalized,
|
||||
request=raw_response.request,
|
||||
)
|
||||
return super().transform_response_api_response(model, normalized_response, logging_obj)
|
||||
|
||||
def transform_get_response_api_response(
|
||||
self, raw_response: httpx.Response, logging_obj: Logging
|
||||
) -> ResponsesAPIResponse:
|
||||
return self.transform_response_api_response("", raw_response, logging_obj)
|
||||
|
||||
def transform_streaming_response(
|
||||
self, model: str, parsed_chunk: Mapping[str, object], logging_obj: Logging
|
||||
) -> ResponsesAPIStreamingResponse:
|
||||
chunk: Final = _JSON_OBJECT.validate_python(parsed_chunk)
|
||||
response: Final = chunk.get("response")
|
||||
item: Final = chunk.get("item")
|
||||
normalized: Final[JsonValue] = {
|
||||
**chunk,
|
||||
**(
|
||||
{"response": _normalize_response(response, _requested_tools(logging_obj))}
|
||||
if isinstance(response, dict)
|
||||
else {}
|
||||
),
|
||||
**({"item": _normalize_output_item(item)} if "item" in chunk else {}),
|
||||
}
|
||||
event_model: Final = self.get_event_model_class(str(normalized.get("type")))
|
||||
match chunk.get("type"):
|
||||
case "response.output_text.done":
|
||||
return event_model.model_validate({"logprobs": [], **normalized})
|
||||
case "response.content_part.done":
|
||||
return event_model.model_validate({**normalized, "part": _normalize_stream_part(chunk.get("part"))})
|
||||
case "error":
|
||||
error: Final = chunk.get("error")
|
||||
error_fields: Final = error if isinstance(error, dict) else chunk
|
||||
normalized_error: Final[JsonValue] = {
|
||||
**normalized,
|
||||
"error": {"type": "api_error", **error_fields, "code": error_fields.get("code") or "unknown_error"},
|
||||
}
|
||||
return event_model.model_validate(normalized_error)
|
||||
case _:
|
||||
return event_model.model_validate(normalized)
|
||||
|
||||
def transform_cancel_response_api_request(
|
||||
self, response_id: str, api_base: str, litellm_params: GenericLiteLLMParams, headers: Mapping[str, str]
|
||||
) -> Never:
|
||||
raise UnsupportedParamsError(message="BigModel does not support cancelling responses", status_code=400)
|
||||
|
||||
def transform_compact_response_api_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str | ResponseInputParam,
|
||||
response_api_optional_request_params: Mapping[str, object],
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> Never:
|
||||
raise UnsupportedParamsError(message="BigModel does not support compacting responses", status_code=400)
|
||||
|
|
@ -764,18 +764,23 @@ class AsyncHTTPHandler:
|
|||
follow_redirects: bool | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
max_response_bytes: int | None = None,
|
||||
stream: bool = False,
|
||||
):
|
||||
# Set follow_redirects to UseClientDefault if None
|
||||
_follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT
|
||||
query: Final = httpx.QueryParams(params).merge(HTTPHandler.extract_query_params(url))
|
||||
request_headers: Final = httpx.Headers(headers)
|
||||
|
||||
params = params or {}
|
||||
params.update(HTTPHandler.extract_query_params(url))
|
||||
if stream:
|
||||
return await self._send_streaming_get(
|
||||
url, params=query, headers=request_headers, follow_redirects=follow_redirects, timeout=timeout
|
||||
)
|
||||
|
||||
if max_response_bytes is not None:
|
||||
return await self._get_with_response_limit(
|
||||
url,
|
||||
params=httpx.QueryParams(params),
|
||||
headers=httpx.Headers(headers),
|
||||
params=query,
|
||||
headers=request_headers,
|
||||
max_bytes=max_response_bytes,
|
||||
follow_redirects=self.client.follow_redirects if follow_redirects is None else follow_redirects,
|
||||
timeout=self.client.timeout if timeout is None else httpx.Timeout(timeout),
|
||||
|
|
@ -783,13 +788,41 @@ class AsyncHTTPHandler:
|
|||
|
||||
response: Final = await self.client.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
params=query,
|
||||
headers=request_headers,
|
||||
follow_redirects=_follow_redirects,
|
||||
timeout=timeout if timeout is not None else USE_CLIENT_DEFAULT,
|
||||
)
|
||||
return response
|
||||
|
||||
async def _send_streaming_get(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
params: httpx.QueryParams,
|
||||
headers: httpx.Headers,
|
||||
follow_redirects: bool | None,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
) -> httpx.Response:
|
||||
start_time: Final = time.time()
|
||||
request: Final = self.client.build_request(
|
||||
"GET", url, params=params, headers=headers, timeout=USE_CLIENT_DEFAULT if timeout is None else timeout
|
||||
)
|
||||
try:
|
||||
response: Final = await self.client.send(
|
||||
request,
|
||||
stream=True,
|
||||
follow_redirects=USE_CLIENT_DEFAULT if follow_redirects is None else follow_redirects,
|
||||
)
|
||||
_anchor_handler_to(response, self)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as e:
|
||||
_raise_timeout(e, start_time)
|
||||
except httpx.HTTPStatusError as e:
|
||||
await _raise_masked_async_error(e, True)
|
||||
else:
|
||||
return response
|
||||
|
||||
async def _get_with_response_limit(
|
||||
self,
|
||||
url: str,
|
||||
|
|
@ -1468,15 +1501,20 @@ class HTTPHandler:
|
|||
headers: Mapping[str, Any] | None = None,
|
||||
follow_redirects: bool | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
stream: bool = False,
|
||||
):
|
||||
# Set follow_redirects to UseClientDefault if None
|
||||
_follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT
|
||||
params = params or {}
|
||||
params.update(self.extract_query_params(url))
|
||||
query: Final = httpx.QueryParams(params).merge(self.extract_query_params(url))
|
||||
|
||||
if stream:
|
||||
return self._send_streaming_get(
|
||||
url, params=query, headers=headers, follow_redirects=follow_redirects, timeout=timeout
|
||||
)
|
||||
|
||||
response: Final = self.client.get(
|
||||
url,
|
||||
params=params,
|
||||
params=query,
|
||||
headers=headers,
|
||||
follow_redirects=_follow_redirects,
|
||||
timeout=timeout if timeout is not None else USE_CLIENT_DEFAULT,
|
||||
|
|
@ -1484,6 +1522,34 @@ class HTTPHandler:
|
|||
|
||||
return response
|
||||
|
||||
def _send_streaming_get(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
params: httpx.QueryParams,
|
||||
headers: Mapping[str, str] | None,
|
||||
follow_redirects: bool | None,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
) -> httpx.Response:
|
||||
start_time: Final = time.time()
|
||||
request: Final = self.client.build_request(
|
||||
"GET", url, params=params, headers=headers, timeout=USE_CLIENT_DEFAULT if timeout is None else timeout
|
||||
)
|
||||
try:
|
||||
response: Final = self.client.send(
|
||||
request,
|
||||
stream=True,
|
||||
follow_redirects=USE_CLIENT_DEFAULT if follow_redirects is None else follow_redirects,
|
||||
)
|
||||
_anchor_handler_to(response, self)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as e:
|
||||
_raise_timeout(e, start_time)
|
||||
except httpx.HTTPStatusError as e:
|
||||
_raise_masked_sync_error(e, True)
|
||||
else:
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def extract_query_params(url: str) -> dict[str, str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -367,12 +367,37 @@ class _SignedBodyKwargs(TypedDict, total=False):
|
|||
json: ReadOnly[dict[str, object]]
|
||||
|
||||
|
||||
def _is_sized_json_body(response: httpx.Response) -> bool:
|
||||
headers: Final = httpx.Headers(response.headers)
|
||||
media_types: Final = (value.split(";")[0].strip().lower() for value in headers.get_list("content-type"))
|
||||
return "content-length" in headers and "application/json" in media_types
|
||||
|
||||
|
||||
def _is_json_document(content: bytes) -> bool:
|
||||
try:
|
||||
return isinstance(json.loads(content), dict)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _signed_body_kwargs(*, signed_body: bytes | None, data: dict[str, object]) -> _SignedBodyKwargs:
|
||||
if signed_body is not None:
|
||||
return {"data": signed_body}
|
||||
return {"json": data}
|
||||
|
||||
|
||||
def _answered_json(response: httpx.Response) -> bool:
|
||||
return "application/json" in response.headers.get("content-type", "").lower()
|
||||
|
||||
|
||||
def _stream_query_params(stream: bool, starting_after: int | None) -> Mapping[str, object]:
|
||||
if not stream:
|
||||
return {}
|
||||
if starting_after is None:
|
||||
return {"stream": "true"}
|
||||
return {"stream": "true", "starting_after": starting_after}
|
||||
|
||||
|
||||
def _google_genai_streaming_hidden_params(
|
||||
*,
|
||||
api_base: str,
|
||||
|
|
@ -3025,6 +3050,17 @@ class BaseLLMHTTPHandler:
|
|||
},
|
||||
)
|
||||
|
||||
replay_as_stream: Final = partial(
|
||||
MockResponsesAPIStreamingIterator,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_stream_request:
|
||||
response = sync_httpx_client.post(
|
||||
|
|
@ -3036,7 +3072,13 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
if caller_requested_stream:
|
||||
if fake_stream is True:
|
||||
return MockResponsesAPIStreamingIterator(
|
||||
return replay_as_stream(response=response)
|
||||
if not (
|
||||
responses_api_provider_config.may_answer_stream_with_json()
|
||||
and _is_sized_json_body(response)
|
||||
and _is_json_document(response.read())
|
||||
):
|
||||
return SyncResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
|
|
@ -3046,17 +3088,6 @@ class BaseLLMHTTPHandler:
|
|||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
|
||||
return SyncResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
response.read()
|
||||
else:
|
||||
response = sync_httpx_client.post(
|
||||
|
|
@ -3071,6 +3102,9 @@ class BaseLLMHTTPHandler:
|
|||
provider_config=responses_api_provider_config,
|
||||
)
|
||||
|
||||
if is_stream_request and caller_requested_stream:
|
||||
return replay_as_stream(response=response)
|
||||
|
||||
initial_response: Final = responses_api_provider_config.transform_response_api_response(
|
||||
model=model,
|
||||
raw_response=response,
|
||||
|
|
@ -3226,6 +3260,17 @@ class BaseLLMHTTPHandler:
|
|||
},
|
||||
)
|
||||
|
||||
replay_as_stream: Final = partial(
|
||||
MockResponsesAPIStreamingIterator,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_stream_request:
|
||||
response = await async_httpx_client.post(
|
||||
|
|
@ -3239,7 +3284,13 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
if caller_requested_stream:
|
||||
if fake_stream is True:
|
||||
return MockResponsesAPIStreamingIterator(
|
||||
return replay_as_stream(response=response)
|
||||
if not (
|
||||
responses_api_provider_config.may_answer_stream_with_json()
|
||||
and _is_sized_json_body(response)
|
||||
and _is_json_document(await response.aread())
|
||||
):
|
||||
return ResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
|
|
@ -3249,17 +3300,6 @@ class BaseLLMHTTPHandler:
|
|||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
|
||||
return ResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data=request_context,
|
||||
call_type=CallTypes.responses.value,
|
||||
)
|
||||
await response.aread()
|
||||
else:
|
||||
response = await async_httpx_client.post(
|
||||
|
|
@ -3276,6 +3316,9 @@ class BaseLLMHTTPHandler:
|
|||
provider_config=responses_api_provider_config,
|
||||
)
|
||||
|
||||
if is_stream_request and caller_requested_stream:
|
||||
return replay_as_stream(response=response)
|
||||
|
||||
initial_response: Final = responses_api_provider_config.transform_response_api_response(
|
||||
model=model,
|
||||
raw_response=response,
|
||||
|
|
@ -3493,12 +3536,20 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: Mapping[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
stream: bool = False,
|
||||
starting_after: int | None = None,
|
||||
litellm_metadata: Mapping[str, object] = MappingProxyType({}),
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
client: HTTPHandler | AsyncHTTPHandler | None = None,
|
||||
_is_async: bool = False,
|
||||
shared_session: Optional["ClientSession"] = None,
|
||||
) -> ResponsesAPIResponse | Coroutine[object, object, ResponsesAPIResponse]:
|
||||
) -> (
|
||||
ResponsesAPIResponse
|
||||
| SyncResponsesAPIStreamingIterator
|
||||
| Coroutine[object, object, ResponsesAPIResponse | ResponsesAPIStreamingIterator]
|
||||
):
|
||||
"""
|
||||
Get a response by ID
|
||||
Uses GET /v1/responses/{response_id} endpoint in the responses API
|
||||
|
|
@ -3511,7 +3562,11 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
extra_query=extra_query,
|
||||
extra_body=extra_body,
|
||||
stream=stream,
|
||||
starting_after=starting_after,
|
||||
litellm_metadata=litellm_metadata,
|
||||
timeout=timeout,
|
||||
client=client,
|
||||
shared_session=shared_session,
|
||||
|
|
@ -3540,20 +3595,23 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
query: Final = {**data, **(extra_query or {}), **_stream_query_params(stream, starting_after)}
|
||||
|
||||
## LOGGING
|
||||
logging_obj.pre_call(
|
||||
input="",
|
||||
api_key="",
|
||||
additional_args={
|
||||
"complete_input_dict": data,
|
||||
"complete_input_dict": query,
|
||||
"api_base": api_base,
|
||||
"headers": headers,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response: Final = sync_httpx_client.get(url=url, headers=headers, params=data)
|
||||
response: Final = sync_httpx_client.get(
|
||||
url=url, headers=headers, params=query, stream=stream, timeout=timeout if stream else None
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise self._handle_error(
|
||||
|
|
@ -3561,6 +3619,20 @@ class BaseLLMHTTPHandler:
|
|||
provider_config=responses_api_provider_config,
|
||||
)
|
||||
|
||||
if stream and not _answered_json(response):
|
||||
return SyncResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model="",
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=dict(litellm_metadata),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data={"litellm_params": dict(litellm_params)},
|
||||
replays_stored_stream=True,
|
||||
)
|
||||
|
||||
if stream:
|
||||
response.read()
|
||||
return responses_api_provider_config.transform_get_response_api_response(
|
||||
raw_response=response,
|
||||
logging_obj=logging_obj,
|
||||
|
|
@ -3574,11 +3646,15 @@ class BaseLLMHTTPHandler:
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_query: Mapping[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
stream: bool = False,
|
||||
starting_after: int | None = None,
|
||||
litellm_metadata: Mapping[str, object] = MappingProxyType({}),
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
client: HTTPHandler | AsyncHTTPHandler | None = None,
|
||||
shared_session: Optional["ClientSession"] = None,
|
||||
) -> ResponsesAPIResponse:
|
||||
) -> ResponsesAPIResponse | ResponsesAPIStreamingIterator:
|
||||
"""
|
||||
Async version of get_responses
|
||||
"""
|
||||
|
|
@ -3616,20 +3692,23 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
query: Final = {**data, **(extra_query or {}), **_stream_query_params(stream, starting_after)}
|
||||
|
||||
## LOGGING
|
||||
logging_obj.pre_call(
|
||||
input="",
|
||||
api_key="",
|
||||
additional_args={
|
||||
"complete_input_dict": data,
|
||||
"complete_input_dict": query,
|
||||
"api_base": api_base,
|
||||
"headers": headers,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response: Final = await async_httpx_client.get(url=url, headers=headers, params=data)
|
||||
response: Final = await async_httpx_client.get(
|
||||
url=url, headers=headers, params=query, stream=stream, timeout=timeout if stream else None
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
verbose_logger.debug("Error retrieving response: %s", e)
|
||||
|
|
@ -3638,6 +3717,20 @@ class BaseLLMHTTPHandler:
|
|||
provider_config=responses_api_provider_config,
|
||||
)
|
||||
|
||||
if stream and not _answered_json(response):
|
||||
return ResponsesAPIStreamingIterator(
|
||||
response=response,
|
||||
model="",
|
||||
logging_obj=logging_obj,
|
||||
responses_api_provider_config=responses_api_provider_config,
|
||||
litellm_metadata=dict(litellm_metadata),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
request_data={"litellm_params": dict(litellm_params)},
|
||||
replays_stored_stream=True,
|
||||
)
|
||||
|
||||
if stream:
|
||||
await response.aread()
|
||||
return responses_api_provider_config.transform_get_response_api_response(
|
||||
raw_response=response,
|
||||
logging_obj=logging_obj,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -13,7 +14,6 @@ from litellm.litellm_core_utils.asyncify import can_block_current_thread
|
|||
from litellm.llms.custom_httpx.http_handler import get_httpx_client
|
||||
|
||||
from .common_utils import (
|
||||
APIKeyExpiredError,
|
||||
GetAccessTokenError,
|
||||
GetAPIKeyError,
|
||||
GetDeviceCodeError,
|
||||
|
|
@ -27,6 +27,44 @@ DEFAULT_GITHUB_ACCESS_TOKEN_URL: Final = "https://github.com/login/oauth/access_
|
|||
DEFAULT_GITHUB_API_KEY_URL: Final = "https://api.github.com/copilot_internal/v2/token"
|
||||
|
||||
|
||||
class CopilotEndpoints(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
api: str | None = None
|
||||
|
||||
|
||||
class CopilotCachedKey(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
token: str
|
||||
expires_at: float
|
||||
endpoints: CopilotEndpoints | None = None
|
||||
|
||||
|
||||
class CopilotCachedEndpoints(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
endpoints: CopilotEndpoints | None = None
|
||||
|
||||
|
||||
ApiKeyFileView = TypeVar("ApiKeyFileView", CopilotCachedKey, CopilotCachedEndpoints)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UnsavedApiKey:
|
||||
access_token: str
|
||||
key: CopilotCachedKey
|
||||
|
||||
|
||||
_UNSAVED_API_KEYS: Final[dict[str, _UnsavedApiKey]] = {} # mutable-ok: refreshed keys api-key.json could not hold
|
||||
|
||||
|
||||
def _read_token_file(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
raw: Final = f.read()
|
||||
return raw.decode(json.detect_encoding(raw))
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _DeviceCode(TypedDict):
|
||||
device_code: ReadOnly[str]
|
||||
|
|
@ -87,12 +125,13 @@ class Authenticator:
|
|||
GetAccessTokenError: If unable to obtain an access token after retries.
|
||||
"""
|
||||
try:
|
||||
with open(self.access_token_file, "r") as f:
|
||||
access_token = f.read().strip()
|
||||
if access_token:
|
||||
return access_token
|
||||
saved_token: Final = _read_token_file(self.access_token_file).strip()
|
||||
if saved_token:
|
||||
return saved_token
|
||||
except OSError:
|
||||
verbose_logger.warning("No existing access token found or error reading file")
|
||||
except ValueError as e:
|
||||
verbose_logger.warning("Ignoring %s, not a text access token: %s", self.access_token_file, e)
|
||||
|
||||
if not can_block_current_thread():
|
||||
raise GetAccessTokenError(
|
||||
|
|
@ -135,64 +174,83 @@ class Authenticator:
|
|||
Raises:
|
||||
GetAPIKeyError: If unable to obtain an API key.
|
||||
"""
|
||||
try:
|
||||
with open(self.api_key_file, "r") as f:
|
||||
api_key_info = json.load(f)
|
||||
if api_key_info.get("expires_at", 0) > datetime.now().timestamp():
|
||||
return api_key_info.get("token")
|
||||
else:
|
||||
verbose_logger.warning("API key expired, refreshing")
|
||||
raise APIKeyExpiredError(
|
||||
message="API key expired",
|
||||
status_code=401,
|
||||
)
|
||||
except OSError:
|
||||
verbose_logger.warning("No API key file found or error opening file")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
verbose_logger.warning("Error reading API key from file: %s", e)
|
||||
except APIKeyExpiredError:
|
||||
pass # Already logged in the try block
|
||||
unsaved: Final = self._unsaved_api_key()
|
||||
if unsaved is not None:
|
||||
return unsaved.token
|
||||
cached: Final = self._read_api_key_file(CopilotCachedKey)
|
||||
if cached is not None and cached.expires_at > datetime.now().timestamp():
|
||||
return cached.token
|
||||
if cached is not None:
|
||||
verbose_logger.warning("API key expired, refreshing")
|
||||
|
||||
try:
|
||||
api_key_info = self._refresh_api_key()
|
||||
with open(self.api_key_file, "w") as f:
|
||||
json.dump(api_key_info, f)
|
||||
token: Final = api_key_info.get("token")
|
||||
if token:
|
||||
return token
|
||||
else:
|
||||
raise GetAPIKeyError(
|
||||
message="API key response missing token",
|
||||
status_code=401,
|
||||
)
|
||||
except OSError as e:
|
||||
verbose_logger.error("Error saving API key to file: %s", e)
|
||||
raise GetAPIKeyError(
|
||||
message=f"Failed to save API key: {e}",
|
||||
status_code=500,
|
||||
)
|
||||
api_key_info: Final = self._refresh_api_key()
|
||||
except RefreshAPIKeyError as e:
|
||||
raise GetAPIKeyError(
|
||||
message=f"Failed to refresh API key: {e}",
|
||||
status_code=401,
|
||||
) from e
|
||||
serialized: Final = json.dumps(api_key_info)
|
||||
try:
|
||||
with open(self.api_key_file, "w") as f:
|
||||
f.write(serialized)
|
||||
except OSError as e:
|
||||
verbose_logger.warning(
|
||||
"Keeping the refreshed API key in memory, it could not be saved to %s: %s", self.api_key_file, e
|
||||
)
|
||||
self._keep_unsaved_api_key(serialized)
|
||||
token: Final = api_key_info.get("token")
|
||||
if not token:
|
||||
raise GetAPIKeyError(
|
||||
message="API key response missing token",
|
||||
status_code=401,
|
||||
)
|
||||
return token
|
||||
|
||||
def get_api_base(self) -> str | None:
|
||||
"""
|
||||
Get the API endpoint from the api-key.json file.
|
||||
Get the API endpoint of the current API key.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The GitHub Copilot API endpoint, or None if not found.
|
||||
"""
|
||||
try:
|
||||
with open(self.api_key_file, "r") as f:
|
||||
api_key_info: Final = json.load(f)
|
||||
endpoints: Final = api_key_info.get("endpoints", {})
|
||||
api_endpoint: Final = endpoints.get("api")
|
||||
return api_endpoint
|
||||
except (OSError, json.JSONDecodeError, KeyError) as e:
|
||||
verbose_logger.warning("Error reading API endpoint from file: %s", e)
|
||||
cached: Final = self._unsaved_api_key() or self._read_api_key_file(CopilotCachedEndpoints)
|
||||
if cached is None or cached.endpoints is None:
|
||||
return None
|
||||
return cached.endpoints.api
|
||||
|
||||
def _read_api_key_file(self, view: type[ApiKeyFileView]) -> ApiKeyFileView | None:
|
||||
try:
|
||||
return view.model_validate_json(_read_token_file(self.api_key_file))
|
||||
except OSError:
|
||||
verbose_logger.warning("No API key file found or error opening file")
|
||||
return None
|
||||
except ValueError as e:
|
||||
verbose_logger.warning("Ignoring %s, not a Copilot API key file: %s", self.api_key_file, e)
|
||||
return None
|
||||
|
||||
def _stored_access_token(self) -> str | None:
|
||||
try:
|
||||
access_token: Final = _read_token_file(self.access_token_file).strip()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return access_token or None
|
||||
|
||||
def _unsaved_api_key(self) -> CopilotCachedKey | None:
|
||||
unsaved: Final = _UNSAVED_API_KEYS.get(self.api_key_file)
|
||||
if unsaved is None or unsaved.key.expires_at <= time.time():
|
||||
return None
|
||||
return unsaved.key if unsaved.access_token == self._stored_access_token() else None
|
||||
|
||||
def _keep_unsaved_api_key(self, serialized: str) -> None:
|
||||
access_token: Final = self._stored_access_token()
|
||||
if access_token is None:
|
||||
return
|
||||
try:
|
||||
key: Final = CopilotCachedKey.model_validate_json(serialized)
|
||||
except ValueError:
|
||||
return
|
||||
_UNSAVED_API_KEYS[self.api_key_file] = _UnsavedApiKey(access_token=access_token, key=key)
|
||||
|
||||
def _refresh_api_key(self) -> dict[str, Any]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> tuple[str, dict]:
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""
|
||||
Transform the get response API request into a URL and data.
|
||||
|
||||
|
|
@ -252,7 +252,7 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
"""
|
||||
encoded_response_id: Final = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url: Final = f"{api_base}/{encoded_response_id}"
|
||||
data: Final[dict] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
return url, data
|
||||
|
||||
def transform_get_response_api_response(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ if TYPE_CHECKING:
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, decoded_json_object
|
||||
from litellm.litellm_core_utils.token_counter import token_counter
|
||||
from litellm.litellm_core_utils.token_counter import token_counter, usage_tokenizer_model
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
_DEFAULT_TTL_FOR_HTTPX_CLIENTS,
|
||||
|
|
@ -242,7 +242,7 @@ async def _aiter_once(chunk: ChatCompletionChunk) -> AsyncIterator[ChatCompletio
|
|||
|
||||
|
||||
def build_output_token_limit_response(
|
||||
e: openai.BadRequestError, data: Mapping[str, object], is_async: bool
|
||||
e: openai.BadRequestError, data: Mapping[str, object], is_async: bool, base_model: str | None
|
||||
) -> tuple[httpx.Headers, ChatCompletion | Iterator[ChatCompletionChunk] | AsyncIterator[ChatCompletionChunk]]:
|
||||
"""Synthesize the length-truncated response the provider itself returns for slightly larger output budgets.
|
||||
|
||||
|
|
@ -251,7 +251,11 @@ def build_output_token_limit_response(
|
|||
"""
|
||||
model: Final[str] = str(data.get("model", ""))
|
||||
messages: Final = data.get("messages")
|
||||
prompt_tokens: Final = token_counter(model=model, messages=messages) if isinstance(messages, list) else 0
|
||||
prompt_tokens: Final = (
|
||||
token_counter(model=usage_tokenizer_model(model, base_model), messages=messages)
|
||||
if isinstance(messages, list)
|
||||
else 0
|
||||
)
|
||||
if not data.get("stream"):
|
||||
return e.response.headers, _output_token_limit_completion(model, prompt_tokens)
|
||||
chunk: Final = _output_token_limit_chunk(model)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from litellm.utils import (
|
|||
CustomStreamWrapper,
|
||||
ProviderConfigManager,
|
||||
convert_to_model_response_object,
|
||||
get_base_model_from_metadata,
|
||||
)
|
||||
|
||||
from ...types.llms.openai import *
|
||||
|
|
@ -557,7 +558,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
|
|||
except openai.BadRequestError as e:
|
||||
if not is_output_token_limit_error(e):
|
||||
raise
|
||||
return build_output_token_limit_response(e=e, data=data, is_async=True)
|
||||
return build_output_token_limit_response(
|
||||
e=e,
|
||||
data=data,
|
||||
is_async=True,
|
||||
base_model=get_base_model_from_metadata(logging_obj.model_call_details),
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
|
@ -604,7 +610,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
|
|||
except openai.BadRequestError as e:
|
||||
if not is_output_token_limit_error(e):
|
||||
raise
|
||||
return build_output_token_limit_response(e=e, data=data, is_async=False)
|
||||
return build_output_token_limit_response(
|
||||
e=e,
|
||||
data=data,
|
||||
is_async=False,
|
||||
base_model=get_base_model_from_metadata(logging_obj.model_call_details),
|
||||
)
|
||||
except Exception as e:
|
||||
if raw_response is not None:
|
||||
raise Exception(
|
||||
|
|
|
|||
|
|
@ -4,11 +4,17 @@ This file contains the calling OpenAI's `/v1/realtime` endpoint.
|
|||
This requires websockets, and is currently only supported on LiteLLM Proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import redact_string, verbose_logger
|
||||
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.responses.streaming_iterator import map_stream_error_to_exception
|
||||
from litellm.types.realtime import RealtimeQueryParams
|
||||
|
||||
from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
|
|
@ -25,6 +31,54 @@ from ..openai import OpenAIChatCompletion
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
from openai.types.realtime import RealtimeError
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
_SERVER_EVENT_FIELDS: Final = TypeAdapter(Mapping[str, object])
|
||||
_CREDENTIALS_ACCEPTED_FOR_A_NON_REALTIME_MODEL: Final = "invalid_model"
|
||||
_CREDENTIALS_REJECTED: Final = "invalid_api_key"
|
||||
|
||||
|
||||
def first_event_error(first_event: str | bytes) -> "RealtimeError | None":
|
||||
event: Final = _SERVER_EVENT_FIELDS.validate_json(first_event)
|
||||
if event.get("type") != "error":
|
||||
return None
|
||||
from openai.types.realtime import RealtimeErrorEvent
|
||||
|
||||
return RealtimeErrorEvent.model_validate(event).error
|
||||
|
||||
|
||||
def _confirm_first_event(
|
||||
first_event: str | bytes, model: str, sent_credentials: bool, model_listed_outside_realtime: bool
|
||||
) -> Literal[True]:
|
||||
error: Final = first_event_error(first_event)
|
||||
if error is None or (
|
||||
error.code == _CREDENTIALS_ACCEPTED_FOR_A_NON_REALTIME_MODEL and not model_listed_outside_realtime
|
||||
):
|
||||
return True
|
||||
if error.code == _CREDENTIALS_REJECTED or not sent_credentials:
|
||||
raise AuthenticationError(message=error.message, llm_provider="openai", model=model)
|
||||
raise map_stream_error_to_exception(error, model=model, custom_llm_provider="openai")
|
||||
|
||||
|
||||
async def confirm_session_started(
|
||||
connection: "ClientConnection", model: str, sent_credentials: bool, model_listed_outside_realtime: bool
|
||||
) -> Literal[True]:
|
||||
return _confirm_first_event(await connection.recv(), model, sent_credentials, model_listed_outside_realtime)
|
||||
|
||||
|
||||
async def confirm_gateway_session_started(
|
||||
connection: "ClientConnection",
|
||||
model: str,
|
||||
sent_credentials: bool,
|
||||
model_listed_outside_realtime: bool,
|
||||
first_event_wait_seconds: float,
|
||||
) -> Literal[True]:
|
||||
try:
|
||||
first_event: Final = await asyncio.wait_for(connection.recv(), first_event_wait_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
return True
|
||||
return _confirm_first_event(first_event, model, sent_credentials, model_listed_outside_realtime)
|
||||
|
||||
|
||||
class OpenAIRealtime(OpenAIChatCompletion):
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from litellm.llms.anthropic.messages.responses_adapters.transformation import (
|
|||
)
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.anthropic import AnthropicMessagesRequest, AnthropicOutputSchema
|
||||
from litellm.types.llms.openai import ResponsesAPIRequestParams
|
||||
from litellm.types.llms.openai import ResponsesAPIRequestParams, replayable_responses_input
|
||||
from litellm.types.utils import all_litellm_params
|
||||
|
||||
|
||||
|
|
@ -387,10 +387,13 @@ def _forwards(field: str, policy: DropPolicy) -> bool:
|
|||
return field in _COUNT_REQUEST_FIELDS or (not policy.drop_params and field in _INFERENCE_REQUEST_FIELDS)
|
||||
|
||||
|
||||
def _restored(field: str, value: object) -> object:
|
||||
def _restored(field: str, value: object, policy: DropPolicy) -> object:
|
||||
match field, value:
|
||||
case "input", _:
|
||||
return ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(value))
|
||||
return replayable_responses_input(
|
||||
ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(value)),
|
||||
drops_set_created_by=policy.drop_params,
|
||||
)
|
||||
case "previous_response_id", str():
|
||||
return ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(value)
|
||||
case _:
|
||||
|
|
@ -398,5 +401,7 @@ def _restored(field: str, value: object) -> object:
|
|||
|
||||
|
||||
def native_count_request(request: Mapping[str, object], model: str, policy: DropPolicy) -> Mapping[str, object]:
|
||||
forwarded: Final = {field: _restored(field, value) for field, value in request.items() if _forwards(field, policy)}
|
||||
forwarded: Final = {
|
||||
field: _restored(field, value, policy) for field, value in request.items() if _forwards(field, policy)
|
||||
}
|
||||
return {**forwarded, "model": model}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ import copy
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping, MutableSequence, Sequence
|
||||
from collections.abc import Iterable, Mapping, MutableSequence, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import accumulate, chain, repeat
|
||||
from itertools import accumulate, chain, groupby, repeat
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Union, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
|
@ -55,7 +55,9 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import (
|
|||
)
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import (
|
||||
blocked_responses_stream_usage,
|
||||
effective_scan_only_tool_results_for_guardrail,
|
||||
effective_skip_system_message_for_guardrail,
|
||||
effective_skip_tool_message_for_guardrail,
|
||||
list_field,
|
||||
merge_guardrailed_scoped_messages,
|
||||
role_out_of_guardrail_scope,
|
||||
|
|
@ -65,7 +67,10 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
|
|||
stream_item_items,
|
||||
unappliable_request_rewrite,
|
||||
)
|
||||
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
|
||||
from litellm.llms.openai.responses.guardrail_translation.tool_merge import (
|
||||
append_returned_tools,
|
||||
merge_guardrailed_tools,
|
||||
)
|
||||
from litellm.responses.litellm_completion_transformation.compaction_items import (
|
||||
compaction_summary,
|
||||
decode_compaction_item,
|
||||
|
|
@ -74,7 +79,9 @@ from litellm.responses.litellm_completion_transformation.compaction_items import
|
|||
from litellm.responses.litellm_completion_transformation.custom_tools import without_malformed_tool_flags
|
||||
from litellm.responses.litellm_completion_transformation.tool_search import is_server_tool_search_tool
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
TOOL_CALL_OUTPUT_ITEM_TYPES,
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
tool_output_to_chat_content,
|
||||
)
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -270,6 +277,7 @@ _TOOL_CALL_PAYLOAD_EVENT_TYPES: Final = _TOOL_CALL_PAYLOAD_DELTA_EVENT_TYPES | f
|
|||
)
|
||||
_OUTPUT_ITEM_EVENT_TYPES: Final = frozenset({"response.output_item.added", "response.output_item.done"})
|
||||
_OUTPUT_TEXT_EVENT_TYPES: Final = frozenset({"response.output_text.delta", "response.output_text.done"})
|
||||
_TOOL_OUTPUT_TEXT_PART_TYPES: Final = frozenset({"input_text", "output_text", "text"})
|
||||
_PATCHABLE_ITEM_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{"function_call_output": "output", "custom_tool_call_output": "output", "message": "content"}
|
||||
)
|
||||
|
|
@ -519,10 +527,109 @@ def scannable_instructions(data: Mapping[str, object], *, skip_system: bool = Fa
|
|||
|
||||
|
||||
def _input_item_role(item: object) -> str:
|
||||
if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES:
|
||||
return "tool"
|
||||
role: Final = item.get("role") if isinstance(item, Mapping) else None
|
||||
return role.lower() if isinstance(role, str) else ""
|
||||
|
||||
|
||||
def _scanned_text_field(item: Mapping[str, object]) -> str:
|
||||
return "output" if item.get("type") in TOOL_CALL_OUTPUT_ITEM_TYPES else "content"
|
||||
|
||||
|
||||
def _tool_output_part_text(part: object) -> str | None:
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
fields: Final = _item_fields_adapter.validate_python(part)
|
||||
text: Final = fields.get("text")
|
||||
return text if fields.get("type") in _TOOL_OUTPUT_TEXT_PART_TYPES and isinstance(text, str) else None
|
||||
|
||||
|
||||
def tool_output_texts(item: object) -> tuple[tuple[int | None, str], ...]:
|
||||
if not stream_item_field(item, "call_id") or stream_item_field(item, "type") == "computer_call_output":
|
||||
return ()
|
||||
parts: Final = stream_item_items(item, "output")
|
||||
part_texts: Final = tuple(
|
||||
(index, text) for index, part in enumerate(parts) if (text := _tool_output_part_text(part))
|
||||
)
|
||||
if part_texts:
|
||||
return part_texts
|
||||
tool_content: Final = tool_output_to_chat_content(stream_item_field(item, "output"))
|
||||
return ((None, tool_content),) if isinstance(tool_content, str) and tool_content else ()
|
||||
|
||||
|
||||
_Grouped: Final = TypeVar("_Grouped")
|
||||
|
||||
|
||||
def _pair_call_id(pair: tuple[str, object]) -> str:
|
||||
return pair[0]
|
||||
|
||||
|
||||
def _grouped_by_call_id(pairs: Iterable[tuple[str, _Grouped]]) -> Mapping[str, tuple[_Grouped, ...]]:
|
||||
ordered: Final = sorted(pairs, key=_pair_call_id)
|
||||
return MappingProxyType(
|
||||
{call_id: tuple(value for _, value in group) for call_id, group in groupby(ordered, key=_pair_call_id)}
|
||||
)
|
||||
|
||||
|
||||
def _texts_by_tool_row(
|
||||
messages: Sequence[Mapping[str, object]], input_data: "str | ResponseInputParam"
|
||||
) -> Mapping[int, tuple[str, ...]]:
|
||||
if isinstance(input_data, str):
|
||||
return MappingProxyType({})
|
||||
output_texts_by_call_id: Final = _grouped_by_call_id(
|
||||
(str(stream_item_field(item, "call_id")), tuple(text for _, text in tool_output_texts(item)))
|
||||
for item in input_data
|
||||
if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES
|
||||
)
|
||||
row_indices_by_call_id: Final = _grouped_by_call_id(
|
||||
(str(message.get("tool_call_id")), index)
|
||||
for index, message in enumerate(messages)
|
||||
if message.get("role") == "tool"
|
||||
)
|
||||
return MappingProxyType(
|
||||
dict(
|
||||
chain.from_iterable(
|
||||
zip(indices, output_texts_by_call_id[call_id], strict=True)
|
||||
for call_id, indices in row_indices_by_call_id.items()
|
||||
if len(indices) == len(output_texts_by_call_id.get(call_id, ()))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_string_tool_row(message: Mapping[str, object]) -> bool:
|
||||
return message.get("role") == "tool" and isinstance(message.get("content"), str)
|
||||
|
||||
|
||||
def _split_into_text_parts(message: AllMessageValues, texts: tuple[str, ...]) -> AllMessageValues:
|
||||
if not _is_string_tool_row(message) or len(texts) < 2:
|
||||
return message
|
||||
return cast( # cast-ok: a tool row whose content is its text parts as chat text blocks
|
||||
"AllMessageValues", {**message, "content": [{"type": "text", "text": text} for text in texts]}
|
||||
)
|
||||
|
||||
|
||||
def _with_split_rows_restored(
|
||||
rows: Sequence[AllMessageValues], split_rows: Sequence[tuple[AllMessageValues, AllMessageValues]]
|
||||
) -> Sequence[AllMessageValues]:
|
||||
if not isinstance(rows, list):
|
||||
return rows
|
||||
return [next((original for split, original in split_rows if row == split), row) for row in rows]
|
||||
|
||||
|
||||
def _is_textless_tool_row(message: Mapping[str, object], texts: tuple[str, ...] | None) -> bool:
|
||||
return _is_string_tool_row(message) and (not message.get("content") or texts == ())
|
||||
|
||||
|
||||
def _rewritten_whole_text(item: object, field: str, guardrail_response: str) -> object | None:
|
||||
if isinstance(stream_item_field(item, field), str):
|
||||
return guardrail_response
|
||||
if tool_output_texts(item) == ((None, guardrail_response),):
|
||||
return None
|
||||
return guardrail_response
|
||||
|
||||
|
||||
def _patched_request_fields(
|
||||
raw_input: object,
|
||||
instructions: object,
|
||||
|
|
@ -636,17 +743,28 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
|
||||
Handles both string input and list of message objects.
|
||||
"""
|
||||
input_data: Final[str | ResponseInputParam | None] = data.get("input")
|
||||
if not isinstance(input_data, (str, list)):
|
||||
raw_input: Final[str | ResponseInputParam | None] = data.get("input")
|
||||
if not isinstance(raw_input, (str, list)):
|
||||
return data
|
||||
input_data: Final = cast("str | ResponseInputParam", raw_input) # cast-ok: fields read via stream_item_field
|
||||
skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply)
|
||||
skip_tool: Final = effective_skip_tool_message_for_guardrail(guardrail_to_apply)
|
||||
scan_only: Final = effective_scan_only_tool_results_for_guardrail(guardrail_to_apply)
|
||||
structured_messages: Final = self.get_structured_messages(data)
|
||||
scoped_indices: Final = scoped_structured_message_indices(
|
||||
structured_messages or [], scan_only_tool_results=False, skip_system=skip_system, skip_tool=False
|
||||
all_messages: Final = structured_messages or []
|
||||
texts_by_tool_row: Final = _texts_by_tool_row(all_messages, input_data)
|
||||
scoped_indices: Final = tuple(
|
||||
index
|
||||
for index in scoped_structured_message_indices(
|
||||
all_messages, scan_only_tool_results=scan_only, skip_system=skip_system, skip_tool=skip_tool
|
||||
)
|
||||
if not _is_textless_tool_row(all_messages[index], texts_by_tool_row.get(index))
|
||||
)
|
||||
scoped_structured_messages: Final = (
|
||||
[structured_messages[index] for index in scoped_indices] if structured_messages else None
|
||||
split_rows: Final = tuple(
|
||||
(_split_into_text_parts(all_messages[index], texts_by_tool_row.get(index, ())), all_messages[index])
|
||||
for index in scoped_indices
|
||||
)
|
||||
scoped_structured_messages: Final = [split for split, _ in split_rows] if structured_messages else None
|
||||
raw_tools: Final = data.get("tools")
|
||||
original_tools: Final[tuple[Mapping[str, object], ...]] = (
|
||||
tuple(raw_tools) if isinstance(raw_tools, list) else ()
|
||||
|
|
@ -661,7 +779,12 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
)
|
||||
)
|
||||
extracted: Final = self._extract_guardrail_inputs(
|
||||
data, input_data, flattened_tool_groups, skip_system=skip_system
|
||||
data,
|
||||
input_data,
|
||||
() if scan_only else flattened_tool_groups,
|
||||
skip_system=skip_system,
|
||||
skip_tool=skip_tool,
|
||||
scan_only=scan_only,
|
||||
)
|
||||
if not extracted.inputs.get("texts"):
|
||||
return data
|
||||
|
|
@ -674,13 +797,14 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
logging_obj=litellm_logging_obj,
|
||||
)
|
||||
self._apply_guardrailed_tools_to_data(
|
||||
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
|
||||
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools"), scan_only=scan_only
|
||||
)
|
||||
written_back: Final = self._written_back_request_fields(
|
||||
data,
|
||||
structured_messages or (),
|
||||
scoped_indices,
|
||||
scoped_structured_messages,
|
||||
tuple((split, original) for split, original in split_rows if split is not original),
|
||||
guardrail_to_apply,
|
||||
guardrailed_inputs,
|
||||
)
|
||||
|
|
@ -730,8 +854,10 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
|
||||
*,
|
||||
skip_system: bool = False,
|
||||
skip_tool: bool = False,
|
||||
scan_only: bool = False,
|
||||
) -> _ExtractedInputs:
|
||||
instructions: Final = scannable_instructions(data, skip_system=skip_system)
|
||||
instructions: Final = scannable_instructions(data, skip_system=skip_system or scan_only)
|
||||
texts_to_check: Final[list[str]] = [] if instructions is None else [instructions]
|
||||
images_to_check: Final[list[str]] = []
|
||||
task_mappings: Final[list[tuple[int, int | None]]] = []
|
||||
|
|
@ -745,11 +871,15 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
)
|
||||
)
|
||||
if isinstance(input_data, str):
|
||||
texts_to_check.append(input_data)
|
||||
if not scan_only:
|
||||
texts_to_check.append(input_data)
|
||||
else:
|
||||
for msg_idx, message in enumerate(input_data):
|
||||
if role_out_of_guardrail_scope(
|
||||
_input_item_role(message), skip_system_message=skip_system, skip_tool_message=False
|
||||
_input_item_role(message),
|
||||
skip_system_message=skip_system,
|
||||
skip_tool_message=skip_tool,
|
||||
scan_only_tool_results=scan_only,
|
||||
):
|
||||
continue
|
||||
self._extract_input_text_and_images(
|
||||
|
|
@ -775,12 +905,14 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
structured_messages: Sequence[AllMessageValues],
|
||||
scoped_indices: Sequence[int],
|
||||
scoped_structured_messages: Sequence[AllMessageValues] | None,
|
||||
split_rows: Sequence[tuple[AllMessageValues, AllMessageValues]],
|
||||
guardrail_to_apply: "CustomGuardrail",
|
||||
guardrailed_inputs: GenericGuardrailAPIInputs,
|
||||
) -> _RequestFields | None:
|
||||
guardrailed: Final = guardrailed_inputs.get("structured_messages")
|
||||
if guardrailed is None or guardrailed is scoped_structured_messages:
|
||||
returned: Final = guardrailed_inputs.get("structured_messages")
|
||||
if returned is None or returned is scoped_structured_messages:
|
||||
return None
|
||||
guardrailed: Final = _with_split_rows_restored(returned, split_rows)
|
||||
covers_full_request: Final = len(scoped_indices) == len(structured_messages) or (
|
||||
guardrail_to_apply.structured_messages_cover_full_request() and len(guardrailed) == len(structured_messages)
|
||||
)
|
||||
|
|
@ -814,11 +946,14 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
original_tools: Sequence[Mapping[str, object]],
|
||||
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
|
||||
guardrailed_tools: Sequence[ChatCompletionToolParam] | None,
|
||||
*,
|
||||
scan_only: bool,
|
||||
) -> None:
|
||||
if guardrailed_tools is None:
|
||||
return
|
||||
merge: Final = append_returned_tools if scan_only else merge_guardrailed_tools
|
||||
data["tools"] = list( # rebind-ok: in-place request rewrite
|
||||
merge_guardrailed_tools(original_tools, flattened_tool_groups, guardrailed_tools)
|
||||
merge(original_tools, flattened_tool_groups, guardrailed_tools)
|
||||
)
|
||||
|
||||
def _extract_input_text_and_images(
|
||||
|
|
@ -839,6 +974,11 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
texts_to_check.append(summary)
|
||||
task_mappings.append((msg_idx, None))
|
||||
return
|
||||
if message.get("type") in TOOL_CALL_OUTPUT_ITEM_TYPES:
|
||||
for content_idx, text in tool_output_texts(message):
|
||||
texts_to_check.append(text)
|
||||
task_mappings.append((msg_idx, content_idx))
|
||||
return
|
||||
content: Final = message.get("content", None)
|
||||
if content is None:
|
||||
return
|
||||
|
|
@ -890,18 +1030,20 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
)
|
||||
continue
|
||||
|
||||
content = messages[msg_idx].get("content", None)
|
||||
field = _scanned_text_field(messages[msg_idx])
|
||||
content = messages[msg_idx].get(field, None)
|
||||
if content is None:
|
||||
continue
|
||||
|
||||
if isinstance(content, str) and content_idx_optional is None:
|
||||
# Replace string content with guardrail response
|
||||
messages[msg_idx]["content"] = guardrail_response
|
||||
if content_idx_optional is None:
|
||||
rewritten = _rewritten_whole_text(messages[msg_idx], field, guardrail_response)
|
||||
if rewritten is not None:
|
||||
messages[msg_idx][field] = rewritten
|
||||
|
||||
elif isinstance(content, list) and content_idx_optional is not None:
|
||||
elif isinstance(content, list):
|
||||
# Replace specific text item in list content
|
||||
if isinstance(messages[msg_idx]["content"][content_idx_optional], dict):
|
||||
messages[msg_idx]["content"][content_idx_optional]["text"] = guardrail_response
|
||||
if isinstance(messages[msg_idx][field][content_idx_optional], dict):
|
||||
messages[msg_idx][field][content_idx_optional]["text"] = guardrail_response
|
||||
|
||||
async def process_output_response(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Final, TypeAlias
|
|||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import merge_returned_tools_into_request_tools, openai_tool_name
|
||||
from litellm.responses.litellm_completion_transformation.custom_tools import custom_tool_grammar_suffix
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
NAMESPACE_DESCRIPTION_SEPARATOR,
|
||||
|
|
@ -183,3 +184,18 @@ def merge_guardrailed_tools(
|
|||
tuple(tool for key, tool in zip(guardrailed_keys, guardrailed) if key not in owned_keys)
|
||||
)
|
||||
return tuple(chain(merged_originals, appended))
|
||||
|
||||
|
||||
def append_returned_tools(
|
||||
original_tools: Sequence[Tool],
|
||||
flattened_groups: Sequence[Sequence[Tool]],
|
||||
returned_tools: Iterable[object],
|
||||
) -> tuple[Tool, ...]:
|
||||
request_chat_tools: Final = tuple(chain.from_iterable(flattened_groups))
|
||||
additions: Final = merge_returned_tools_into_request_tools(
|
||||
request_tools=request_chat_tools, returned_tools=_validated_tools(returned_tools), tool_name=openai_tool_name
|
||||
)[len(request_chat_tools) :]
|
||||
return (
|
||||
*original_tools,
|
||||
*LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(additions),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import Callable, Collection, Mapping, Sequence
|
||||
from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
|
||||
from functools import lru_cache
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, cast, get_type_hints
|
||||
|
|
@ -10,7 +10,11 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook, targets_openai_api
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
openai_prompt_cache_breakpoint_support,
|
||||
targets_openai_api,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params, process_response_headers
|
||||
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
||||
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
|
||||
|
|
@ -20,6 +24,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
cache_control_as_prompt_cache_breakpoint,
|
||||
drop_non_python_regex_patterns,
|
||||
flatten_combinators_and_drop_non_python_regex_patterns,
|
||||
prompt_cache_breakpoint_for_wire,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
|
|
@ -66,10 +71,41 @@ _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE,
|
|||
_PROVIDERS_REPLAYING_ONLY_THEIR_OWN_REASONING: Final = _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS
|
||||
_ROLES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"system", "developer", "user"})
|
||||
_PART_TYPES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"input_text", "input_image", "input_file"})
|
||||
_INPUT_PART_LIST_KEYS: Final = frozenset({"content", "output"})
|
||||
_COMPACT_TRACKING_PARAMS: Final = frozenset({"metadata", "user"})
|
||||
_COMPACT_REJECTED_PARAMS: Final = frozenset(
|
||||
{
|
||||
"background",
|
||||
"context_management",
|
||||
"max_output_tokens",
|
||||
"max_tool_calls",
|
||||
"moderation",
|
||||
"partial_images",
|
||||
"prompt",
|
||||
"safety_identifier",
|
||||
"store",
|
||||
"stream_options",
|
||||
"temperature",
|
||||
"tool_choice",
|
||||
"top_logprobs",
|
||||
"top_p",
|
||||
"truncation",
|
||||
}
|
||||
)
|
||||
_PROVIDERS_WITH_STRICT_COMPACT_PARAMS: Final = frozenset(
|
||||
{LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI}
|
||||
)
|
||||
_INPUT_ITEM: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
_CONTENT_PARTS: Final[TypeAdapter[Sequence[Mapping[str, object]]]] = TypeAdapter(Sequence[Mapping[str, object]])
|
||||
|
||||
|
||||
class _DeclaredModel(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="ignore", from_attributes=True)
|
||||
|
||||
base_model: object = None
|
||||
model_info: object = None
|
||||
|
||||
|
||||
class _DeploymentHost(BaseModel):
|
||||
"""`responses()` builds the params from raw kwargs, where callers may spell the host `base_url`."""
|
||||
|
||||
|
|
@ -85,7 +121,7 @@ class _DropParamsSetting(BaseModel):
|
|||
drop_params: object = None
|
||||
|
||||
|
||||
def _drops_params(litellm_params: GenericLiteLLMParams) -> bool:
|
||||
def drops_params(litellm_params: GenericLiteLLMParams) -> bool:
|
||||
setting: Final = _DropParamsSetting.model_validate(litellm_params)
|
||||
return litellm.drop_params is True or normalize_drop_params(setting.drop_params) is True
|
||||
|
||||
|
|
@ -193,6 +229,63 @@ def _with_breakpoint_on_last_reading_part(
|
|||
return (*parts[:last], marked_last, *parts[last + 1 :])
|
||||
|
||||
|
||||
def _json_object_or_none(value: object) -> Mapping[str, object] | None:
|
||||
return value if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def _json_array_or_empty(value: object) -> Sequence[object]:
|
||||
return value if isinstance(value, (list, tuple)) else ()
|
||||
|
||||
|
||||
def _item_with_wire_safe_prompt_cache_breakpoints(item: object, keeps_markers: bool) -> object:
|
||||
fields: Final = _json_object_or_none(item)
|
||||
if fields is None or not _carries_prompt_cache_breakpoint(fields):
|
||||
return item
|
||||
wire_safe_fields: Final = {
|
||||
key: _parts_with_wire_safe_prompt_cache_breakpoints(key, value, keeps_markers)
|
||||
for key, value in fields.items()
|
||||
if key != "prompt_cache_breakpoint"
|
||||
}
|
||||
unchanged: Final = "prompt_cache_breakpoint" not in fields and _same_objects(
|
||||
wire_safe_fields.values(), fields.values()
|
||||
)
|
||||
return item if unchanged else wire_safe_fields
|
||||
|
||||
|
||||
def _same_objects(rebuilt: Iterable[object], originals: Iterable[object]) -> bool:
|
||||
return all(new is old for new, old in zip(rebuilt, originals, strict=True))
|
||||
|
||||
|
||||
def _carries_prompt_cache_breakpoint(fields: Mapping[str, object]) -> bool:
|
||||
return "prompt_cache_breakpoint" in fields or any(
|
||||
_parts_carry_prompt_cache_breakpoint(fields.get(key)) for key in _INPUT_PART_LIST_KEYS
|
||||
)
|
||||
|
||||
|
||||
def _parts_carry_prompt_cache_breakpoint(parts: object) -> bool:
|
||||
return any(isinstance(part, Mapping) and "prompt_cache_breakpoint" in part for part in _json_array_or_empty(parts))
|
||||
|
||||
|
||||
def _parts_with_wire_safe_prompt_cache_breakpoints(key: str, value: object, keeps_markers: bool) -> object:
|
||||
if key not in _INPUT_PART_LIST_KEYS or not _parts_carry_prompt_cache_breakpoint(value):
|
||||
return value
|
||||
parts: Final = _json_array_or_empty(value)
|
||||
wire_safe_parts: Final = tuple(_part_with_wire_safe_prompt_cache_breakpoint(part, keeps_markers) for part in parts)
|
||||
return value if _same_objects(wire_safe_parts, parts) else list(wire_safe_parts)
|
||||
|
||||
|
||||
def _part_with_wire_safe_prompt_cache_breakpoint(part: object, keeps_markers: bool) -> object:
|
||||
fields: Final = _json_object_or_none(part)
|
||||
if fields is None or "prompt_cache_breakpoint" not in fields:
|
||||
return part
|
||||
marker: Final = fields["prompt_cache_breakpoint"]
|
||||
wire_marker: Final = prompt_cache_breakpoint_for_wire(marker, drop_params=True) if keeps_markers else None
|
||||
if keeps_markers and wire_marker == marker:
|
||||
return part
|
||||
unmarked: Final = {key: value for key, value in fields.items() if key != "prompt_cache_breakpoint"}
|
||||
return with_prompt_cache_breakpoint(unmarked, wire_marker)
|
||||
|
||||
|
||||
class _ReasoningSupportEntry(LiteLLMBaseModel):
|
||||
litellm_provider: str | None = None
|
||||
supports_reasoning: bool | None = None
|
||||
|
|
@ -518,13 +611,14 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
litellm_params: GenericLiteLLMParams,
|
||||
prompt_cache_options: object = None,
|
||||
) -> tuple[str | ResponseInputParam, Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None]:
|
||||
validated_input: Final = self._validate_input_param(self._drop_bridge_minted_reasoning_items(input))
|
||||
marked_input: Final = (
|
||||
self._input_with_prompt_cache_breakpoints(validated_input)
|
||||
if self._reads_prompt_cache_breakpoints(
|
||||
model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options
|
||||
)
|
||||
else validated_input
|
||||
validated_input: Final = self._validate_input_param(
|
||||
self._drop_bridge_minted_reasoning_items(input), drops_set_created_by=drops_params(litellm_params)
|
||||
)
|
||||
reads_breakpoints: Final = self._reads_prompt_cache_breakpoints(
|
||||
model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options
|
||||
)
|
||||
marker_safe_input: Final = self._input_with_wire_safe_prompt_cache_breakpoints(
|
||||
model=model, input=validated_input, litellm_params=litellm_params, reads_breakpoints=reads_breakpoints
|
||||
)
|
||||
maps_anthropic_tool_search: Final = self._targets_openai_api(litellm_params) and any(
|
||||
is_anthropic_tool_search_chat_tool(tool) for tool in tools or ()
|
||||
|
|
@ -534,20 +628,71 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
if maps_anthropic_tool_search
|
||||
else tools
|
||||
)
|
||||
replayable_input: Final = (
|
||||
input_without_unanswered_anthropic_tool_search_calls(marked_input)
|
||||
if maps_anthropic_tool_search
|
||||
else marked_input
|
||||
)
|
||||
stripped_input, stripped_tools = self.remove_cache_control_flag_from_input_and_tools(
|
||||
model=model, input=replayable_input, tools=mapped_tools
|
||||
model=model,
|
||||
input=self._replayable_input(marker_safe_input, reads_breakpoints, maps_anthropic_tool_search),
|
||||
tools=mapped_tools,
|
||||
)
|
||||
if marker_safe_input is not validated_input:
|
||||
self._strip_caller_input_as_if_unmarked(
|
||||
model=model,
|
||||
caller_input=validated_input,
|
||||
reads_breakpoints=reads_breakpoints,
|
||||
maps_anthropic_tool_search=maps_anthropic_tool_search,
|
||||
)
|
||||
object_schema_tools: Final = self._tools_with_object_parameters(model=model, tools=stripped_tools)
|
||||
sanitized_tools: Final = self._sanitized_tool_schemas_for_openai(
|
||||
model=model, tools=object_schema_tools, litellm_params=litellm_params
|
||||
)
|
||||
return self._drop_foreign_tool_call_item_ids(stripped_input), sanitized_tools
|
||||
|
||||
def _replayable_input(
|
||||
self, input: str | ResponseInputParam, reads_breakpoints: bool, maps_anthropic_tool_search: bool
|
||||
) -> str | ResponseInputParam:
|
||||
marked_input: Final = self._input_with_prompt_cache_breakpoints(input) if reads_breakpoints else input
|
||||
if not maps_anthropic_tool_search:
|
||||
return marked_input
|
||||
return input_without_unanswered_anthropic_tool_search_calls(marked_input)
|
||||
|
||||
def _strip_caller_input_as_if_unmarked(
|
||||
self,
|
||||
model: str,
|
||||
caller_input: str | ResponseInputParam,
|
||||
reads_breakpoints: bool,
|
||||
maps_anthropic_tool_search: bool,
|
||||
) -> None:
|
||||
self.remove_cache_control_flag_from_input_and_tools(
|
||||
model=model, input=self._replayable_input(caller_input, reads_breakpoints, maps_anthropic_tool_search)
|
||||
)
|
||||
|
||||
def _input_with_wire_safe_prompt_cache_breakpoints(
|
||||
self,
|
||||
model: str,
|
||||
input: str | ResponseInputParam,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
reads_breakpoints: bool,
|
||||
) -> str | ResponseInputParam:
|
||||
if not isinstance(input, list) or not drops_params(litellm_params):
|
||||
return input
|
||||
keeps_markers: Final = reads_breakpoints or self._keeps_prompt_cache_breakpoints(
|
||||
model=model, litellm_params=litellm_params
|
||||
)
|
||||
wire_safe_items: Final = tuple(
|
||||
_item_with_wire_safe_prompt_cache_breakpoints(item, keeps_markers) for item in input
|
||||
)
|
||||
if _same_objects(wire_safe_items, input):
|
||||
return input
|
||||
wire_safe_input: Final = list(wire_safe_items)
|
||||
return cast("ResponseInputParam", wire_safe_input) # cast-ok: items keep their shape, minus a rejected marker
|
||||
|
||||
def _keeps_prompt_cache_breakpoints(self, model: str, litellm_params: GenericLiteLLMParams) -> bool:
|
||||
provider: Final = _provider_name(self.custom_llm_provider)
|
||||
base_model: Final = self._declared_base_model(model=model, litellm_params=litellm_params)
|
||||
known_support: Final = (
|
||||
openai_prompt_cache_breakpoint_support(name, provider) for name in dict.fromkeys((base_model, model))
|
||||
)
|
||||
return next((support for support in known_support if support is not None), None) is not False
|
||||
|
||||
def _targets_openai_api(self, litellm_params: GenericLiteLLMParams) -> bool:
|
||||
host: Final = _DeploymentHost.model_validate(litellm_params)
|
||||
return self.custom_llm_provider == LlmProviders.OPENAI and targets_openai_api(host.api_base or host.base_url)
|
||||
|
|
@ -559,7 +704,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
if tools is None or not any(is_anthropic_tool_search_chat_tool(tool) for tool in tools):
|
||||
return tools
|
||||
callers_own_search: Final = any(is_openai_tool_search_tool(tool) for tool in tools)
|
||||
if callers_own_search or not _drops_params(litellm_params) or _runs_tool_search(model, tools):
|
||||
if callers_own_search or not drops_params(litellm_params) or _runs_tool_search(model, tools):
|
||||
return tools_with_anthropic_tool_search_mapped(tools)
|
||||
verbose_logger.warning(
|
||||
"Dropping tool search under drop_params: OpenAI model '%s' cannot run it or no tool is deferred, so every "
|
||||
|
|
@ -663,14 +808,15 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
|
||||
@staticmethod
|
||||
def _without_foreign_tool_call_item_id(item: object) -> object:
|
||||
if not isinstance(item, dict):
|
||||
fields: Final = _json_object_or_none(item)
|
||||
if fields is None:
|
||||
return item
|
||||
item_type: Final = item.get("type")
|
||||
item_id: Final = item.get("id")
|
||||
item_type: Final = fields.get("type")
|
||||
item_id: Final = fields.get("id")
|
||||
genuine_prefix: Final = TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE.get(item_type) if isinstance(item_type, str) else None
|
||||
if genuine_prefix is None or not isinstance(item_id, str) or item_id.startswith(genuine_prefix):
|
||||
return item
|
||||
return {key: value for key, value in item.items() if key != "id"}
|
||||
return {key: value for key, value in fields.items() if key != "id"}
|
||||
|
||||
def _sanitized_tool_schemas_for_openai(
|
||||
self,
|
||||
|
|
@ -714,6 +860,13 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
base_model: Final[object] = model_info.get("base_model") if isinstance(model_info, dict) else None
|
||||
return base_model if isinstance(base_model, str) and base_model else model
|
||||
|
||||
@staticmethod
|
||||
def _declared_base_model(model: str, litellm_params: GenericLiteLLMParams) -> str:
|
||||
declared: Final = _DeclaredModel.model_validate(litellm_params)
|
||||
model_info: Final = _json_object_or_none(declared.model_info)
|
||||
candidates: Final = (declared.base_model, None if model_info is None else model_info.get("base_model"))
|
||||
return next((name for name in candidates if isinstance(name, str) and name), model)
|
||||
|
||||
@staticmethod
|
||||
def _sanitized_tool_entry(
|
||||
entry: Mapping[str, object],
|
||||
|
|
@ -752,7 +905,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
]
|
||||
return tools if all(new is old for new, old in zip(sanitized, tools, strict=True)) else sanitized
|
||||
|
||||
def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam:
|
||||
def _validate_input_param(
|
||||
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
|
||||
) -> str | ResponseInputParam:
|
||||
"""
|
||||
Ensure all input fields if pydantic are converted to dict
|
||||
|
||||
|
|
@ -764,18 +919,20 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
for item in input:
|
||||
# if it's pydantic, convert to dict
|
||||
if isinstance(item, BaseModel):
|
||||
validated_input.append(item.model_dump(exclude_none=True))
|
||||
validated_input.append(
|
||||
replayable_responses_item(
|
||||
item.model_dump(exclude_none=True), drops_set_created_by=drops_set_created_by
|
||||
)
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
# Handle reasoning items specifically to filter out status=None
|
||||
if item.get("type") == "reasoning":
|
||||
verbose_logger.debug("Handling reasoning item: %s", item)
|
||||
# Type assertion since we know it's a dict at this point
|
||||
dict_item = cast(dict[str, object], item)
|
||||
filtered_item = self._handle_reasoning_item(dict_item)
|
||||
else:
|
||||
# For other dict items, just pass through
|
||||
filtered_item = cast(dict[str, object], item)
|
||||
validated_input.append(filtered_item)
|
||||
validated_input.append(
|
||||
replayable_responses_item(
|
||||
self._handle_reasoning_item(cast(dict[str, object], item))
|
||||
if item.get("type") == "reasoning"
|
||||
else item,
|
||||
drops_set_created_by=drops_set_created_by,
|
||||
)
|
||||
)
|
||||
else:
|
||||
validated_input.append(item)
|
||||
return validated_input
|
||||
|
|
@ -1065,7 +1222,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> tuple[str, dict]:
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""
|
||||
Transform the get response API request into a URL and data
|
||||
|
||||
|
|
@ -1074,7 +1231,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
"""
|
||||
encoded_response_id: Final = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url: Final = f"{api_base}/{encoded_response_id}"
|
||||
data: Final[dict] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
return url, data
|
||||
|
||||
def transform_get_response_api_response(
|
||||
|
|
@ -1182,6 +1339,17 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
#########################################################
|
||||
########## COMPACT RESPONSE API TRANSFORMATION ##########
|
||||
#########################################################
|
||||
def compact_url(self, api_base: str) -> str:
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
return str(parsed_url.copy_with(path=parsed_url.path.rstrip("/") + "/compact"))
|
||||
|
||||
def compact_dropped_params(self, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
|
||||
if self.custom_llm_provider not in _PROVIDERS_WITH_STRICT_COMPACT_PARAMS:
|
||||
return frozenset()
|
||||
if drops_params(litellm_params):
|
||||
return _COMPACT_TRACKING_PARAMS | _COMPACT_REJECTED_PARAMS
|
||||
return _COMPACT_TRACKING_PARAMS
|
||||
|
||||
def transform_compact_response_api_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -1197,10 +1365,8 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
OpenAI API expects the following request
|
||||
- POST /v1/responses/compact
|
||||
"""
|
||||
# Preserve query params (e.g., api-version) while appending /compact.
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
compact_path: Final = parsed_url.path.rstrip("/") + "/compact"
|
||||
url: Final = str(parsed_url.copy_with(path=compact_path))
|
||||
url: Final = self.compact_url(api_base)
|
||||
dropped_params: Final = self.compact_dropped_params(litellm_params)
|
||||
|
||||
request_params: Final = cast( # cast-ok: the base signature leaves the params bag untyped
|
||||
"Mapping[str, object]", response_api_optional_request_params
|
||||
|
|
@ -1214,14 +1380,11 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
)
|
||||
if sanitized_tools is not None:
|
||||
response_api_optional_request_params["tools"] = sanitized_tools
|
||||
data: Final = cast( # cast-ok: typed dict spread is inferred as Unknown by basedpyright
|
||||
dict[str, object],
|
||||
{
|
||||
"model": model,
|
||||
"input": replay_safe_input,
|
||||
**response_api_optional_request_params,
|
||||
},
|
||||
)
|
||||
data: Final = {
|
||||
"model": model,
|
||||
"input": replay_safe_input,
|
||||
**{key: value for key, value in request_params.items() if key not in dropped_params},
|
||||
}
|
||||
|
||||
return url, data
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ def create_config_class(provider: SimpleProviderConfig):
|
|||
else:
|
||||
return super()._transform_messages(messages=messages, model=model, is_async=False)
|
||||
|
||||
def _should_preserve_cache_control_for_endpoint(
|
||||
self,
|
||||
custom_llm_provider: str | None,
|
||||
api_base: str | None,
|
||||
) -> bool:
|
||||
return provider.special_handling.get(
|
||||
"preserve_cache_control", False
|
||||
) or super()._should_preserve_cache_control_for_endpoint(custom_llm_provider, api_base)
|
||||
|
||||
def _get_openai_compatible_provider_info(
|
||||
self, api_base: str | None, api_key: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
|
|
@ -177,7 +186,7 @@ def create_config_class(provider: SimpleProviderConfig):
|
|||
return JSONProviderConfig
|
||||
|
||||
|
||||
_responses_config_cache: Final[dict] = {}
|
||||
_responses_config_cache: Final[dict[str, "type[OpenAILikeResponsesConfig]"]] = {}
|
||||
|
||||
|
||||
def create_responses_config_class(provider: SimpleProviderConfig) -> "type[OpenAILikeResponsesConfig]":
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ JSON-based provider configuration loader for OpenAI-compatible providers.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm.types.utils import ProviderSpecificModelInfo
|
||||
|
||||
_PROVIDER_INFO_ADAPTER: Final = TypeAdapter(ProviderSpecificModelInfo)
|
||||
_SPECIAL_HANDLING_ADAPTER: Final = TypeAdapter(Mapping[str, bool])
|
||||
|
||||
|
||||
class SimpleProviderConfig:
|
||||
|
|
@ -20,12 +22,14 @@ class SimpleProviderConfig:
|
|||
def __init__(self, slug: str, data: dict):
|
||||
self.slug = slug
|
||||
self.base_url = data["base_url"]
|
||||
self.api_key_env = data["api_key_env"]
|
||||
self.api_base_env = data.get("api_base_env")
|
||||
self.api_key_env: str = data["api_key_env"]
|
||||
self.api_base_env: str | None = data.get("api_base_env")
|
||||
self.base_class = data.get("base_class", "openai_gpt")
|
||||
self.param_mappings = data.get("param_mappings", {})
|
||||
self.constraints = data.get("constraints", {})
|
||||
self.special_handling = data.get("special_handling", {})
|
||||
self.special_handling: Mapping[str, bool] = _SPECIAL_HANDLING_ADAPTER.validate_python(
|
||||
data.get("special_handling", {})
|
||||
)
|
||||
self.supported_endpoints = data.get("supported_endpoints", [])
|
||||
self.provider_info: ProviderSpecificModelInfo = (
|
||||
_PROVIDER_INFO_ADAPTER.validate_python(data["provider_info"]) if "provider_info" in data else {}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
def is_native_messages_route(self) -> bool:
|
||||
return False
|
||||
|
||||
def hoists_wrapped_defer_loading(self, model: str) -> bool:
|
||||
return False
|
||||
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
|
|
|
|||
|
|
@ -269,5 +269,21 @@
|
|||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
]
|
||||
},
|
||||
"streamlake": {
|
||||
"base_url": "https://vanchin.streamlake.ai/api/gateway/v1/endpoints",
|
||||
"api_key_env": "STREAMLAKE_API_KEY",
|
||||
"api_base_env": "STREAMLAKE_API_BASE",
|
||||
"param_mappings": {
|
||||
"max_tokens": "max_completion_tokens"
|
||||
},
|
||||
"provider_info": {
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"special_handling": {
|
||||
"preserve_cache_control": true
|
||||
},
|
||||
"supported_endpoints": ["/v1/chat/completions"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from pydantic import TypeAdapter
|
|||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig, drops_params
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import ResponseInputParam, ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
|
@ -84,7 +84,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
|
|||
"""Handle preset/ model prefix: send as {"preset": name} instead of {"model": name}."""
|
||||
input = self._ensure_message_type(input)
|
||||
if model.startswith("preset/"):
|
||||
input = self._validate_input_param(input)
|
||||
input = self._validate_input_param(input, drops_set_created_by=drops_params(litellm_params))
|
||||
data: Final[dict] = {
|
||||
"preset": model[len("preset/") :],
|
||||
"input": input,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -8,7 +9,6 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.caching.caching import Cache, LiteLLMCacheType
|
||||
from litellm.constants import MINIMUM_PROMPT_CACHE_TOKEN_COUNT
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
|
|
@ -16,10 +16,11 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.openai.openai import AllMessageValues
|
||||
from litellm.utils import is_prompt_caching_valid_prompt
|
||||
from litellm.types.llms.openai import ChatCompletionToolParam
|
||||
from litellm.utils import get_prompt_cache_min_tokens, is_prompt_caching_valid_prompt
|
||||
|
||||
from ..common_utils import VertexAIError, get_vertex_base_url
|
||||
from ..vertex_llm_base import VertexBase
|
||||
from ..vertex_llm_base import VertexBase, publisher_model_location
|
||||
from .known_cached_contents import KnownCachedContents, known_cached_contents
|
||||
from .transformation import (
|
||||
cached_messages_end_on_supported_turn,
|
||||
|
|
@ -65,6 +66,19 @@ class _CreatedCachedContentRoot(RootModel[_CreatedCachedContent]):
|
|||
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
|
||||
|
||||
|
||||
_TOOL_PARAMS: Final = ("tools", "tool_choice")
|
||||
|
||||
|
||||
def _sync_cache_client(timeout: float | httpx.Timeout | None) -> HTTPHandler:
|
||||
if timeout is None:
|
||||
return HTTPHandler()
|
||||
return HTTPHandler(timeout=httpx.Timeout(timeout) if isinstance(timeout, (float, int)) else timeout)
|
||||
|
||||
|
||||
class _CachedContentsNotServedError(VertexAIError):
|
||||
pass
|
||||
|
||||
|
||||
def _record_cache_creation_tokens(logging_obj: Logging, created: _CreatedCachedContent) -> None:
|
||||
tokens: Final = (created.get("usageMetadata") or {}).get("totalTokenCount") or 0
|
||||
logging_obj.vertex_ai_cache_creation_input_tokens = max(tokens, 0) # rebind-ok: set provider count
|
||||
|
|
@ -78,6 +92,61 @@ def _wait_seconds(timeout: float | httpx.Timeout | None) -> float | None:
|
|||
return timeout
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CacheTarget:
|
||||
model: str
|
||||
project: str | None
|
||||
location: str | None
|
||||
|
||||
|
||||
def _cache_target(
|
||||
custom_llm_provider: Literal["vertex_ai", "vertex_ai_beta", "gemini"],
|
||||
api_base: str | None,
|
||||
model: str,
|
||||
vertex_project: str | None,
|
||||
vertex_location: str | None,
|
||||
) -> _CacheTarget:
|
||||
model_location: Final = (
|
||||
None if custom_llm_provider == "gemini" or api_base is None else publisher_model_location(api_base)
|
||||
)
|
||||
if model_location is None:
|
||||
return _CacheTarget(model=model, project=vertex_project, location=vertex_location)
|
||||
return _CacheTarget(model=model_location.model, project=model_location.project, location=model_location.location)
|
||||
|
||||
|
||||
def _prompt_cache_min_tokens(models: Sequence[str], custom_llm_provider: str) -> int:
|
||||
return max(
|
||||
get_prompt_cache_min_tokens(
|
||||
model if model.startswith(custom_llm_provider) else f"{custom_llm_provider}/{model}"
|
||||
)
|
||||
for model in models
|
||||
)
|
||||
|
||||
|
||||
def _context_caching_skip_reason(
|
||||
cached_messages: Sequence[AllMessageValues],
|
||||
tools: Sequence[ChatCompletionToolParam] | None,
|
||||
deployment_model: str,
|
||||
cache_model: str,
|
||||
custom_llm_provider: Literal["vertex_ai", "vertex_ai_beta", "gemini"],
|
||||
) -> str | None:
|
||||
if not cached_messages_end_on_supported_turn(cached_messages):
|
||||
return (
|
||||
"Vertex AI context caching: cached message block ends on a model turn once "
|
||||
"system messages are extracted, which the cachedContents API rejects."
|
||||
)
|
||||
min_tokens: Final = _prompt_cache_min_tokens((deployment_model, cache_model), custom_llm_provider)
|
||||
if not is_prompt_caching_valid_prompt(
|
||||
model=cache_model,
|
||||
messages=list(cached_messages),
|
||||
tools=None if tools is None else list(tools),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
min_token_count=min_tokens,
|
||||
):
|
||||
return f"Vertex AI context caching: cached content is below minimum token count ({min_tokens})."
|
||||
return None
|
||||
|
||||
|
||||
class ContextCachingEndpoints(VertexBase):
|
||||
"""
|
||||
Covers context caching endpoints for Vertex AI + Google AI Studio
|
||||
|
|
@ -135,6 +204,7 @@ class ContextCachingEndpoints(VertexBase):
|
|||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_api_version=("v1beta1" if custom_llm_provider == "vertex_ai_beta" else "v1"),
|
||||
collection_endpoint=True,
|
||||
)
|
||||
|
||||
def check_cache(
|
||||
|
|
@ -206,6 +276,8 @@ class ContextCachingEndpoints(VertexBase):
|
|||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return None
|
||||
if e.response.status_code == 404 and custom_llm_provider == "gemini":
|
||||
raise _CachedContentsNotServedError(status_code=404, message=e.response.text)
|
||||
raise VertexAIError(status_code=e.response.status_code, message=e.response.text)
|
||||
except Exception as e:
|
||||
raise VertexAIError(status_code=500, message=str(e))
|
||||
|
|
@ -305,6 +377,8 @@ class ContextCachingEndpoints(VertexBase):
|
|||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return None
|
||||
if e.response.status_code == 404 and custom_llm_provider == "gemini":
|
||||
raise _CachedContentsNotServedError(status_code=404, message=e.response.text)
|
||||
raise VertexAIError(status_code=e.response.status_code, message=e.response.text)
|
||||
except Exception as e:
|
||||
raise VertexAIError(status_code=500, message=str(e))
|
||||
|
|
@ -370,41 +444,25 @@ class ContextCachingEndpoints(VertexBase):
|
|||
if len(cached_messages) == 0:
|
||||
return messages, optional_params, None
|
||||
|
||||
if not cached_messages_end_on_supported_turn(cached_messages):
|
||||
verbose_logger.debug(
|
||||
"Vertex AI context caching: cached message block ends on a model turn once "
|
||||
"system messages are extracted, which the cachedContents API rejects. "
|
||||
"Skipping context caching."
|
||||
)
|
||||
target: Final = _cache_target(custom_llm_provider, api_base, model, vertex_project, vertex_location)
|
||||
tools: Final = optional_params.get("tools")
|
||||
tool_choice: Final = optional_params.get("tool_choice")
|
||||
skip_reason: Final = _context_caching_skip_reason(
|
||||
cached_messages, tools, model, target.model, custom_llm_provider
|
||||
)
|
||||
if skip_reason is not None:
|
||||
verbose_logger.debug("%s Skipping context caching.", skip_reason)
|
||||
return messages, optional_params, None
|
||||
|
||||
# Gemini requires a minimum of 1024 tokens for context caching.
|
||||
# Skip caching if the cached content is too small to avoid API errors.
|
||||
if not is_prompt_caching_valid_prompt(
|
||||
model=model,
|
||||
messages=cached_messages,
|
||||
tools=optional_params.get("tools"),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
):
|
||||
verbose_logger.debug(
|
||||
"Vertex AI context caching: cached content is below minimum token "
|
||||
"count (%d). Skipping context caching.",
|
||||
MINIMUM_PROMPT_CACHE_TOKEN_COUNT,
|
||||
)
|
||||
return messages, optional_params, None
|
||||
|
||||
tools: Final = optional_params.pop("tools", None)
|
||||
tool_choice: Final = optional_params.pop("tool_choice", None)
|
||||
|
||||
## AUTHORIZATION ##
|
||||
token, url = self._get_token_and_url_context_caching(
|
||||
gemini_api_key=api_key,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
vertex_auth_header=vertex_auth_header,
|
||||
model=model,
|
||||
model=target.model,
|
||||
)
|
||||
|
||||
headers: Final = {
|
||||
|
|
@ -417,44 +475,42 @@ class ContextCachingEndpoints(VertexBase):
|
|||
if extra_headers is not None:
|
||||
headers.update(extra_headers)
|
||||
|
||||
if client is None or not isinstance(client, HTTPHandler):
|
||||
_params: Final = {}
|
||||
if timeout is not None:
|
||||
if isinstance(timeout, float) or isinstance(timeout, int):
|
||||
timeout = httpx.Timeout(timeout)
|
||||
_params["timeout"] = timeout
|
||||
client = HTTPHandler(**_params)
|
||||
else:
|
||||
client = client
|
||||
http_client: Final = client if isinstance(client, HTTPHandler) else _sync_cache_client(timeout)
|
||||
|
||||
## CHECK IF CACHED ALREADY
|
||||
generated_cache_key: Final = local_cache_obj.get_cache_key(
|
||||
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=model
|
||||
)
|
||||
google_cache_name: Final = self.check_cache(
|
||||
cache_key=generated_cache_key,
|
||||
client=client,
|
||||
headers=headers,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_auth_header=vertex_auth_header,
|
||||
model=model,
|
||||
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=target.model
|
||||
)
|
||||
try:
|
||||
google_cache_name: Final = self.check_cache(
|
||||
cache_key=generated_cache_key,
|
||||
client=http_client,
|
||||
headers=headers,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
vertex_auth_header=vertex_auth_header,
|
||||
model=target.model,
|
||||
)
|
||||
except _CachedContentsNotServedError:
|
||||
verbose_logger.debug("Gemini context caching: %s answers 404. Skipping context caching.", url)
|
||||
return messages, optional_params, None
|
||||
for tool_param in _TOOL_PARAMS:
|
||||
optional_params.pop(tool_param, None)
|
||||
if google_cache_name:
|
||||
return non_cached_messages, optional_params, google_cache_name
|
||||
|
||||
## TRANSFORM REQUEST
|
||||
cached_content_request_body: Final = transform_openai_messages_to_gemini_context_caching(
|
||||
model=model,
|
||||
model=target.model,
|
||||
messages=cached_messages,
|
||||
cache_key=generated_cache_key,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
)
|
||||
|
||||
cached_content_request_body["tools"] = tools
|
||||
|
|
@ -473,7 +529,7 @@ class ContextCachingEndpoints(VertexBase):
|
|||
)
|
||||
|
||||
try:
|
||||
response: Final = client.post(
|
||||
response: Final = http_client.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=cached_content_request_body,
|
||||
|
|
@ -489,7 +545,7 @@ class ContextCachingEndpoints(VertexBase):
|
|||
_record_cache_creation_tokens(logging_obj, raw_response_cached)
|
||||
self._known_caches.remember(
|
||||
KnownCachedContents.scope(
|
||||
url, custom_llm_provider, headers, vertex_project=vertex_project, vertex_location=vertex_location
|
||||
url, custom_llm_provider, headers, vertex_project=target.project, vertex_location=target.location
|
||||
),
|
||||
generated_cache_key,
|
||||
raw_response_cached.get("name"),
|
||||
|
|
@ -536,41 +592,25 @@ class ContextCachingEndpoints(VertexBase):
|
|||
if len(cached_messages) == 0:
|
||||
return messages, optional_params, None
|
||||
|
||||
if not cached_messages_end_on_supported_turn(cached_messages):
|
||||
verbose_logger.debug(
|
||||
"Vertex AI context caching: cached message block ends on a model turn once "
|
||||
"system messages are extracted, which the cachedContents API rejects. "
|
||||
"Skipping context caching."
|
||||
)
|
||||
target: Final = _cache_target(custom_llm_provider, api_base, model, vertex_project, vertex_location)
|
||||
tools: Final = optional_params.get("tools")
|
||||
tool_choice: Final = optional_params.get("tool_choice")
|
||||
skip_reason: Final = _context_caching_skip_reason(
|
||||
cached_messages, tools, model, target.model, custom_llm_provider
|
||||
)
|
||||
if skip_reason is not None:
|
||||
verbose_logger.debug("%s Skipping context caching.", skip_reason)
|
||||
return messages, optional_params, None
|
||||
|
||||
# Gemini requires a minimum of 1024 tokens for context caching.
|
||||
# Skip caching if the cached content is too small to avoid API errors.
|
||||
if not is_prompt_caching_valid_prompt(
|
||||
model=model,
|
||||
messages=cached_messages,
|
||||
tools=optional_params.get("tools"),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
):
|
||||
verbose_logger.debug(
|
||||
"Vertex AI context caching: cached content is below minimum token "
|
||||
"count (%d). Skipping context caching.",
|
||||
MINIMUM_PROMPT_CACHE_TOKEN_COUNT,
|
||||
)
|
||||
return messages, optional_params, None
|
||||
|
||||
tools: Final = optional_params.pop("tools", None)
|
||||
tool_choice: Final = optional_params.pop("tool_choice", None)
|
||||
|
||||
## AUTHORIZATION ##
|
||||
token, url = self._get_token_and_url_context_caching(
|
||||
gemini_api_key=api_key,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
vertex_auth_header=vertex_auth_header,
|
||||
model=model,
|
||||
model=target.model,
|
||||
)
|
||||
|
||||
headers: Final = {
|
||||
|
|
@ -590,10 +630,10 @@ class ContextCachingEndpoints(VertexBase):
|
|||
|
||||
## CHECK IF CACHED ALREADY
|
||||
generated_cache_key: Final = local_cache_obj.get_cache_key(
|
||||
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=model
|
||||
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=target.model
|
||||
)
|
||||
scope: Final = KnownCachedContents.scope(
|
||||
url, custom_llm_provider, headers, vertex_project=vertex_project, vertex_location=vertex_location
|
||||
url, custom_llm_provider, headers, vertex_project=target.project, vertex_location=target.location
|
||||
)
|
||||
|
||||
async def lookup_or_create() -> str | None:
|
||||
|
|
@ -605,10 +645,10 @@ class ContextCachingEndpoints(VertexBase):
|
|||
api_base=api_base,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
vertex_auth_header=vertex_auth_header,
|
||||
model=model,
|
||||
model=target.model,
|
||||
)
|
||||
|
||||
if google_cache_name:
|
||||
|
|
@ -616,12 +656,12 @@ class ContextCachingEndpoints(VertexBase):
|
|||
|
||||
## TRANSFORM REQUEST
|
||||
cached_content_request_body: Final = transform_openai_messages_to_gemini_context_caching(
|
||||
model=model,
|
||||
model=target.model,
|
||||
messages=cached_messages,
|
||||
cache_key=generated_cache_key,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_project=target.project,
|
||||
vertex_location=target.location,
|
||||
)
|
||||
|
||||
cached_content_request_body["tools"] = tools
|
||||
|
|
@ -659,9 +699,15 @@ class ContextCachingEndpoints(VertexBase):
|
|||
)
|
||||
return raw_response_cached.get("name")
|
||||
|
||||
cache_name: Final = await self._known_caches.single_flight(
|
||||
scope, generated_cache_key, lookup_or_create, _wait_seconds(timeout)
|
||||
)
|
||||
try:
|
||||
cache_name: Final = await self._known_caches.single_flight(
|
||||
scope, generated_cache_key, lookup_or_create, _wait_seconds(timeout)
|
||||
)
|
||||
except _CachedContentsNotServedError:
|
||||
verbose_logger.debug("Gemini context caching: %s answers 404. Skipping context caching.", url)
|
||||
return messages, optional_params, None
|
||||
for tool_param in _TOOL_PARAMS:
|
||||
optional_params.pop(tool_param, None)
|
||||
return non_cached_messages, optional_params, cache_name
|
||||
|
||||
def get_cache(self):
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ Unlike Gemini models which use Google's token counting API, partner models use
|
|||
their respective publisher-specific count-tokens endpoints.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.anthropic.common_utils import OBJECT_LIST_ADAPTER, is_claude_model_target, tools_without_custom_field
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
|
|
@ -20,6 +23,22 @@ _COUNT_TOKENS_PUBLISHERS: Final = (
|
|||
)
|
||||
|
||||
|
||||
def count_tokens_request_body(
|
||||
request_data: Mapping[str, object], model: str, drop_params: object
|
||||
) -> Mapping[str, object]:
|
||||
tools: Final = request_data.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
return request_data
|
||||
return {
|
||||
**request_data,
|
||||
"tools": tools_without_custom_field(
|
||||
OBJECT_LIST_ADAPTER.validate_python(tools),
|
||||
hoist_defer_loading=is_claude_model_target("vertex_ai", model),
|
||||
drop_custom=drops_unsupported_params(drop_params),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def count_tokens_publisher(model: str) -> str | None:
|
||||
return next((publisher for marker, publisher in _COUNT_TOKENS_PUBLISHERS if marker in model), None)
|
||||
|
||||
|
|
@ -169,7 +188,7 @@ class VertexAIPartnerModelsTokenCounter(VertexBase):
|
|||
response: Final = await async_client.post(
|
||||
endpoint_url,
|
||||
headers=headers,
|
||||
json=request_data,
|
||||
json=dict(count_tokens_request_body(request_data, model, litellm_params.get("drop_params"))),
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ Handles Authentication and generating request urls for Vertex AI and Google AI S
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -46,6 +48,31 @@ def graft_default_vertex_path(api_base: str, default_url: str) -> str:
|
|||
|
||||
_graft_default_vertex_path = graft_default_vertex_path
|
||||
|
||||
|
||||
_PUBLISHER_MODEL_LOCATION: Final = re.compile(r".*/projects/[^/]+/locations/[^/]+(?=/publishers/[^/]+/models/[^/]+/?$)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublisherModelLocation:
|
||||
parent_url: str
|
||||
project: str
|
||||
location: str
|
||||
model: str
|
||||
|
||||
|
||||
def publisher_model_location(api_base: str) -> PublisherModelLocation | None:
|
||||
match: Final = _PUBLISHER_MODEL_LOCATION.match(api_base)
|
||||
if match is None:
|
||||
return None
|
||||
_, _, project, _, location = match.group().rsplit("/", 4)
|
||||
return PublisherModelLocation(
|
||||
parent_url=match.group(),
|
||||
project=project,
|
||||
location=location,
|
||||
model=api_base.rstrip("/").rsplit("/", 1)[-1],
|
||||
)
|
||||
|
||||
|
||||
GOOGLE_IMPORT_ERROR_MESSAGE: Final = (
|
||||
"Google Cloud SDK not found. Install it with: pip install 'litellm[google]' or pip install google-cloud-aiplatform"
|
||||
)
|
||||
|
|
@ -637,15 +664,18 @@ class VertexBase:
|
|||
vertex_location: str | None = None,
|
||||
vertex_api_version: Literal["v1", "v1beta1"] | None = None,
|
||||
use_psc_endpoint_format: bool = False,
|
||||
collection_endpoint: bool = False,
|
||||
) -> tuple[str | None, str]:
|
||||
"""
|
||||
for cloudflare ai gateway - https://github.com/BerriAI/litellm/issues/4317
|
||||
|
||||
Handles custom api_base for:
|
||||
1. Gemini (Google AI Studio) - constructs /models/{model}:{endpoint}
|
||||
1. Gemini (Google AI Studio) - constructs /models/{model}:{endpoint}, or {api_base}/{endpoint}
|
||||
for a collection endpoint
|
||||
2. Vertex AI with standard proxies - grafts the default vertex URL path onto the
|
||||
api_base when its path is empty or only an API version (/v1, /v1beta1);
|
||||
otherwise constructs {api_base}:{endpoint}
|
||||
otherwise constructs {api_base}:{endpoint}, or, for a collection endpoint under an api_base
|
||||
naming a publisher model, the collection under that model's projects/{project}/locations/{location}
|
||||
3. Vertex AI with PSC endpoints - constructs full path structure
|
||||
{api_base}/v1/projects/{project}/locations/{location}/endpoints/{model}:{endpoint}
|
||||
(only when use_psc_endpoint_format=True)
|
||||
|
|
@ -653,22 +683,22 @@ class VertexBase:
|
|||
Args:
|
||||
use_psc_endpoint_format: If True, constructs PSC endpoint URL format.
|
||||
If False (default), uses api_base as-is and appends :{endpoint}
|
||||
collection_endpoint: True when `endpoint` names a collection resource such as
|
||||
cachedContents rather than a model action such as generateContent
|
||||
|
||||
## Returns
|
||||
- (auth_header, url) - Tuple[Optional[str], str]
|
||||
"""
|
||||
if api_base:
|
||||
if custom_llm_provider == "gemini":
|
||||
# For Gemini (Google AI Studio), construct the full path like other providers
|
||||
if model is None:
|
||||
if model is None and not collection_endpoint:
|
||||
raise ValueError("Model parameter is required for Gemini custom API base URLs")
|
||||
url = join_api_base(api_base, f"models/{model}:{endpoint}")
|
||||
url = join_api_base(api_base, endpoint if collection_endpoint else f"models/{model}:{endpoint}")
|
||||
if gemini_api_key is None:
|
||||
raise ValueError(
|
||||
"Missing Gemini API key. Set the GEMINI_API_KEY or GOOGLE_API_KEY environment variable."
|
||||
)
|
||||
if gemini_api_key is not None:
|
||||
auth_header = {"x-goog-api-key": gemini_api_key}
|
||||
auth_header = {"x-goog-api-key": gemini_api_key}
|
||||
else:
|
||||
# For Vertex AI
|
||||
if use_psc_endpoint_format:
|
||||
|
|
@ -692,7 +722,12 @@ class VertexBase:
|
|||
elif urlparse(api_base).path.rstrip("/") in ("/v1", "/v1beta1") and "/projects/" in urlparse(url).path:
|
||||
url = graft_default_vertex_path(api_base=api_base, default_url=url)
|
||||
else:
|
||||
url = f"{api_base.rstrip('/')}:{endpoint}"
|
||||
model_location: Final = publisher_model_location(api_base) if collection_endpoint else None
|
||||
url = (
|
||||
f"{api_base.rstrip('/')}:{endpoint}"
|
||||
if model_location is None
|
||||
else join_api_base(model_location.parent_url, endpoint)
|
||||
)
|
||||
if stream is True:
|
||||
parsed_stream_url: Final = urlparse(url)
|
||||
stream_query: Final = f"{parsed_stream_url.query}&alt=sse" if parsed_stream_url.query else "alt=sse"
|
||||
|
|
|
|||
|
|
@ -313,10 +313,10 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> tuple[str, dict]:
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
encoded_response_id: Final = encode_url_path_segment(response_id, field_name="response_id")
|
||||
url: Final = f"{api_base}/{encoded_response_id}"
|
||||
data: Final[dict] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
return url, data
|
||||
|
||||
def transform_get_response_api_response(
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ from .litellm_core_utils.streaming_chunk_builder_utils import (
|
|||
ChunkProcessor as ChunkProcessor, # noqa: PLC0414 # public re-export
|
||||
)
|
||||
from .llms.anthropic.chat import AnthropicChatCompletion
|
||||
from .llms.anthropic.common_utils import is_claude_tool_target
|
||||
from .llms.azure.audio_transcriptions import AzureAudioTranscription
|
||||
from .llms.azure.azure import AzureChatCompletion, check_dynamic_azure_params
|
||||
from .llms.azure.chat.o_series_handler import AzureOpenAIO1ChatCompletion
|
||||
|
|
@ -1160,17 +1161,6 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o
|
|||
_ANTHROPIC_ONLY_TOOL_KEYS: Final = frozenset({"input_examples", "eager_input_streaming"})
|
||||
|
||||
|
||||
def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
|
||||
if custom_llm_provider == "anthropic":
|
||||
return True
|
||||
model_lower: Final = model.lower()
|
||||
if custom_llm_provider == "bedrock":
|
||||
return "claude" in model_lower or ("arn:" in model_lower and ":bedrock:" in model_lower)
|
||||
if custom_llm_provider == "azure_ai" or custom_llm_provider == "vertex_ai":
|
||||
return "claude" in model_lower
|
||||
return False
|
||||
|
||||
|
||||
def _history_without_unanswered_tool_search_calls(
|
||||
messages: Sequence[object], custom_llm_provider: str | None, provider_config: BaseConfig
|
||||
) -> tuple[object, ...] | None:
|
||||
|
|
@ -2697,10 +2687,13 @@ def _complete_custom_openai(
|
|||
|
||||
# for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
|
||||
api_base = _resolve_openai_api_base(api_base)
|
||||
if custom_llm_provider == "bigmodel" and not api_key:
|
||||
raise litellm.AuthenticationError(
|
||||
message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model
|
||||
)
|
||||
organization = (
|
||||
organization
|
||||
or litellm.organization
|
||||
or get_secret("OPENAI_ORGANIZATION")
|
||||
or (None if custom_llm_provider == "bigmodel" else litellm.organization or get_secret("OPENAI_ORGANIZATION"))
|
||||
or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105
|
||||
)
|
||||
openai.organization = organization
|
||||
|
|
@ -5771,7 +5764,7 @@ def completion(
|
|||
}
|
||||
)
|
||||
|
||||
if not _is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model):
|
||||
if not is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model):
|
||||
tools = _drop_anthropic_only_tool_keys(tools=tools)
|
||||
|
||||
if provider_specific_header is not None:
|
||||
|
|
@ -6322,6 +6315,7 @@ def completion(
|
|||
or custom_llm_provider == "nebius"
|
||||
or custom_llm_provider == "wandb"
|
||||
or custom_llm_provider == "clarifai"
|
||||
or custom_llm_provider == "bigmodel"
|
||||
or custom_llm_provider in litellm.openai_compatible_providers
|
||||
or JSONProviderRegistry.exists(custom_llm_provider) # JSON-configured providers
|
||||
or "ft:gpt-3.5-turbo" in model # finetune gpt-3.5-turbo
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ _FEATURES: Final[detectors.Detectors] = MappingProxyType(
|
|||
Feature.CACHE_CONTROL_INJECTION: detectors.cache_control_injection,
|
||||
Feature.STREAM_COST_INJECTION: route_detectors.stream_cost_injection,
|
||||
Feature.THINKING_SIGNATURE_RETRY: route_detectors.thinking_signature_retry,
|
||||
Feature.TOOL_CUSTOM_FIELD: route_detectors.tool_custom_field,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28071,7 +28071,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
|
|
@ -28345,7 +28345,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": true
|
||||
},
|
||||
"fireworks_ai/qwen3p7-plus": {
|
||||
"cache_read_input_token_cost": 8e-08,
|
||||
|
|
@ -41939,6 +41939,120 @@
|
|||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/ap-southeast-2/mistral.voxtral-mini-3b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 4.12e-08,
|
||||
"input_cost_per_token_batches": 2.06e-08,
|
||||
"input_cost_per_token_flex": 2.06e-08,
|
||||
"input_cost_per_token_priority": 7.21e-08,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.12e-08,
|
||||
"output_cost_per_token_batches": 2.06e-08,
|
||||
"output_cost_per_token_flex": 2.06e-08,
|
||||
"output_cost_per_token_priority": 7.21e-08,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/ap-southeast-2/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/ap-southeast-2/mistral.voxtral-small-24b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 1.03e-07,
|
||||
"input_cost_per_token_batches": 5.15e-08,
|
||||
"input_cost_per_token_flex": 5.15e-08,
|
||||
"input_cost_per_token_priority": 1.8025e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.09e-07,
|
||||
"output_cost_per_token_batches": 1.545e-07,
|
||||
"output_cost_per_token_flex": 1.545e-07,
|
||||
"output_cost_per_token_priority": 5.4075e-07,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/ap-southeast-2/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/ap-northeast-1/mistral.voxtral-mini-3b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"input_cost_per_token_batches": 3e-08,
|
||||
"input_cost_per_token_flex": 3e-08,
|
||||
"input_cost_per_token_priority": 9e-08,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5e-08,
|
||||
"output_cost_per_token_batches": 3e-08,
|
||||
"output_cost_per_token_flex": 3e-08,
|
||||
"output_cost_per_token_priority": 9e-08,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/ap-northeast-1/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/ap-northeast-1/mistral.voxtral-small-24b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 1.2e-07,
|
||||
"input_cost_per_token_batches": 6e-08,
|
||||
"input_cost_per_token_flex": 6e-08,
|
||||
"input_cost_per_token_priority": 2.1e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.6e-07,
|
||||
"output_cost_per_token_batches": 1.8e-07,
|
||||
"output_cost_per_token_flex": 1.8e-07,
|
||||
"output_cost_per_token_priority": 6.3e-07,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/ap-northeast-1/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/eu-west-2/mistral.voxtral-mini-3b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 6e-08,
|
||||
"input_cost_per_token_batches": 3e-08,
|
||||
"input_cost_per_token_flex": 3e-08,
|
||||
"input_cost_per_token_priority": 1.1e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-08,
|
||||
"output_cost_per_token_batches": 3e-08,
|
||||
"output_cost_per_token_flex": 3e-08,
|
||||
"output_cost_per_token_priority": 1.1e-07,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/eu-west-2/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"bedrock/eu-west-2/mistral.voxtral-small-24b-2507": {
|
||||
"deprecation_date": "2027-03-30",
|
||||
"input_cost_per_token": 1.6e-07,
|
||||
"input_cost_per_token_batches": 8e-08,
|
||||
"input_cost_per_token_flex": 8e-08,
|
||||
"input_cost_per_token_priority": 2.8e-07,
|
||||
"litellm_provider": "bedrock",
|
||||
"max_input_tokens": 32768,
|
||||
"max_output_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.7e-07,
|
||||
"output_cost_per_token_batches": 2.3e-07,
|
||||
"output_cost_per_token_flex": 2.3e-07,
|
||||
"output_cost_per_token_priority": 8.2e-07,
|
||||
"source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/eu-west-2/index.json",
|
||||
"supports_audio_input": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_native_structured_output": true
|
||||
},
|
||||
"mistral/codestral-2508": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"input_cost_per_token": 3e-07,
|
||||
|
|
@ -61275,6 +61389,359 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"streamlake/deepseek-v4-flash-0731": {
|
||||
"cache_read_input_token_cost": 1.4e-08,
|
||||
"input_cost_per_token": 4.4e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.32e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/DeepSeek-V4-Pro-0813": {
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"input_cost_per_token": 1.32e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048566,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/DeepSeek-V3": {
|
||||
"cache_read_input_token_cost": 1.14e-07,
|
||||
"input_cost_per_token": 2.86e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 56000,
|
||||
"max_output_tokens": 8000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.143e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/GLM-5.3": {
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/GLM-5.3-Flash": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/Kimi-K2-Instruct-0905": {
|
||||
"cache_read_input_token_cost": 1.43e-07,
|
||||
"input_cost_per_token": 5.71e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 224000,
|
||||
"max_output_tokens": 32000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.286e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-r1": {
|
||||
"cache_read_input_token_cost": 2.29e-07,
|
||||
"input_cost_per_token": 5.71e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 98304,
|
||||
"max_output_tokens": 16000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.286e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-v3.1": {
|
||||
"cache_read_input_token_cost": 2.29e-07,
|
||||
"input_cost_per_token": 5.71e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 98304,
|
||||
"max_output_tokens": 32000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.714e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-v4-flash": {
|
||||
"cache_read_input_token_cost": 2.8e-08,
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-v4-pro": {
|
||||
"cache_read_input_token_cost": 1.45e-07,
|
||||
"input_cost_per_token": 1.74e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.48e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-v4.1-flash-1017": {
|
||||
"cache_read_input_token_cost": 6e-09,
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/deepseek-v4.1-flash-260910": {
|
||||
"cache_read_input_token_cost": 6e-09,
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048568,
|
||||
"max_output_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/glm-5": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 1e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 202752,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.2e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/glm-5.1": {
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 202752,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/glm-5.2": {
|
||||
"cache_read_input_token_cost": 2.6e-07,
|
||||
"input_cost_per_token": 1.4e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1048566,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4.4e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/kimi-k2.6": {
|
||||
"cache_read_input_token_cost": 1.6e-07,
|
||||
"input_cost_per_token": 9.5e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 256000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_reasoning": false,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/kimi-k2.7-code": {
|
||||
"cache_read_input_token_cost": 1.9e-07,
|
||||
"input_cost_per_token": 9.5e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 32000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 4e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/mimo-v2.6-flash": {
|
||||
"cache_read_input_token_cost": 2.8e-09,
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/mimo-v2.6-pro": {
|
||||
"cache_read_input_token_cost": 3.6e-09,
|
||||
"input_cost_per_token": 4.35e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 8.7e-07,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/minimax-m2.5": {
|
||||
"cache_creation_input_token_cost": 3.75e-07,
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"input_cost_per_token": 3e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 196608,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/minimax-m3": {
|
||||
"cache_read_input_token_cost": 1.2e-07,
|
||||
"cache_read_input_token_cost_above_512k_tokens": 2.4e-07,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"input_cost_per_token_above_512k_tokens": 1.2e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 512000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.4e-06,
|
||||
"output_cost_per_token_above_512k_tokens": 4.8e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/qwen3.5-397b-a17b": {
|
||||
"cache_read_input_token_cost": 1.2e-07,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 258048,
|
||||
"max_output_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.6e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/qwen3.6-27b": {
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 229376,
|
||||
"max_output_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.6e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_reasoning": false,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/qwen3.6-35b-a3b": {
|
||||
"input_cost_per_token": 3.75e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 229376,
|
||||
"max_output_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.25e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_reasoning": false,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/qwen3.8-2.4t-a95b": {
|
||||
"cache_read_input_token_cost": 2.5e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 983616,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"explicit_cache_read_input_token_cost": 1.7e-07,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"streamlake/qwen3.8-27b": {
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "streamlake",
|
||||
"max_input_tokens": 983616,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-06,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true,
|
||||
"cache_creation_input_token_cost": 6.25e-07,
|
||||
"explicit_cache_read_input_token_cost": 5e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://www.streamlake.ai/document/DOC/mgrnm4xm362hvp5wyce"
|
||||
},
|
||||
"deepseek-v4-flash": {
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"cache_read_input_token_cost": 6e-09,
|
||||
|
|
@ -62590,6 +63057,14 @@
|
|||
"litellm_provider": "anthropic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "typesafe-jev-ids",
|
||||
"pattern": "^jev-(?:latest|preview|1\\.13\\.0|1\\.14\\.0)$",
|
||||
"description": "A bare Jev id that has a typesafe/ price entry (jev-latest, jev-preview, jev-1.13.0, jev-1.14.0), the name the /typesafe pass-through forwards to TypeSafe. Listed by name, matched case-insensitively like every rule here, so foo/jev-latest or a model group named like a Jev id TypeSafe does not serve, such as jev-1.0 or jev-internal-router, never resolves to typesafe. A new Jev version goes here together with its price entry. Provider inference feeds the proxy's provider/* wildcard access checks, so this rule is what lets typesafe/* on a key, team or project allowlist cover a jev-latest request.",
|
||||
"model_info": {
|
||||
"litellm_provider": "typesafe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-family-baseline",
|
||||
"pattern": "claude-[a-z]+-\\d+(?:[-.]\\d+)?(?:-\\d{8})?",
|
||||
|
|
@ -78520,5 +78995,81 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"bigmodel/glm-5.3": {
|
||||
"input_cost_per_token": 1.1428571428571428e-06,
|
||||
"output_cost_per_token": 4e-06,
|
||||
"cache_read_input_token_cost": 2.857142857142857e-07,
|
||||
"litellm_provider": "bigmodel",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"source": "https://docs.bigmodel.cn/cn/guide/start/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"thinking_always_on": true,
|
||||
"metadata": {
|
||||
"comment": "Official API list price checked 2026-10-27: input 8, output 28, cached input 2 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded."
|
||||
},
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
"/v1/messages"
|
||||
],
|
||||
"supports_native_streaming": true
|
||||
},
|
||||
"bigmodel/glm-5.3-flash": {
|
||||
"input_cost_per_token": 1.142857142857143e-07,
|
||||
"output_cost_per_token": 4e-07,
|
||||
"cache_read_input_token_cost": 3.2857142857142855e-08,
|
||||
"litellm_provider": "bigmodel",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"source": "https://docs.bigmodel.cn/cn/guide/start/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"thinking_always_on": true,
|
||||
"supports_vision": true,
|
||||
"supports_video_input": true,
|
||||
"metadata": {
|
||||
"comment": "Official API list price checked 2026-10-27: input 0.8, output 2.8, cached input 0.23 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded."
|
||||
},
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
"/v1/messages"
|
||||
],
|
||||
"supports_native_streaming": true
|
||||
},
|
||||
"bigmodel/glm-5.3-flashx": {
|
||||
"input_cost_per_token": 2.857142857142857e-07,
|
||||
"output_cost_per_token": 1e-06,
|
||||
"cache_read_input_token_cost": 8.142857142857142e-08,
|
||||
"litellm_provider": "bigmodel",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"source": "https://docs.bigmodel.cn/cn/guide/start/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"thinking_always_on": true,
|
||||
"supports_vision": true,
|
||||
"supports_video_input": true,
|
||||
"metadata": {
|
||||
"comment": "Official API list price checked 2026-10-27: input 2, output 7, cached input 0.57 CNY per million tokens; converted at 7.0 CNY/USD. Cache storage is temporarily free and is not encoded."
|
||||
},
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
"/v1/messages"
|
||||
],
|
||||
"supports_native_streaming": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3785,6 +3785,32 @@
|
|||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"streamlake": {
|
||||
"display_name": "StreamLake (`streamlake`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/openai_compatible",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"tencent": {
|
||||
"display_name": "Tencent TokenHub (`tencent`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/tencent",
|
||||
|
|
@ -5300,6 +5326,32 @@
|
|||
"a2a": false,
|
||||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"bigmodel": {
|
||||
"display_name": "BigModel (Zhipu AI) (`bigmodel`)",
|
||||
"url": "https://docs.bigmodel.cn/cn/guide/develop/responses/introduction",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
|
|||
|
|
@ -7816,7 +7816,7 @@
|
|||
"paths": {
|
||||
"/claude-code/marketplace.json": {
|
||||
"get": {
|
||||
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` or an access group on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
|
||||
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout a key the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` or an access group on the key or its team.\n\nWith `general_settings.claude_code_marketplace_auth_required: true` the key must come\nin the `Authorization` or `x-litellm-api-key` header (Claude Code sends them from the\nmarketplace source's `headers`), `?key=` is ignored, and a caller with no valid key\ngets a 401 with `WWW-Authenticate: Bearer`.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
|
||||
"operationId": "get_marketplace_claude_code_marketplace_json_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -33639,7 +33639,7 @@
|
|||
]
|
||||
},
|
||||
"get": {
|
||||
"description": "Get a response by ID.\n\nSupports both:\n- Polling IDs (litellm_poll_*): Returns cumulative cached content from background responses\n- Provider response IDs: Passes through to provider API\n\nFollows the OpenAI Responses API spec: https://platform.openai.com/docs/api-reference/responses/get\n\n```bash\n# Get polling response\ncurl -X GET http://localhost:4000/v1/responses/litellm_poll_abc123 -H \"Authorization: Bearer $LITELLM_MASTER_KEY\"\n\n# Get provider response\ncurl -X GET http://localhost:4000/v1/responses/resp_abc123 -H \"Authorization: Bearer $LITELLM_MASTER_KEY\"\n```",
|
||||
"description": "Get a response by ID.\n\nSupports both:\n- Polling IDs (litellm_poll_*): Returns cumulative cached content from background responses\n- Provider response IDs: Passes through to provider API\n\nFollows the OpenAI Responses API spec: https://platform.openai.com/docs/api-reference/responses/get\n\n```bash\n# Get polling response\ncurl -X GET http://localhost:4000/v1/responses/litellm_poll_abc123 -H \"Authorization: Bearer $LITELLM_MASTER_KEY\"\n\n# Get provider response\ncurl -X GET http://localhost:4000/v1/responses/resp_abc123 -H \"Authorization: Bearer $LITELLM_MASTER_KEY\"\n\n# Resume a background response's event stream after sequence number 7\ncurl -N \"http://localhost:4000/v1/responses/resp_abc123?stream=true&starting_after=7\" -H \"Authorization: Bearer $LITELLM_MASTER_KEY\"\n```",
|
||||
"operationId": "get_response_openai_v1_responses__response_id__get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -33650,6 +33650,46 @@
|
|||
"title": "Response Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Replay the response's events as server-sent events (OpenAI, Azure, and Azure AI Foundry)",
|
||||
"in": "query",
|
||||
"name": "stream",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "With stream=true, replay only the events after this sequence number",
|
||||
"in": "query",
|
||||
"name": "starting_after",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Additional output data to include, forwarded to the provider",
|
||||
"in": "query",
|
||||
"name": "include",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "With stream=true, whether delta events carry obfuscation padding",
|
||||
"in": "query",
|
||||
"name": "include_obfuscation",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
@ -33659,6 +33699,12 @@
|
|||
"schema": {
|
||||
"$ref": "#/components/schemas/ResponsesAPIResponse"
|
||||
}
|
||||
},
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"description": "Server sent events when stream=true",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue