mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
Merge remote-tracking branch 'origin/main' into litellm_openai_ws_frame_model_gate
# Conflicts: # litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
This commit is contained in:
commit
9f8a3baa3f
307 changed files with 24435 additions and 4231 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"
|
||||
2
.github/workflows/test-rust.yml
vendored
2
.github/workflows/test-rust.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
|||
push:
|
||||
paths:
|
||||
- "litellm-rust/**"
|
||||
- "model_prices_and_context_window.json"
|
||||
- "litellm/rust_bridge/**"
|
||||
- "scripts/generate_trace_types.py"
|
||||
- "scripts/trace_codegen/**"
|
||||
|
|
@ -38,6 +39,7 @@ on:
|
|||
- "litellm_**"
|
||||
paths:
|
||||
- "litellm-rust/**"
|
||||
- "model_prices_and_context_window.json"
|
||||
- "litellm/rust_bridge/**"
|
||||
- "scripts/generate_trace_types.py"
|
||||
- "scripts/trace_codegen/**"
|
||||
|
|
|
|||
3
.github/workflows/test-unit.yml
vendored
3
.github/workflows/test-unit.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
id: rust-bridge-key
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
env:
|
||||
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm-rust/Cargo.lock', 'litellm-rust/Cargo.toml', 'litellm-rust/crates/**', 'pyproject.toml') }}
|
||||
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm-rust/Cargo.lock', 'litellm-rust/Cargo.toml', 'litellm-rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml') }}
|
||||
run: echo "key=$RUST_BRIDGE_CACHE_KEY" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Python
|
||||
|
|
@ -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
|
||||
#########################################################
|
||||
|
|
|
|||
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
|
||||
34
litellm-rust/Cargo.lock
generated
34
litellm-rust/Cargo.lock
generated
|
|
@ -3150,6 +3150,7 @@ dependencies = [
|
|||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
|
|
@ -3178,6 +3179,12 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
|
|
@ -3729,9 +3736,12 @@ dependencies = [
|
|||
name = "litellm-cost"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"proptest",
|
||||
"jiff",
|
||||
"rstest",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
|
|
@ -4277,17 +4287,35 @@ name = "litellm-model-catalog"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"jiff",
|
||||
"jsonschema",
|
||||
"litellm-cost",
|
||||
"litellm-error",
|
||||
"litellm-llms-types",
|
||||
"macro_rules_attribute",
|
||||
"rstest",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-model-catalog-refresh"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-error",
|
||||
"litellm-http",
|
||||
"litellm-model-catalog",
|
||||
"reqwest",
|
||||
"rstest",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-python-bridge"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ litellm-python-compat = { path = "crates/python-compat" }
|
|||
litellm-circuit-breaker = { path = "crates/circuit-breaker" }
|
||||
litellm-redis-client = { path = "crates/redis-client" }
|
||||
litellm-redis-testing = { path = "crates/redis-testing" }
|
||||
litellm-cost = { path = "crates/cost" }
|
||||
litellm-model-catalog = { path = "crates/model-catalog" }
|
||||
litellm-model-catalog-refresh = { path = "crates/model-catalog-refresh" }
|
||||
|
||||
askama = { version = "0.16.1", default-features = false, features = ["derive", "std"] }
|
||||
tracing = "0.1"
|
||||
|
|
@ -109,6 +112,7 @@ serde = { version = "1.0", features = ["derive"] }
|
|||
serde_json = { version = "1.0", features = ["float_roundtrip"] }
|
||||
serde_with = { version = "=3.21.0", default-features = false, features = ["std", "macros"] }
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
syn = { version = "2", default-features = false }
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["json", "macros", "postgres", "runtime-tokio", "chrono", "tls-rustls-ring-native-roots"] }
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
|
|
|
|||
|
|
@ -5,14 +5,17 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
serde = ["dep:serde"]
|
||||
schema = ["serde", "dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
jiff = { version = "0.2.37", default-features = false, features = ["std"] }
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
thiserror.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
proptest.workspace = true
|
||||
rstest.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "calculate"
|
||||
harness = false
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,13 +1,3 @@
|
|||
# litellm-cost
|
||||
|
||||
This crate calculates text token charges from rates and usage supplied by its caller. It is standalone and has no Python bridge or proxy integration
|
||||
|
||||
Call `compile(&pricing)` once for an immutable plan, then `plan.calculate(&request)` for each supported request. `calculate(&pricing, &request)` compiles on each call. A successful result exposes pre-multiplier component costs, selected rates, the multiplier, and derived `input()`, `output()`, and `total()` values
|
||||
|
||||
The caller states whether `prompt_tokens` includes cache tokens. Threshold selection uses total input tokens for either convention and selects one rate for the whole request. Thresholds are sorted when compiled, and duplicate thresholds or tier overrides fail deterministically. `Fast` selects priority rates; unknown tiers use standard rates
|
||||
|
||||
`Rate::Missing`, `Rate::Null`, and `Rate::Value(0.0)` remain distinct. Missing cache rates fall back to the selected input rate, and an absent one-hour write rate falls back to the selected write rate. Missing input or output rates return typed errors, including for zero usage. Python's sparse-entry behavior remains outside this native contract
|
||||
|
||||
The supported off-peak shape is one non-wrapping UTC daily window. The caller supplies the applicable regional multiplier after provider-specific selection. Negative or non-finite rates, ambiguous rules, inconsistent cache counts, incomplete write splits, invalid windows and overflow return errors. Callers must decline unsupported inputs before native execution if their public contract accepts those shapes
|
||||
|
||||
This crate does not select models, read catalogs, fetch provider prices, normalize multimodal usage, process provider-reported costs, or calculate non-token charges. It does not change proxy behavior. The reference fixture was generated by `tests/generate_python_reference.py` against the Python implementation at the commit recorded in `tests/python_reference.tsv`, using synthetic rates and fixed usage
|
||||
This crate holds the price data model that `litellm-model-catalog` lowers each catalog entry into, plus the usage, call context and priced outcome types that `calculate` will compute over. There is no `calculate` until the token pricing PR lands. `UnitPrice` is USD per unit and `Usd` is an amount, and only the operations that make sense between them exist, so a price is never added to an amount. `Prices::band` and `Prices::rate` resolve a token rate the way Python's `_get_token_base_cost` does
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use litellm_cost::{
|
||||
Pricing, PromptConvention, Rate, Rates, Request, ServiceTier, ThresholdPolicy, ThresholdRates,
|
||||
Usage, calculate, compile,
|
||||
};
|
||||
use std::hint::black_box;
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let pricing = Pricing {
|
||||
standard: Rates {
|
||||
input: Rate::Value(0.000002),
|
||||
output: Rate::Value(0.000008),
|
||||
cache_read: Rate::Value(0.0000005),
|
||||
cache_write: Rate::Missing,
|
||||
cache_write_1h: Rate::Missing,
|
||||
},
|
||||
tiers: &[],
|
||||
thresholds: &[],
|
||||
off_peak: None,
|
||||
};
|
||||
let request = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
cache_read_tokens: 250,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_5m_tokens: None,
|
||||
cache_write_1h_tokens: None,
|
||||
prompt_convention: PromptConvention::IncludesCache,
|
||||
},
|
||||
service_tier: ServiceTier::Standard,
|
||||
threshold_policy: ThresholdPolicy::Exclusive,
|
||||
region_multiplier: None,
|
||||
billed_at_utc_minute: None,
|
||||
};
|
||||
let plan = compile(&pricing).unwrap();
|
||||
c.bench_function("native_compiled_calculation", |b| {
|
||||
b.iter(|| black_box(plan.calculate(black_box(&request)).unwrap()))
|
||||
});
|
||||
c.bench_function("native_full_wrapper", |b| {
|
||||
b.iter(|| black_box(calculate(black_box(&pricing), black_box(&request)).unwrap()))
|
||||
});
|
||||
c.bench_function("native_rate_compilation", |b| {
|
||||
b.iter(|| black_box(compile(black_box(&pricing)).unwrap()))
|
||||
});
|
||||
let threshold = ThresholdRates {
|
||||
above_prompt_tokens: 1000,
|
||||
standard: Rates {
|
||||
input: Rate::Value(0.000004),
|
||||
output: Rate::Value(0.000016),
|
||||
..Rates::EMPTY
|
||||
},
|
||||
tiers: &[],
|
||||
};
|
||||
let threshold_pricing = Pricing {
|
||||
thresholds: &[threshold],
|
||||
..pricing
|
||||
};
|
||||
let threshold_plan = compile(&threshold_pricing).unwrap();
|
||||
c.bench_function("native_threshold_boundary", |b| {
|
||||
b.iter(|| black_box(threshold_plan.calculate(black_box(&request)).unwrap()))
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
criterion_main!(benches);
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
use litellm_cost::{
|
||||
Pricing, PromptConvention, Rate, Rates, Request, ServiceTier, ThresholdPolicy, Usage, compile,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let pricing = Pricing {
|
||||
standard: Rates {
|
||||
input: Rate::Value(2.0),
|
||||
output: Rate::Value(4.0),
|
||||
cache_read: Rate::Value(0.5),
|
||||
cache_write: Rate::Value(3.0),
|
||||
cache_write_1h: Rate::Missing,
|
||||
},
|
||||
tiers: &[],
|
||||
thresholds: &[],
|
||||
off_peak: None,
|
||||
};
|
||||
let request = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
cache_read_tokens: 25,
|
||||
cache_write_tokens: 10,
|
||||
cache_write_5m_tokens: None,
|
||||
cache_write_1h_tokens: None,
|
||||
prompt_convention: PromptConvention::IncludesCache,
|
||||
},
|
||||
service_tier: ServiceTier::Standard,
|
||||
threshold_policy: ThresholdPolicy::Exclusive,
|
||||
region_multiplier: None,
|
||||
billed_at_utc_minute: None,
|
||||
};
|
||||
let cost = compile(&pricing).unwrap().calculate(&request).unwrap();
|
||||
println!(
|
||||
"input={} output={} total={}",
|
||||
cost.input(),
|
||||
cost.output(),
|
||||
cost.total()
|
||||
);
|
||||
}
|
||||
42
litellm-rust/crates/cost/src/call.rs
Normal file
42
litellm-rust/crates/cost/src/call.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use crate::price::{Channel, Prices, Residency, ServiceTier};
|
||||
|
||||
/// Facts about the call, never about the row or the provider
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CallContext {
|
||||
/// Requested service tier, `"fast"` maps to `Priority` as on main
|
||||
pub tier: ServiceTier,
|
||||
/// Online, batch or decisions
|
||||
pub channel: Channel,
|
||||
/// OpenAI data residency, picks the residency uplift
|
||||
pub residency: Option<Residency>,
|
||||
/// Vertex call sent to a regional endpoint, picks the regional uplift
|
||||
pub non_global_endpoint: bool,
|
||||
/// Anthropic `speed: fast`, picks the speed uplift
|
||||
pub speed: Option<Speed>,
|
||||
/// Anthropic and Bedrock `inference_geo`, picks the geo uplift
|
||||
pub inference_geo: Option<InferenceGeo>,
|
||||
/// When the call was billed, for off-peak windows
|
||||
pub at: jiff::Timestamp,
|
||||
/// Per-call custom pricing, lowered like a row and applied field by field over it
|
||||
pub overlay: Option<Prices>,
|
||||
}
|
||||
|
||||
/// Anthropic `speed`
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Speed {
|
||||
Fast,
|
||||
}
|
||||
|
||||
/// Anthropic and Bedrock `inference_geo`, lowercased as on main
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct InferenceGeo(Box<str>);
|
||||
|
||||
impl InferenceGeo {
|
||||
pub fn new(geo: &str) -> Self {
|
||||
Self(geo.to_lowercase().into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,24 @@
|
|||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error, strum::VariantArray)]
|
||||
pub enum Error {
|
||||
#[error("model has no input rate")]
|
||||
MissingInputRate,
|
||||
#[error("model has no output rate")]
|
||||
MissingOutputRate,
|
||||
#[error("rate must be finite and nonnegative")]
|
||||
InvalidRate,
|
||||
#[error("region multiplier must be finite and positive")]
|
||||
InvalidRegionMultiplier,
|
||||
#[error("off-peak pricing needs a billing minute within the day")]
|
||||
InvalidBillingTime,
|
||||
#[error("off-peak window must start before it ends within the day")]
|
||||
InvalidOffPeakWindow,
|
||||
#[error("cached tokens exceed prompt tokens")]
|
||||
CacheExceedsPrompt,
|
||||
#[error("cache write details do not add up to the cache write tokens")]
|
||||
InvalidCacheWriteDetails,
|
||||
#[error("token count or cost overflows")]
|
||||
TokenCountOverflow,
|
||||
#[error("service tier is repeated or cannot carry its own rates")]
|
||||
DuplicateTier,
|
||||
#[error("prompt token threshold is repeated")]
|
||||
DuplicateThreshold,
|
||||
#[error("service tier is repeated or cannot carry its own rates within a threshold")]
|
||||
DuplicateThresholdTier,
|
||||
#[error("expected a UTC window HH:MM-HH:MM")]
|
||||
InvalidUtcWindow,
|
||||
#[error("expected an ISO weekday number 1-7 or an English day name")]
|
||||
InvalidWeekday,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::Error;
|
||||
use crate::schedule::{UtcWindow, Weekday};
|
||||
|
||||
#[rstest]
|
||||
#[case::window("not-a-window".parse::<UtcWindow>().unwrap_err(), Error::InvalidUtcWindow)]
|
||||
#[case::weekday("not-a-weekday".parse::<Weekday>().unwrap_err(), Error::InvalidWeekday)]
|
||||
fn schedule_parsers_share_the_crate_error(#[case] error: Error, #[case] expected: Error) {
|
||||
assert_eq!(error, expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,383 +1,9 @@
|
|||
pub mod call;
|
||||
mod error;
|
||||
pub mod money;
|
||||
pub mod price;
|
||||
pub mod priced;
|
||||
pub mod schedule;
|
||||
pub mod usage;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Rate {
|
||||
Missing,
|
||||
Null,
|
||||
Value(f64),
|
||||
}
|
||||
|
||||
impl Rate {
|
||||
fn value(self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Value(value) => Some(value),
|
||||
Self::Missing | Self::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn or(self, fallback: Self) -> Self {
|
||||
if self.value().is_some() {
|
||||
self
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rates {
|
||||
pub input: Rate,
|
||||
pub output: Rate,
|
||||
pub cache_read: Rate,
|
||||
pub cache_write: Rate,
|
||||
pub cache_write_1h: Rate,
|
||||
}
|
||||
|
||||
impl Rates {
|
||||
pub const EMPTY: Self = Self {
|
||||
input: Rate::Missing,
|
||||
output: Rate::Missing,
|
||||
cache_read: Rate::Missing,
|
||||
cache_write: Rate::Missing,
|
||||
cache_write_1h: Rate::Missing,
|
||||
};
|
||||
|
||||
fn overlay(self, base: Self) -> Self {
|
||||
Self {
|
||||
input: self.input.or(base.input),
|
||||
output: self.output.or(base.output),
|
||||
cache_read: self.cache_read.or(base.cache_read),
|
||||
cache_write: self.cache_write.or(base.cache_write),
|
||||
cache_write_1h: self.cache_write_1h.or(base.cache_write_1h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ServiceTier {
|
||||
Standard,
|
||||
Flex,
|
||||
Priority,
|
||||
Fast,
|
||||
Ultrafast,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ThresholdPolicy {
|
||||
Exclusive,
|
||||
Inclusive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PromptConvention {
|
||||
IncludesCache,
|
||||
ExcludesCache,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
pub cache_write_5m_tokens: Option<u64>,
|
||||
pub cache_write_1h_tokens: Option<u64>,
|
||||
pub prompt_convention: PromptConvention,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TierRates {
|
||||
pub tier: ServiceTier,
|
||||
pub rates: Rates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ThresholdRates<'a> {
|
||||
pub above_prompt_tokens: u64,
|
||||
pub standard: Rates,
|
||||
pub tiers: &'a [TierRates],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OffPeakRates {
|
||||
pub start_utc_minute: u16,
|
||||
pub end_utc_minute: u16,
|
||||
pub rates: Rates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Pricing<'a> {
|
||||
pub standard: Rates,
|
||||
pub tiers: &'a [TierRates],
|
||||
pub thresholds: &'a [ThresholdRates<'a>],
|
||||
pub off_peak: Option<OffPeakRates>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Request {
|
||||
pub usage: Usage,
|
||||
pub service_tier: ServiceTier,
|
||||
pub threshold_policy: ThresholdPolicy,
|
||||
pub region_multiplier: Option<f64>,
|
||||
pub billed_at_utc_minute: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Cost {
|
||||
pub uncached_input: f64,
|
||||
pub cache_read: f64,
|
||||
pub cache_write_5m: f64,
|
||||
pub cache_write_1h: f64,
|
||||
pub output: f64,
|
||||
pub multiplier: f64,
|
||||
pub rates: EffectiveRates,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
pub fn input(self) -> f64 {
|
||||
(self.uncached_input + self.cache_read + self.cache_write_5m + self.cache_write_1h)
|
||||
* self.multiplier
|
||||
}
|
||||
|
||||
pub fn output(self) -> f64 {
|
||||
self.output * self.multiplier
|
||||
}
|
||||
|
||||
pub fn total(self) -> f64 {
|
||||
self.input() + self.output()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct EffectiveRates {
|
||||
pub input: f64,
|
||||
pub output: f64,
|
||||
pub cache_read: f64,
|
||||
pub cache_write_5m: f64,
|
||||
pub cache_write_1h: f64,
|
||||
}
|
||||
|
||||
fn selected_tier(tier: ServiceTier) -> ServiceTier {
|
||||
if tier == ServiceTier::Fast {
|
||||
ServiceTier::Priority
|
||||
} else {
|
||||
tier
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CompiledThreshold {
|
||||
above_prompt_tokens: u64,
|
||||
standard: Rates,
|
||||
tiers: Vec<TierRates>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PricingPlan {
|
||||
standard: Rates,
|
||||
tiers: Vec<TierRates>,
|
||||
thresholds: Vec<CompiledThreshold>,
|
||||
off_peak: Option<OffPeakRates>,
|
||||
}
|
||||
|
||||
fn valid_rates(rates: Rates) -> bool {
|
||||
[
|
||||
rates.input,
|
||||
rates.output,
|
||||
rates.cache_read,
|
||||
rates.cache_write,
|
||||
rates.cache_write_1h,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|rate| {
|
||||
rate.value()
|
||||
.is_none_or(|value| value.is_finite() && value >= 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_tiers(tiers: &[TierRates], duplicate: Error) -> Result<(), Error> {
|
||||
if tiers.iter().any(|entry| !valid_rates(entry.rates)) {
|
||||
return Err(Error::InvalidRate);
|
||||
}
|
||||
if tiers.iter().enumerate().any(|(index, entry)| {
|
||||
matches!(
|
||||
entry.tier,
|
||||
ServiceTier::Standard | ServiceTier::Unknown | ServiceTier::Fast
|
||||
) || tiers[..index]
|
||||
.iter()
|
||||
.any(|previous| previous.tier == entry.tier)
|
||||
}) {
|
||||
return Err(duplicate);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile(pricing: &Pricing<'_>) -> Result<PricingPlan, Error> {
|
||||
if !valid_rates(pricing.standard) {
|
||||
return Err(Error::InvalidRate);
|
||||
}
|
||||
validate_tiers(pricing.tiers, Error::DuplicateTier)?;
|
||||
if let Some(window) = pricing.off_peak {
|
||||
if window.start_utc_minute >= 1440
|
||||
|| window.end_utc_minute > 1440
|
||||
|| window.start_utc_minute >= window.end_utc_minute
|
||||
{
|
||||
return Err(Error::InvalidOffPeakWindow);
|
||||
}
|
||||
if !valid_rates(window.rates) {
|
||||
return Err(Error::InvalidRate);
|
||||
}
|
||||
}
|
||||
let mut thresholds: Vec<_> = pricing
|
||||
.thresholds
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
if !valid_rates(entry.standard) {
|
||||
return Err(Error::InvalidRate);
|
||||
}
|
||||
validate_tiers(entry.tiers, Error::DuplicateThresholdTier)?;
|
||||
Ok(CompiledThreshold {
|
||||
above_prompt_tokens: entry.above_prompt_tokens,
|
||||
standard: entry.standard,
|
||||
tiers: entry.tiers.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
thresholds.sort_unstable_by_key(|entry| entry.above_prompt_tokens);
|
||||
if thresholds
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].above_prompt_tokens == pair[1].above_prompt_tokens)
|
||||
{
|
||||
return Err(Error::DuplicateThreshold);
|
||||
}
|
||||
Ok(PricingPlan {
|
||||
standard: pricing.standard,
|
||||
tiers: pricing.tiers.to_vec(),
|
||||
thresholds,
|
||||
off_peak: pricing.off_peak,
|
||||
})
|
||||
}
|
||||
|
||||
impl PricingPlan {
|
||||
fn resolve_rates(&self, request: &Request, threshold_tokens: u64) -> Result<Rates, Error> {
|
||||
let tier = selected_tier(request.service_tier);
|
||||
let base = self
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|entry| tier != ServiceTier::Standard && entry.tier == tier)
|
||||
.map_or(self.standard, |entry| entry.rates.overlay(self.standard));
|
||||
let threshold = self.thresholds.iter().rev().find(|entry| {
|
||||
threshold_tokens > entry.above_prompt_tokens
|
||||
|| (request.threshold_policy == ThresholdPolicy::Inclusive
|
||||
&& threshold_tokens == entry.above_prompt_tokens)
|
||||
});
|
||||
let selected = threshold.map_or(base, |entry| {
|
||||
let standard = entry.standard.overlay(base);
|
||||
entry
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|specific| tier != ServiceTier::Standard && specific.tier == tier)
|
||||
.map_or(standard, |specific| specific.rates.overlay(standard))
|
||||
});
|
||||
match self.off_peak {
|
||||
None => Ok(selected),
|
||||
Some(window) => {
|
||||
if window.start_utc_minute >= 1440
|
||||
|| window.end_utc_minute > 1440
|
||||
|| window.start_utc_minute >= window.end_utc_minute
|
||||
{
|
||||
return Err(Error::InvalidOffPeakWindow);
|
||||
}
|
||||
let minute = request
|
||||
.billed_at_utc_minute
|
||||
.ok_or(Error::InvalidBillingTime)?;
|
||||
if minute >= 1440 {
|
||||
return Err(Error::InvalidBillingTime);
|
||||
}
|
||||
if (window.start_utc_minute..window.end_utc_minute).contains(&minute) {
|
||||
Ok(window.rates.overlay(selected))
|
||||
} else {
|
||||
Ok(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_rate(rate: Rate, missing: Error) -> Result<f64, Error> {
|
||||
let value = rate.value().ok_or(missing)?;
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return Err(Error::InvalidRate);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn calculate(&self, request: &Request) -> Result<Cost, Error> {
|
||||
let usage = request.usage;
|
||||
let cached = usage
|
||||
.cache_read_tokens
|
||||
.checked_add(usage.cache_write_tokens)
|
||||
.ok_or(Error::TokenCountOverflow)?;
|
||||
let (regular, threshold_tokens) = match usage.prompt_convention {
|
||||
PromptConvention::IncludesCache => (
|
||||
usage
|
||||
.prompt_tokens
|
||||
.checked_sub(cached)
|
||||
.ok_or(Error::CacheExceedsPrompt)?,
|
||||
usage.prompt_tokens,
|
||||
),
|
||||
PromptConvention::ExcludesCache => (
|
||||
usage.prompt_tokens,
|
||||
usage
|
||||
.prompt_tokens
|
||||
.checked_add(cached)
|
||||
.ok_or(Error::TokenCountOverflow)?,
|
||||
),
|
||||
};
|
||||
let writes = match (usage.cache_write_5m_tokens, usage.cache_write_1h_tokens) {
|
||||
(None, None) => (usage.cache_write_tokens, 0),
|
||||
(Some(five), Some(one)) if five.checked_add(one) == Some(usage.cache_write_tokens) => {
|
||||
(five, one)
|
||||
}
|
||||
_ => return Err(Error::InvalidCacheWriteDetails),
|
||||
};
|
||||
let rates = self.resolve_rates(request, threshold_tokens)?;
|
||||
let input = Self::checked_rate(rates.input, Error::MissingInputRate)?;
|
||||
let output = Self::checked_rate(rates.output, Error::MissingOutputRate)?;
|
||||
let read = Self::checked_rate(rates.cache_read.or(rates.input), Error::MissingInputRate)?;
|
||||
let write = Self::checked_rate(rates.cache_write.or(rates.input), Error::MissingInputRate)?;
|
||||
let write_1h = Self::checked_rate(
|
||||
rates.cache_write_1h.or(rates.cache_write).or(rates.input),
|
||||
Error::MissingInputRate,
|
||||
)?;
|
||||
let multiplier = request.region_multiplier.unwrap_or(1.0);
|
||||
if !multiplier.is_finite() || multiplier <= 0.0 {
|
||||
return Err(Error::InvalidRegionMultiplier);
|
||||
}
|
||||
let cost = Cost {
|
||||
uncached_input: regular as f64 * input,
|
||||
cache_read: usage.cache_read_tokens as f64 * read,
|
||||
cache_write_5m: writes.0 as f64 * write,
|
||||
cache_write_1h: writes.1 as f64 * write_1h,
|
||||
output: usage.completion_tokens as f64 * output,
|
||||
multiplier,
|
||||
rates: EffectiveRates {
|
||||
input,
|
||||
output,
|
||||
cache_read: read,
|
||||
cache_write_5m: write,
|
||||
cache_write_1h: write_1h,
|
||||
},
|
||||
};
|
||||
if !cost.total().is_finite() {
|
||||
return Err(Error::TokenCountOverflow);
|
||||
}
|
||||
Ok(cost)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate(pricing: &Pricing<'_>, request: &Request) -> Result<Cost, Error> {
|
||||
compile(pricing)?.calculate(request)
|
||||
}
|
||||
pub use error::*;
|
||||
|
|
|
|||
257
litellm-rust/crates/cost/src/money.rs
Normal file
257
litellm-rust/crates/cost/src/money.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
use std::iter::Sum;
|
||||
use std::ops::{Add, Mul};
|
||||
|
||||
use crate::priced::Quantity;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
|
||||
pub struct UnitPrice(f64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
|
||||
pub struct Usd(f64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
|
||||
pub struct Multiplier(f64);
|
||||
|
||||
impl UnitPrice {
|
||||
pub fn new(usd_per_unit: f64) -> Option<Self> {
|
||||
(usd_per_unit.is_finite() && usd_per_unit >= 0.0).then_some(Self(usd_per_unit))
|
||||
}
|
||||
pub fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Usd {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
pub fn new(amount: f64) -> Option<Self> {
|
||||
(amount.is_finite() && amount >= 0.0).then_some(Self(amount))
|
||||
}
|
||||
pub fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Multiplier {
|
||||
pub fn new(factor: f64) -> Option<Self> {
|
||||
(factor.is_finite() && factor >= 1.0).then_some(Self(factor))
|
||||
}
|
||||
pub fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Quantity> for UnitPrice {
|
||||
type Output = Usd;
|
||||
|
||||
fn mul(self, quantity: Quantity) -> Usd {
|
||||
match quantity {
|
||||
Quantity::Count(units) => Usd(self.0 * units as f64),
|
||||
Quantity::Duration(duration) => Usd(self.0 * duration.as_secs_f64()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Multiplier> for UnitPrice {
|
||||
type Output = UnitPrice;
|
||||
|
||||
fn mul(self, factor: Multiplier) -> UnitPrice {
|
||||
UnitPrice(self.0 * factor.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Multiplier> for Usd {
|
||||
type Output = Usd;
|
||||
|
||||
fn mul(self, factor: Multiplier) -> Usd {
|
||||
Usd(self.0 * factor.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Usd {
|
||||
type Output = Usd;
|
||||
|
||||
fn add(self, other: Usd) -> Usd {
|
||||
Usd(self.0 + other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sum for Usd {
|
||||
fn sum<I: Iterator<Item = Usd>>(amounts: I) -> Usd {
|
||||
amounts.fold(Usd::ZERO, Add::add)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
mod serde_impls {
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Deserialize, Deserializer, Visitor};
|
||||
|
||||
use super::{Multiplier, UnitPrice};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Number(&'static str);
|
||||
|
||||
impl Visitor<'_> for Number {
|
||||
type Value = f64;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.0)
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, value: i64) -> Result<f64, E> {
|
||||
Ok(value as f64)
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, value: u64) -> Result<f64, E> {
|
||||
Ok(value as f64)
|
||||
}
|
||||
|
||||
fn visit_f64<E: de::Error>(self, value: f64) -> Result<f64, E> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<f64, E> {
|
||||
value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| E::invalid_value(de::Unexpected::Str(value), &self))
|
||||
}
|
||||
}
|
||||
|
||||
fn checked<'de, D: Deserializer<'de>, T>(
|
||||
deserializer: D,
|
||||
expected: Number,
|
||||
new: fn(f64) -> Option<T>,
|
||||
) -> Result<T, D::Error> {
|
||||
let number = deserializer.deserialize_any(expected)?;
|
||||
new(number)
|
||||
.ok_or_else(|| de::Error::invalid_value(de::Unexpected::Float(number), &expected))
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UnitPrice {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
checked(deserializer, Number("a non-negative USD amount"), Self::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Multiplier {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
checked(
|
||||
deserializer,
|
||||
Number("a multiplier of at least 1"),
|
||||
Self::new,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
mod schema {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
|
||||
use super::{Multiplier, UnitPrice};
|
||||
|
||||
impl JsonSchema for UnitPrice {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"UnitPrice".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "number", "minimum": 0})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Multiplier {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Multiplier".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "number", "minimum": 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::price(0.5)]
|
||||
#[case::free(0.0)]
|
||||
fn unit_price_keeps_finite_non_negative_values(#[case] value: f64) {
|
||||
assert_eq!(UnitPrice::new(value).map(UnitPrice::get), Some(value));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::negative(-1e-9)]
|
||||
#[case::nan(f64::NAN)]
|
||||
#[case::infinite(f64::INFINITY)]
|
||||
fn unit_price_rejects_what_no_price_can_be(#[case] value: f64) {
|
||||
assert_eq!(UnitPrice::new(value), None);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::uplift(1.1, true)]
|
||||
#[case::neutral(1.0, true)]
|
||||
#[case::discount(0.5, false)]
|
||||
#[case::nan(f64::NAN, false)]
|
||||
fn multiplier_is_never_a_discount(#[case] value: f64, #[case] accepted: bool) {
|
||||
assert_eq!(Multiplier::new(value).is_some(), accepted);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn amounts_follow_units_times_price_then_uplift() {
|
||||
let price = UnitPrice::new(0.25).unwrap();
|
||||
let uplift = Multiplier::new(2.0).unwrap();
|
||||
|
||||
let total: Usd = [
|
||||
price * Quantity::Count(4),
|
||||
(price * uplift) * Quantity::Count(2),
|
||||
]
|
||||
.into_iter()
|
||||
.sum();
|
||||
|
||||
assert_eq!(total.get(), 2.0);
|
||||
assert_eq!((total * uplift).get(), 4.0);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_duration_is_billed_in_fractional_seconds() {
|
||||
let per_second = UnitPrice::new(0.01).unwrap();
|
||||
|
||||
let amount = per_second * Quantity::Duration(std::time::Duration::from_millis(2_500));
|
||||
|
||||
assert!((amount.get() - 0.025).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
#[rstest]
|
||||
#[case::number(serde_json::json!(1.5e-6), Some(1.5e-6))]
|
||||
#[case::integer(serde_json::json!(2), Some(2.0))]
|
||||
#[case::numeric_string(serde_json::json!(" 0.000002 "), Some(2e-6))]
|
||||
#[case::negative(serde_json::json!(-1), None)]
|
||||
#[case::word(serde_json::json!("free"), None)]
|
||||
#[case::boolean(serde_json::json!(true), None)]
|
||||
fn unit_price_reads_numbers_and_numeric_strings(
|
||||
#[case] written: serde_json::Value,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
use serde::Deserialize;
|
||||
|
||||
assert_eq!(
|
||||
UnitPrice::deserialize(&written).ok().map(UnitPrice::get),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
505
litellm-rust/crates/cost/src/price.rs
Normal file
505
litellm-rust/crates/cost/src/price.rs
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
mod meter;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
pub use meter::*;
|
||||
|
||||
use crate::call::{InferenceGeo, Speed};
|
||||
use crate::money::{Multiplier, UnitPrice};
|
||||
use crate::schedule::Schedule;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ServiceTier {
|
||||
#[default]
|
||||
Standard,
|
||||
Priority,
|
||||
Flex,
|
||||
Ultrafast,
|
||||
Balanced,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Band {
|
||||
#[default]
|
||||
Base,
|
||||
OverPromptTokens(NonZeroU64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct When {
|
||||
pub tier: ServiceTier,
|
||||
pub band: Band,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
Online,
|
||||
Batch,
|
||||
Decisions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Price {
|
||||
pub meter: Meter,
|
||||
pub channel: Channel,
|
||||
pub when: When,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Prices {
|
||||
rates: BTreeMap<Price, UnitPrice>,
|
||||
off_peak: Option<OffPeak>,
|
||||
billing: Billing,
|
||||
}
|
||||
|
||||
/// Row attributes pricing reads, lowered by model-catalog from the row and its provider
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Billing {
|
||||
/// Unit the row bills text in. `Character` only when the row prices characters and not tokens
|
||||
pub text_unit: TextUnit,
|
||||
/// Whether a band's floor is in the band. xAI rows bill the band from the floor itself
|
||||
pub band_edge: BandEdge,
|
||||
/// Rate a cache read borrows when the row has no cache-read price. Fireworks rows borrow half the input rate
|
||||
pub cache_read_fallback: CacheReadFallback,
|
||||
/// Whether a row with no token price is priced by wall clock, from its `mode`
|
||||
pub wall_clock: WallClock,
|
||||
/// From `web_search_billing_unit`, whether web search and Maps grounding bill per query or once per prompt
|
||||
pub web_search: WebSearchUnit,
|
||||
/// Whether `tiered_pricing` bills each meter in its own tier or the whole call at the tier the input picks.
|
||||
/// Dashscope rows bill the whole call (inferred, 10 checks no other row has that shape)
|
||||
pub tier_scope: TierScope,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TextUnit {
|
||||
#[default]
|
||||
Token,
|
||||
Character,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum BandEdge {
|
||||
#[default]
|
||||
Exclusive,
|
||||
Inclusive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CacheReadFallback {
|
||||
#[default]
|
||||
InputRate,
|
||||
HalfInputRate,
|
||||
}
|
||||
|
||||
/// `ResponseTime` for `chat`, `completion`, `embedding`, `responses` or no mode, `AudioFile` for
|
||||
/// `audio_transcription`, which prices the input audio when it has one, `Never` for every other mode
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum WallClock {
|
||||
Never,
|
||||
#[default]
|
||||
ResponseTime,
|
||||
AudioFile,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum WebSearchUnit {
|
||||
PerQuery,
|
||||
#[default]
|
||||
PerPrompt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TierScope {
|
||||
#[default]
|
||||
PerMeter,
|
||||
WholeCall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OffPeak {
|
||||
schedule: Schedule,
|
||||
rates: OffPeakRates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct OffPeakRates {
|
||||
pub input: Option<UnitPrice>,
|
||||
pub output: Option<UnitPrice>,
|
||||
pub cache_read: Option<UnitPrice>,
|
||||
pub explicit_cache_read: Option<UnitPrice>,
|
||||
pub cache_write: Option<UnitPrice>,
|
||||
pub reasoning: Option<UnitPrice>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adjustments(BTreeMap<Uplift, Multiplier>);
|
||||
|
||||
/// Which call fact picks which multiplier in `Adjustments`, lowered by model-catalog from the row
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Uplift {
|
||||
/// OpenAI data residency
|
||||
Residency(Residency),
|
||||
/// Vertex non-global endpoint, from `regional_endpoint_uplift_multiplier`
|
||||
NonGlobalEndpoint,
|
||||
/// Anthropic `speed: fast`, from `provider_specific_entry.fast`
|
||||
Speed(Speed),
|
||||
/// Anthropic and Bedrock geo, from `provider_specific_entry.<geo>`, lowercased as on main
|
||||
InferenceGeo(InferenceGeo),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Residency {
|
||||
Eu,
|
||||
Us,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
pub fn over_prompt_tokens(tokens: u64) -> Self {
|
||||
NonZeroU64::new(tokens).map_or(Self::Base, Self::OverPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl Price {
|
||||
pub fn online(meter: Meter, when: When) -> Self {
|
||||
Self {
|
||||
meter,
|
||||
channel: Channel::Online,
|
||||
when,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prices {
|
||||
pub fn new(
|
||||
rates: BTreeMap<Price, UnitPrice>,
|
||||
off_peak: Option<OffPeak>,
|
||||
billing: Billing,
|
||||
) -> Self {
|
||||
Self {
|
||||
rates,
|
||||
off_peak,
|
||||
billing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, price: &Price) -> Option<UnitPrice> {
|
||||
self.rates.get(price).copied()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&Price, UnitPrice)> {
|
||||
self.rates.iter().map(|(price, rate)| (price, *rate))
|
||||
}
|
||||
|
||||
pub fn off_peak(&self) -> Option<&OffPeak> {
|
||||
self.off_peak.as_ref()
|
||||
}
|
||||
|
||||
pub fn billing(&self) -> Billing {
|
||||
self.billing
|
||||
}
|
||||
|
||||
/// The online prompt band a request falls in. Every token meter's `_above_` prices count,
|
||||
/// and the highest band the prompt passes wins. `billing.band_edge` says whether the floor is in the band.
|
||||
pub fn band(&self, tier: ServiceTier, prompt_tokens: u64) -> Band {
|
||||
let inclusive = self.billing.band_edge == BandEdge::Inclusive;
|
||||
self.rates
|
||||
.keys()
|
||||
.filter(|price| {
|
||||
price.channel == Channel::Online
|
||||
&& price.meter.sets_prompt_band()
|
||||
&& (price.when.tier == ServiceTier::Standard || price.when.tier == tier)
|
||||
})
|
||||
.filter_map(|price| match price.when.band {
|
||||
Band::OverPromptTokens(floor) => Some(floor.get()),
|
||||
Band::Base => None,
|
||||
})
|
||||
.filter(|floor| prompt_tokens > *floor || (inclusive && prompt_tokens == *floor))
|
||||
.max()
|
||||
.map_or(Band::Base, Band::over_prompt_tokens)
|
||||
}
|
||||
|
||||
/// The online rate for `meter`, tried at `(tier, band)`, `(standard, band)`, `(tier, base)`
|
||||
/// and `(standard, base)` in that order. A missing cache price falls back to the input price
|
||||
/// resolved the same way.
|
||||
pub fn rate(&self, meter: &Meter, tier: ServiceTier, band: Band) -> Option<UnitPrice> {
|
||||
[
|
||||
When { tier, band },
|
||||
When {
|
||||
tier: ServiceTier::Standard,
|
||||
band,
|
||||
},
|
||||
When {
|
||||
tier,
|
||||
band: Band::Base,
|
||||
},
|
||||
When::default(),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|when| self.get(&Price::online(meter.clone(), when)))
|
||||
.or_else(|| self.rate(&meter.fallback()?, tier, band))
|
||||
}
|
||||
}
|
||||
|
||||
impl OffPeak {
|
||||
pub fn new(schedule: Schedule, rates: OffPeakRates) -> Option<Self> {
|
||||
(rates != OffPeakRates::default()).then_some(Self { schedule, rates })
|
||||
}
|
||||
|
||||
pub fn schedule(&self) -> &Schedule {
|
||||
&self.schedule
|
||||
}
|
||||
|
||||
pub fn rates(&self) -> &OffPeakRates {
|
||||
&self.rates
|
||||
}
|
||||
}
|
||||
|
||||
impl Adjustments {
|
||||
pub fn new(uplifts: impl IntoIterator<Item = (Uplift, Multiplier)>) -> Self {
|
||||
Self(uplifts.into_iter().collect())
|
||||
}
|
||||
|
||||
pub fn get(&self, uplift: &Uplift) -> Option<Multiplier> {
|
||||
self.0.get(uplift).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use super::*;
|
||||
|
||||
const INPUT: Meter = Meter::TEXT_INPUT;
|
||||
const OUTPUT: Meter = Meter::Output(OutputUnit::Token(TokenKind::Text));
|
||||
const CACHE_READ: Meter = Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Implicit,
|
||||
};
|
||||
const CACHE_WRITE_HOUR: Meter = Meter::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::OneHour,
|
||||
};
|
||||
|
||||
fn over(tokens: u64) -> Band {
|
||||
Band::over_prompt_tokens(tokens)
|
||||
}
|
||||
|
||||
fn prices(rates: &[(Meter, Channel, ServiceTier, Band, f64)]) -> Prices {
|
||||
priced_with(rates, Billing::default())
|
||||
}
|
||||
|
||||
fn priced_with(rates: &[(Meter, Channel, ServiceTier, Band, f64)], billing: Billing) -> Prices {
|
||||
Prices::new(
|
||||
rates
|
||||
.iter()
|
||||
.map(|(meter, channel, tier, band, rate)| {
|
||||
(
|
||||
Price {
|
||||
meter: meter.clone(),
|
||||
channel: *channel,
|
||||
when: When {
|
||||
tier: *tier,
|
||||
band: *band,
|
||||
},
|
||||
},
|
||||
UnitPrice::new(*rate).unwrap(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
None,
|
||||
billing,
|
||||
)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn priority_with_a_standard_band() -> Prices {
|
||||
use Channel::Online;
|
||||
use ServiceTier::{Priority, Standard};
|
||||
prices(&[
|
||||
(INPUT, Online, Standard, Band::Base, 1.0),
|
||||
(INPUT, Online, Priority, Band::Base, 2.0),
|
||||
(INPUT, Online, Standard, over(200_000), 10.0),
|
||||
(OUTPUT, Online, Standard, Band::Base, 3.0),
|
||||
(OUTPUT, Online, Priority, over(200_000), 30.0),
|
||||
])
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::below(1_000, false, Band::Base)]
|
||||
#[case::at_floor_exclusive(200_000, false, Band::Base)]
|
||||
#[case::at_floor_inclusive(200_000, true, over(200_000))]
|
||||
#[case::above(200_001, false, over(200_000))]
|
||||
fn the_band_starts_strictly_above_its_floor_unless_inclusive(
|
||||
#[case] prompt_tokens: u64,
|
||||
#[case] inclusive: bool,
|
||||
#[case] expected: Band,
|
||||
) {
|
||||
let band_edge = if inclusive {
|
||||
BandEdge::Inclusive
|
||||
} else {
|
||||
BandEdge::Exclusive
|
||||
};
|
||||
let prices = priced_with(
|
||||
&[(
|
||||
INPUT,
|
||||
Channel::Online,
|
||||
ServiceTier::Standard,
|
||||
over(200_000),
|
||||
10.0,
|
||||
)],
|
||||
Billing {
|
||||
band_edge,
|
||||
..Billing::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(prices.band(ServiceTier::Standard, prompt_tokens), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_band_only_another_tier_prices_does_not_apply() {
|
||||
let prices = prices(&[(
|
||||
OUTPUT,
|
||||
Channel::Online,
|
||||
ServiceTier::Flex,
|
||||
over(100_000),
|
||||
1.0,
|
||||
)]);
|
||||
assert_eq!(prices.band(ServiceTier::Standard, 300_000), Band::Base);
|
||||
assert_eq!(prices.band(ServiceTier::Flex, 300_000), over(100_000));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn batch_bands_never_pick_the_online_band() {
|
||||
let prices = prices(&[(
|
||||
INPUT,
|
||||
Channel::Batch,
|
||||
ServiceTier::Standard,
|
||||
over(100_000),
|
||||
1.0,
|
||||
)]);
|
||||
assert_eq!(prices.band(ServiceTier::Standard, 300_000), Band::Base);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_highest_band_passed_wins() {
|
||||
let prices = prices(&[
|
||||
(
|
||||
INPUT,
|
||||
Channel::Online,
|
||||
ServiceTier::Standard,
|
||||
over(100_000),
|
||||
1.0,
|
||||
),
|
||||
(
|
||||
OUTPUT,
|
||||
Channel::Online,
|
||||
ServiceTier::Standard,
|
||||
over(200_000),
|
||||
1.0,
|
||||
),
|
||||
]);
|
||||
assert_eq!(prices.band(ServiceTier::Standard, 300_000), over(200_000));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tier_base(INPUT, ServiceTier::Priority, Band::Base, Some(2.0))]
|
||||
#[case::standard_band_beats_tier_base(INPUT, ServiceTier::Priority, over(200_000), Some(10.0))]
|
||||
#[case::tier_band(OUTPUT, ServiceTier::Priority, over(200_000), Some(30.0))]
|
||||
#[case::standard_base_when_nothing_else(OUTPUT, ServiceTier::Flex, over(200_000), Some(3.0))]
|
||||
#[case::cache_read_falls_back_to_the_resolved_input(
|
||||
CACHE_READ,
|
||||
ServiceTier::Priority,
|
||||
over(200_000),
|
||||
Some(10.0)
|
||||
)]
|
||||
#[case::one_hour_write_falls_back_through_write_to_input(
|
||||
CACHE_WRITE_HOUR,
|
||||
ServiceTier::Priority,
|
||||
Band::Base,
|
||||
Some(2.0)
|
||||
)]
|
||||
#[case::twelve_hour_write_falls_back_through_write_to_input(
|
||||
Meter::CacheWrite { kind: TokenKind::Text, ttl: CacheTtl::TwelveHours },
|
||||
ServiceTier::Standard,
|
||||
Band::Base,
|
||||
Some(1.0)
|
||||
)]
|
||||
#[case::no_fallback_for_other_meters(
|
||||
Meter::Input(InputUnit::Token(TokenKind::Audio)),
|
||||
ServiceTier::Standard,
|
||||
Band::Base,
|
||||
None
|
||||
)]
|
||||
fn rate_walks_tier_then_band(
|
||||
priority_with_a_standard_band: Prices,
|
||||
#[case] meter: Meter,
|
||||
#[case] tier: ServiceTier,
|
||||
#[case] band: Band,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
assert_eq!(
|
||||
priority_with_a_standard_band
|
||||
.rate(&meter, tier, band)
|
||||
.map(UnitPrice::get),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn an_explicit_cache_price_wins_over_the_input_fallback() {
|
||||
let prices = prices(&[
|
||||
(
|
||||
INPUT,
|
||||
Channel::Online,
|
||||
ServiceTier::Standard,
|
||||
Band::Base,
|
||||
1.0,
|
||||
),
|
||||
(
|
||||
CACHE_READ,
|
||||
Channel::Online,
|
||||
ServiceTier::Standard,
|
||||
Band::Base,
|
||||
0.0,
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
prices
|
||||
.rate(&CACHE_READ, ServiceTier::Standard, Band::Base)
|
||||
.map(UnitPrice::get),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn off_peak_needs_a_price() {
|
||||
let schedule = Schedule {
|
||||
windows: Vec::new(),
|
||||
weekday_timezone: None,
|
||||
};
|
||||
assert_eq!(
|
||||
OffPeak::new(schedule.clone(), OffPeakRates::default()),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
OffPeak::new(
|
||||
schedule,
|
||||
OffPeakRates {
|
||||
output: UnitPrice::new(1.0),
|
||||
..OffPeakRates::default()
|
||||
}
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::zero(0, Band::Base)]
|
||||
#[case::positive(272_000, Band::OverPromptTokens(NonZeroU64::new(272_000).unwrap()))]
|
||||
fn a_zero_floor_is_the_base_band(#[case] tokens: u64, #[case] expected: Band) {
|
||||
assert_eq!(Band::over_prompt_tokens(tokens), expected);
|
||||
}
|
||||
}
|
||||
197
litellm-rust/crates/cost/src/price/meter.rs
Normal file
197
litellm-rust/crates/cost/src/price/meter.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
use std::num::NonZeroU64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Meter {
|
||||
Input(InputUnit),
|
||||
Output(OutputUnit),
|
||||
CacheRead {
|
||||
kind: TokenKind,
|
||||
mode: CacheReadMode,
|
||||
},
|
||||
CacheWrite {
|
||||
kind: TokenKind,
|
||||
ttl: CacheTtl,
|
||||
},
|
||||
Feature(Feature),
|
||||
WallClockSecond,
|
||||
WebSearch(SearchContextSize),
|
||||
Guardrail(GuardrailUnit),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TokenKind {
|
||||
Text,
|
||||
Audio,
|
||||
Image,
|
||||
Video,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InputUnit {
|
||||
Token(TokenKind),
|
||||
Character,
|
||||
Image,
|
||||
Pixel,
|
||||
Query { over_results: Option<NonZeroU64> },
|
||||
Request,
|
||||
Second,
|
||||
TextMessage,
|
||||
AudioSecond,
|
||||
VideoSecond { over_seconds: Option<NonZeroU64> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum OutputUnit {
|
||||
Token(TokenKind),
|
||||
ReasoningToken,
|
||||
Character,
|
||||
Image(Option<ImageSize>),
|
||||
Pixel,
|
||||
Second(Option<Resolution>),
|
||||
VideoSecond,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CacheReadMode {
|
||||
Implicit,
|
||||
Explicit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CacheTtl {
|
||||
Default,
|
||||
OneHour,
|
||||
TwelveHours,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Feature {
|
||||
AnnotationPage,
|
||||
CitationToken,
|
||||
CodeInterpreterSession,
|
||||
GoogleMapsGroundingQuery,
|
||||
OcrCredit,
|
||||
OcrPage,
|
||||
/// `browser_open_cost_per_request`, Groq `browser.open`
|
||||
BrowserOpen,
|
||||
/// `cost_per_additional_result`, Parallel AI results past the ones the query price includes
|
||||
AdditionalResult,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SearchContextSize {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct GuardrailUnit(Box<str>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ImageSize(Box<str>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Resolution(Box<str>);
|
||||
|
||||
impl Meter {
|
||||
pub(crate) const TEXT_INPUT: Self = Self::Input(InputUnit::Token(TokenKind::Text));
|
||||
|
||||
pub(crate) fn sets_prompt_band(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Input(InputUnit::Token(TokenKind::Text))
|
||||
| Self::Output(OutputUnit::Token(TokenKind::Text))
|
||||
| Self::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Implicit,
|
||||
}
|
||||
| Self::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fallback(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Implicit,
|
||||
}
|
||||
| Self::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::Default,
|
||||
} => Some(Self::TEXT_INPUT),
|
||||
Self::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::OneHour | CacheTtl::TwelveHours,
|
||||
} => Some(Self::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::Default,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GuardrailUnit {
|
||||
pub fn new(counter: &str) -> Self {
|
||||
Self(counter.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageSize {
|
||||
pub fn new(label: &str) -> Option<Self> {
|
||||
let number = label.strip_suffix('K').unwrap_or(label);
|
||||
let (whole, fraction) = number.split_once('.').unwrap_or((number, "0"));
|
||||
(is_digits(whole) && is_digits(fraction)).then(|| Self(label.into()))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
pub fn new(label: &str) -> Option<Self> {
|
||||
let digits = label.strip_suffix(['p', 'k'])?;
|
||||
is_digits(digits).then(|| Self(label.into()))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn is_digits(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::kilo("1K", true)]
|
||||
#[case::half_kilo("0.5K", true)]
|
||||
#[case::pixels("1024", true)]
|
||||
#[case::word("big", false)]
|
||||
#[case::bare_suffix("K", false)]
|
||||
#[case::trailing_dot("1.K", false)]
|
||||
fn image_size_labels_are_numbers_with_an_optional_k(#[case] label: &str, #[case] valid: bool) {
|
||||
assert_eq!(ImageSize::new(label).is_some(), valid);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::progressive("720p", true)]
|
||||
#[case::kilo("4k", true)]
|
||||
#[case::bare("720", false)]
|
||||
#[case::word("hd", false)]
|
||||
fn resolution_labels_are_numbers_with_p_or_k(#[case] label: &str, #[case] valid: bool) {
|
||||
assert_eq!(Resolution::new(label).is_some(), valid);
|
||||
}
|
||||
}
|
||||
53
litellm-rust/crates/cost/src/priced.rs
Normal file
53
litellm-rust/crates/cost/src/priced.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::money::{UnitPrice, Usd};
|
||||
use crate::price::Meter;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Outcome {
|
||||
Priced(Priced),
|
||||
/// No meter with usage had a price
|
||||
Unpriced {
|
||||
missing: Vec<Meter>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Priced {
|
||||
/// One line per billed meter, fallbacks included
|
||||
pub lines: Vec<Line>,
|
||||
/// Meters with usage and no price and no fallback
|
||||
pub missing: Vec<Meter>,
|
||||
/// Sum of `lines`
|
||||
pub total: Usd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Line {
|
||||
/// The meter this line bills
|
||||
pub meter: Meter,
|
||||
/// How much of the meter the call used
|
||||
pub quantity: Quantity,
|
||||
/// Rate after off-peak and before uplifts
|
||||
pub unit_price: UnitPrice,
|
||||
/// `unit_price * quantity` times every uplift that applies
|
||||
pub amount: Usd,
|
||||
/// The meter whose rate was borrowed, when this meter had none
|
||||
pub fallback_from: Option<Meter>,
|
||||
/// Which half of main's `(prompt, completion)` pair the line belongs to
|
||||
pub side: Side,
|
||||
}
|
||||
|
||||
/// A billed amount of one meter
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Quantity {
|
||||
Count(u64),
|
||||
Duration(Duration),
|
||||
}
|
||||
|
||||
/// Meters main bills as one total (video, Replicate, Amazon Transcribe) take a fixed side, chosen in 09
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Prompt,
|
||||
Completion,
|
||||
}
|
||||
241
litellm-rust/crates/cost/src/schedule.rs
Normal file
241
litellm-rust/crates/cost/src/schedule.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Schedule {
|
||||
pub windows: Vec<Window>,
|
||||
pub weekday_timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub hours: Vec<UtcWindow>,
|
||||
pub weekdays: Option<Vec<Weekday>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct UtcWindow {
|
||||
pub start_minute: u16,
|
||||
pub end_minute: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Weekday {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
impl FromStr for UtcWindow {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(window: &str) -> Result<Self, Self::Err> {
|
||||
let (start, end) = window.split_once('-').ok_or(Error::InvalidUtcWindow)?;
|
||||
Ok(Self {
|
||||
start_minute: minute_of_day(start).ok_or(Error::InvalidUtcWindow)?,
|
||||
end_minute: minute_of_day(end).ok_or(Error::InvalidUtcWindow)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn minute_of_day(clock: &str) -> Option<u16> {
|
||||
let (hour, minute) = clock.split_once(':')?;
|
||||
if hour.len() != 2 || minute.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let hour: u16 = hour.parse().ok().filter(|hour| *hour < 24)?;
|
||||
let minute: u16 = minute.parse().ok().filter(|minute| *minute < 60)?;
|
||||
Some(hour * 60 + minute)
|
||||
}
|
||||
|
||||
impl Weekday {
|
||||
pub fn from_iso(number: u8) -> Option<Self> {
|
||||
match number {
|
||||
1 => Some(Self::Monday),
|
||||
2 => Some(Self::Tuesday),
|
||||
3 => Some(Self::Wednesday),
|
||||
4 => Some(Self::Thursday),
|
||||
5 => Some(Self::Friday),
|
||||
6 => Some(Self::Saturday),
|
||||
7 => Some(Self::Sunday),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Weekday {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(name: &str) -> Result<Self, Self::Err> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"mon" | "monday" => Ok(Self::Monday),
|
||||
"tue" | "tues" | "tuesday" => Ok(Self::Tuesday),
|
||||
"wed" | "wednesday" => Ok(Self::Wednesday),
|
||||
"thu" | "thur" | "thurs" | "thursday" => Ok(Self::Thursday),
|
||||
"fri" | "friday" => Ok(Self::Friday),
|
||||
"sat" | "saturday" => Ok(Self::Saturday),
|
||||
"sun" | "sunday" => Ok(Self::Sunday),
|
||||
_ => Err(Error::InvalidWeekday),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
mod serde_impls {
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Deserialize, Deserializer, Visitor};
|
||||
|
||||
use super::{UtcWindow, Weekday};
|
||||
|
||||
impl<'de> Deserialize<'de> for UtcWindow {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let window = String::deserialize(deserializer)?;
|
||||
window.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
struct WrittenWeekday;
|
||||
|
||||
impl Visitor<'_> for WrittenWeekday {
|
||||
type Value = Weekday;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("an ISO weekday number 1-7 or an English day name")
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, number: u64) -> Result<Weekday, E> {
|
||||
u8::try_from(number)
|
||||
.ok()
|
||||
.and_then(Weekday::from_iso)
|
||||
.ok_or_else(|| E::invalid_value(de::Unexpected::Unsigned(number), &self))
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, number: i64) -> Result<Weekday, E> {
|
||||
u64::try_from(number)
|
||||
.map_err(|_| E::invalid_value(de::Unexpected::Signed(number), &self))
|
||||
.and_then(|number| self.visit_u64(number))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, name: &str) -> Result<Weekday, E> {
|
||||
name.parse()
|
||||
.map_err(|_| E::invalid_value(de::Unexpected::Str(name), &self))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Weekday {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_any(WrittenWeekday)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
mod schema {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
|
||||
use super::{UtcWindow, Weekday};
|
||||
|
||||
impl JsonSchema for UtcWindow {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"UtcWindow".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "string",
|
||||
"pattern": r"^([01]\d|2[0-3]):[0-5]\d-([01]\d|2[0-3]):[0-5]\d$"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Weekday {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Weekday".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 1, "maximum": 7},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": r"(?i)^\s*(mon|monday|tue|tues|tuesday|wed|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday|sun|sunday)\s*$"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::same_day("10:00-18:30", Some((600, 1110)))]
|
||||
#[case::wraps_midnight("22:00-02:00", Some((1320, 120)))]
|
||||
#[case::whole_day("00:00-00:00", Some((0, 0)))]
|
||||
#[case::hour_out_of_range("25:00-01:00", None)]
|
||||
#[case::minute_out_of_range("10:60-11:00", None)]
|
||||
#[case::one_digit_hour("9:00-10:00", None)]
|
||||
#[case::no_separator("10:00", None)]
|
||||
fn utc_window_reads_hh_mm_ranges(#[case] text: &str, #[case] expected: Option<(u16, u16)>) {
|
||||
assert_eq!(
|
||||
text.parse::<UtcWindow>()
|
||||
.ok()
|
||||
.map(|window| (window.start_minute, window.end_minute)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::short("thurs", Some(Weekday::Thursday))]
|
||||
#[case::upper("SATURDAY", Some(Weekday::Saturday))]
|
||||
#[case::padded(" mon ", Some(Weekday::Monday))]
|
||||
#[case::unknown("someday", None)]
|
||||
#[case::empty("", None)]
|
||||
fn weekday_names_match_python_after_trimming(
|
||||
#[case] name: &str,
|
||||
#[case] expected: Option<Weekday>,
|
||||
) {
|
||||
assert_eq!(name.parse().ok(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::monday(1, Some(Weekday::Monday))]
|
||||
#[case::sunday(7, Some(Weekday::Sunday))]
|
||||
#[case::zero(0, None)]
|
||||
#[case::eight(8, None)]
|
||||
fn weekday_numbers_are_iso(#[case] number: u8, #[case] expected: Option<Weekday>) {
|
||||
assert_eq!(Weekday::from_iso(number), expected);
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
#[rstest]
|
||||
#[case::number(serde_json::json!(6), Some(Weekday::Saturday))]
|
||||
#[case::name(serde_json::json!("Sun"), Some(Weekday::Sunday))]
|
||||
#[case::negative(serde_json::json!(-1), None)]
|
||||
#[case::boolean(serde_json::json!(true), None)]
|
||||
fn weekday_reads_numbers_and_names(
|
||||
#[case] written: serde_json::Value,
|
||||
#[case] expected: Option<Weekday>,
|
||||
) {
|
||||
use serde::Deserialize;
|
||||
|
||||
assert_eq!(Weekday::deserialize(&written).ok(), expected);
|
||||
}
|
||||
}
|
||||
104
litellm-rust/crates/cost/src/usage.rs
Normal file
104
litellm-rust/crates/cost/src/usage.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::price::{CacheReadMode, CacheTtl, ImageSize, Resolution, SearchContextSize, TokenKind};
|
||||
|
||||
/// What one call consumed. Python fills it from the provider response, `calculate` never reads the catalog for it
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
/// Prompt size as the provider reported it. Only picks `_above_Nk_tokens` bands, never billed.
|
||||
/// Filled even when the call bills characters, `0` when the provider reports none
|
||||
pub prompt_tokens: u64,
|
||||
/// Billed token quantities by meter
|
||||
pub tokens: Tokens,
|
||||
/// Billed character quantities. Used only when the row's `billing.text_unit` is `Character`
|
||||
pub characters: Characters,
|
||||
/// Billed time per second meter, never a bare number of seconds or milliseconds
|
||||
pub durations: BTreeMap<DurationMeter, Duration>,
|
||||
/// Images sent and returned
|
||||
pub images: Images,
|
||||
/// Search queries, web searches and grounding requests
|
||||
pub search: Search,
|
||||
/// Realtime text messages, xAI bills them per message
|
||||
pub text_messages: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Tokens {
|
||||
/// Uncached prompt tokens by modality
|
||||
pub input: BTreeMap<TokenKind, u64>,
|
||||
/// Prompt tokens served from cache, by modality and implicit or explicit cache
|
||||
pub cache_read: BTreeMap<(TokenKind, CacheReadMode), u64>,
|
||||
/// Prompt tokens written to cache, by modality and cache lifetime
|
||||
pub cache_write: BTreeMap<(TokenKind, CacheTtl), u64>,
|
||||
/// Completion tokens by modality, reasoning excluded
|
||||
pub output: BTreeMap<TokenKind, u64>,
|
||||
/// Reasoning tokens, billed at the reasoning rate or the text output rate
|
||||
pub reasoning: u64,
|
||||
/// Perplexity citation tokens, prompt side
|
||||
pub citation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Characters {
|
||||
/// Characters of the formatted prompt
|
||||
pub prompt: u64,
|
||||
/// Characters of the response text
|
||||
pub completion: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum DurationMeter {
|
||||
/// Response time of the call, for rows priced by wall clock
|
||||
WallClock,
|
||||
/// Length of the input audio, a transcription file or a realtime transcription
|
||||
InputAudio,
|
||||
/// Realtime voice session time
|
||||
LiveVoice,
|
||||
/// Length of generated video, already multiplied by the video count. `resolution` picks `output_cost_per_second_<resolution>`
|
||||
OutputVideo { resolution: Option<Resolution> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Images {
|
||||
/// Images sent in the request
|
||||
pub input: u64,
|
||||
/// Pixels sent in the request, for rows priced per input pixel
|
||||
pub input_pixels: u64,
|
||||
/// One entry per returned image, since price can depend on each image's size
|
||||
pub output: Vec<ImageOut>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ImageOut {
|
||||
/// Size the request asked for, picks a size keyed price like fal's `output_cost_per_image_<size>`
|
||||
pub size: Option<ImageSize>,
|
||||
/// Pixel width the response reported, for per-pixel and nearest-size pricing
|
||||
pub width: Option<u32>,
|
||||
/// Pixel height the response reported, for per-pixel and nearest-size pricing
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Search {
|
||||
/// Search API queries, for `input_cost_per_query` rows
|
||||
pub queries: u64,
|
||||
/// Results the query asked for, picks the `max_results_range` tier
|
||||
pub max_results: Option<u64>,
|
||||
/// Web searches a model ran as a tool
|
||||
pub web_search: Option<WebSearch>,
|
||||
/// Google Maps grounding requests
|
||||
pub maps_grounding: u64,
|
||||
/// Groq `browser.open` calls
|
||||
pub browser_opens: u64,
|
||||
/// Results past the ones the query price includes. Python counts them, since the included 10 is a request default
|
||||
pub additional_results: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct WebSearch {
|
||||
/// Web search requests the model made
|
||||
pub requests: u64,
|
||||
/// Context size the request asked for, picks `search_context_size_<size>`
|
||||
pub context: SearchContextSize,
|
||||
}
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
use litellm_cost::{
|
||||
Error, OffPeakRates, Pricing, PromptConvention, Rate, Rates, Request, ServiceTier,
|
||||
ThresholdPolicy, ThresholdRates, TierRates, Usage, calculate, compile,
|
||||
};
|
||||
use rstest::rstest;
|
||||
|
||||
fn rates(input: Rate, output: Rate) -> Rates {
|
||||
Rates {
|
||||
input,
|
||||
output,
|
||||
..Rates::EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> Request {
|
||||
Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
cache_read_tokens: 25,
|
||||
cache_write_tokens: 10,
|
||||
cache_write_5m_tokens: None,
|
||||
cache_write_1h_tokens: None,
|
||||
prompt_convention: PromptConvention::IncludesCache,
|
||||
},
|
||||
service_tier: ServiceTier::Standard,
|
||||
threshold_policy: ThresholdPolicy::Exclusive,
|
||||
region_multiplier: None,
|
||||
billed_at_utc_minute: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pricing(standard: Rates) -> Pricing<'static> {
|
||||
Pricing {
|
||||
standard,
|
||||
tiers: &[],
|
||||
thresholds: &[],
|
||||
off_peak: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakdown_and_total_agree() {
|
||||
let standard = Rates {
|
||||
cache_read: Rate::Value(0.5),
|
||||
cache_write: Rate::Value(3.0),
|
||||
..rates(Rate::Value(2.0), Rate::Value(4.0))
|
||||
};
|
||||
let result = calculate(&pricing(standard), &request()).unwrap();
|
||||
assert_eq!(result.uncached_input, 65.0 * 2.0);
|
||||
assert_eq!(result.cache_read, 25.0 * 0.5);
|
||||
assert_eq!(result.cache_write_5m, 10.0 * 3.0);
|
||||
assert_eq!(result.output(), 20.0 * 4.0);
|
||||
assert_eq!(result.total(), result.input() + result.output());
|
||||
assert_eq!(result.rates.cache_read, 0.5);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(Rate::Missing, Rate::Missing, 200.0)]
|
||||
#[case::null(Rate::Null, Rate::Missing, 200.0)]
|
||||
#[case::zero(Rate::Value(0.0), Rate::Value(0.0), 130.0)]
|
||||
fn absent_null_and_zero_cache_rates_are_distinct(
|
||||
#[case] cache_read: Rate,
|
||||
#[case] cache_write: Rate,
|
||||
#[case] expected_input: f64,
|
||||
) {
|
||||
let base = rates(Rate::Value(2.0), Rate::Value(4.0));
|
||||
let standard = Rates {
|
||||
cache_read,
|
||||
cache_write,
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&pricing(standard), &request()).unwrap().input(),
|
||||
expected_input
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_prompt_conventions_select_the_same_threshold() {
|
||||
let threshold = ThresholdRates {
|
||||
above_prompt_tokens: 90,
|
||||
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
|
||||
tiers: &[],
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
|
||||
tiers: &[],
|
||||
thresholds: &[threshold],
|
||||
off_peak: None,
|
||||
};
|
||||
let included = request();
|
||||
let excluded = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 65,
|
||||
prompt_convention: PromptConvention::ExcludesCache,
|
||||
..included.usage
|
||||
},
|
||||
..included
|
||||
};
|
||||
let plan = compile(&specification).unwrap();
|
||||
assert_eq!(plan.calculate(&included), plan.calculate(&excluded));
|
||||
assert_eq!(plan.calculate(&included).unwrap().rates.input, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_writes_and_invalid_accounting() {
|
||||
let standard = Rates {
|
||||
cache_read: Rate::Value(0.5),
|
||||
cache_write: Rate::Value(3.0),
|
||||
cache_write_1h: Rate::Value(5.0),
|
||||
..rates(Rate::Value(2.0), Rate::Value(4.0))
|
||||
};
|
||||
let base = request();
|
||||
let split = Request {
|
||||
usage: Usage {
|
||||
cache_write_5m_tokens: Some(4),
|
||||
cache_write_1h_tokens: Some(6),
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
let result = calculate(&pricing(standard), &split).unwrap();
|
||||
assert_eq!(result.cache_write_5m, 12.0);
|
||||
assert_eq!(result.cache_write_1h, 30.0);
|
||||
let overlapping = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 30,
|
||||
..split.usage
|
||||
},
|
||||
..split
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&pricing(standard), &overlapping),
|
||||
Err(Error::CacheExceedsPrompt)
|
||||
);
|
||||
let incomplete = Request {
|
||||
usage: Usage {
|
||||
cache_write_1h_tokens: None,
|
||||
..split.usage
|
||||
},
|
||||
..split
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&pricing(standard), &incomplete),
|
||||
Err(Error::InvalidCacheWriteDetails)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_tiers_and_boundaries() {
|
||||
let priority = TierRates {
|
||||
tier: ServiceTier::Priority,
|
||||
rates: rates(Rate::Value(3.0), Rate::Missing),
|
||||
};
|
||||
let threshold = ThresholdRates {
|
||||
above_prompt_tokens: 100,
|
||||
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
|
||||
tiers: &[
|
||||
TierRates {
|
||||
tier: ServiceTier::Priority,
|
||||
rates: rates(Rate::Value(7.0), Rate::Missing),
|
||||
},
|
||||
TierRates {
|
||||
tier: ServiceTier::Flex,
|
||||
rates: rates(Rate::Value(6.0), Rate::Missing),
|
||||
},
|
||||
],
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
|
||||
tiers: &[priority],
|
||||
thresholds: &[threshold],
|
||||
off_peak: None,
|
||||
};
|
||||
let base = request();
|
||||
let no_cache = Request {
|
||||
usage: Usage {
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
let fast = Request {
|
||||
service_tier: ServiceTier::Fast,
|
||||
..no_cache
|
||||
};
|
||||
let inclusive = Request {
|
||||
threshold_policy: ThresholdPolicy::Inclusive,
|
||||
..fast
|
||||
};
|
||||
let flex = Request {
|
||||
service_tier: ServiceTier::Flex,
|
||||
..inclusive
|
||||
};
|
||||
assert_eq!(calculate(&specification, &no_cache).unwrap().input(), 200.0);
|
||||
assert_eq!(calculate(&specification, &fast).unwrap().input(), 300.0);
|
||||
assert_eq!(
|
||||
calculate(&specification, &inclusive).unwrap().input(),
|
||||
700.0
|
||||
);
|
||||
assert_eq!(calculate(&specification, &flex).unwrap().input(), 600.0);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::ultrafast_above_threshold(ServiceTier::Ultrafast, 300_000, 9_301_000.0, 37_000.0)]
|
||||
#[case::ultrafast_at_threshold(ServiceTier::Ultrafast, 272_000, 544_500.0, 5_000.0)]
|
||||
#[case::standard_above_threshold(ServiceTier::Standard, 300_000, 3_300_600.0, 13_000.0)]
|
||||
#[case::priority_above_threshold(ServiceTier::Priority, 300_000, 5_701_000.0, 23_000.0)]
|
||||
fn tiered_long_context_rates_are_selected_by_service_tier(
|
||||
#[case] service_tier: ServiceTier,
|
||||
#[case] prompt_tokens: u64,
|
||||
#[case] expected_input: f64,
|
||||
#[case] expected_output: f64,
|
||||
) {
|
||||
let standard = Rates {
|
||||
cache_read: Rate::Value(3.0),
|
||||
..rates(Rate::Value(1.0), Rate::Value(2.0))
|
||||
};
|
||||
let tiers = [
|
||||
TierRates {
|
||||
tier: ServiceTier::Priority,
|
||||
rates: Rates {
|
||||
cache_read: Rate::Value(5.0),
|
||||
..rates(Rate::Value(3.0), Rate::Value(4.0))
|
||||
},
|
||||
},
|
||||
TierRates {
|
||||
tier: ServiceTier::Ultrafast,
|
||||
rates: Rates {
|
||||
cache_read: Rate::Value(7.0),
|
||||
..rates(Rate::Value(2.0), Rate::Value(5.0))
|
||||
},
|
||||
},
|
||||
];
|
||||
let threshold_tiers = [
|
||||
TierRates {
|
||||
tier: ServiceTier::Priority,
|
||||
rates: Rates {
|
||||
cache_read: Rate::Value(29.0),
|
||||
..rates(Rate::Value(19.0), Rate::Value(23.0))
|
||||
},
|
||||
},
|
||||
TierRates {
|
||||
tier: ServiceTier::Ultrafast,
|
||||
rates: Rates {
|
||||
cache_read: Rate::Value(41.0),
|
||||
..rates(Rate::Value(31.0), Rate::Value(37.0))
|
||||
},
|
||||
},
|
||||
];
|
||||
let thresholds = [ThresholdRates {
|
||||
above_prompt_tokens: 272_000,
|
||||
standard: Rates {
|
||||
cache_read: Rate::Value(17.0),
|
||||
..rates(Rate::Value(11.0), Rate::Value(13.0))
|
||||
},
|
||||
tiers: &threshold_tiers,
|
||||
}];
|
||||
let pricing = Pricing {
|
||||
standard,
|
||||
tiers: &tiers,
|
||||
thresholds: &thresholds,
|
||||
off_peak: None,
|
||||
};
|
||||
let base = request();
|
||||
let long_context_request = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens: 1_000,
|
||||
cache_read_tokens: 100,
|
||||
cache_write_tokens: 0,
|
||||
..base.usage
|
||||
},
|
||||
service_tier,
|
||||
..base
|
||||
};
|
||||
let cost = calculate(&pricing, &long_context_request).unwrap();
|
||||
|
||||
assert_eq!(cost.input(), expected_input);
|
||||
assert_eq!(cost.output(), expected_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_rejects_ambiguous_rates() {
|
||||
let duplicate = ThresholdRates {
|
||||
above_prompt_tokens: 100,
|
||||
standard: Rates::EMPTY,
|
||||
tiers: &[],
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(1.0), Rate::Value(1.0)),
|
||||
tiers: &[],
|
||||
thresholds: &[duplicate, duplicate],
|
||||
off_peak: None,
|
||||
};
|
||||
assert_eq!(
|
||||
compile(&specification).err(),
|
||||
Some(Error::DuplicateThreshold)
|
||||
);
|
||||
let invalid = pricing(rates(Rate::Value(f64::NAN), Rate::Value(1.0)));
|
||||
assert_eq!(compile(&invalid).err(), Some(Error::InvalidRate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_peak_is_one_non_wrapping_utc_window() {
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
|
||||
tiers: &[],
|
||||
thresholds: &[],
|
||||
off_peak: Some(OffPeakRates {
|
||||
start_utc_minute: 60,
|
||||
end_utc_minute: 120,
|
||||
rates: rates(Rate::Value(1.0), Rate::Value(2.0)),
|
||||
}),
|
||||
};
|
||||
let base = request();
|
||||
let start = Request {
|
||||
billed_at_utc_minute: Some(60),
|
||||
..base
|
||||
};
|
||||
let end = Request {
|
||||
billed_at_utc_minute: Some(120),
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&specification, &base),
|
||||
Err(Error::InvalidBillingTime)
|
||||
);
|
||||
assert_eq!(calculate(&specification, &start).unwrap().input(), 100.0);
|
||||
assert_eq!(calculate(&specification, &end).unwrap().input(), 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rates_and_free_rates_remain_distinct() {
|
||||
let base = request();
|
||||
let empty = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&pricing(Rates::EMPTY), &empty),
|
||||
Err(Error::MissingInputRate)
|
||||
);
|
||||
assert_eq!(
|
||||
calculate(&pricing(rates(Rate::Value(0.0), Rate::Missing)), &empty),
|
||||
Err(Error::MissingOutputRate)
|
||||
);
|
||||
assert_eq!(
|
||||
calculate(&pricing(rates(Rate::Value(0.0), Rate::Value(0.0))), &empty)
|
||||
.unwrap()
|
||||
.total(),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_executed_python_reference_cases() {
|
||||
for row in include_str!("python_reference.tsv")
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
{
|
||||
let fields: Vec<_> = row.split('\t').collect();
|
||||
let count = |index: usize| fields[index].parse::<u64>().unwrap();
|
||||
let number = |index: usize| fields[index].parse::<f64>().unwrap();
|
||||
let optional_rate = |index: usize| {
|
||||
if fields[index].is_empty() {
|
||||
Rate::Missing
|
||||
} else {
|
||||
Rate::Value(number(index))
|
||||
}
|
||||
};
|
||||
let threshold = ThresholdRates {
|
||||
above_prompt_tokens: if fields[9].is_empty() { 0 } else { count(9) },
|
||||
standard: rates(optional_rate(10), optional_rate(11)),
|
||||
tiers: &[],
|
||||
};
|
||||
let thresholds = if fields[9].is_empty() {
|
||||
&[][..]
|
||||
} else {
|
||||
std::slice::from_ref(&threshold)
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: Rates {
|
||||
cache_read: optional_rate(7),
|
||||
cache_write: optional_rate(8),
|
||||
..rates(Rate::Value(number(5)), Rate::Value(number(6)))
|
||||
},
|
||||
tiers: &[],
|
||||
thresholds,
|
||||
off_peak: None,
|
||||
};
|
||||
let base = request();
|
||||
let input = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: count(1),
|
||||
completion_tokens: count(2),
|
||||
cache_read_tokens: count(3),
|
||||
cache_write_tokens: count(4),
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
let actual = calculate(&specification, &input).unwrap();
|
||||
assert_eq!(actual.input(), number(12), "{}", fields[0]);
|
||||
assert_eq!(actual.output(), number(13), "{}", fields[0]);
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn equivalent_usage_conventions_and_breakdown_agree(
|
||||
regular in 0_u64..1000,
|
||||
read in 0_u64..1000,
|
||||
write in 0_u64..1000,
|
||||
output in 0_u64..1000,
|
||||
) {
|
||||
let threshold = ThresholdRates {
|
||||
above_prompt_tokens: 1000,
|
||||
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
|
||||
tiers: &[],
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
|
||||
tiers: &[],
|
||||
thresholds: &[threshold],
|
||||
off_peak: None,
|
||||
};
|
||||
let base = request();
|
||||
let included = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: regular + read + write,
|
||||
completion_tokens: output,
|
||||
cache_read_tokens: read,
|
||||
cache_write_tokens: write,
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
let excluded = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: regular,
|
||||
prompt_convention: PromptConvention::ExcludesCache,
|
||||
..included.usage
|
||||
},
|
||||
..included
|
||||
};
|
||||
let plan = compile(&specification).unwrap();
|
||||
let left = plan.calculate(&included).unwrap();
|
||||
let right = plan.calculate(&excluded).unwrap();
|
||||
proptest::prop_assert_eq!(left, right);
|
||||
proptest::prop_assert_eq!(left.total(), left.input() + left.output());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regional_multiplier_applies_after_input_components_are_summed() {
|
||||
let standard = Rates {
|
||||
cache_read: Rate::Value(0.5),
|
||||
cache_write: Rate::Value(3.0),
|
||||
..rates(Rate::Value(2.0), Rate::Value(4.0))
|
||||
};
|
||||
let base = request();
|
||||
let regional = Request {
|
||||
region_multiplier: Some(1.1),
|
||||
..base
|
||||
};
|
||||
let result = calculate(&pricing(standard), ®ional).unwrap();
|
||||
assert_eq!(result.input(), (65.0 * 2.0 + 25.0 * 0.5 + 10.0 * 3.0) * 1.1);
|
||||
assert_eq!(result.output(), 20.0 * 4.0 * 1.1);
|
||||
let invalid = Request {
|
||||
region_multiplier: Some(f64::NAN),
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
calculate(&pricing(standard), &invalid),
|
||||
Err(Error::InvalidRegionMultiplier)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_sorts_thresholds_and_rejects_duplicate_tiers() {
|
||||
let high = ThresholdRates {
|
||||
above_prompt_tokens: 200,
|
||||
standard: rates(Rate::Value(7.0), Rate::Missing),
|
||||
tiers: &[],
|
||||
};
|
||||
let low = ThresholdRates {
|
||||
above_prompt_tokens: 100,
|
||||
standard: rates(Rate::Value(5.0), Rate::Missing),
|
||||
tiers: &[],
|
||||
};
|
||||
let specification = Pricing {
|
||||
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
|
||||
tiers: &[],
|
||||
thresholds: &[high, low],
|
||||
off_peak: None,
|
||||
};
|
||||
let base = request();
|
||||
let above_both = Request {
|
||||
usage: Usage {
|
||||
prompt_tokens: 201,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
..base.usage
|
||||
},
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
compile(&specification)
|
||||
.unwrap()
|
||||
.calculate(&above_both)
|
||||
.unwrap()
|
||||
.rates
|
||||
.input,
|
||||
7.0
|
||||
);
|
||||
let duplicate = TierRates {
|
||||
tier: ServiceTier::Flex,
|
||||
rates: Rates::EMPTY,
|
||||
};
|
||||
let invalid = Pricing {
|
||||
tiers: &[duplicate, duplicate],
|
||||
thresholds: &[],
|
||||
..specification
|
||||
};
|
||||
assert_eq!(compile(&invalid).err(), Some(Error::DuplicateTier));
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Case:
|
||||
name: str
|
||||
prompt: int
|
||||
completion: int
|
||||
cache_read: int
|
||||
cache_write: int
|
||||
input_rate: float
|
||||
output_rate: float
|
||||
cache_read_rate: float | None = None
|
||||
cache_write_rate: float | None = None
|
||||
threshold: int | None = None
|
||||
threshold_input_rate: float | None = None
|
||||
threshold_output_rate: float | None = None
|
||||
|
||||
|
||||
CASES = (
|
||||
Case("ordinary", 100, 20, 0, 0, 2.0, 4.0),
|
||||
Case("cache_fallback", 100, 20, 25, 10, 2.0, 4.0),
|
||||
Case("cache_specific", 100, 20, 25, 10, 2.0, 4.0, 0.5, 3.0),
|
||||
Case("free_cache", 100, 20, 25, 10, 2.0, 4.0, 0.0, 0.0),
|
||||
Case("threshold_below", 99, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0),
|
||||
Case("threshold_at", 100, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0),
|
||||
Case(
|
||||
"threshold_above", 101, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0
|
||||
),
|
||||
Case(
|
||||
"cache_threshold_above",
|
||||
101,
|
||||
20,
|
||||
25,
|
||||
10,
|
||||
2.0,
|
||||
4.0,
|
||||
threshold=100,
|
||||
threshold_input_rate=5.0,
|
||||
threshold_output_rate=8.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def reference(case: Case) -> tuple[float, float]:
|
||||
info = {"input_cost_per_token": case.input_rate, "output_cost_per_token": case.output_rate}
|
||||
if case.cache_read_rate is not None:
|
||||
info["cache_read_input_token_cost"] = case.cache_read_rate
|
||||
if case.cache_write_rate is not None:
|
||||
info["cache_creation_input_token_cost"] = case.cache_write_rate
|
||||
if case.threshold is not None:
|
||||
info[f"input_cost_per_token_above_{case.threshold}_tokens"] = case.threshold_input_rate
|
||||
info[f"output_cost_per_token_above_{case.threshold}_tokens"] = case.threshold_output_rate
|
||||
details = {"cached_tokens": case.cache_read, "cache_write_tokens": case.cache_write}
|
||||
usage = Usage(prompt_tokens=case.prompt, completion_tokens=case.completion, prompt_tokens_details=details)
|
||||
return generic_cost_per_token(
|
||||
model="synthetic",
|
||||
usage=usage,
|
||||
custom_llm_provider="openai",
|
||||
model_info=info,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
revision = subprocess.check_output(("git", "rev-parse", "HEAD"), text=True).strip()
|
||||
rows = ("# Python reference commit: " + revision,) + tuple(
|
||||
"\t".join(
|
||||
str(value) if value is not None else ""
|
||||
for value in (
|
||||
case.name,
|
||||
case.prompt,
|
||||
case.completion,
|
||||
case.cache_read,
|
||||
case.cache_write,
|
||||
case.input_rate,
|
||||
case.output_rate,
|
||||
case.cache_read_rate,
|
||||
case.cache_write_rate,
|
||||
case.threshold,
|
||||
case.threshold_input_rate,
|
||||
case.threshold_output_rate,
|
||||
*reference(case),
|
||||
)
|
||||
)
|
||||
for case in CASES
|
||||
)
|
||||
Path(__file__).with_name("python_reference.tsv").write_text("\n".join(rows) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Python reference commit: dc4be2fd987c993aefcf16444e34f960c12c8627
|
||||
ordinary 100 20 0 0 2.0 4.0 200.0 80.0
|
||||
cache_fallback 100 20 25 10 2.0 4.0 200.0 80.0
|
||||
cache_specific 100 20 25 10 2.0 4.0 0.5 3.0 172.5 80.0
|
||||
free_cache 100 20 25 10 2.0 4.0 0.0 0.0 130.0 80.0
|
||||
threshold_below 99 20 0 0 2.0 4.0 100 5.0 8.0 198.0 80.0
|
||||
threshold_at 100 20 0 0 2.0 4.0 100 5.0 8.0 200.0 80.0
|
||||
threshold_above 101 20 0 0 2.0 4.0 100 5.0 8.0 505.0 160.0
|
||||
cache_threshold_above 101 20 25 10 2.0 4.0 100 5.0 8.0 505.0 160.0
|
||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -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]
|
||||
|
|
|
|||
19
litellm-rust/crates/model-catalog-refresh/Cargo.toml
Normal file
19
litellm-rust/crates/model-catalog-refresh/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "litellm-model-catalog-refresh"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
litellm-http.workspace = true
|
||||
litellm-model-catalog.workspace = true
|
||||
thiserror.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
litellm-error.workspace = true
|
||||
litellm-http = { workspace = true, features = ["test-support"] }
|
||||
reqwest.workspace = true
|
||||
rstest.workspace = true
|
||||
tokio.workspace = true
|
||||
105
litellm-rust/crates/model-catalog-refresh/src/error.rs
Normal file
105
litellm-rust/crates/model-catalog-refresh/src/error.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use std::io;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error(transparent)]
|
||||
pub struct Error(Box<Repr>);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum Repr {
|
||||
#[error(transparent)]
|
||||
Http(#[from] litellm_http::Error),
|
||||
#[error("could not read the catalog")]
|
||||
Read(#[source] io::Error),
|
||||
#[error("invalid catalog URL")]
|
||||
InvalidUrl(#[source] url::ParseError),
|
||||
#[error("unsupported catalog URL scheme {scheme}")]
|
||||
UnsupportedScheme { scheme: String },
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn unsupported_scheme(scheme: String) -> Self {
|
||||
Self(Box::new(Repr::UnsupportedScheme { scheme }))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<litellm_http::Error> for Error {
|
||||
fn from(cause: litellm_http::Error) -> Self {
|
||||
Self(Box::new(Repr::Http(cause)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(cause: io::Error) -> Self {
|
||||
Self(Box::new(Repr::Read(cause)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(cause: url::ParseError) -> Self {
|
||||
Self(Box::new(Repr::InvalidUrl(cause)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use litellm_error::{Report, find};
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use crate::{DoneReason, Reply, Step};
|
||||
|
||||
#[rstest]
|
||||
fn exhausted_transport_keeps_its_typed_cause() {
|
||||
let reply = Reply::Transport {
|
||||
error: io::Error::from(io::ErrorKind::ConnectionReset).into(),
|
||||
};
|
||||
let Reply::Transport { error } = reply else {
|
||||
panic!("transport failure")
|
||||
};
|
||||
let step = Step::Done {
|
||||
reason: DoneReason::Exhausted(error),
|
||||
};
|
||||
let Step::Done {
|
||||
reason: DoneReason::Exhausted(error),
|
||||
} = step
|
||||
else {
|
||||
panic!("exhausted attempts")
|
||||
};
|
||||
let cause = find::<io::Error>(&error).expect("typed I/O cause");
|
||||
assert_eq!(cause.kind(), io::ErrorKind::ConnectionReset);
|
||||
assert_eq!(
|
||||
Report::new(&error)
|
||||
.to_string()
|
||||
.matches(&cause.to_string())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(!error.to_string().contains(&cause.to_string()));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn invalid_source_keeps_its_typed_cause() {
|
||||
let error = Error::from(url::Url::parse("http://[invalid").unwrap_err());
|
||||
assert!(find::<url::ParseError>(&error).is_some());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn http_failures_keep_the_cause_without_the_url() {
|
||||
let cause = litellm_http::Client::for_test()
|
||||
.get("http://localhost:invalid/private?api_key=secret")
|
||||
.send()
|
||||
.await
|
||||
.unwrap_err();
|
||||
let error = Error::from(litellm_http::Error::from(cause));
|
||||
assert!(find::<reqwest::Error>(&error).is_some());
|
||||
let report = Report::new(&error).to_string();
|
||||
assert!(!report.contains("secret"), "{report}");
|
||||
assert!(!report.contains("private"), "{report}");
|
||||
let debug = format!("{error:?}");
|
||||
assert!(!debug.contains("secret"), "{debug}");
|
||||
assert!(!debug.contains("private"), "{debug}");
|
||||
}
|
||||
}
|
||||
9
litellm-rust/crates/model-catalog-refresh/src/lib.rs
Normal file
9
litellm-rust/crates/model-catalog-refresh/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
mod error;
|
||||
mod policy;
|
||||
mod source;
|
||||
mod step;
|
||||
|
||||
pub use error::Error;
|
||||
pub use policy::*;
|
||||
pub use source::*;
|
||||
pub use step::*;
|
||||
20
litellm-rust/crates/model-catalog-refresh/src/policy.rs
Normal file
20
litellm-rust/crates/model-catalog-refresh/src/policy.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_model_catalog::IntegrityLimits;
|
||||
|
||||
/// Main's defaults, 3 attempts, 30 s max wait, 5 s timeout, backoff base 2.0, jitter 0.0 to 1.0 s
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u32,
|
||||
pub max_wait: Duration,
|
||||
pub timeout: Duration,
|
||||
pub backoff_base_seconds: f64,
|
||||
pub jitter_min_seconds: f64,
|
||||
pub jitter_max_seconds: f64,
|
||||
/// Main's 50 model floor and 0.5 shrink ratio
|
||||
pub limits: IntegrityLimits,
|
||||
}
|
||||
|
||||
/// Main's default `LITELLM_MODEL_COST_MAP_URL`
|
||||
pub const DEFAULT_REGISTRY_URL: &str =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
17
litellm-rust/crates/model-catalog-refresh/src/source.rs
Normal file
17
litellm-rust/crates/model-catalog-refresh/src/source.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use url::Url;
|
||||
|
||||
/// Where a fetched catalog comes from
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Http(Url),
|
||||
File(PathBuf),
|
||||
S3(S3Object),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct S3Object {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
}
|
||||
66
litellm-rust/crates/model-catalog-refresh/src/step.rs
Normal file
66
litellm-rust/crates/model-catalog-refresh/src/step.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_model_catalog::Catalog;
|
||||
|
||||
use crate::Error;
|
||||
use crate::source::Source;
|
||||
|
||||
/// One attempt. Where to fetch from is the `Source`, so the request carries no URL
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FetchRequest {
|
||||
pub if_none_match: Option<String>,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Reply {
|
||||
/// An HTTP, S3 or file answer. The body is read only on 200
|
||||
Response {
|
||||
status: u16,
|
||||
etag: Option<String>,
|
||||
retry_after: Option<Duration>,
|
||||
body: Option<Vec<u8>>,
|
||||
},
|
||||
/// No answer reached us
|
||||
Transport { error: Error },
|
||||
}
|
||||
|
||||
/// What the fetch state machine asks its driver to do next
|
||||
#[derive(Debug)]
|
||||
pub enum Step {
|
||||
Fetch(FetchRequest),
|
||||
Sleep(Duration),
|
||||
/// A parsed document to install, with its rejected row errors
|
||||
Adopt {
|
||||
catalog: Box<Catalog>,
|
||||
installed: Installed,
|
||||
errors: Vec<litellm_model_catalog::Error>,
|
||||
},
|
||||
/// 304, a final status, or retries exhausted
|
||||
Done {
|
||||
reason: DoneReason,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DoneReason {
|
||||
NotModified,
|
||||
Status(u16),
|
||||
Exhausted(Error),
|
||||
}
|
||||
|
||||
/// What is installed now, the input to the next revalidation
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Installed {
|
||||
pub origin: Origin,
|
||||
pub etag: Option<String>,
|
||||
/// `git_blob_id` of the installed bytes
|
||||
pub revision: String,
|
||||
pub model_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
Bundled,
|
||||
Remote { source: Source },
|
||||
}
|
||||
|
|
@ -1,5 +1,27 @@
|
|||
## Validation
|
||||
## Layout
|
||||
|
||||
For `model_prices_and_context_window.json` validation, we should eventually:
|
||||
`wire/` reads one entry as written. `EntryWire::read` makes one pass over the entry's keys and puts every non-null key in exactly one place, which is an attribute, a flat price parsed by `FlatKey::parse`, a price block, a `supports_*` capability or `unknown`. A known key with a bad value becomes an `Error` that names the key, and the rest of the entry is still read
|
||||
|
||||
- Stop skipping this crate's tests
|
||||
`lower.rs` is the only code that knows both the wire types and `litellm-cost`. It lowers an `EntryWire` into `Prices`, `Adjustments` and `Model` and checks the rules that span keys, such as one price per meter and condition, contiguous tiers, no suffix inside a block, and off-peak needing hours and prices. Lowering only goes one way. Nothing turns `Prices` back into JSON, since the raw fields are kept
|
||||
|
||||
`catalog.rs` reads the document, indexes aliases and runs the checks across entries. A bad entry keeps its raw fields and its errors are listed in `Loaded.errors`. Deciding what to do with them is the caller's job. `Catalog::written` gives back every top-level key and value as the document wrote them, in document order. `Catalog::served` gives back every model and alias as Python serves it, without `sample_spec` and `fallback_generalizations`
|
||||
|
||||
`Billing` on `Prices` holds the row attributes pricing reads (text unit, band edge, cache-read fallback, wall clock, web search unit, tier scope). `lower.rs` derives it from the row and its `litellm_provider`, so `litellm-cost` holds no provider list
|
||||
|
||||
`store/` is the catalog's in-memory store and the only place that indexes model names. Its rules are in [src/store/AGENTS.md](src/store/AGENTS.md)
|
||||
|
||||
`layers.rs`, `resolve/`, `model_info.rs` and `register.rs` hold types only. The functions over them land with their first caller
|
||||
|
||||
Catalog errors use one opaque `Error` with typed causes and JSON Pointer accessors (`path`, `key`, `field`). `Error::rejects` alone decides whether an error fails the document, rejects its row or neither, and `ErrorKind` is the stable name hosts report it by
|
||||
|
||||
## Prices
|
||||
|
||||
The price vocabulary lives in `litellm-cost`, and only this crate knows JSON key names. Every price is keyed by `Meter`, `Channel` and `When`. A variation only one meter has stays in the meter (cache TTL, image size, resolution, `_above_Ns_interval`, `max_results_range`). Service tier and prompt band make up `When`. Batch and decisions are a `Channel` because Python bills them with their own rules. Off-peak is an overlay on `Prices`, and regional uplifts are `Adjustments`, never prices
|
||||
|
||||
A new `N`, image size or resolution in an existing key shape parses with no Rust change. A new unit, token kind, feature or tier needs a variant in `litellm-cost` plus its spelling in `wire/flat_key.rs` and `FLAT_KEY_PATTERN` in `schema.rs`
|
||||
|
||||
`Prices::band` and `Prices::rate` are pinned to Python's `_get_token_base_cost` by `tests/price_lookup_reference.json`. After a billing change in Python, regenerate it from the repo root with `PYTHONPATH=. python litellm-rust/crates/model-catalog/tests/generate_price_lookup_reference.py`
|
||||
|
||||
## Tests
|
||||
|
||||
`BUNDLED_REGISTRY` embeds the root `model_prices_and_context_window.json`, so the tests over it fail a JSON change with a bad value or a key no type reads
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
schema = ["dep:schemars", "litellm-llms-types/schema"]
|
||||
schema = ["dep:schemars", "litellm-cost/schema"]
|
||||
|
||||
[dependencies]
|
||||
litellm-llms-types.workspace = true
|
||||
litellm-cost = { workspace = true, features = ["serde"] }
|
||||
|
||||
indexmap = { version = "2.14.0", features = ["serde"] }
|
||||
jiff = { version = "0.2.37", default-features = false, features = ["std", "tzdb-bundle-always"] }
|
||||
macro_rules_attribute.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
serde_with.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
|
||||
|
|
|
|||
3
litellm-rust/crates/model-catalog/src/bundled.rs
Normal file
3
litellm-rust/crates/model-catalog/src/bundled.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// The registry document this build serves when no other one is loaded.
|
||||
pub const BUNDLED_REGISTRY: &[u8] =
|
||||
include_bytes!("../../../../model_prices_and_context_window.json");
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Primary API surface / task type of the model.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Mode {
|
||||
AudioSpeech,
|
||||
AudioTranscription,
|
||||
Chat,
|
||||
Completion,
|
||||
Decisions,
|
||||
Embedding,
|
||||
Evaluation,
|
||||
Guardrail,
|
||||
ImageEdit,
|
||||
ImageGeneration,
|
||||
Moderation,
|
||||
Ocr,
|
||||
Realtime,
|
||||
Rerank,
|
||||
Responses,
|
||||
Search,
|
||||
VectorStore,
|
||||
VideoGeneration,
|
||||
}
|
||||
|
||||
/// Gemini audio generation API the model is served through.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VertexAiAudioApi {
|
||||
LyriaPredict,
|
||||
LyriaInteractions,
|
||||
}
|
||||
|
||||
/// Audio container format the model can return.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AudioFormat {
|
||||
Mp3,
|
||||
Wav,
|
||||
}
|
||||
|
||||
/// Input modality the model accepts.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputModality {
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
}
|
||||
|
||||
/// Output modality the model can produce.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputModality {
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
Code,
|
||||
}
|
||||
|
|
@ -1,54 +1,29 @@
|
|||
use crate::error::Error;
|
||||
use crate::fallback::{FallbackGeneralizations, FallbackRule};
|
||||
use crate::model_info::ModelInfo;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Provenance {
|
||||
pub source: Option<String>,
|
||||
pub revision: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
use crate::error::{Error, Loaded, Rejects, Repr};
|
||||
use crate::fallback::{FallbackGeneralizations, FallbackRule};
|
||||
use crate::model::Model;
|
||||
use crate::store::{Rows, Store};
|
||||
use crate::wire::{EntryWire, Fields};
|
||||
|
||||
const SAMPLE_SPEC: &str = "sample_spec";
|
||||
const FALLBACK_GENERALIZATIONS: &str = "fallback_generalizations";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct IntegrityLimits {
|
||||
pub reference_model_count: usize,
|
||||
pub min_model_count: usize,
|
||||
pub min_reference_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AliasIssue {
|
||||
#[error("model {model:?} has aliases that are not an array")]
|
||||
InvalidList { model: String },
|
||||
#[error("model {model:?} has an alias that is not a string")]
|
||||
InvalidName { model: String },
|
||||
#[error("alias {alias:?} of model {model:?} is already a model name")]
|
||||
CanonicalCollision { model: String, alias: String },
|
||||
#[error("alias {alias:?} of model {model:?} is already an alias of another model")]
|
||||
AliasCollision { model: String, alias: String },
|
||||
min_model_count: usize,
|
||||
min_reference_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelEntry {
|
||||
fields: Map<String, Value>,
|
||||
info: ModelInfo,
|
||||
}
|
||||
|
||||
impl ModelEntry {
|
||||
pub fn field(&self, name: &str) -> Option<&Value> {
|
||||
self.fields.get(name)
|
||||
}
|
||||
pub fn fields(&self) -> &Map<String, Value> {
|
||||
&self.fields
|
||||
}
|
||||
/// The entry deserialized into the typed mirror of the catalog schema.
|
||||
pub fn info(&self) -> &ModelInfo {
|
||||
&self.info
|
||||
}
|
||||
fields: Fields,
|
||||
model: Option<Model>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
@ -58,142 +33,312 @@ pub struct ModelMatch<'a> {
|
|||
pub entry: &'a ModelEntry,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct Section {
|
||||
index: usize,
|
||||
key: String,
|
||||
value: Value,
|
||||
}
|
||||
|
||||
/// One fetched key kept off the served map because its row failed validation
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rejection<'a> {
|
||||
pub key: String,
|
||||
pub errors: Vec<&'a Error>,
|
||||
/// The bundled row for the key is served instead. `false` means the key is left out
|
||||
pub replaced_by_bundled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Written<'a> {
|
||||
Entry(&'a Fields),
|
||||
Section(&'a Value),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Catalog {
|
||||
store: Store<CatalogRows>,
|
||||
sections: Vec<Section>,
|
||||
fallback_generalizations: Option<FallbackGeneralizations>,
|
||||
}
|
||||
|
||||
/// Models and aliases, an alias served as its owner's row
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogRows {
|
||||
entries: IndexMap<String, ModelEntry>,
|
||||
aliases: IndexMap<String, String>,
|
||||
lowercase_keys: HashMap<String, String>,
|
||||
sample_spec: Option<Value>,
|
||||
fallback_generalizations: Option<FallbackGeneralizations>,
|
||||
provenance: Provenance,
|
||||
alias_issues: Vec<AliasIssue>,
|
||||
}
|
||||
|
||||
impl CatalogRows {
|
||||
fn owner<'a>(&'a self, key: &'a str) -> &'a str {
|
||||
self.aliases.get(key).map_or(key, String::as_str)
|
||||
}
|
||||
|
||||
fn fields(&self, key: &str) -> Option<&Fields> {
|
||||
Some(self.entries.get(self.owner(key))?.fields())
|
||||
}
|
||||
}
|
||||
|
||||
/// Model names match ignoring case, and keys are grouped by their raw `litellm_provider`
|
||||
impl Rows for CatalogRows {
|
||||
type Row = Fields;
|
||||
type Stamp = ();
|
||||
|
||||
fn fold(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
}
|
||||
|
||||
fn stamp(&self) -> Self::Stamp {}
|
||||
|
||||
fn contains(&self, key: &str) -> bool {
|
||||
self.entries.contains_key(key) || self.aliases.contains_key(key)
|
||||
}
|
||||
|
||||
fn row(&self, key: &str) -> Option<Cow<'_, Fields>> {
|
||||
self.fields(key).map(Cow::Borrowed)
|
||||
}
|
||||
|
||||
fn scan(&self, visit: &mut dyn FnMut(&str, Option<&str>)) {
|
||||
let keys = self.entries.keys().chain(self.aliases.keys());
|
||||
for key in keys {
|
||||
let provider = self
|
||||
.fields(key)
|
||||
.and_then(|fields| fields.get("litellm_provider")?.as_str());
|
||||
visit(key, provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntegrityLimits {
|
||||
pub fn new(min_model_count: usize, min_reference_ratio: f64) -> Option<Self> {
|
||||
(0.0..=1.0).contains(&min_reference_ratio).then_some(Self {
|
||||
min_model_count,
|
||||
min_reference_ratio,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelEntry {
|
||||
pub fn field(&self, name: &str) -> Option<&Value> {
|
||||
self.fields.get(name)
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> &Fields {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
pub fn model(&self) -> Option<&Model> {
|
||||
self.model.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
pub fn parse(body: &[u8], provenance: Provenance) -> Result<Self, Error> {
|
||||
let root: IndexMap<String, Value> = serde_json::from_slice(body)?;
|
||||
if root.is_empty() {
|
||||
return Err(Error::Empty);
|
||||
}
|
||||
pub fn parse(body: &[u8]) -> Result<Loaded<Self>, Error> {
|
||||
let document = serde_json::from_slice::<Fields>(body)
|
||||
.map_err(|error| Error::new("", Repr::Json(error)))?;
|
||||
|
||||
let mut entries = IndexMap::with_capacity(root.len());
|
||||
let mut problems = Vec::new();
|
||||
let mut sections = Vec::new();
|
||||
let mut entries = IndexMap::with_capacity(document.len());
|
||||
let mut alias_lists = Vec::new();
|
||||
let mut alias_issues = Vec::new();
|
||||
let mut sample_spec = None;
|
||||
let mut fallback_generalizations = None;
|
||||
for (name, value) in root {
|
||||
match name.as_str() {
|
||||
"sample_spec" => {
|
||||
sample_spec = Some(value);
|
||||
for (index, (name, value)) in document.into_iter().enumerate() {
|
||||
let fields = match value {
|
||||
Value::Object(fields) if !is_section(&name) => fields,
|
||||
value => {
|
||||
if !is_section(&name) {
|
||||
problems.push(Error::from_key(&name, Repr::NotAnObject));
|
||||
}
|
||||
sections.push(Section {
|
||||
index,
|
||||
key: name,
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
"fallback_generalizations" => {
|
||||
fallback_generalizations =
|
||||
Some(serde_json::from_value::<FallbackGeneralizations>(value)?);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Value::Object(mut fields) = value else {
|
||||
return Err(Error::EntryNotObject { model: name });
|
||||
};
|
||||
let info = ModelInfo::deserialize(&fields)?;
|
||||
if let Some(aliases) = fields.remove("aliases") {
|
||||
match aliases {
|
||||
Value::Array(names) => alias_lists.push((name.clone(), names)),
|
||||
_ => alias_issues.push(AliasIssue::InvalidList {
|
||||
model: name.clone(),
|
||||
}),
|
||||
}
|
||||
let (model, aliases, row_problems) = read_row(&fields);
|
||||
problems.extend(
|
||||
row_problems
|
||||
.into_iter()
|
||||
.map(|problem| problem.prepend(&name)),
|
||||
);
|
||||
if let Some(aliases) = aliases {
|
||||
alias_lists.push((name.clone(), aliases));
|
||||
}
|
||||
entries.insert(name, ModelEntry { fields, info });
|
||||
entries.insert(name, ModelEntry { fields, model });
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(Error::new("", Repr::Empty));
|
||||
}
|
||||
|
||||
let mut aliases = IndexMap::new();
|
||||
let mut aliases: IndexMap<String, String> = IndexMap::new();
|
||||
for (model, names) in alias_lists {
|
||||
for name in names {
|
||||
let Value::String(alias) = name else {
|
||||
alias_issues.push(AliasIssue::InvalidName {
|
||||
model: model.clone(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
for alias in names {
|
||||
if entries.contains_key(&alias) {
|
||||
alias_issues.push(AliasIssue::CanonicalCollision {
|
||||
model: model.clone(),
|
||||
alias,
|
||||
});
|
||||
} else if aliases.contains_key(&alias) {
|
||||
alias_issues.push(AliasIssue::AliasCollision {
|
||||
model: model.clone(),
|
||||
alias,
|
||||
});
|
||||
problems.push(Error::from_key(&model, Repr::AliasShadowsModel { alias }));
|
||||
} else if let Some(taken_by) = aliases.get(&alias) {
|
||||
problems.push(Error::from_key(
|
||||
&model,
|
||||
Repr::AliasTaken {
|
||||
alias,
|
||||
taken_by: taken_by.clone(),
|
||||
},
|
||||
));
|
||||
} else {
|
||||
aliases.insert(alias, model.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lowercase_keys = entries
|
||||
.keys()
|
||||
.chain(aliases.keys())
|
||||
.map(|key| (key.to_lowercase(), key.clone()))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
entries,
|
||||
aliases,
|
||||
lowercase_keys,
|
||||
sample_spec,
|
||||
fallback_generalizations,
|
||||
provenance,
|
||||
alias_issues,
|
||||
let fallback_generalizations = sections
|
||||
.iter()
|
||||
.find(|section| section.key == FALLBACK_GENERALIZATIONS)
|
||||
.and_then(|section| {
|
||||
FallbackGeneralizations::deserialize(§ion.value)
|
||||
.map_err(|error| {
|
||||
problems.push(Error::from_key(§ion.key, Repr::Invalid(error)))
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
Ok(Loaded {
|
||||
value: Self {
|
||||
store: Store::new(CatalogRows { entries, aliases }),
|
||||
sections,
|
||||
fallback_generalizations,
|
||||
},
|
||||
errors: problems,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self, limits: IntegrityLimits) -> Result<(), Error> {
|
||||
if !limits.min_reference_ratio.is_finite()
|
||||
|| !(0.0..=1.0).contains(&limits.min_reference_ratio)
|
||||
pub fn row_errors(model: &str, fields: &Fields) -> Vec<Error> {
|
||||
let (_, _, errors) = read_row(fields);
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|error| error.prepend(model))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn written(&self) -> impl Iterator<Item = (&str, Written<'_>)> {
|
||||
let mut sections = self.sections.iter().peekable();
|
||||
let mut entries = self.rows().entries.iter();
|
||||
(0..self.rows().entries.len() + self.sections.len()).map_while(move |index| match sections
|
||||
.next_if(|section| section.index == index)
|
||||
{
|
||||
return Err(Error::InvalidRatio);
|
||||
}
|
||||
let actual = self.entries.len();
|
||||
Some(section) => Some((section.key.as_str(), Written::Section(§ion.value))),
|
||||
None => entries
|
||||
.next()
|
||||
.map(|(name, entry)| (name.as_str(), Written::Entry(&entry.fields))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn served(&self) -> impl Iterator<Item = (&str, &Fields)> {
|
||||
let rows = self.rows();
|
||||
rows.entries
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.as_str(), entry.fields()))
|
||||
.chain(
|
||||
rows.aliases
|
||||
.iter()
|
||||
.map(|(alias, owner)| (alias.as_str(), rows.entries[owner].fields())),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn check_size(&self, reference: usize, limits: IntegrityLimits) -> Result<(), Error> {
|
||||
let actual = self.model_count();
|
||||
if actual < limits.min_model_count {
|
||||
return Err(Error::BelowMinimum {
|
||||
actual,
|
||||
minimum: limits.min_model_count,
|
||||
});
|
||||
return Err(Error::new(
|
||||
"",
|
||||
Repr::BelowMinimum {
|
||||
actual,
|
||||
minimum: limits.min_model_count,
|
||||
},
|
||||
));
|
||||
}
|
||||
if limits.reference_model_count > 0
|
||||
&& (actual as f64) < (limits.reference_model_count as f64) * limits.min_reference_ratio
|
||||
{
|
||||
return Err(Error::Shrunk {
|
||||
actual,
|
||||
reference: limits.reference_model_count,
|
||||
ratio: limits.min_reference_ratio,
|
||||
});
|
||||
if (actual as f64) < (reference as f64) * limits.min_reference_ratio {
|
||||
return Err(Error::new(
|
||||
"",
|
||||
Repr::Shrunk {
|
||||
actual,
|
||||
reference,
|
||||
ratio: limits.min_reference_ratio,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lookup(&self, key: &str) -> Option<ModelMatch<'_>> {
|
||||
let matched_key = if self.entries.contains_key(key) || self.aliases.contains_key(key) {
|
||||
key
|
||||
} else {
|
||||
self.lowercase_keys.get(&key.to_lowercase())?.as_str()
|
||||
};
|
||||
let canonical_key = self
|
||||
.aliases
|
||||
.get(matched_key)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(matched_key);
|
||||
let (canonical_key, entry) = self.entries.get_key_value(canonical_key)?;
|
||||
let matched_key = self
|
||||
pub fn without_rejected(self, errors: &[Error]) -> (Self, Vec<&Error>) {
|
||||
let row_names: HashSet<String> = self
|
||||
.rows()
|
||||
.entries
|
||||
.get_key_value(matched_key)
|
||||
.keys()
|
||||
.cloned()
|
||||
.chain(
|
||||
self.sections
|
||||
.iter()
|
||||
.filter(|section| !is_section(§ion.key))
|
||||
.map(|section| section.key.clone()),
|
||||
)
|
||||
.collect();
|
||||
let rejected: HashSet<String> = errors
|
||||
.iter()
|
||||
.filter(|error| error.rejects() == Rejects::Row)
|
||||
.filter_map(|error| crate::error::pointer_root(error.path()))
|
||||
.filter(|root| row_names.contains(root))
|
||||
.collect();
|
||||
let removed_errors = errors
|
||||
.iter()
|
||||
.filter(|error| {
|
||||
crate::error::pointer_root(error.path())
|
||||
.is_some_and(|root| rejected.contains(&root))
|
||||
})
|
||||
.collect();
|
||||
let removed_positions: HashSet<usize> = self
|
||||
.written()
|
||||
.enumerate()
|
||||
.filter(|(_, (key, _))| rejected.contains(*key))
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
let mut catalog = self;
|
||||
catalog.store.edit(|rows| {
|
||||
rows.entries.retain(|name, _| !rejected.contains(name));
|
||||
rows.aliases.retain(|_, owner| !rejected.contains(owner));
|
||||
});
|
||||
catalog
|
||||
.sections
|
||||
.retain(|section| is_section(§ion.key) || !rejected.contains(§ion.key));
|
||||
for section in &mut catalog.sections {
|
||||
let removed_before = removed_positions
|
||||
.iter()
|
||||
.filter(|index| **index < section.index)
|
||||
.count();
|
||||
section.index -= removed_before;
|
||||
}
|
||||
(catalog, removed_errors)
|
||||
}
|
||||
|
||||
pub fn unrecognized(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.rows().entries.iter().flat_map(|(name, entry)| {
|
||||
entry
|
||||
.model
|
||||
.iter()
|
||||
.flat_map(|model| &model.unrecognized)
|
||||
.map(move |key| (name.as_str(), key.as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn lookup(&self, key: &str) -> Option<ModelMatch<'_>> {
|
||||
let rows = self.rows();
|
||||
let found = self.store.find_key(key)?;
|
||||
let (canonical_key, entry) = rows.entries.get_key_value(rows.owner(&found))?;
|
||||
let matched_key = rows
|
||||
.entries
|
||||
.get_key_value(found.as_str())
|
||||
.map(|(key, _)| key.as_str())
|
||||
.or_else(|| {
|
||||
self.aliases
|
||||
.get_key_value(matched_key)
|
||||
rows.aliases
|
||||
.get_key_value(found.as_str())
|
||||
.map(|(key, _)| key.as_str())
|
||||
})?;
|
||||
Some(ModelMatch {
|
||||
|
|
@ -204,30 +349,58 @@ impl Catalog {
|
|||
}
|
||||
|
||||
pub fn model_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
self.rows().entries.len()
|
||||
}
|
||||
|
||||
pub fn model_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
pub fn alias_count(&self) -> usize {
|
||||
self.aliases.len()
|
||||
self.rows().entries.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn aliases(&self) -> &IndexMap<String, String> {
|
||||
&self.aliases
|
||||
&self.rows().aliases
|
||||
}
|
||||
pub fn alias_issues(&self) -> &[AliasIssue] {
|
||||
&self.alias_issues
|
||||
|
||||
fn rows(&self) -> &CatalogRows {
|
||||
self.store.rows()
|
||||
}
|
||||
|
||||
pub fn sample_spec(&self) -> Option<&Value> {
|
||||
self.sample_spec.as_ref()
|
||||
}
|
||||
pub fn fallback_generalizations(&self) -> Option<&FallbackGeneralizations> {
|
||||
self.fallback_generalizations.as_ref()
|
||||
self.sections
|
||||
.iter()
|
||||
.find(|section| section.key == SAMPLE_SPEC)
|
||||
.map(|section| §ion.value)
|
||||
}
|
||||
|
||||
pub fn fallback_rules(&self) -> Option<&[FallbackRule]> {
|
||||
Some(self.fallback_generalizations.as_ref()?.rules.as_slice())
|
||||
}
|
||||
pub fn provenance(&self) -> &Provenance {
|
||||
&self.provenance
|
||||
}
|
||||
|
||||
fn read_row(fields: &Fields) -> (Option<Model>, Option<Vec<String>>, Vec<Error>) {
|
||||
let wire = EntryWire::read(fields);
|
||||
let aliases = wire.attributes.aliases.clone();
|
||||
let timezone_error = unknown_timezone(&wire);
|
||||
match Model::try_from(wire) {
|
||||
Ok(model) => (Some(model), aliases, timezone_error.into_iter().collect()),
|
||||
Err(mut errors) => {
|
||||
errors.extend(timezone_error);
|
||||
(None, aliases, errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_section(key: &str) -> bool {
|
||||
key == SAMPLE_SPEC || key == FALLBACK_GENERALIZATIONS
|
||||
}
|
||||
|
||||
fn unknown_timezone(wire: &EntryWire) -> Option<Error> {
|
||||
let zone = wire.off_peak.as_ref()?.weekday_timezone.as_deref()?.trim();
|
||||
(!zone.is_empty() && jiff::tz::TimeZone::get(zone).is_err()).then(|| {
|
||||
Error::from_path(
|
||||
"off_peak_pricing.weekday_timezone",
|
||||
Repr::UnknownTimezone {
|
||||
zone: zone.to_owned(),
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,392 @@
|
|||
use std::num::ParseIntError;
|
||||
|
||||
use strum::{EnumDiscriminants, IntoStaticStr, VariantArray};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid catalog JSON")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("catalog is empty")]
|
||||
#[error(transparent)]
|
||||
pub struct Error(Box<Context>);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum Context {
|
||||
#[error(transparent)]
|
||||
Bare(Repr),
|
||||
#[error("{}", display_path(path))]
|
||||
At {
|
||||
path: String,
|
||||
#[source]
|
||||
cause: Repr,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum DateCause {
|
||||
#[error("invalid date component")]
|
||||
Number(#[from] ParseIntError),
|
||||
#[error("calendar component out of range")]
|
||||
Range(#[from] time::error::ComponentRange),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, EnumDiscriminants)]
|
||||
#[strum_discriminants(name(ErrorKind), derive(IntoStaticStr, VariantArray), vis(pub))]
|
||||
pub(crate) enum Repr {
|
||||
#[error("invalid JSON")]
|
||||
#[strum_discriminants(strum(serialize = "json"))]
|
||||
Json(#[source] serde_json::Error),
|
||||
#[error("has no models")]
|
||||
#[strum_discriminants(strum(serialize = "empty"))]
|
||||
Empty,
|
||||
#[error("model {model:?} must be an object")]
|
||||
EntryNotObject { model: String },
|
||||
#[error("catalog has {actual} models, below minimum {minimum}")]
|
||||
#[error("has {actual} models, below minimum {minimum}")]
|
||||
#[strum_discriminants(strum(serialize = "below_minimum"))]
|
||||
BelowMinimum { actual: usize, minimum: usize },
|
||||
#[error("catalog has {actual} models, below {ratio} of reference count {reference}")]
|
||||
#[error("has {actual} models, below {ratio} of reference count {reference}")]
|
||||
#[strum_discriminants(strum(serialize = "shrunk"))]
|
||||
Shrunk {
|
||||
actual: usize,
|
||||
reference: usize,
|
||||
ratio: f64,
|
||||
},
|
||||
#[error("minimum reference ratio must be finite and between zero and one")]
|
||||
InvalidRatio,
|
||||
#[error("must be a JSON object")]
|
||||
#[strum_discriminants(strum(serialize = "not_an_object"))]
|
||||
NotAnObject,
|
||||
#[error("invalid value")]
|
||||
#[strum_discriminants(strum(serialize = "invalid"))]
|
||||
Invalid(#[source] serde_json::Error),
|
||||
#[error("not a field this block reads")]
|
||||
#[strum_discriminants(strum(serialize = "unknown_block_key"))]
|
||||
UnknownBlockKey,
|
||||
#[error("required")]
|
||||
#[strum_discriminants(strum(serialize = "missing_provider"))]
|
||||
MissingProvider,
|
||||
#[error("{zone:?} is not an IANA time zone")]
|
||||
#[strum_discriminants(strum(serialize = "unknown_timezone"))]
|
||||
UnknownTimezone { zone: String },
|
||||
#[error("alias {alias:?} is already a model name")]
|
||||
#[strum_discriminants(strum(serialize = "alias_shadows_model"))]
|
||||
AliasShadowsModel { alias: String },
|
||||
#[error("alias {alias:?} already belongs to {taken_by}")]
|
||||
#[strum_discriminants(strum(serialize = "alias_taken"))]
|
||||
AliasTaken { alias: String, taken_by: String },
|
||||
#[error("{first} and {second} price the same meter under the same conditions")]
|
||||
#[strum_discriminants(strum(serialize = "duplicate_price"))]
|
||||
DuplicatePrice { first: String, second: String },
|
||||
#[error("a price inside {block} takes no band or tier suffix")]
|
||||
#[strum_discriminants(strum(serialize = "suffix_inside_block"))]
|
||||
SuffixInsideBlock { block: &'static str },
|
||||
#[error("a tier needs exactly one of range and max_results_range")]
|
||||
#[strum_discriminants(strum(serialize = "tier_range"))]
|
||||
TierRange,
|
||||
#[error("the range ends below its start")]
|
||||
#[strum_discriminants(strum(serialize = "tier_reversed"))]
|
||||
TierReversed,
|
||||
#[error("starts at {start}, but the tiers before it end at {expected}")]
|
||||
#[strum_discriminants(strum(serialize = "tier_gap"))]
|
||||
TierGap { start: u64, expected: u64 },
|
||||
#[error("tiered_pricing mixes token ranges and result ranges")]
|
||||
#[strum_discriminants(strum(serialize = "mixed_tier_basis"))]
|
||||
MixedTierBasis,
|
||||
#[error("a tier by result count only prices input_cost_per_query")]
|
||||
#[strum_discriminants(strum(serialize = "not_priced_by_results"))]
|
||||
NotPricedByResults,
|
||||
#[error("tiered_pricing and prompt token bands are set in two places")]
|
||||
#[strum_discriminants(strum(serialize = "bands_from_two_sources"))]
|
||||
BandsFromTwoSources,
|
||||
#[error("has no off-peak rate")]
|
||||
#[strum_discriminants(strum(serialize = "not_an_off_peak_rate"))]
|
||||
NotAnOffPeakRate,
|
||||
#[error("off_peak_pricing has no prices")]
|
||||
#[strum_discriminants(strum(serialize = "no_off_peak_prices"))]
|
||||
NoOffPeakPrices,
|
||||
#[error("off_peak_pricing needs hours_utc or windows")]
|
||||
#[strum_discriminants(strum(serialize = "no_off_peak_hours"))]
|
||||
NoOffPeakHours,
|
||||
#[error("must list at least one entry")]
|
||||
#[strum_discriminants(strum(serialize = "empty_list"))]
|
||||
EmptyList,
|
||||
#[error("expected a calendar date YYYY-MM-DD")]
|
||||
#[strum_discriminants(strum(serialize = "invalid_date"))]
|
||||
InvalidDate {
|
||||
#[source]
|
||||
cause: Option<DateCause>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a problem takes out of the served map
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, VariantArray)]
|
||||
pub enum Rejects {
|
||||
Document,
|
||||
Row,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
ErrorKind::from(self.repr())
|
||||
}
|
||||
|
||||
pub fn rejects(&self) -> Rejects {
|
||||
match self.repr() {
|
||||
Repr::Json(_) | Repr::Empty | Repr::BelowMinimum { .. } | Repr::Shrunk { .. } => {
|
||||
Rejects::Document
|
||||
}
|
||||
Repr::NotAnObject
|
||||
| Repr::Invalid(_)
|
||||
| Repr::UnknownBlockKey
|
||||
| Repr::UnknownTimezone { .. }
|
||||
| Repr::AliasShadowsModel { .. }
|
||||
| Repr::AliasTaken { .. }
|
||||
| Repr::DuplicatePrice { .. }
|
||||
| Repr::SuffixInsideBlock { .. }
|
||||
| Repr::TierRange
|
||||
| Repr::TierReversed
|
||||
| Repr::TierGap { .. }
|
||||
| Repr::MixedTierBasis
|
||||
| Repr::NotPricedByResults
|
||||
| Repr::BandsFromTwoSources
|
||||
| Repr::NotAnOffPeakRate
|
||||
| Repr::NoOffPeakPrices
|
||||
| Repr::NoOffPeakHours
|
||||
| Repr::EmptyList
|
||||
| Repr::InvalidDate { .. } => Rejects::Row,
|
||||
Repr::MissingProvider => Rejects::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Loaded<T> {
|
||||
pub value: T,
|
||||
pub errors: Vec<Error>,
|
||||
}
|
||||
|
||||
impl From<Repr> for Error {
|
||||
fn from(cause: Repr) -> Self {
|
||||
Self(Box::new(Context::Bare(cause)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateCause> for Error {
|
||||
fn from(cause: DateCause) -> Self {
|
||||
Repr::InvalidDate { cause: Some(cause) }.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn repr(&self) -> &Repr {
|
||||
match &*self.0 {
|
||||
Context::Bare(cause) | Context::At { cause, .. } => cause,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new(path: impl Into<String>, cause: Repr) -> Self {
|
||||
Self(Box::new(Context::At {
|
||||
path: path.into(),
|
||||
cause,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn from_key(key: &str, cause: Repr) -> Self {
|
||||
Self::new(pointer_for_key(key), cause)
|
||||
}
|
||||
|
||||
pub(crate) fn from_path(path: &str, cause: Repr) -> Self {
|
||||
Self::new(path_to_pointer(path), cause)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &str {
|
||||
match &*self.0 {
|
||||
Context::Bare(_) => "",
|
||||
Context::At { path, .. } => path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> Option<String> {
|
||||
pointer_root(self.path())
|
||||
}
|
||||
|
||||
pub fn field(&self) -> Option<String> {
|
||||
let segments: Vec<_> = self
|
||||
.path()
|
||||
.split('/')
|
||||
.skip(2)
|
||||
.map(unescape_pointer_segment)
|
||||
.collect();
|
||||
(!segments.is_empty()).then(|| segments.join("/"))
|
||||
}
|
||||
|
||||
pub(crate) fn prepend(self, key: &str) -> Self {
|
||||
let (path, cause) = match *self.0 {
|
||||
Context::Bare(cause) => (String::new(), cause),
|
||||
Context::At { path, cause } => (path, cause),
|
||||
};
|
||||
Self::new(format!("/{}{path}", escape_pointer_segment(key)), cause)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_root(path: &str) -> Option<String> {
|
||||
path.strip_prefix('/')
|
||||
.and_then(|path| path.split('/').next())
|
||||
.map(unescape_pointer_segment)
|
||||
}
|
||||
|
||||
fn escape_pointer_segment(segment: &str) -> String {
|
||||
segment.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_for_key(key: &str) -> String {
|
||||
format!("/{}", escape_pointer_segment(key))
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_child(parent: &str, key: &str) -> String {
|
||||
format!("{parent}/{}", escape_pointer_segment(key))
|
||||
}
|
||||
|
||||
fn unescape_pointer_segment(segment: &str) -> String {
|
||||
segment.replace("~1", "/").replace("~0", "~")
|
||||
}
|
||||
|
||||
fn path_to_pointer(key: &str) -> String {
|
||||
let mut pointer = String::new();
|
||||
let mut segment = String::new();
|
||||
let mut chars = key.chars();
|
||||
let mut push_segment = |segment: &mut String| {
|
||||
if !segment.is_empty() {
|
||||
pointer.push('/');
|
||||
pointer.push_str(&escape_pointer_segment(segment));
|
||||
segment.clear();
|
||||
}
|
||||
};
|
||||
while let Some(character) = chars.next() {
|
||||
match character {
|
||||
'.' => push_segment(&mut segment),
|
||||
'[' => {
|
||||
push_segment(&mut segment);
|
||||
let mut index = String::new();
|
||||
for character in chars.by_ref() {
|
||||
if character == ']' {
|
||||
break;
|
||||
}
|
||||
index.push(character);
|
||||
}
|
||||
push_segment(&mut index);
|
||||
}
|
||||
character => segment.push(character),
|
||||
}
|
||||
}
|
||||
push_segment(&mut segment);
|
||||
pointer
|
||||
}
|
||||
|
||||
fn display_path(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
return "catalog".to_owned();
|
||||
}
|
||||
path.split('/')
|
||||
.skip(1)
|
||||
.map(unescape_pointer_segment)
|
||||
.enumerate()
|
||||
.fold(String::new(), |mut display, (index, segment)| {
|
||||
if index == 0 {
|
||||
display.push_str(&segment);
|
||||
} else if segment.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
display.push('[');
|
||||
display.push_str(&segment);
|
||||
display.push(']');
|
||||
} else {
|
||||
display.push('.');
|
||||
display.push_str(&segment);
|
||||
}
|
||||
display
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use litellm_error::{Report, find};
|
||||
use rstest::rstest;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
use crate::{Catalog, wire::IsoDate};
|
||||
|
||||
#[rstest]
|
||||
#[case::json(Repr::Json(serde_json::from_str::<Value>("{").unwrap_err()), Rejects::Document)]
|
||||
#[case::empty(Repr::Empty, Rejects::Document)]
|
||||
#[case::below_minimum(Repr::BelowMinimum { actual: 1, minimum: 2 }, Rejects::Document)]
|
||||
#[case::shrunk(Repr::Shrunk { actual: 1, reference: 3, ratio: 0.5 }, Rejects::Document)]
|
||||
#[case::not_an_object(Repr::NotAnObject, Rejects::Row)]
|
||||
#[case::invalid(Repr::Invalid(serde_json::from_str::<Value>("x").unwrap_err()), Rejects::Row)]
|
||||
#[case::unknown_block_key(Repr::UnknownBlockKey, Rejects::Row)]
|
||||
#[case::missing_provider(Repr::MissingProvider, Rejects::Nothing)]
|
||||
#[case::unknown_timezone(Repr::UnknownTimezone { zone: "invalid".into() }, Rejects::Row)]
|
||||
#[case::alias_shadows_model(Repr::AliasShadowsModel { alias: "model".into() }, Rejects::Row)]
|
||||
#[case::alias_taken(Repr::AliasTaken { alias: "alias".into(), taken_by: "owner".into() }, Rejects::Row)]
|
||||
#[case::duplicate_price(Repr::DuplicatePrice { first: "a".into(), second: "b".into() }, Rejects::Row)]
|
||||
#[case::suffix_inside_block(Repr::SuffixInsideBlock { block: "tiered_pricing" }, Rejects::Row)]
|
||||
#[case::tier_range(Repr::TierRange, Rejects::Row)]
|
||||
#[case::tier_reversed(Repr::TierReversed, Rejects::Row)]
|
||||
#[case::tier_gap(Repr::TierGap { start: 1, expected: 0 }, Rejects::Row)]
|
||||
#[case::mixed_tier_basis(Repr::MixedTierBasis, Rejects::Row)]
|
||||
#[case::not_priced_by_results(Repr::NotPricedByResults, Rejects::Row)]
|
||||
#[case::bands_from_two_sources(Repr::BandsFromTwoSources, Rejects::Row)]
|
||||
#[case::not_an_off_peak_rate(Repr::NotAnOffPeakRate, Rejects::Row)]
|
||||
#[case::no_off_peak_prices(Repr::NoOffPeakPrices, Rejects::Row)]
|
||||
#[case::no_off_peak_hours(Repr::NoOffPeakHours, Rejects::Row)]
|
||||
#[case::empty_list(Repr::EmptyList, Rejects::Row)]
|
||||
#[case::invalid_date(Repr::InvalidDate { cause: None }, Rejects::Row)]
|
||||
fn policy_classifies_each_error(#[case] error: Repr, #[case] expected: Rejects) {
|
||||
assert_eq!(Error::from(error).rejects(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::root("", "x", "/x")]
|
||||
#[case::empty_model("/", "x", "//x")]
|
||||
#[case::empty_parent_segment("/m/", "x", "/m//x")]
|
||||
#[case::escaped("/m~1n", "a~/b", "/m~1n/a~0~1b")]
|
||||
fn pointers_preserve_segments(#[case] parent: &str, #[case] key: &str, #[case] expected: &str) {
|
||||
assert_eq!(pointer_child(parent, key), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::malformed(br#"{"#)]
|
||||
#[case::bad_price(br#"{"m":{"litellm_provider":"test","input_cost_per_token":"free"}}"#)]
|
||||
fn json_causes_are_typed_and_rendered_once(#[case] body: &[u8]) {
|
||||
let error = match Catalog::parse(body) {
|
||||
Ok(loaded) => loaded.errors.into_iter().next().expect("invalid price"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let cause = find::<serde_json::Error>(&error)
|
||||
.expect("typed JSON cause")
|
||||
.to_string();
|
||||
let report = Report::new(&error).to_string();
|
||||
assert_eq!(report.matches(&cause).count(), 1, "{report}");
|
||||
assert!(!error.to_string().contains(&cause));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::year("xxxx-01-01")]
|
||||
#[case::month("2026-xx-01")]
|
||||
#[case::day("2026-01-xx")]
|
||||
fn date_number_errors_keep_the_cause(#[case] value: &str) {
|
||||
let error = value.parse::<IsoDate>().unwrap_err();
|
||||
let cause = find::<ParseIntError>(&error)
|
||||
.expect("typed integer cause")
|
||||
.to_string();
|
||||
assert_eq!(error.rejects(), Rejects::Row);
|
||||
assert_eq!(Report::new(&error).to_string().matches(&cause).count(), 1);
|
||||
assert!(!error.to_string().contains(&cause));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::month("2026-13-01")]
|
||||
#[case::day("2026-02-30")]
|
||||
fn date_range_errors_keep_the_cause(#[case] value: &str) {
|
||||
let error = value.parse::<IsoDate>().unwrap_err();
|
||||
let cause = find::<time::error::ComponentRange>(&error)
|
||||
.expect("typed calendar cause")
|
||||
.to_string();
|
||||
assert_eq!(error.rejects(), Rejects::Row);
|
||||
assert_eq!(Report::new(&error).to_string().matches(&cause).count(), 1);
|
||||
assert!(!error.to_string().contains(&cause));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One regex rule generalizing unknown model ids to known families.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Eq, Hash)]
|
||||
pub struct FallbackRule {
|
||||
pub name: String,
|
||||
pub pattern: String,
|
||||
|
|
@ -15,8 +14,8 @@ pub struct FallbackRule {
|
|||
}
|
||||
|
||||
/// Regex rules that generalize unknown model ids to known families; not a model entry.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Eq, Hash)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FallbackGeneralizations {
|
||||
pub rules: Vec<FallbackRule>,
|
||||
|
|
|
|||
32
litellm-rust/crates/model-catalog/src/layers.rs
Normal file
32
litellm-rust/crates/model-catalog/src/layers.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::catalog::Catalog;
|
||||
use crate::register::Registrations;
|
||||
use crate::wire::Fields;
|
||||
|
||||
/// One field of a fetched row over the layer below, RFC 7386. An absent field inherits, JSON `null` is `Remove`
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Patch {
|
||||
Set(Value),
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// The four layers served rows are composed from, lowest first
|
||||
#[derive(Clone, Debug)]
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "02 and 07 add the functions that read these layers"
|
||||
)]
|
||||
pub struct Layers {
|
||||
/// 0, the wheel's embedded map
|
||||
bundled: Arc<Catalog>,
|
||||
/// 1, the last installed fetch, rejected rows left out
|
||||
fetched: Option<Arc<Catalog>>,
|
||||
/// 2, `register(persist=False)`, cleared by every install and refilled by the router replay
|
||||
transient: IndexMap<String, Fields>,
|
||||
/// 3, `register(persist=True)`, applied over every install
|
||||
persisted: Registrations,
|
||||
}
|
||||
|
|
@ -7,20 +7,18 @@
|
|||
//! in `litellm-router-core`, which reads this catalog through its dynamic config.
|
||||
//!
|
||||
//! # Structure
|
||||
//! - `Catalog`: the parsed map. `Catalog::parse` takes the JSON body and a `Provenance` (where
|
||||
//! the body came from: the bundled copy in the repo or the remote copy, with revision and etag),
|
||||
//! and keeps every entry in file order with its raw fields.
|
||||
//! - `Catalog`: the parsed map. `Catalog::parse` takes the JSON body and returns `Loaded<Catalog>`,
|
||||
//! keeping every entry in file order with its raw fields and collecting row and alias errors.
|
||||
//! - `ModelEntry`: one model. `ModelEntry::fields` is the raw JSON object, so unknown fields are
|
||||
//! never lost; `ModelEntry::info` is the typed mirror `ModelInfo` (pricing in `pricing`,
|
||||
//! modalities and modes in `capabilities`).
|
||||
//! never lost; `ModelEntry::model` is the optional typed `Model` with attributes, prices,
|
||||
//! adjustments and capabilities. Invalid rows retain their raw fields without a typed model.
|
||||
//! - Aliases: an entry may list `aliases`; each alias resolves to its canonical key. Problems
|
||||
//! found while indexing them (bad lists, collisions) are kept as `AliasIssue`s instead of
|
||||
//! failing the parse.
|
||||
//! found while indexing them (bad lists, collisions) are kept in `Loaded::errors` instead of
|
||||
//! failing the document.
|
||||
//! - `FallbackGeneralizations` / `FallbackRule`: regex rules that map unknown model ids onto known
|
||||
//! families. They are data in the file, not entries.
|
||||
//! - Validation: `Catalog::validate` checks a catalog against `IntegrityLimits` (a remote copy that
|
||||
//! lost most of its models is refused), and `validate_registry` checks every entry against the
|
||||
//! checked-in contract.
|
||||
//! - Validation: `Catalog::check_size` checks a catalog against `IntegrityLimits` (a remote copy
|
||||
//! that lost most of its models is refused), and `Catalog::row_errors` validates one row.
|
||||
//!
|
||||
//! # Lookups
|
||||
//! `Catalog::lookup` resolves a key exactly first (entry or alias), then case-insensitively, and
|
||||
|
|
@ -31,21 +29,37 @@
|
|||
//! Cost tracking, the router (through `RouterDynamicConfig`) and the gateway. Nothing in this crate
|
||||
//! performs I/O; loading and refreshing the file is the caller's job.
|
||||
|
||||
mod capabilities;
|
||||
macro_rules_attribute::attribute_alias! {
|
||||
#[apply(wire_type)] =
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))];
|
||||
}
|
||||
|
||||
mod bundled;
|
||||
mod catalog;
|
||||
mod error;
|
||||
mod fallback;
|
||||
mod layers;
|
||||
mod lower;
|
||||
mod model;
|
||||
mod model_info;
|
||||
mod pricing;
|
||||
mod validation;
|
||||
mod register;
|
||||
mod resolve;
|
||||
mod store;
|
||||
mod vocabulary;
|
||||
pub mod wire;
|
||||
|
||||
pub use capabilities::*;
|
||||
pub use bundled::*;
|
||||
pub use catalog::*;
|
||||
pub use error::*;
|
||||
pub use fallback::*;
|
||||
pub use layers::*;
|
||||
pub use model::*;
|
||||
pub use model_info::*;
|
||||
pub use pricing::*;
|
||||
pub use validation::*;
|
||||
pub use register::*;
|
||||
pub use resolve::*;
|
||||
pub use store::*;
|
||||
pub use vocabulary::*;
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
mod schema;
|
||||
|
|
|
|||
855
litellm-rust/crates/model-catalog/src/lower.rs
Normal file
855
litellm-rust/crates/model-catalog/src/lower.rs
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use litellm_cost::money::UnitPrice;
|
||||
use litellm_cost::price::{
|
||||
Adjustments, Band, BandEdge, Billing, CacheReadFallback, CacheReadMode, CacheTtl,
|
||||
GuardrailUnit, InputUnit, Meter, OffPeak, OffPeakRates, OutputUnit, Price, Prices, Residency,
|
||||
SearchContextSize, TextUnit, TierScope, TokenKind, Uplift, WallClock, WebSearchUnit, When,
|
||||
};
|
||||
use litellm_cost::schedule::{Schedule, Window};
|
||||
|
||||
use crate::error::{Error, Repr, pointer_child, pointer_for_key};
|
||||
use crate::model::Model;
|
||||
use crate::vocabulary::Mode;
|
||||
use crate::wire::{EntryWire, FlatPrice, OffPeakBlock, TierRow, WebSearchBillingUnit};
|
||||
|
||||
impl TryFrom<&EntryWire> for Prices {
|
||||
type Error = Vec<Error>;
|
||||
|
||||
fn try_from(wire: &EntryWire) -> Result<Self, Self::Error> {
|
||||
let mut problems = Vec::new();
|
||||
let sourced = flat_prices(&wire.prices)
|
||||
.chain(tier_prices(wire, &mut problems))
|
||||
.chain(search_context_prices(wire))
|
||||
.chain(guardrail_prices(wire));
|
||||
let rates = dedupe(sourced, &mut problems);
|
||||
let off_peak = wire
|
||||
.off_peak
|
||||
.as_ref()
|
||||
.and_then(|block| off_peak(block, &mut problems));
|
||||
if problems.is_empty() {
|
||||
let billing = billing(wire, &rates);
|
||||
Ok(Prices::new(rates, off_peak, billing))
|
||||
} else {
|
||||
Err(problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn billing(wire: &EntryWire, rates: &BTreeMap<Price, UnitPrice>) -> Billing {
|
||||
let provider = wire.attributes.litellm_provider.as_deref();
|
||||
let prices_meter = |priced: fn(&Meter) -> bool| rates.keys().any(|price| priced(&price.meter));
|
||||
let prices_tokens = prices_meter(|meter| {
|
||||
matches!(
|
||||
meter,
|
||||
Meter::Input(InputUnit::Token(_)) | Meter::Output(OutputUnit::Token(_))
|
||||
)
|
||||
});
|
||||
let prices_characters = prices_meter(|meter| {
|
||||
matches!(
|
||||
meter,
|
||||
Meter::Input(InputUnit::Character) | Meter::Output(OutputUnit::Character)
|
||||
)
|
||||
});
|
||||
let prices_cache_read = prices_meter(|meter| {
|
||||
matches!(
|
||||
meter,
|
||||
Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
Billing {
|
||||
text_unit: if prices_characters && !prices_tokens {
|
||||
TextUnit::Character
|
||||
} else {
|
||||
TextUnit::Token
|
||||
},
|
||||
band_edge: if provider == Some("xai") {
|
||||
BandEdge::Inclusive
|
||||
} else {
|
||||
BandEdge::Exclusive
|
||||
},
|
||||
cache_read_fallback: if provider == Some("fireworks_ai") && !prices_cache_read {
|
||||
CacheReadFallback::HalfInputRate
|
||||
} else {
|
||||
CacheReadFallback::InputRate
|
||||
},
|
||||
wall_clock: match wire.attributes.mode {
|
||||
None | Some(Mode::Chat | Mode::Completion | Mode::Embedding | Mode::Responses) => {
|
||||
WallClock::ResponseTime
|
||||
}
|
||||
Some(Mode::AudioTranscription) => WallClock::AudioFile,
|
||||
Some(_) => WallClock::Never,
|
||||
},
|
||||
web_search: match wire.attributes.web_search_billing_unit {
|
||||
Some(WebSearchBillingUnit::PerQuery) => WebSearchUnit::PerQuery,
|
||||
Some(WebSearchBillingUnit::PerPrompt) | None => WebSearchUnit::PerPrompt,
|
||||
},
|
||||
tier_scope: if provider == Some("dashscope") && wire.tier_rows.is_some() {
|
||||
TierScope::WholeCall
|
||||
} else {
|
||||
TierScope::PerMeter
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&EntryWire> for Adjustments {
|
||||
fn from(wire: &EntryWire) -> Self {
|
||||
Adjustments::new(
|
||||
[
|
||||
(Uplift::NonGlobalEndpoint, wire.regional_endpoint_uplift),
|
||||
(Uplift::Residency(Residency::Eu), wire.residency_uplift_eu),
|
||||
(Uplift::Residency(Residency::Us), wire.residency_uplift_us),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(uplift, factor)| Some((uplift, factor?))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<EntryWire> for Model {
|
||||
type Error = Vec<Error>;
|
||||
|
||||
fn try_from(wire: EntryWire) -> Result<Self, Self::Error> {
|
||||
let prices = Prices::try_from(&wire);
|
||||
let adjustments = Adjustments::from(&wire);
|
||||
let EntryWire {
|
||||
attributes,
|
||||
capabilities,
|
||||
unknown,
|
||||
problems,
|
||||
..
|
||||
} = wire;
|
||||
let missing_provider = attributes.litellm_provider.is_none().then_some(Error::new(
|
||||
pointer_for_key("litellm_provider"),
|
||||
Repr::MissingProvider,
|
||||
));
|
||||
match prices {
|
||||
Ok(prices) if problems.is_empty() && missing_provider.is_none() => Ok(Model {
|
||||
attributes,
|
||||
prices,
|
||||
adjustments,
|
||||
capabilities,
|
||||
unrecognized: unknown.into_iter().map(|(key, _)| key).collect(),
|
||||
}),
|
||||
prices => Err(problems
|
||||
.into_iter()
|
||||
.chain(missing_provider)
|
||||
.chain(prices.err().into_iter().flatten())
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sourced {
|
||||
price: Price,
|
||||
rate: UnitPrice,
|
||||
source: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn flat_prices(prices: &[FlatPrice]) -> impl Iterator<Item = Sourced> + '_ {
|
||||
prices.iter().map(|flat| {
|
||||
let (channel, when) = flat.flat.channel_and_when();
|
||||
Sourced {
|
||||
price: Price {
|
||||
meter: flat.flat.meter.clone(),
|
||||
channel,
|
||||
when,
|
||||
},
|
||||
rate: flat.price,
|
||||
source: flat.key.clone(),
|
||||
path: flat.path.clone(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn dedupe(
|
||||
sourced: impl Iterator<Item = Sourced>,
|
||||
problems: &mut Vec<Error>,
|
||||
) -> BTreeMap<Price, UnitPrice> {
|
||||
let mut seen: BTreeMap<Price, (UnitPrice, String, String)> = BTreeMap::new();
|
||||
for Sourced {
|
||||
price,
|
||||
rate,
|
||||
source,
|
||||
path,
|
||||
} in sourced
|
||||
{
|
||||
match seen.entry(price) {
|
||||
Entry::Occupied(first) if first.get().0 == rate => {}
|
||||
Entry::Occupied(first) => problems.push(Error::new(
|
||||
path,
|
||||
Repr::DuplicatePrice {
|
||||
first: first.get().1.clone(),
|
||||
second: source.clone(),
|
||||
},
|
||||
)),
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert((rate, source, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.into_iter()
|
||||
.map(|(price, (rate, _, _))| (price, rate))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum TierBasis {
|
||||
PromptTokens,
|
||||
Results,
|
||||
}
|
||||
|
||||
fn tier_basis(row: &TierRow) -> Result<(TierBasis, [u64; 2]), Error> {
|
||||
match (row.range, row.max_results_range) {
|
||||
(Some(range), None) => Ok((TierBasis::PromptTokens, range)),
|
||||
(None, Some(range)) => Ok((TierBasis::Results, range)),
|
||||
_ => Err(Error::new(row.path.clone(), Repr::TierRange)),
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_prices(wire: &EntryWire, problems: &mut Vec<Error>) -> Vec<Sourced> {
|
||||
let Some(rows) = wire.tier_rows.as_deref().filter(|rows| !rows.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let based: Vec<_> = rows
|
||||
.iter()
|
||||
.filter_map(|row| match tier_basis(row) {
|
||||
Ok((basis, range)) => Some((basis, range, row)),
|
||||
Err(problem) => {
|
||||
problems.push(problem);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Some(&(basis, ..)) = based.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if based.iter().any(|(other, ..)| *other != basis) {
|
||||
problems.push(Error::from_key("tiered_pricing", Repr::MixedTierBasis));
|
||||
return Vec::new();
|
||||
}
|
||||
if basis == TierBasis::PromptTokens {
|
||||
problems.extend(
|
||||
wire.prices
|
||||
.iter()
|
||||
.filter(|flat| flat.flat.band != Band::Base)
|
||||
.map(|flat| Error::new(flat.path.clone(), Repr::BandsFromTwoSources)),
|
||||
);
|
||||
}
|
||||
let mut ordered = based;
|
||||
ordered.sort_by_key(|(_, [start, _], _)| *start);
|
||||
problems.extend(ladder_problems(basis, &ordered));
|
||||
ordered
|
||||
.iter()
|
||||
.flat_map(|(basis, [start, _], row)| {
|
||||
row.prices.iter().map(move |flat| (*basis, *start, flat))
|
||||
})
|
||||
.filter_map(
|
||||
|(basis, start, flat)| match tier_price(basis, start, flat) {
|
||||
Ok(sourced) => Some(sourced),
|
||||
Err(problem) => {
|
||||
problems.push(problem);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ladder_problems(basis: TierBasis, ordered: &[(TierBasis, [u64; 2], &TierRow)]) -> Vec<Error> {
|
||||
let step = match basis {
|
||||
TierBasis::PromptTokens => 0,
|
||||
TierBasis::Results => 1,
|
||||
};
|
||||
let reversed = ordered
|
||||
.iter()
|
||||
.filter(|(_, [start, end], _)| end < start)
|
||||
.map(|(.., row)| Error::new(row.path.clone(), Repr::TierReversed));
|
||||
let first = ordered
|
||||
.first()
|
||||
.filter(|(_, [start, _], _)| *start > step)
|
||||
.map(|(_, [start, _], row)| {
|
||||
Error::new(
|
||||
row.path.clone(),
|
||||
Repr::TierGap {
|
||||
start: *start,
|
||||
expected: 0,
|
||||
},
|
||||
)
|
||||
});
|
||||
let gaps = ordered.windows(2).filter_map(|pair| {
|
||||
let (_, [_, end], _) = pair[0];
|
||||
let (_, [start, _], row) = pair[1];
|
||||
(end.checked_add(step) != Some(start)).then(|| {
|
||||
Error::new(
|
||||
row.path.clone(),
|
||||
Repr::TierGap {
|
||||
start,
|
||||
expected: end,
|
||||
},
|
||||
)
|
||||
})
|
||||
});
|
||||
reversed.chain(first).chain(gaps).collect()
|
||||
}
|
||||
|
||||
fn tier_price(basis: TierBasis, start: u64, flat: &FlatPrice) -> Result<Sourced, Error> {
|
||||
if flat.flat.has_suffix() {
|
||||
return Err(Error::new(
|
||||
flat.path.clone(),
|
||||
Repr::SuffixInsideBlock {
|
||||
block: "tiered_pricing",
|
||||
},
|
||||
));
|
||||
}
|
||||
let (meter, band) = match (basis, &flat.flat.meter) {
|
||||
(TierBasis::PromptTokens, meter) => (meter.clone(), Band::over_prompt_tokens(start)),
|
||||
(TierBasis::Results, Meter::Input(InputUnit::Query { over_results: None })) => (
|
||||
Meter::Input(InputUnit::Query {
|
||||
over_results: NonZeroU64::new(start.saturating_sub(1)),
|
||||
}),
|
||||
Band::Base,
|
||||
),
|
||||
(TierBasis::Results, _) => {
|
||||
return Err(Error::new(flat.path.clone(), Repr::NotPricedByResults));
|
||||
}
|
||||
};
|
||||
Ok(Sourced {
|
||||
price: Price::online(
|
||||
meter,
|
||||
When {
|
||||
band,
|
||||
..When::default()
|
||||
},
|
||||
),
|
||||
rate: flat.price,
|
||||
source: flat.key.clone(),
|
||||
path: flat.path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn search_context_prices(wire: &EntryWire) -> impl Iterator<Item = Sourced> + '_ {
|
||||
wire.search_context.iter().flat_map(|costs| {
|
||||
[
|
||||
(SearchContextSize::Low, "low", costs.search_context_size_low),
|
||||
(
|
||||
SearchContextSize::Medium,
|
||||
"medium",
|
||||
costs.search_context_size_medium,
|
||||
),
|
||||
(
|
||||
SearchContextSize::High,
|
||||
"high",
|
||||
costs.search_context_size_high,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(size, name, rate)| {
|
||||
Some(Sourced {
|
||||
price: Price::online(Meter::WebSearch(size), When::default()),
|
||||
rate: rate?,
|
||||
source: format!("search_context_cost_per_query.search_context_size_{name}"),
|
||||
path: pointer_child(
|
||||
&pointer_for_key("search_context_cost_per_query"),
|
||||
&format!("search_context_size_{name}"),
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn guardrail_prices(wire: &EntryWire) -> impl Iterator<Item = Sourced> + '_ {
|
||||
wire.guardrail.iter().flatten().map(|(unit, rate)| Sourced {
|
||||
price: Price::online(Meter::Guardrail(GuardrailUnit::new(unit)), When::default()),
|
||||
rate: *rate,
|
||||
source: format!("guardrail_cost_per_unit.{unit}"),
|
||||
path: pointer_child(&pointer_for_key("guardrail_cost_per_unit"), unit),
|
||||
})
|
||||
}
|
||||
|
||||
fn off_peak(block: &OffPeakBlock, problems: &mut Vec<Error>) -> Option<OffPeak> {
|
||||
let rates = off_peak_rates(&block.prices, problems);
|
||||
let schedule = schedule(block, problems)?;
|
||||
let off_peak = OffPeak::new(schedule, rates);
|
||||
if off_peak.is_none() {
|
||||
problems.push(Error::from_key("off_peak_pricing", Repr::NoOffPeakPrices));
|
||||
}
|
||||
off_peak
|
||||
}
|
||||
|
||||
fn off_peak_rates(prices: &[FlatPrice], problems: &mut Vec<Error>) -> OffPeakRates {
|
||||
let mut rates = OffPeakRates::default();
|
||||
for flat in prices {
|
||||
if flat.flat.has_suffix() {
|
||||
problems.push(Error::new(
|
||||
flat.path.clone(),
|
||||
Repr::SuffixInsideBlock {
|
||||
block: "off_peak_pricing",
|
||||
},
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let slot = match flat.flat.meter {
|
||||
Meter::Input(InputUnit::Token(TokenKind::Text)) => &mut rates.input,
|
||||
Meter::Output(OutputUnit::Token(TokenKind::Text)) => &mut rates.output,
|
||||
Meter::Output(OutputUnit::ReasoningToken) => &mut rates.reasoning,
|
||||
Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Implicit,
|
||||
} => &mut rates.cache_read,
|
||||
Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Explicit,
|
||||
} => &mut rates.explicit_cache_read,
|
||||
Meter::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::Default,
|
||||
} => &mut rates.cache_write,
|
||||
_ => {
|
||||
problems.push(Error::new(flat.path.clone(), Repr::NotAnOffPeakRate));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
*slot = Some(flat.price);
|
||||
}
|
||||
rates
|
||||
}
|
||||
|
||||
fn schedule(block: &OffPeakBlock, problems: &mut Vec<Error>) -> Option<Schedule> {
|
||||
let empty = |key: String| Error::from_path(&key, Repr::EmptyList);
|
||||
let every_day = block
|
||||
.hours_utc
|
||||
.iter()
|
||||
.map(|hours| ("off_peak_pricing.hours_utc".to_owned(), hours, None));
|
||||
let windows = block
|
||||
.windows
|
||||
.iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(index, window)| {
|
||||
(
|
||||
format!("off_peak_pricing.windows[{index}].hours_utc"),
|
||||
&window.hours_utc,
|
||||
Some((index, window.weekdays.as_ref())),
|
||||
)
|
||||
});
|
||||
let windows: Vec<Window> = every_day
|
||||
.chain(windows)
|
||||
.map(|(key, hours, weekdays)| {
|
||||
if hours.windows().is_empty() {
|
||||
problems.push(empty(key));
|
||||
}
|
||||
if let Some((index, Some(days))) = weekdays
|
||||
&& days.is_empty()
|
||||
{
|
||||
problems.push(empty(format!("off_peak_pricing.windows[{index}].weekdays")));
|
||||
}
|
||||
Window {
|
||||
hours: hours.windows().to_vec(),
|
||||
weekdays: weekdays.and_then(|(_, days)| days.cloned()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if block.windows.as_ref().is_some_and(Vec::is_empty) {
|
||||
problems.push(empty("off_peak_pricing.windows".to_owned()));
|
||||
}
|
||||
if block.hours_utc.is_none() && block.windows.is_none() {
|
||||
problems.push(Error::from_key("off_peak_pricing", Repr::NoOffPeakHours));
|
||||
return None;
|
||||
}
|
||||
Some(Schedule {
|
||||
windows,
|
||||
weekday_timezone: block.weekday_timezone.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use litellm_cost::money::Multiplier;
|
||||
use litellm_cost::price::{Channel, ServiceTier};
|
||||
use litellm_cost::schedule::{UtcWindow, Weekday};
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const TEXT_INPUT: Meter = Meter::Input(InputUnit::Token(TokenKind::Text));
|
||||
|
||||
fn wire(entry: Value) -> EntryWire {
|
||||
let Value::Object(fields) = entry else {
|
||||
panic!("entry must be an object")
|
||||
};
|
||||
let wire = EntryWire::read(&fields);
|
||||
assert!(wire.problems.is_empty(), "{:?}", wire.problems);
|
||||
wire
|
||||
}
|
||||
|
||||
fn prices(entry: Value) -> Result<Prices, Vec<Error>> {
|
||||
Prices::try_from(&wire(entry))
|
||||
}
|
||||
|
||||
fn online(meter: Meter, tier: ServiceTier, band: Band) -> Price {
|
||||
Price::online(meter, When { tier, band })
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::flat_key(json!({"input_cost_per_token": 1e-6}), online(TEXT_INPUT, ServiceTier::Standard, Band::Base))]
|
||||
#[case::band_and_tier(
|
||||
json!({"input_cost_per_token_above_272k_tokens_priority": 1e-6}),
|
||||
online(TEXT_INPUT, ServiceTier::Priority, Band::over_prompt_tokens(272_000))
|
||||
)]
|
||||
#[case::batch(
|
||||
json!({"input_cost_per_token_batches": 1e-6}),
|
||||
Price { meter: TEXT_INPUT, channel: Channel::Batch, when: When::default() }
|
||||
)]
|
||||
#[case::first_token_tier_is_the_base_band(
|
||||
json!({"tiered_pricing": [{"range": [0.0, 256000.0], "input_cost_per_token": 1e-6}]}),
|
||||
online(TEXT_INPUT, ServiceTier::Standard, Band::Base)
|
||||
)]
|
||||
#[case::result_tier_stays_in_its_meter(
|
||||
json!({"tiered_pricing": [
|
||||
{"max_results_range": [0, 25], "input_cost_per_query": 2e-6},
|
||||
{"max_results_range": [26, 100], "input_cost_per_query": 1e-6}
|
||||
]}),
|
||||
online(
|
||||
Meter::Input(InputUnit::Query { over_results: NonZeroU64::new(25) }),
|
||||
ServiceTier::Standard,
|
||||
Band::Base
|
||||
)
|
||||
)]
|
||||
#[case::search_context(
|
||||
json!({"search_context_cost_per_query": {"search_context_size_low": 1e-6}}),
|
||||
online(Meter::WebSearch(SearchContextSize::Low), ServiceTier::Standard, Band::Base)
|
||||
)]
|
||||
#[case::character_band_is_a_prompt_token_band(
|
||||
json!({"input_cost_per_character_above_128k_tokens": 1e-6}),
|
||||
online(Meter::Input(InputUnit::Character), ServiceTier::Standard, Band::over_prompt_tokens(128_000))
|
||||
)]
|
||||
#[case::guardrail(
|
||||
json!({"guardrail_cost_per_unit": {"contentPolicyUnits": 1e-6}}),
|
||||
online(Meter::Guardrail(GuardrailUnit::new("contentPolicyUnits")), ServiceTier::Standard, Band::Base)
|
||||
)]
|
||||
fn every_spelling_lowers_to_one_price(#[case] entry: Value, #[case] expected: Price) {
|
||||
assert_eq!(prices(entry).unwrap().get(&expected), UnitPrice::new(1e-6));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn two_spellings_that_agree_are_one_price() {
|
||||
let prices = prices(json!({
|
||||
"input_cost_per_token": 1e-6,
|
||||
"tiered_pricing": [
|
||||
{"range": [0, 10], "input_cost_per_token": 1e-6},
|
||||
{"range": [10, 20], "input_cost_per_token": 2e-6}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prices.get(&Price::online(TEXT_INPUT, When::default())),
|
||||
UnitPrice::new(1e-6)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn token_tiers_lower_to_prompt_bands_at_each_start() {
|
||||
let prices = prices(json!({"tiered_pricing": [
|
||||
{"range": [0, 256000], "input_cost_per_token": 1e-6},
|
||||
{"range": [256000, 1000000], "input_cost_per_token": 2e-6}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prices.band(ServiceTier::Standard, 256_001),
|
||||
Band::over_prompt_tokens(256_000)
|
||||
);
|
||||
assert_eq!(
|
||||
prices
|
||||
.rate(
|
||||
&TEXT_INPUT,
|
||||
ServiceTier::Standard,
|
||||
Band::over_prompt_tokens(256_000)
|
||||
)
|
||||
.map(UnitPrice::get),
|
||||
Some(2e-6)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::two_spellings_of_one_price(
|
||||
json!({
|
||||
"input_cost_per_token": 1e-6,
|
||||
"tiered_pricing": [{"range": [0, 10], "input_cost_per_token": 2e-6}]
|
||||
}),
|
||||
"/tiered_pricing/0/input_cost_per_token",
|
||||
"duplicate_price"
|
||||
)]
|
||||
#[case::zero_band_is_the_base_price(
|
||||
json!({"input_cost_per_token": 1e-6, "input_cost_per_token_above_0k_tokens": 2e-6}),
|
||||
"/input_cost_per_token_above_0k_tokens",
|
||||
"duplicate_price"
|
||||
)]
|
||||
#[case::tier_without_range(
|
||||
json!({"tiered_pricing": [{"input_cost_per_token": 1e-6}]}),
|
||||
"/tiered_pricing/0",
|
||||
"tier_range"
|
||||
)]
|
||||
#[case::tier_with_suffix(
|
||||
json!({"tiered_pricing": [{"range": [0, 10], "input_cost_per_token_priority": 1e-6}]}),
|
||||
"/tiered_pricing/0/input_cost_per_token_priority",
|
||||
"suffix_inside_block"
|
||||
)]
|
||||
#[case::reversed_range(
|
||||
json!({"tiered_pricing": [{"range": [0, 10], "input_cost_per_token": 1e-6}, {"range": [10, 5], "input_cost_per_token": 1e-6}]}),
|
||||
"/tiered_pricing/1",
|
||||
"tier_reversed"
|
||||
)]
|
||||
#[case::gap_between_tiers(
|
||||
json!({"tiered_pricing": [
|
||||
{"range": [0, 10], "input_cost_per_token": 1e-6},
|
||||
{"range": [20, 30], "input_cost_per_token": 2e-6}
|
||||
]}),
|
||||
"/tiered_pricing/1",
|
||||
"tier_gap"
|
||||
)]
|
||||
#[case::ladder_not_from_zero(
|
||||
json!({"tiered_pricing": [{"range": [5, 10], "input_cost_per_token": 1e-6}]}),
|
||||
"/tiered_pricing/0",
|
||||
"tier_gap"
|
||||
)]
|
||||
#[case::result_ladder_overlaps(
|
||||
json!({"tiered_pricing": [
|
||||
{"max_results_range": [1, 10], "input_cost_per_query": 1e-6},
|
||||
{"max_results_range": [10, 20], "input_cost_per_query": 2e-6}
|
||||
]}),
|
||||
"/tiered_pricing/1",
|
||||
"tier_gap"
|
||||
)]
|
||||
#[case::mixed_basis(
|
||||
json!({"tiered_pricing": [
|
||||
{"range": [0, 10], "input_cost_per_token": 1e-6},
|
||||
{"max_results_range": [11, 20], "input_cost_per_query": 2e-6}
|
||||
]}),
|
||||
"/tiered_pricing",
|
||||
"mixed_tier_basis"
|
||||
)]
|
||||
#[case::result_tier_prices_a_token(
|
||||
json!({"tiered_pricing": [{"max_results_range": [0, 10], "input_cost_per_token": 1e-6}]}),
|
||||
"/tiered_pricing/0/input_cost_per_token",
|
||||
"not_priced_by_results"
|
||||
)]
|
||||
#[case::two_band_sources(
|
||||
json!({
|
||||
"output_cost_per_token_above_200k_tokens": 1e-6,
|
||||
"tiered_pricing": [{"range": [0, 10], "input_cost_per_token": 1e-6}]
|
||||
}),
|
||||
"/output_cost_per_token_above_200k_tokens",
|
||||
"bands_from_two_sources"
|
||||
)]
|
||||
#[case::off_peak_without_hours(
|
||||
json!({"off_peak_pricing": {"input_cost_per_token": 1e-6}}),
|
||||
"/off_peak_pricing",
|
||||
"no_off_peak_hours"
|
||||
)]
|
||||
#[case::off_peak_without_prices(
|
||||
json!({"off_peak_pricing": {"hours_utc": "00:00-01:00"}}),
|
||||
"/off_peak_pricing",
|
||||
"no_off_peak_prices"
|
||||
)]
|
||||
#[case::off_peak_empty_windows(
|
||||
json!({"off_peak_pricing": {"windows": [], "input_cost_per_token": 1e-6}}),
|
||||
"/off_peak_pricing/windows",
|
||||
"empty_list"
|
||||
)]
|
||||
#[case::off_peak_empty_hours(
|
||||
json!({"off_peak_pricing": {"hours_utc": [], "input_cost_per_token": 1e-6}}),
|
||||
"/off_peak_pricing/hours_utc",
|
||||
"empty_list"
|
||||
)]
|
||||
#[case::off_peak_empty_weekdays(
|
||||
json!({"off_peak_pricing": {
|
||||
"windows": [{"hours_utc": "00:00-01:00", "weekdays": []}],
|
||||
"input_cost_per_token": 1e-6
|
||||
}}),
|
||||
"/off_peak_pricing/windows/0/weekdays",
|
||||
"empty_list"
|
||||
)]
|
||||
#[case::off_peak_image_price(
|
||||
json!({"off_peak_pricing": {"hours_utc": "00:00-01:00", "output_cost_per_image": 1}}),
|
||||
"/off_peak_pricing/output_cost_per_image",
|
||||
"not_an_off_peak_rate"
|
||||
)]
|
||||
#[case::off_peak_band(
|
||||
json!({"off_peak_pricing": {"hours_utc": "00:00-01:00", "input_cost_per_token_above_200k_tokens": 1}}),
|
||||
"/off_peak_pricing/input_cost_per_token_above_200k_tokens",
|
||||
"suffix_inside_block"
|
||||
)]
|
||||
fn rules_across_keys_name_where_each_price_came_from(
|
||||
#[case] entry: Value,
|
||||
#[case] expected_path: &str,
|
||||
#[case] expected_variant: &str,
|
||||
) {
|
||||
let problems = prices(entry).unwrap_err();
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| problem.path() == expected_path
|
||||
&& <&str>::from(problem.kind()) == expected_variant),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn off_peak_hours_and_windows_form_one_schedule_with_its_rates() {
|
||||
let prices = prices(json!({"off_peak_pricing": {
|
||||
"hours_utc": ["00:00-01:00"],
|
||||
"windows": [{"hours_utc": "10:00-12:00", "weekdays": [6, " sun "]}],
|
||||
"weekday_timezone": "Asia/Shanghai",
|
||||
"output_cost_per_token": 1e-6,
|
||||
"explicit_cache_read_input_token_cost": 2e-6
|
||||
}}))
|
||||
.unwrap();
|
||||
let off_peak = prices.off_peak().unwrap();
|
||||
let window = |text: &str| text.parse::<UtcWindow>().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
off_peak.schedule(),
|
||||
&Schedule {
|
||||
windows: vec![
|
||||
Window {
|
||||
hours: vec![window("00:00-01:00")],
|
||||
weekdays: None,
|
||||
},
|
||||
Window {
|
||||
hours: vec![window("10:00-12:00")],
|
||||
weekdays: Some(vec![Weekday::Saturday, Weekday::Sunday]),
|
||||
},
|
||||
],
|
||||
weekday_timezone: Some("Asia/Shanghai".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
off_peak.rates(),
|
||||
&OffPeakRates {
|
||||
output: UnitPrice::new(1e-6),
|
||||
explicit_cache_read: UnitPrice::new(2e-6),
|
||||
..OffPeakRates::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(prices.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn uplifts_are_adjustments_not_prices() {
|
||||
let wire = wire(json!({
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"regional_processing_uplift_multiplier_eu": "1.2"
|
||||
}));
|
||||
let adjustments = Adjustments::from(&wire);
|
||||
|
||||
assert_eq!(Prices::try_from(&wire).unwrap().iter().count(), 0);
|
||||
assert_eq!(
|
||||
adjustments
|
||||
.get(&Uplift::NonGlobalEndpoint)
|
||||
.map(Multiplier::get),
|
||||
Some(1.1)
|
||||
);
|
||||
assert_eq!(
|
||||
adjustments
|
||||
.get(&Uplift::Residency(Residency::Eu))
|
||||
.map(Multiplier::get),
|
||||
Some(1.2)
|
||||
);
|
||||
assert_eq!(adjustments.get(&Uplift::Residency(Residency::Us)), None);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_model_reports_field_provider_and_price_problems_together() {
|
||||
let Value::Object(fields) = json!({
|
||||
"mode": "chatt",
|
||||
"tiered_pricing": [{"input_cost_per_token": 1e-6}]
|
||||
}) else {
|
||||
unreachable!()
|
||||
};
|
||||
let problems: Vec<String> = Model::from_fields(&fields)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.map(|problem| litellm_error::Report::new(problem).to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(problems.len(), 3, "{problems:?}");
|
||||
assert!(problems[0].starts_with("mode: invalid value: unknown variant `chatt`"));
|
||||
assert_eq!(problems[1], "litellm_provider: required");
|
||||
assert_eq!(
|
||||
problems[2],
|
||||
"tiered_pricing[0]: a tier needs exactly one of range and max_results_range"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_null_value_is_absent() {
|
||||
let Value::Object(fields) = json!({
|
||||
"litellm_provider": "openai",
|
||||
"input_cost_per_token": null,
|
||||
"mode": null
|
||||
}) else {
|
||||
unreachable!()
|
||||
};
|
||||
let model = Model::from_fields(&fields).unwrap();
|
||||
|
||||
assert_eq!(model.prices.iter().count(), 0);
|
||||
assert_eq!(model.attributes.mode, None);
|
||||
assert!(model.unrecognized.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tokens_win_over_characters(
|
||||
json!({"litellm_provider": "vertex_ai-language-models", "input_cost_per_character": 1e-6, "input_cost_per_token": 1e-6}),
|
||||
Billing::default()
|
||||
)]
|
||||
#[case::characters_only(
|
||||
json!({"litellm_provider": "vertex_ai-language-models", "input_cost_per_character": 1e-6}),
|
||||
Billing { text_unit: TextUnit::Character, ..Billing::default() }
|
||||
)]
|
||||
#[case::xai_band_is_inclusive(
|
||||
json!({"litellm_provider": "xai", "input_cost_per_token": 1e-6}),
|
||||
Billing { band_edge: BandEdge::Inclusive, ..Billing::default() }
|
||||
)]
|
||||
#[case::fireworks_without_cache_read_borrows_half(
|
||||
json!({"litellm_provider": "fireworks_ai", "input_cost_per_token": 1e-6}),
|
||||
Billing { cache_read_fallback: CacheReadFallback::HalfInputRate, ..Billing::default() }
|
||||
)]
|
||||
#[case::fireworks_with_cache_read_keeps_it(
|
||||
json!({"litellm_provider": "fireworks_ai", "input_cost_per_token": 1e-6, "cache_read_input_token_cost": 1e-7}),
|
||||
Billing::default()
|
||||
)]
|
||||
#[case::transcription_prices_the_audio_file(
|
||||
json!({"litellm_provider": "openai", "mode": "audio_transcription", "input_cost_per_second": 1e-4}),
|
||||
Billing { wall_clock: WallClock::AudioFile, ..Billing::default() }
|
||||
)]
|
||||
#[case::provisioned_chat_prices_response_time(
|
||||
json!({"litellm_provider": "bedrock", "mode": "chat", "cost_per_second": 1e-2}),
|
||||
Billing::default()
|
||||
)]
|
||||
#[case::image_generation_is_never_wall_clock(
|
||||
json!({"litellm_provider": "openai", "mode": "image_generation"}),
|
||||
Billing { wall_clock: WallClock::Never, ..Billing::default() }
|
||||
)]
|
||||
#[case::gemini_per_query_search(
|
||||
json!({"litellm_provider": "gemini", "web_search_billing_unit": "per_query"}),
|
||||
Billing { web_search: WebSearchUnit::PerQuery, ..Billing::default() }
|
||||
)]
|
||||
#[case::dashscope_tiers_bill_the_whole_call(
|
||||
json!({"litellm_provider": "dashscope", "tiered_pricing": [{"range": [0, 32000], "input_cost_per_token": 1e-6}]}),
|
||||
Billing { tier_scope: TierScope::WholeCall, ..Billing::default() }
|
||||
)]
|
||||
fn billing_is_lowered_from_the_row_and_its_provider(
|
||||
#[case] entry: Value,
|
||||
#[case] expected: Billing,
|
||||
) {
|
||||
assert_eq!(prices(entry).unwrap().billing(), expected);
|
||||
}
|
||||
}
|
||||
22
litellm-rust/crates/model-catalog/src/model.rs
Normal file
22
litellm-rust/crates/model-catalog/src/model.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_cost::price::{Adjustments, Prices};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::wire::{Attributes, EntryWire, Fields};
|
||||
|
||||
/// One catalog entry read into types.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Model {
|
||||
pub attributes: Attributes,
|
||||
pub prices: Prices,
|
||||
pub adjustments: Adjustments,
|
||||
pub capabilities: BTreeMap<String, bool>,
|
||||
pub unrecognized: Vec<String>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn from_fields(fields: &Fields) -> Result<Self, Vec<Error>> {
|
||||
Self::try_from(EntryWire::read(fields))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,582 +1,194 @@
|
|||
use crate::capabilities::{AudioFormat, InputModality, Mode, OutputModality, VertexAiAudioApi};
|
||||
use crate::pricing::{OffPeakPricing, SearchContextCostPerQuery, TieredRate, WebSearchBillingUnit};
|
||||
use litellm_llms_types::formats::chat_completions::ReasoningEffort;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Typed mirror of one catalog model entry.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
pub struct ModelInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub annotation_cost_per_page: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub annotation_cost_per_page_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio_transcription_config: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bedrock_converse_supports_strict_tools: Option<bool>,
|
||||
/// Highest reasoning effort the Bedrock output_config accepts for this model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bedrock_output_config_effort_ceiling: Option<ReasoningEffort>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_audio_token_cost: Option<f64>,
|
||||
/// USD per token written to the provider's prompt cache.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr_above_100k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_1hr_above_200k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_200k_tokens_batches: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_256k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_272k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_ultrafast: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost_ultrafast: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_audio_token_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_image_token_cost: Option<f64>,
|
||||
/// USD per prompt token served from the provider's prompt cache.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_200k_tokens_batches: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_200k_tokens_priority: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_256k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_272k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_272k_tokens_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_272k_tokens_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_272k_tokens_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_272k_tokens_ultrafast: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_above_512k_tokens: Option<f64>,
|
||||
/// Balanced service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_balanced: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost_ultrafast: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub citation_cost_per_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_interpreter_cost_per_session: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub computer_use_input_cost_per_1k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub computer_use_output_cost_per_1k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_per_second: Option<f64>,
|
||||
/// Reasoning effort the provider applies when the request omits reasoning_effort. Gates whether a non-default temperature or the top_p/logprobs sampling params are accepted, which hold only when the effort resolves to 'none'.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_reasoning_effort: Option<ReasoningEffort>,
|
||||
/// Date the provider deprecates the model, YYYY-MM-DD.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deprecation_date: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_search_cost_per_1k_calls: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_search_cost_per_gb_per_day: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_audio_only_live: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_native_audio: Option<bool>,
|
||||
/// USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub google_maps_grounding_cost_per_query: Option<f64>,
|
||||
/// USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guardrail_cost_per_unit: Option<BTreeMap<String, f64>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_per_second: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_per_second_above_128k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_token_batches: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_token_priority: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_character: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_character_above_128k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_image: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_image_above_128k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_image_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_image_token_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_pixel: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_query: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_request: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_second: Option<f64>,
|
||||
/// USD per prompt token.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// The fields main's `ModelInfoBase(...)` copies (`litellm/utils.py` at c7c895db45), one typed field each,
|
||||
/// read from the raw row without lowering it, so building one never fails
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModelInfoFields {
|
||||
pub key: String,
|
||||
pub max_tokens: Option<i64>,
|
||||
pub max_input_tokens: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub input_cost_per_token: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_200k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_200k_tokens_batches: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_200k_tokens_priority: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_256k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_272k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_272k_tokens_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_272k_tokens_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_272k_tokens_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_272k_tokens_ultrafast: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_512k_tokens: Option<f64>,
|
||||
/// Balanced service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_balanced: Option<f64>,
|
||||
/// USD per prompt token via the provider's batch API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_cache_hit: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_balanced: Option<f64>,
|
||||
pub input_cost_per_token_ultrafast: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_video_per_second: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_video_per_second_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_video_per_second_above_15s_interval: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_video_per_second_above_8s_interval: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_272k_tokens: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_priority: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_flex: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_ultrafast: Option<f64>,
|
||||
pub cache_creation_input_token_cost_flex: Option<f64>,
|
||||
pub cache_creation_input_token_cost_priority: Option<f64>,
|
||||
pub cache_creation_input_token_cost_ultrafast: Option<f64>,
|
||||
pub cache_read_input_token_cost: Option<f64>,
|
||||
pub cache_read_input_audio_token_cost: Option<f64>,
|
||||
pub cache_read_input_audio_token_cost_flex: Option<f64>,
|
||||
pub cache_read_input_audio_token_cost_priority: Option<f64>,
|
||||
pub cache_read_input_audio_token_cost_batches: Option<f64>,
|
||||
pub explicit_cache_read_input_token_cost: Option<f64>,
|
||||
pub prompt_cache_min_tokens: Option<i64>,
|
||||
pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_200k_tokens_priority: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_272k_tokens: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_272k_tokens_priority: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_272k_tokens_flex: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_272k_tokens_ultrafast: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_512k_tokens: Option<f64>,
|
||||
pub cache_read_input_token_cost_flex: Option<f64>,
|
||||
pub cache_read_input_token_cost_priority: Option<f64>,
|
||||
pub cache_read_input_token_cost_balanced: Option<f64>,
|
||||
pub cache_read_input_token_cost_ultrafast: Option<f64>,
|
||||
pub cache_read_input_token_cost_batches: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_200k_tokens_batches: Option<f64>,
|
||||
pub cache_read_input_token_cost_above_272k_tokens_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_200k_tokens_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_272k_tokens_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_1hr: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_flex: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_priority: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_above_272k_tokens: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_above_272k_tokens_flex: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_above_272k_tokens_priority: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_ultrafast: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_above_272k_tokens_batches: Option<f64>,
|
||||
pub cache_creation_input_token_cost_above_12hr_above_272k_tokens_ultrafast: Option<f64>,
|
||||
pub off_peak_pricing: Option<Value>,
|
||||
pub input_cost_per_character: Option<f64>,
|
||||
pub input_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
pub input_cost_per_token_above_200k_tokens: Option<f64>,
|
||||
pub input_cost_per_token_above_200k_tokens_priority: Option<f64>,
|
||||
pub input_cost_per_token_above_272k_tokens: Option<f64>,
|
||||
pub input_cost_per_token_above_272k_tokens_priority: Option<f64>,
|
||||
pub input_cost_per_token_above_272k_tokens_flex: Option<f64>,
|
||||
pub input_cost_per_token_above_272k_tokens_ultrafast: Option<f64>,
|
||||
pub input_cost_per_token_above_512k_tokens: Option<f64>,
|
||||
pub input_cost_per_query: Option<f64>,
|
||||
pub cost_per_second: Option<f64>,
|
||||
pub input_cost_per_second: Option<f64>,
|
||||
pub input_cost_per_text_message: Option<f64>,
|
||||
pub input_cost_per_audio_token: Option<f64>,
|
||||
pub input_cost_per_audio_token_flex: Option<f64>,
|
||||
pub input_cost_per_audio_token_priority: Option<f64>,
|
||||
pub input_cost_per_image_token: Option<f64>,
|
||||
pub input_cost_per_video_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_audio_token_batches: Option<f64>,
|
||||
pub input_cost_per_image_token_batches: Option<f64>,
|
||||
pub input_cost_per_image: Option<f64>,
|
||||
pub input_cost_per_audio_per_second: Option<f64>,
|
||||
pub input_cost_per_video_per_second: Option<f64>,
|
||||
pub input_cost_per_token_batches: Option<f64>,
|
||||
pub input_cost_per_video_token_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_dbu_cost_per_token: Option<f64>,
|
||||
/// LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub litellm_provider: Option<String>,
|
||||
/// Maximum prompt/context tokens the model accepts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_input_tokens: Option<u64>,
|
||||
/// Maximum tokens the model can generate in one response.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
/// Legacy field: max output tokens if the provider specifies it, else max input tokens.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u64>,
|
||||
/// Free-form notes about the entry (e.g. pricing derivation).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<BTreeMap<String, Value>>,
|
||||
/// Primary API surface / task type of the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<Mode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ocr_cost_per_credit: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ocr_cost_per_page: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ocr_cost_per_page_batches: Option<f64>,
|
||||
/// Rates that replace the same-named base fields while the request falls inside the stated UTC windows.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub off_peak_pricing: Option<OffPeakPricing>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_audio_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_character: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_character_above_128k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_image: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_image_1024: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_image_1536: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_image_512: Option<f64>,
|
||||
#[serde(
|
||||
rename = "output_cost_per_image_0.5K",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub output_cost_per_image_0_5k: Option<f64>,
|
||||
#[serde(
|
||||
rename = "output_cost_per_image_1K",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub output_cost_per_image_1k: Option<f64>,
|
||||
#[serde(
|
||||
rename = "output_cost_per_image_2K",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub output_cost_per_image_2k: Option<f64>,
|
||||
#[serde(
|
||||
rename = "output_cost_per_image_4K",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub output_cost_per_image_4k: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_image_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_pixel: Option<f64>,
|
||||
/// USD per reasoning/thinking token, when billed separately.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_reasoning_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_1080p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_2k: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_480p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_4k: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_720p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_second_768p: Option<f64>,
|
||||
/// USD per generated token.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_32k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_100k_tokens: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_100k_tokens_batches: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_200k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_200k_tokens_batches: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_200k_tokens_priority: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_256k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_272k_tokens: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_272k_tokens_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_272k_tokens_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_272k_tokens_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_272k_tokens_ultrafast: Option<f64>,
|
||||
/// Rate applied once the prompt exceeds the token threshold in the field name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_512k_tokens: Option<f64>,
|
||||
/// Balanced service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_balanced: Option<f64>,
|
||||
/// USD per generated token via the provider's batch API.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token_above_200k_tokens_batches: Option<f64>,
|
||||
pub input_cost_per_token_above_272k_tokens_batches: Option<f64>,
|
||||
pub output_cost_per_token_batches: Option<f64>,
|
||||
/// Flex service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_above_200k_tokens_batches: Option<f64>,
|
||||
pub output_cost_per_token_above_272k_tokens_batches: Option<f64>,
|
||||
pub output_cost_per_token: Option<f64>,
|
||||
pub output_cost_per_token_flex: Option<f64>,
|
||||
/// Priority service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_priority: Option<f64>,
|
||||
/// Ultrafast service-tier rate for the same-named base field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token_balanced: Option<f64>,
|
||||
pub output_cost_per_token_ultrafast: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_video_per_second: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_video_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_dbu_cost_per_token: Option<f64>,
|
||||
/// Embedding dimension for embedding models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_vector_size: Option<u64>,
|
||||
/// Smallest prefix the provider will actually cache; absent means the provider default applies.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_min_tokens: Option<u64>,
|
||||
/// Provider-internal routing hints (e.g. bedrock_invocation_schema).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_specific_entry: Option<BTreeMap<String, Value>>,
|
||||
/// Exact reasoning_effort levels this deployment accepts; wins over supports_* flags.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort_levels: Option<Vec<ReasoningEffort>>,
|
||||
/// Multiplier applied to all token costs when served from a non-global Vertex AI endpoint (e.g. 1.10 = +10%).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regional_endpoint_uplift_multiplier: Option<f64>,
|
||||
/// Multiplier applied to all token costs for EU data residency (e.g. 1.10 = +10%).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regional_processing_uplift_multiplier_eu: Option<f64>,
|
||||
/// Multiplier applied to all token costs for US data residency (e.g. 1.10 = +10%).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regional_processing_uplift_multiplier_us: Option<f64>,
|
||||
/// Provider default requests-per-minute limit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rpm: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rules: Option<Vec<Value>>,
|
||||
/// USD cost per web search query, keyed by search context size.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_context_cost_per_query: Option<SearchContextCostPerQuery>,
|
||||
/// URL of the provider pricing/model page this entry was taken from.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
/// Audio container formats the model can return.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_audio_formats: Option<Vec<AudioFormat>>,
|
||||
/// OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regional_endpoint_uplift_multiplier: Option<f64>,
|
||||
pub output_cost_per_audio_token: Option<f64>,
|
||||
pub output_cost_per_character: Option<f64>,
|
||||
pub output_cost_per_reasoning_token: Option<f64>,
|
||||
pub output_cost_per_reasoning_token_flex: Option<f64>,
|
||||
pub output_cost_per_reasoning_token_priority: Option<f64>,
|
||||
pub output_cost_per_token_above_128k_tokens: Option<f64>,
|
||||
pub output_cost_per_character_above_128k_tokens: Option<f64>,
|
||||
pub output_cost_per_token_above_200k_tokens: Option<f64>,
|
||||
pub output_cost_per_token_above_200k_tokens_priority: Option<f64>,
|
||||
pub output_cost_per_token_above_272k_tokens: Option<f64>,
|
||||
pub output_cost_per_token_above_272k_tokens_priority: Option<f64>,
|
||||
pub output_cost_per_token_above_272k_tokens_flex: Option<f64>,
|
||||
pub output_cost_per_token_above_272k_tokens_ultrafast: Option<f64>,
|
||||
pub output_cost_per_token_above_512k_tokens: Option<f64>,
|
||||
pub output_cost_per_second: Option<f64>,
|
||||
pub output_cost_per_second_1080p: Option<f64>,
|
||||
pub output_cost_per_second_480p: Option<f64>,
|
||||
pub output_cost_per_second_720p: Option<f64>,
|
||||
pub output_cost_per_second_768p: Option<f64>,
|
||||
pub output_cost_per_second_2k: Option<f64>,
|
||||
pub output_cost_per_second_4k: Option<f64>,
|
||||
pub output_cost_per_video_per_second: Option<f64>,
|
||||
pub output_cost_per_image: Option<f64>,
|
||||
pub output_cost_per_pixel: Option<f64>,
|
||||
pub output_cost_per_image_token: Option<f64>,
|
||||
pub output_cost_per_image_token_batches: Option<f64>,
|
||||
pub output_cost_per_video_token: Option<f64>,
|
||||
pub output_vector_size: Option<i64>,
|
||||
pub citation_cost_per_token: Option<f64>,
|
||||
pub tiered_pricing: Option<Value>,
|
||||
pub litellm_provider: Option<String>,
|
||||
pub mode: Option<Value>,
|
||||
pub supported_endpoints: Option<Vec<String>>,
|
||||
/// Input modalities the model accepts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_modalities: Option<Vec<InputModality>>,
|
||||
/// Output modalities the model can produce.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_output_modalities: Option<Vec<OutputModality>>,
|
||||
/// Cloud regions the model is available in ('global' or region ids).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_regions: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_adaptive_thinking: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_anthropic_compaction: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_anthropic_thinking_payload: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_assistant_prefill: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_audio_input: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_audio_output: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_bedrock_runtime_chat_completions_response_format: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_bedrock_runtime_chat_completions_tools_with_reasoning: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_computer_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_embedding_image_input: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_fast_mode: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_forced_tool_use: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_function_calling: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_image_input: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_image_size: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_legacy_thinking: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_low_reasoning_effort: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_max_reasoning_effort: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_mid_conversation_system: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_minimal_reasoning_effort: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_multimodal: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_native_streaming: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_native_structured_output: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_none_reasoning_effort: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_nova_canvas_image_edit: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_output_config: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_parallel_function_calling: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_parallel_tool_use_config: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_pdf_input: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_prompt_cache_breakpoint: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_prompt_caching: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_reasoning: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_response_schema: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_sampling_params: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_speed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_system_messages: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_thinking_cache_preservation: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_tool_choice: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_tool_search: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_url_context: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_video_input: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_response_schema: Option<bool>,
|
||||
pub supports_vision: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_function_calling: Option<bool>,
|
||||
pub supports_parallel_function_calling: Option<bool>,
|
||||
pub supports_tool_choice: Option<bool>,
|
||||
pub supports_assistant_prefill: Option<bool>,
|
||||
pub supports_prompt_caching: Option<bool>,
|
||||
pub supports_prompt_cache_breakpoint: Option<bool>,
|
||||
pub supports_thinking_cache_preservation: Option<bool>,
|
||||
pub supports_audio_input: Option<bool>,
|
||||
pub supports_video_input: Option<bool>,
|
||||
pub supports_box_questions: Option<bool>,
|
||||
pub supports_audio_output: Option<bool>,
|
||||
pub supports_pdf_input: Option<bool>,
|
||||
pub supports_embedding_image_input: Option<bool>,
|
||||
pub supports_native_streaming: Option<bool>,
|
||||
pub supports_native_structured_output: Option<bool>,
|
||||
pub supports_bedrock_native_structured_output: Option<bool>,
|
||||
pub supports_web_search: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_xhigh_reasoning_effort: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_url_context: Option<bool>,
|
||||
pub supports_reasoning: Option<bool>,
|
||||
pub supports_adaptive_thinking: Option<bool>,
|
||||
pub supports_legacy_thinking: Option<bool>,
|
||||
pub thinking_always_on: Option<bool>,
|
||||
/// Context-length or result-count tiered rates; each tier's costs apply within its range.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tiered_pricing: Option<Vec<TieredRate>>,
|
||||
/// Provider default tokens-per-minute limit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tpm: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_openai_responses_path: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supports_tool_search: Option<bool>,
|
||||
pub supports_mid_conversation_system: Option<bool>,
|
||||
pub supports_anthropic_thinking_payload: Option<bool>,
|
||||
pub supports_anthropic_compaction: Option<bool>,
|
||||
pub supports_none_reasoning_effort: Option<bool>,
|
||||
pub supports_minimal_reasoning_effort: Option<bool>,
|
||||
pub supports_low_reasoning_effort: Option<bool>,
|
||||
pub supports_xhigh_reasoning_effort: Option<bool>,
|
||||
pub supports_max_reasoning_effort: Option<bool>,
|
||||
pub reasoning_effort_levels: Option<Value>,
|
||||
pub default_reasoning_effort: Option<Value>,
|
||||
pub bedrock_output_config_effort_ceiling: Option<Value>,
|
||||
pub bedrock_converse_supports_strict_tools: Option<bool>,
|
||||
pub supports_regex_lookaround: Option<bool>,
|
||||
pub supports_computer_use: Option<bool>,
|
||||
pub search_context_cost_per_query: Option<Value>,
|
||||
pub web_search_billing_unit: Option<Value>,
|
||||
pub search_free_requests: Option<Value>,
|
||||
pub google_maps_grounding_cost_per_query: Option<f64>,
|
||||
pub tpm: Option<i64>,
|
||||
pub rpm: Option<i64>,
|
||||
pub ocr_cost_per_page: Option<f64>,
|
||||
pub ocr_cost_per_page_batches: Option<f64>,
|
||||
pub ocr_cost_per_credit: Option<f64>,
|
||||
pub annotation_cost_per_page: Option<f64>,
|
||||
pub annotation_cost_per_page_batches: Option<f64>,
|
||||
pub provider_specific_entry: Option<Value>,
|
||||
pub uses_embed_content: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vector_store_cost_per_gb_per_day: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vertex_ai_audio_api: Option<VertexAiAudioApi>,
|
||||
/// Whether web search is billed per query or per prompt.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_search_billing_unit: Option<WebSearchBillingUnit>,
|
||||
pub supports_image_size: Option<bool>,
|
||||
pub supported_audio_formats: Option<Value>,
|
||||
pub token_count_framing: Option<i64>,
|
||||
pub token_count_model: Option<Value>,
|
||||
pub token_count_tokenizer: Option<Value>,
|
||||
pub vertex_ai_audio_api: Option<Value>,
|
||||
/// Every key matching `ABOVE_THRESHOLD_COST_KEY_PATTERN`, copied as written
|
||||
pub above_threshold_costs: IndexMap<String, Value>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Whether web search is billed per query or per prompt.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebSearchBillingUnit {
|
||||
PerQuery,
|
||||
PerPrompt,
|
||||
}
|
||||
|
||||
/// UTC "HH:MM-HH:MM" window, or a list of them; a window may wrap past midnight.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(untagged)]
|
||||
pub enum UtcHours {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
/// ISO-8601 weekday number (1 = Monday .. 7 = Sunday) or English day name.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(untagged)]
|
||||
pub enum Weekday {
|
||||
Number(u8),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// One off-peak window entry inside `windows`.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OffPeakWindow {
|
||||
pub hours_utc: UtcHours,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub weekdays: Option<Vec<Weekday>>,
|
||||
}
|
||||
|
||||
/// Rates that replace the same-named base fields inside the stated UTC windows.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OffPeakPricing {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hours_utc: Option<UtcHours>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows: Option<Vec<OffPeakWindow>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub weekday_timezone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_reasoning_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost: Option<f64>,
|
||||
}
|
||||
|
||||
/// USD cost per web search query, keyed by search context size.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SearchContextCostPerQuery {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_context_size_low: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_context_size_medium: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_context_size_high: Option<f64>,
|
||||
}
|
||||
|
||||
/// One tier of a context-length or result-count tiered rate.
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TieredRate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub range: Option<[f64; 2]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_results_range: Option<[f64; 2]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_cost_per_reasoning_token: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_token_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_token_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_cost_per_query: Option<f64>,
|
||||
}
|
||||
26
litellm-rust/crates/model-catalog/src/register.rs
Normal file
26
litellm-rust/crates/model-catalog/src/register.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::wire::Fields;
|
||||
|
||||
/// Where one `register` call writes and which row it inherits from
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RegistrationTarget {
|
||||
/// Always the caller's key, never a case variant of it
|
||||
pub write: String,
|
||||
/// The first candidate that is an exact key whose row passes `provider_matches`
|
||||
pub inherit_from: Option<String>,
|
||||
}
|
||||
|
||||
/// Python's inheritance candidates in main's order, `get_model_info`'s then `_resolve_builtin_model_cost_entry`'s
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RegistrationCandidates(pub Vec<String>);
|
||||
|
||||
/// Layer 3, and the fields `register` last wrote, which litellm owns again
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[expect(dead_code, reason = "07 adds the functions that read registrations")]
|
||||
pub struct Registrations {
|
||||
persisted: IndexMap<String, Fields>,
|
||||
registered_fields: HashMap<String, BTreeSet<String>>,
|
||||
}
|
||||
18
litellm-rust/crates/model-catalog/src/resolve/image.rs
Normal file
18
litellm-rust/crates/model-catalog/src/resolve/image.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/// What an image call asks for. `resolve_image` builds main's eight key spellings from it
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImageCandidates {
|
||||
pub model: String,
|
||||
pub provider: Option<String>,
|
||||
/// Width and height, `1024 x 1024` when the request names none, as on main
|
||||
pub size: Option<(u32, u32)>,
|
||||
pub quality: Option<String>,
|
||||
}
|
||||
|
||||
/// A size and quality keyed row, `{quality}/{W}-x-{H}/{model}`, parsed once per index build for fal's nearest match
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImageKey {
|
||||
pub quality: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub model: String,
|
||||
}
|
||||
46
litellm-rust/crates/model-catalog/src/resolve/mod.rs
Normal file
46
litellm-rust/crates/model-catalog/src/resolve/mod.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
mod image;
|
||||
|
||||
pub use image::*;
|
||||
|
||||
/// The seven names main's `get_model_info` tries, built by Python, walked in this order by Rust
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Candidates {
|
||||
pub combined_model_name: Option<String>,
|
||||
pub model: String,
|
||||
pub region_free_combined_model_name: Option<String>,
|
||||
pub split_model: Option<String>,
|
||||
pub combined_stripped_model_name: Option<String>,
|
||||
pub stripped_model_name: Option<String>,
|
||||
pub provider_prefixed_model_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CandidateSlot {
|
||||
CombinedModelName,
|
||||
Model,
|
||||
RegionFreeCombinedModelName,
|
||||
SplitModel,
|
||||
CombinedStrippedModelName,
|
||||
StrippedModelName,
|
||||
ProviderPrefixedModelName,
|
||||
}
|
||||
|
||||
/// Which step matched, so a test can name the step that changed
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Via {
|
||||
pub slot: CandidateSlot,
|
||||
/// Matched through the case-insensitive index rather than an exact key
|
||||
pub case_insensitive: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Resolution {
|
||||
Found {
|
||||
key: String,
|
||||
via: Via,
|
||||
},
|
||||
/// Every candidate tried, in order
|
||||
Missing {
|
||||
tried: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,9 +1,80 @@
|
|||
use schemars::Schema;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_cost::money::{Multiplier, UnitPrice};
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::fallback::FallbackGeneralizations;
|
||||
use crate::wire::{Attributes, OffPeakWindow, SearchContextCostPerQuery, UtcHours};
|
||||
|
||||
#[derive(JsonSchema)]
|
||||
#[schemars(rename = "ModelEntry")]
|
||||
struct ModelEntry {
|
||||
#[serde(flatten)]
|
||||
_attributes: Attributes,
|
||||
#[serde(flatten)]
|
||||
_blocks: PriceBlocks,
|
||||
}
|
||||
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct PriceBlocks {
|
||||
/// USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).
|
||||
guardrail_cost_per_unit: Option<BTreeMap<String, UnitPrice>>,
|
||||
/// Rates that replace the same-named base fields while the request falls inside the stated UTC windows.
|
||||
off_peak_pricing: Option<OffPeakPricing>,
|
||||
/// Multiplier applied to all token costs when served from a non-global Vertex AI endpoint (e.g. 1.10 = +10%).
|
||||
regional_endpoint_uplift_multiplier: Option<Multiplier>,
|
||||
/// Multiplier applied to all token costs for EU data residency (e.g. 1.10 = +10%).
|
||||
regional_processing_uplift_multiplier_eu: Option<Multiplier>,
|
||||
/// Multiplier applied to all token costs for US data residency (e.g. 1.10 = +10%).
|
||||
regional_processing_uplift_multiplier_us: Option<Multiplier>,
|
||||
/// USD cost per web search query, keyed by search context size.
|
||||
search_context_cost_per_query: Option<SearchContextCostPerQuery>,
|
||||
/// Context-length or result-count tiered rates; each tier's costs apply within its range.
|
||||
tiered_pricing: Option<Vec<TieredRate>>,
|
||||
}
|
||||
|
||||
/// Rates that replace the same-named base fields inside the stated UTC windows.
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct OffPeakPricing {
|
||||
hours_utc: Option<UtcHours>,
|
||||
windows: Option<Vec<OffPeakWindow>>,
|
||||
weekday_timezone: Option<String>,
|
||||
}
|
||||
|
||||
/// One tier of a context-length or result-count tiered rate.
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct TieredRate {
|
||||
range: Option<[u64; 2]>,
|
||||
max_results_range: Option<[u64; 2]>,
|
||||
}
|
||||
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct RegistryDocument {
|
||||
sample_spec: Option<Map<String, Value>>,
|
||||
fallback_generalizations: Option<FallbackGeneralizations>,
|
||||
}
|
||||
|
||||
pub(crate) const FLAT_KEY_PATTERN: &str = concat!(
|
||||
r"^(",
|
||||
r"input_cost_per_((audio_|image_|video_)?token|character|image|pixel|query|request|second|text_message|audio_per_second|video_per_second(_above_\d+s_interval)?)",
|
||||
r"|output_cost_per_((audio_|image_|video_|reasoning_)?token|character|image(_\d+(\.\d+)?K?)?|pixel|second(_\d+[pk])?|video_per_second)",
|
||||
r"|cache_read_input_(audio_|image_|video_)?token_cost",
|
||||
r"|explicit_cache_read_input_token_cost",
|
||||
r"|cache_creation_input_(audio_|image_|video_)?token_cost(_above_1hr|_above_12hr)?",
|
||||
r"|annotation_cost_per_page|citation_cost_per_token|code_interpreter_cost_per_session",
|
||||
r"|google_maps_grounding_cost_per_query|ocr_cost_per_credit|ocr_cost_per_page|cost_per_second",
|
||||
r"|browser_open_cost_per_request|cost_per_additional_result",
|
||||
r")(_above_\d+k?_tokens)?(_(priority|flex|ultrafast|balanced|batches|decisions))?$",
|
||||
);
|
||||
|
||||
/// JSON Schema for one model entry, including registry validation constraints.
|
||||
pub fn model_entry_json_schema() -> Schema {
|
||||
let mut schema = serde_json::to_value(schemars::schema_for!(crate::ModelInfo))
|
||||
let mut schema = serde_json::to_value(schemars::schema_for!(ModelEntry))
|
||||
.expect("derived model schema serializes");
|
||||
remove_nullable_optional_fields(&mut schema);
|
||||
decorate_model_entry(&mut schema);
|
||||
|
|
@ -22,24 +93,21 @@ pub fn registry_json_schema() -> Schema {
|
|||
entry.as_object_mut().unwrap().remove("$schema");
|
||||
definitions.insert("modelEntry".into(), entry);
|
||||
|
||||
let mut fallback = serde_json::to_value(schemars::schema_for!(crate::FallbackGeneralizations))
|
||||
.expect("derived fallback schema serializes");
|
||||
remove_nullable_optional_fields(&mut fallback);
|
||||
definitions.extend(take_definitions(&mut fallback));
|
||||
fallback.as_object_mut().unwrap().remove("$schema");
|
||||
|
||||
let root = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "LiteLLM model prices and context window registry",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sample_spec": {"type": "object"},
|
||||
"fallback_generalizations": fallback
|
||||
},
|
||||
"additionalProperties": {"$ref": "#/$defs/modelEntry"},
|
||||
"$defs": definitions
|
||||
});
|
||||
Schema::from(root.as_object().unwrap().clone())
|
||||
let mut registry = serde_json::to_value(schemars::schema_for!(RegistryDocument))
|
||||
.expect("derived registry schema serializes");
|
||||
remove_nullable_optional_fields(&mut registry);
|
||||
definitions.extend(take_definitions(&mut registry));
|
||||
let root = registry.as_object_mut().unwrap();
|
||||
root.insert(
|
||||
"title".into(),
|
||||
"LiteLLM model prices and context window registry".into(),
|
||||
);
|
||||
root.insert(
|
||||
"additionalProperties".into(),
|
||||
json!({"$ref": "#/$defs/modelEntry"}),
|
||||
);
|
||||
root.insert("$defs".into(), Value::Object(definitions));
|
||||
Schema::from(root.clone())
|
||||
}
|
||||
|
||||
fn take_definitions(schema: &mut Value) -> Map<String, Value> {
|
||||
|
|
@ -80,42 +148,21 @@ fn remove_nullable_optional_fields(value: &mut Value) {
|
|||
}
|
||||
|
||||
fn decorate_model_entry(schema: &mut Value) {
|
||||
let rates = json!({FLAT_KEY_PATTERN: {"type": "number", "minimum": 0}});
|
||||
let object = schema.as_object_mut().unwrap();
|
||||
object.insert("required".into(), json!(["litellm_provider"]));
|
||||
object.insert("additionalProperties".into(), Value::Bool(true));
|
||||
let properties = object
|
||||
.get_mut("properties")
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap();
|
||||
properties.insert(
|
||||
"aliases".into(),
|
||||
json!({"type": "array", "items": {"type": "string"}}),
|
||||
object.insert(
|
||||
"patternProperties".into(),
|
||||
json!({
|
||||
FLAT_KEY_PATTERN: {"type": "number", "minimum": 0},
|
||||
"^supports_": {"type": "boolean"},
|
||||
}),
|
||||
);
|
||||
properties.get_mut("deprecation_date").unwrap()["format"] = json!("date");
|
||||
properties.get_mut("deprecation_date").unwrap()["pattern"] =
|
||||
json!(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$");
|
||||
|
||||
properties.iter_mut().for_each(|(name, property)| {
|
||||
if name.contains("cost") {
|
||||
property["minimum"] = json!(0);
|
||||
} else if name.contains("uplift_multiplier") {
|
||||
property["minimum"] = json!(1);
|
||||
}
|
||||
});
|
||||
properties.get_mut("guardrail_cost_per_unit").unwrap()["additionalProperties"]["minimum"] =
|
||||
json!(0);
|
||||
|
||||
let definitions = object.get_mut("$defs").unwrap().as_object_mut().unwrap();
|
||||
for definition in ["OffPeakPricing", "TieredRate", "SearchContextCostPerQuery"] {
|
||||
let properties = definitions[definition]["properties"]
|
||||
.as_object_mut()
|
||||
.unwrap();
|
||||
properties.iter_mut().for_each(|(name, property)| {
|
||||
if name.contains("cost") || definition == "SearchContextCostPerQuery" {
|
||||
property["minimum"] = json!(0);
|
||||
}
|
||||
});
|
||||
for definition in ["OffPeakPricing", "TieredRate"] {
|
||||
definitions[definition]["patternProperties"] = rates.clone();
|
||||
definitions[definition]["additionalProperties"] = Value::Bool(false);
|
||||
}
|
||||
definitions["OffPeakPricing"]["anyOf"] = json!([
|
||||
{"required": ["hours_utc"]},
|
||||
|
|
@ -123,15 +170,43 @@ fn decorate_model_entry(schema: &mut Value) {
|
|||
]);
|
||||
definitions["OffPeakPricing"]["properties"]["windows"]["minItems"] = json!(1);
|
||||
definitions["OffPeakWindow"]["properties"]["weekdays"]["minItems"] = json!(1);
|
||||
definitions["TieredRate"]["properties"]["range"]["items"]["minimum"] = json!(0);
|
||||
definitions["TieredRate"]["properties"]["max_results_range"]["items"]["minimum"] = json!(0);
|
||||
definitions["Weekday"]["anyOf"][0]["minimum"] = json!(1);
|
||||
definitions["Weekday"]["anyOf"][0]["maximum"] = json!(7);
|
||||
definitions["Weekday"]["anyOf"][1]["pattern"] = json!(
|
||||
r"(?i)^(mon|monday|tue|tues|tuesday|wed|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday|sun|sunday)$"
|
||||
);
|
||||
let window_pattern = json!(r"^([01]\d|2[0-3]):[0-5]\d-([01]\d|2[0-3]):[0-5]\d$");
|
||||
definitions["UtcHours"]["anyOf"][0]["pattern"] = window_pattern.clone();
|
||||
definitions["UtcHours"]["anyOf"][1]["items"]["pattern"] = window_pattern;
|
||||
definitions["UtcHours"]["anyOf"][1]["minItems"] = json!(1);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::FLAT_KEY_PATTERN;
|
||||
use crate::wire::FlatKey;
|
||||
|
||||
#[rstest]
|
||||
#[case::input_token("input_cost_per_token")]
|
||||
#[case::cache_write_one_hour_band(
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens"
|
||||
)]
|
||||
#[case::image_label("output_cost_per_image_0.5K")]
|
||||
#[case::resolution_tier("output_cost_per_second_4k_flex")]
|
||||
#[case::feature_batch("annotation_cost_per_page_batches")]
|
||||
#[case::video_interval("input_cost_per_video_per_second_above_8s_interval")]
|
||||
#[case::explicit_cache_read("explicit_cache_read_input_token_cost")]
|
||||
#[case::wall_clock("cost_per_second")]
|
||||
#[case::unused_meter("cost_per_token")]
|
||||
#[case::other_currency("input_dbu_cost_per_token")]
|
||||
#[case::typo("input_cost_per_tokne")]
|
||||
#[case::read_has_no_ttl("cache_read_input_token_cost_above_1hr")]
|
||||
#[case::interval_on_a_token_meter("input_cost_per_token_above_8s_interval")]
|
||||
fn flat_key_pattern_agrees_with_the_parser(#[case] key: &str) {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"patternProperties": {FLAT_KEY_PATTERN: {}},
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let validator = jsonschema::validator_for(&schema).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
validator.is_valid(&serde_json::json!({key: 1})),
|
||||
FlatKey::parse(key).is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
litellm-rust/crates/model-catalog/src/store/AGENTS.md
Normal file
35
litellm-rust/crates/model-catalog/src/store/AGENTS.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Store
|
||||
|
||||
`Store<R: Rows>` is an in-memory store, a small database built for exactly three queries. It knows nothing about the catalog. A source picks its own `Rows::Row` type, how names fold for matching, and the group each key is listed under. The catalog's sources fold to lowercase and group by raw `litellm_provider`
|
||||
|
||||
| Query | Method | Real caller |
|
||||
|---|---|---|
|
||||
| row by exact key | `Store::get` | reading `litellm.model_cost["gpt-4o"]` |
|
||||
| actual key for a name that folds to the same form | `Store::find_key` | `get_model_info("GPT-4o")` finding `gpt-4o` |
|
||||
| keys in a group | `Store::keys_in` | the provider sets, `litellm.open_ai_chat_completion_models` |
|
||||
|
||||
## Rules
|
||||
|
||||
- nothing in `store/` imports from the rest of the crate. Wire types, case rules and field names such as `litellm_provider` belong to the source that implements `Rows`
|
||||
- the index in `name_index.rs` is private to the store. Never build a folded-name map or a provider grouping anywhere else in the workspace. A new lookup pattern is a new `Store` method backed by `NameIndex`
|
||||
- the index holds keys, never rows. Rows are always read from `Rows`, so a price edit is seen on the next read and never rebuilds the index
|
||||
- `find_key` and `keys_in` never return a key the rows lack. A hit on a removed key rebuilds the index once and asks again
|
||||
- `keys_in` matches the group exactly as the source wrote it. Provider aliases such as `vertex_ai` over `vertex_ai-language-models` are Python's job
|
||||
- when two keys fold to the same name the later key in row order wins, like main's `_model_cost_lowercase_map`
|
||||
|
||||
## Stamps
|
||||
|
||||
`Rows::stamp` is how the store learns that the index may be stale. The store keeps the stamp it built the index at and rebuilds when the source returns a different one. A source must change its stamp whenever a key or a key's group changes, since those are the two things the index holds
|
||||
|
||||
| Source | Stamp | Blind spot |
|
||||
|---|---|---|
|
||||
| `Catalog`'s `CatalogRows` | `()`, every change goes through `Store::edit`, which drops the index | none |
|
||||
| python-bridge's bound `litellm.model_cost` | the dict object and its `len` | a delete plus an add between two reads, or a `litellm_provider` rewrite on an existing key |
|
||||
|
||||
A source with a blind spot calls `Store::invalidate` when it knows keys changed. Until then a key added in the blind spot is not found, and a key moved to another provider stays listed under the old one. Removed keys are still never returned
|
||||
|
||||
## Tests
|
||||
|
||||
Tests live in `mod.rs`'s `tests` module and drive the store through `ModelCost`, a fake `litellm.model_cost` with real bundled keys whose row is just its `litellm_provider`. Each Python write is a method (`set`, `del`, `rebind`) and its stamp is what python-bridge can see. Add a scenario as a named test that reads like the Python user code it stands for
|
||||
|
||||
The two property tests compare every answer with a linear scan of the same dict. With a stamp that sees every key change, answers must match exactly. With the dict stamp, every answer must be sound (present, same folded name), and `invalidate` must make them match again. A store change that fails either is a bug in the store, not in the test
|
||||
460
litellm-rust/crates/model-catalog/src/store/mod.rs
Normal file
460
litellm-rust/crates/model-catalog/src/store/mod.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
mod name_index;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::sync::{PoisonError, RwLock};
|
||||
|
||||
use name_index::NameIndex;
|
||||
|
||||
/// Where a `Store`'s rows live. The store rebuilds its index whenever `stamp` changes, so a source must change its
|
||||
/// stamp whenever a key or a key's group changes, or call `Store::invalidate` when it cannot
|
||||
pub trait Rows {
|
||||
type Row: ToOwned + ?Sized;
|
||||
type Stamp: PartialEq;
|
||||
|
||||
/// The name two spellings of one key share, which `Store::find_key` matches on
|
||||
fn fold(name: &str) -> String;
|
||||
fn stamp(&self) -> Self::Stamp;
|
||||
fn contains(&self, key: &str) -> bool;
|
||||
fn row(&self, key: &str) -> Option<Cow<'_, Self::Row>>;
|
||||
/// Every key in row order with the group it is listed under
|
||||
fn scan(&self, visit: &mut dyn FnMut(&str, Option<&str>));
|
||||
}
|
||||
|
||||
/// An in-memory store for three queries (row by exact key, actual key for a name by its folded form, keys in a group)
|
||||
/// that owns the only index behind them
|
||||
pub struct Store<R: Rows> {
|
||||
rows: R,
|
||||
index: RwLock<Option<Built<R::Stamp>>>,
|
||||
}
|
||||
|
||||
struct Built<S> {
|
||||
stamp: S,
|
||||
names: NameIndex,
|
||||
}
|
||||
|
||||
impl<R: Rows> Store<R> {
|
||||
pub fn new(rows: R) -> Self {
|
||||
Self {
|
||||
rows,
|
||||
index: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &R {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
/// Changes the rows in place. The index is rebuilt on the next query
|
||||
pub fn edit<T>(&mut self, edit: impl FnOnce(&mut R) -> T) -> T {
|
||||
let edited = edit(&mut self.rows);
|
||||
*self.index.get_mut().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
edited
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<Cow<'_, R::Row>> {
|
||||
self.rows.row(key)
|
||||
}
|
||||
|
||||
/// The key itself when present, otherwise the actual key that folds to the same name. Never a key the rows lack
|
||||
pub fn find_key(&self, name: &str) -> Option<String> {
|
||||
if self.rows.contains(name) {
|
||||
return Some(name.to_owned());
|
||||
}
|
||||
let folded = R::fold(name);
|
||||
let hit = self.query(|names| names.key_for(&folded).map(str::to_owned))?;
|
||||
if self.rows.contains(&hit) {
|
||||
return Some(hit);
|
||||
}
|
||||
self.invalidate();
|
||||
self.query(|names| names.key_for(&folded).map(str::to_owned))
|
||||
.filter(|key| self.rows.contains(key))
|
||||
}
|
||||
|
||||
/// Keys whose group is exactly `group`, in row order. Never a key the rows lack
|
||||
pub fn keys_in(&self, group: &str) -> Vec<String> {
|
||||
self.query(|names| names.keys_in(group).to_vec())
|
||||
.into_iter()
|
||||
.filter(|key| self.rows.contains(key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the index, for a source whose stamp cannot see every key change
|
||||
pub fn invalidate(&self) {
|
||||
*self.index.write().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
fn query<T>(&self, read: impl Fn(&NameIndex) -> T) -> T {
|
||||
let stamp = self.rows.stamp();
|
||||
if let Some(built) = self
|
||||
.index
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.filter(|built| built.stamp == stamp)
|
||||
{
|
||||
return read(&built.names);
|
||||
}
|
||||
let names = NameIndex::scan(&self.rows);
|
||||
let answer = read(&names);
|
||||
*self.index.write().unwrap_or_else(PoisonError::into_inner) = Some(Built { stamp, names });
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rows + Clone> Clone for Store<R> {
|
||||
fn clone(&self) -> Self {
|
||||
Self::new(self.rows.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Rows + fmt::Debug> fmt::Debug for Store<R> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Store")
|
||||
.field("rows", &self.rows)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use super::*;
|
||||
|
||||
const BUNDLED: &[(&str, Option<&str>)] = &[
|
||||
("gpt-4o", Some("openai")),
|
||||
("o3-pro", Some("openai")),
|
||||
("azure/gpt-4o", Some("azure")),
|
||||
("claude-sonnet-4-5", Some("anthropic")),
|
||||
("gemini-2.5-pro", Some("vertex_ai-language-models")),
|
||||
(
|
||||
"fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
Some("fireworks_ai"),
|
||||
),
|
||||
(
|
||||
"anyscale/meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
Some("anyscale"),
|
||||
),
|
||||
("sample_free_row", None),
|
||||
];
|
||||
|
||||
/// `litellm.model_cost` as Python sees it. The stamp is what python-bridge can observe for free, the dict's
|
||||
/// identity and its length, so a write that keeps both hides a key change from the store
|
||||
struct ModelCost {
|
||||
dict: RefCell<IndexMap<String, Option<String>>>,
|
||||
identity: Cell<u64>,
|
||||
index_changes: Cell<u64>,
|
||||
honest_stamp: bool,
|
||||
scans: Cell<usize>,
|
||||
}
|
||||
|
||||
impl ModelCost {
|
||||
fn new(rows: &[(&str, Option<&str>)], honest_stamp: bool) -> Self {
|
||||
Self {
|
||||
dict: RefCell::new(
|
||||
rows.iter()
|
||||
.map(|(key, provider)| ((*key).to_owned(), provider.map(str::to_owned)))
|
||||
.collect(),
|
||||
),
|
||||
identity: Cell::new(0),
|
||||
index_changes: Cell::new(0),
|
||||
honest_stamp,
|
||||
scans: Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// `model_cost[key] = {"litellm_provider": provider, ...}`
|
||||
fn set(&self, key: &str, provider: Option<&str>) {
|
||||
let previous = self
|
||||
.dict
|
||||
.borrow_mut()
|
||||
.insert(key.to_owned(), provider.map(str::to_owned));
|
||||
if previous.as_ref().map(Option::as_deref) != Some(provider) {
|
||||
self.index_changes.set(self.index_changes.get() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `del model_cost[key]`
|
||||
fn del(&self, key: &str) {
|
||||
if self.dict.borrow_mut().shift_remove(key).is_some() {
|
||||
self.index_changes.set(self.index_changes.get() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `litellm.model_cost = {...}`, a new dict object
|
||||
fn rebind(&self, rows: &[(&str, Option<&str>)]) {
|
||||
*self.dict.borrow_mut() = rows
|
||||
.iter()
|
||||
.map(|(key, provider)| ((*key).to_owned(), provider.map(str::to_owned)))
|
||||
.collect();
|
||||
self.identity.set(self.identity.get() + 1);
|
||||
self.index_changes.set(self.index_changes.get() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row is its `litellm_provider`, which is also the group the store lists its key under
|
||||
impl Rows for &ModelCost {
|
||||
type Row = Option<String>;
|
||||
type Stamp = (u64, u64);
|
||||
|
||||
fn fold(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
}
|
||||
|
||||
fn stamp(&self) -> Self::Stamp {
|
||||
if self.honest_stamp {
|
||||
(self.identity.get(), self.index_changes.get())
|
||||
} else {
|
||||
(self.identity.get(), self.dict.borrow().len() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, key: &str) -> bool {
|
||||
self.dict.borrow().contains_key(key)
|
||||
}
|
||||
|
||||
fn row(&self, key: &str) -> Option<Cow<'_, Option<String>>> {
|
||||
Some(Cow::Owned(self.dict.borrow().get(key)?.clone()))
|
||||
}
|
||||
|
||||
fn scan(&self, visit: &mut dyn FnMut(&str, Option<&str>)) {
|
||||
self.scans.set(self.scans.get() + 1);
|
||||
for (key, provider) in self.dict.borrow().iter() {
|
||||
visit(key, provider.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn model_cost() -> ModelCost {
|
||||
ModelCost::new(BUNDLED, false)
|
||||
}
|
||||
|
||||
/// Rows only `Store::edit` can change, so the stamp never moves
|
||||
#[derive(Debug)]
|
||||
struct Owned(IndexMap<String, Option<String>>);
|
||||
|
||||
impl Rows for Owned {
|
||||
type Row = Option<String>;
|
||||
type Stamp = ();
|
||||
|
||||
fn fold(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
}
|
||||
|
||||
fn stamp(&self) -> Self::Stamp {}
|
||||
|
||||
fn contains(&self, key: &str) -> bool {
|
||||
self.0.contains_key(key)
|
||||
}
|
||||
|
||||
fn row(&self, key: &str) -> Option<Cow<'_, Option<String>>> {
|
||||
self.0.get(key).map(Cow::Borrowed)
|
||||
}
|
||||
|
||||
fn scan(&self, visit: &mut dyn FnMut(&str, Option<&str>)) {
|
||||
for (key, provider) in &self.0 {
|
||||
visit(key, provider.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn owned(rows: &[(&str, Option<&str>)]) -> Store<Owned> {
|
||||
Store::new(Owned(
|
||||
rows.iter()
|
||||
.map(|(key, provider)| ((*key).to_owned(), provider.map(str::to_owned)))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::bundled_key_as_written("gpt-4o", Some("gpt-4o"))]
|
||||
#[case::user_typed_upper_case("GPT-4o", Some("gpt-4o"))]
|
||||
#[case::provider_prefixed("Azure/GPT-4o", Some("azure/gpt-4o"))]
|
||||
#[case::fireworks_path_in_another_case(
|
||||
"fireworks_ai/accounts/fireworks/models/Llama-v3p1-8B-Instruct",
|
||||
Some("fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct")
|
||||
)]
|
||||
#[case::mixed_case_bundled_key(
|
||||
"anyscale/meta-llama/meta-llama-3-8b-instruct",
|
||||
Some("anyscale/meta-llama/Meta-Llama-3-8B-Instruct")
|
||||
)]
|
||||
#[case::unmapped_model("my-finetune", None)]
|
||||
#[case::prefix_is_not_a_match("gpt-4", None)]
|
||||
fn find_key_maps_a_requested_model_to_its_key(
|
||||
model_cost: ModelCost,
|
||||
#[case] model: &str,
|
||||
#[case] expected: Option<&str>,
|
||||
) {
|
||||
assert_eq!(Store::new(&model_cost).find_key(model).as_deref(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::the_bundled_spelling("gpt-4o", "gpt-4o")]
|
||||
#[case::the_registered_spelling("GPT-4o", "GPT-4o")]
|
||||
#[case::another_spelling_takes_the_later_key("Gpt-4O", "GPT-4o")]
|
||||
fn a_registered_case_variant_is_its_own_row(
|
||||
model_cost: ModelCost,
|
||||
#[case] model: &str,
|
||||
#[case] expected: &str,
|
||||
) {
|
||||
model_cost.set("GPT-4o", Some("openai"));
|
||||
assert_eq!(
|
||||
Store::new(&model_cost).find_key(model).as_deref(),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::openai_in_map_order("openai", &["gpt-4o", "o3-pro"])]
|
||||
#[case::azure("azure", &["azure/gpt-4o"])]
|
||||
#[case::raw_vertex_family("vertex_ai-language-models", &["gemini-2.5-pro"])]
|
||||
#[case::provider_alias_is_python_s_job("vertex_ai", &[])]
|
||||
#[case::matched_as_written("OpenAI", &[])]
|
||||
#[case::unknown_provider("acme", &[])]
|
||||
fn keys_in_lists_the_keys_a_raw_provider_names(
|
||||
model_cost: ModelCost,
|
||||
#[case] provider: &str,
|
||||
#[case] expected: &[&str],
|
||||
) {
|
||||
assert_eq!(Store::new(&model_cost).keys_in(provider), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_model_written_straight_into_the_dict_is_found_in_any_case(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("mycorp/custom-model"), None);
|
||||
|
||||
model_cost.set("MyCorp/Custom-Model", Some("openai"));
|
||||
|
||||
assert_eq!(
|
||||
store.find_key("mycorp/custom-model").as_deref(),
|
||||
Some("MyCorp/Custom-Model")
|
||||
);
|
||||
assert_eq!(
|
||||
store.keys_in("openai"),
|
||||
["gpt-4o", "o3-pro", "MyCorp/Custom-Model"]
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_price_edit_reuses_the_index(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("GPT-4o").as_deref(), Some("gpt-4o"));
|
||||
|
||||
model_cost.set("gpt-4o", Some("openai"));
|
||||
model_cost.set("o3-pro", Some("openai"));
|
||||
model_cost.set("gpt-4o", Some("openai"));
|
||||
|
||||
assert_eq!(store.find_key("O3-PRO").as_deref(), Some("o3-pro"));
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o", "o3-pro"]);
|
||||
assert_eq!(model_cost.scans.get(), 1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_reload_that_rebinds_the_dict_is_seen_even_at_the_same_length(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("GPT-4o").as_deref(), Some("gpt-4o"));
|
||||
|
||||
let mut reloaded = BUNDLED.to_vec();
|
||||
reloaded[0] = ("gpt-4o-2024-11-20", Some("openai"));
|
||||
model_cost.rebind(&reloaded);
|
||||
|
||||
assert_eq!(store.find_key("GPT-4o"), None);
|
||||
assert_eq!(
|
||||
store.find_key("GPT-4o-2024-11-20").as_deref(),
|
||||
Some("gpt-4o-2024-11-20")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_deleted_model_is_never_returned(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("O3-PRO").as_deref(), Some("o3-pro"));
|
||||
|
||||
model_cost.del("o3-pro");
|
||||
|
||||
assert_eq!(store.find_key("O3-PRO"), None);
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_respelled_key_at_the_same_length_is_found_after_one_rebuild(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("o3-PRO").as_deref(), Some("o3-pro"));
|
||||
|
||||
model_cost.del("o3-pro");
|
||||
model_cost.set("O3-Pro", Some("openai"));
|
||||
|
||||
assert_eq!(store.find_key("o3-PRO").as_deref(), Some("O3-Pro"));
|
||||
assert_eq!(model_cost.scans.get(), 2);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_swap_at_the_same_length_hides_the_new_key_until_invalidate(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.find_key("O3-PRO").as_deref(), Some("o3-pro"));
|
||||
|
||||
model_cost.del("o3-pro");
|
||||
model_cost.set("MyCorp/Custom-Model", Some("openai"));
|
||||
|
||||
assert_eq!(store.find_key("mycorp/custom-model"), None);
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o"]);
|
||||
|
||||
store.invalidate();
|
||||
|
||||
assert_eq!(
|
||||
store.find_key("mycorp/custom-model").as_deref(),
|
||||
Some("MyCorp/Custom-Model")
|
||||
);
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o", "MyCorp/Custom-Model"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_provider_rewrite_on_an_existing_key_needs_invalidate(model_cost: ModelCost) {
|
||||
let store = Store::new(&model_cost);
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o", "o3-pro"]);
|
||||
|
||||
model_cost.set("o3-pro", Some("azure"));
|
||||
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o", "o3-pro"]);
|
||||
|
||||
store.invalidate();
|
||||
|
||||
assert_eq!(store.keys_in("openai"), ["gpt-4o"]);
|
||||
assert_eq!(store.keys_in("azure"), ["o3-pro", "azure/gpt-4o"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn an_edit_on_owned_rows_is_seen_by_the_next_query() {
|
||||
let mut store = owned(BUNDLED);
|
||||
assert_eq!(store.find_key("GPT-4o").as_deref(), Some("gpt-4o"));
|
||||
|
||||
store.edit(|rows| {
|
||||
rows.0.shift_remove("gpt-4o");
|
||||
rows.0
|
||||
.insert("MyCorp/Custom-Model".to_owned(), Some("openai".to_owned()));
|
||||
});
|
||||
|
||||
assert_eq!(store.find_key("GPT-4o"), None);
|
||||
assert_eq!(
|
||||
store.find_key("mycorp/custom-model").as_deref(),
|
||||
Some("MyCorp/Custom-Model")
|
||||
);
|
||||
assert_eq!(store.keys_in("openai"), ["o3-pro", "MyCorp/Custom-Model"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn get_reads_the_row_by_exact_key_only() {
|
||||
let store = owned(BUNDLED);
|
||||
assert_eq!(
|
||||
store.get("azure/gpt-4o").as_deref(),
|
||||
Some(&Some("azure".to_owned()))
|
||||
);
|
||||
assert_eq!(store.get("Azure/GPT-4o"), None);
|
||||
}
|
||||
}
|
||||
35
litellm-rust/crates/model-catalog/src/store/name_index.rs
Normal file
35
litellm-rust/crates/model-catalog/src/store/name_index.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use super::Rows;
|
||||
|
||||
/// Keys only, never rows, so a value write never makes it stale. A later key wins when two keys fold to one name
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct NameIndex {
|
||||
by_folded: HashMap<String, String>,
|
||||
by_group: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl NameIndex {
|
||||
pub(super) fn scan<R: Rows>(rows: &R) -> Self {
|
||||
let mut index = Self::default();
|
||||
rows.scan(&mut |key, group| {
|
||||
index.by_folded.insert(R::fold(key), key.to_owned());
|
||||
if let Some(group) = group {
|
||||
index
|
||||
.by_group
|
||||
.entry(group.to_owned())
|
||||
.or_default()
|
||||
.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
index
|
||||
}
|
||||
|
||||
pub(super) fn key_for(&self, folded: &str) -> Option<&str> {
|
||||
self.by_folded.get(folded).map(String::as_str)
|
||||
}
|
||||
|
||||
pub(super) fn keys_in(&self, group: &str) -> &[String] {
|
||||
self.by_group.get(group).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AliasIssue, Catalog, ModelInfo, UtcHours, Weekday};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegistryValidationError {
|
||||
#[error("invalid registry entry {model:?}")]
|
||||
Entry {
|
||||
model: String,
|
||||
#[source]
|
||||
reason: EntryViolation,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Alias(AliasIssue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EntryViolation {
|
||||
#[error("entry must be an object")]
|
||||
NotObject,
|
||||
#[error("aliases must be an array")]
|
||||
AliasesNotArray,
|
||||
#[error("aliases must contain strings")]
|
||||
AliasNotString,
|
||||
#[error("entry does not match the model schema")]
|
||||
Schema(#[source] serde_json::Error),
|
||||
#[error("litellm_provider is required")]
|
||||
MissingProvider,
|
||||
#[error("invalid deprecation_date")]
|
||||
DeprecationDate(#[source] time::error::Parse),
|
||||
#[error("off_peak_pricing needs hours or windows")]
|
||||
OffPeakWithoutHours,
|
||||
#[error("off_peak_pricing.hours_utc is invalid")]
|
||||
InvalidHours,
|
||||
#[error("off_peak_pricing.windows is empty")]
|
||||
EmptyWindows,
|
||||
#[error("off_peak_pricing.weekdays is invalid")]
|
||||
InvalidWeekdays,
|
||||
#[error("typed entry could not be serialized")]
|
||||
Serialize(#[source] serde_json::Error),
|
||||
#[error("entry has an unknown field, null, or changed value: {}", fields.join(", "))]
|
||||
ChangedFields { fields: Vec<String> },
|
||||
#[error("{field} must be nonnegative")]
|
||||
Negative { field: String },
|
||||
#[error("{field} must be at least one")]
|
||||
UpliftBelowOne { field: String },
|
||||
}
|
||||
|
||||
pub fn validate_model_entry(model: &str, value: &Value) -> Result<(), RegistryValidationError> {
|
||||
validate_entry_inner(value).map_err(|reason| RegistryValidationError::Entry {
|
||||
model: model.to_owned(),
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_registry(catalog: &Catalog) -> Result<(), RegistryValidationError> {
|
||||
if let Some(issue) = catalog.alias_issues().first() {
|
||||
return Err(RegistryValidationError::Alias(issue.clone()));
|
||||
}
|
||||
catalog.model_names().try_for_each(|name| {
|
||||
let entry = catalog.lookup(name).expect("catalog name must resolve");
|
||||
validate_model_entry(name, &Value::Object(entry.entry.fields().clone()))
|
||||
})
|
||||
}
|
||||
|
||||
fn json_eq(left: &Value, right: &Value) -> bool {
|
||||
match (left, right) {
|
||||
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
|
||||
(Value::Array(left), Value::Array(right)) => {
|
||||
left.len() == right.len() && left.iter().zip(right).all(|(a, b)| json_eq(a, b))
|
||||
}
|
||||
(Value::Object(left), Value::Object(right)) => {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.all(|(key, value)| right.get(key).is_some_and(|other| json_eq(value, other)))
|
||||
}
|
||||
_ => left == right,
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(value: &Map<String, Value>) -> BTreeSet<String> {
|
||||
value.keys().cloned().collect()
|
||||
}
|
||||
|
||||
fn symmetric_difference(left: &BTreeSet<String>, right: &BTreeSet<String>) -> Vec<String> {
|
||||
left.symmetric_difference(right).cloned().collect()
|
||||
}
|
||||
|
||||
fn validate_entry_inner(value: &Value) -> Result<(), EntryViolation> {
|
||||
let object = value.as_object().ok_or(EntryViolation::NotObject)?;
|
||||
if let Some(aliases) = object.get("aliases") {
|
||||
let names = aliases.as_array().ok_or(EntryViolation::AliasesNotArray)?;
|
||||
if names.iter().any(|name| !name.is_string()) {
|
||||
return Err(EntryViolation::AliasNotString);
|
||||
}
|
||||
}
|
||||
let info: ModelInfo = serde_json::from_value(value.clone()).map_err(EntryViolation::Schema)?;
|
||||
if info.litellm_provider.is_none() {
|
||||
return Err(EntryViolation::MissingProvider);
|
||||
}
|
||||
validate_dates_and_windows(&info)?;
|
||||
let serialized = serde_json::to_value(info).map_err(EntryViolation::Serialize)?;
|
||||
let mut expected = object.clone();
|
||||
expected.remove("aliases");
|
||||
if !json_eq(&Value::Object(expected.clone()), &serialized) {
|
||||
let actual = serialized
|
||||
.as_object()
|
||||
.expect("ModelInfo serializes as an object");
|
||||
return Err(EntryViolation::ChangedFields {
|
||||
fields: symmetric_difference(&keys(&expected), &keys(actual)),
|
||||
});
|
||||
}
|
||||
check_prices("", value)
|
||||
}
|
||||
|
||||
fn validate_dates_and_windows(info: &ModelInfo) -> Result<(), EntryViolation> {
|
||||
if let Some(date) = &info.deprecation_date {
|
||||
let format = time::format_description::parse_borrowed::<2>("[year]-[month]-[day]").unwrap();
|
||||
time::Date::parse(date, &format).map_err(EntryViolation::DeprecationDate)?;
|
||||
}
|
||||
let Some(pricing) = &info.off_peak_pricing else {
|
||||
return Ok(());
|
||||
};
|
||||
if pricing.hours_utc.is_none() && pricing.windows.is_none() {
|
||||
return Err(EntryViolation::OffPeakWithoutHours);
|
||||
}
|
||||
if let Some(hours) = &pricing.hours_utc {
|
||||
validate_hours(hours)?;
|
||||
}
|
||||
if let Some(windows) = &pricing.windows {
|
||||
if windows.is_empty() {
|
||||
return Err(EntryViolation::EmptyWindows);
|
||||
}
|
||||
windows.iter().try_for_each(|window| {
|
||||
validate_hours(&window.hours_utc)?;
|
||||
if let Some(days) = &window.weekdays
|
||||
&& (days.is_empty() || days.iter().any(|day| !valid_weekday(day)))
|
||||
{
|
||||
return Err(EntryViolation::InvalidWeekdays);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_hours(hours: &UtcHours) -> Result<(), EntryViolation> {
|
||||
let values = match hours {
|
||||
UtcHours::Single(value) => std::slice::from_ref(value),
|
||||
UtcHours::Multiple(values) => values.as_slice(),
|
||||
};
|
||||
if values.is_empty() || values.iter().any(|value| !valid_utc_window(value)) {
|
||||
return Err(EntryViolation::InvalidHours);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_utc_window(value: &str) -> bool {
|
||||
let Some((start, end)) = value.split_once('-') else {
|
||||
return false;
|
||||
};
|
||||
[start, end].into_iter().all(|clock| {
|
||||
let Some((hour, minute)) = clock.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
hour.len() == 2
|
||||
&& minute.len() == 2
|
||||
&& hour.parse::<u8>().is_ok_and(|hour| hour < 24)
|
||||
&& minute.parse::<u8>().is_ok_and(|minute| minute < 60)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_weekday(day: &Weekday) -> bool {
|
||||
match day {
|
||||
Weekday::Number(number) => (1..=7).contains(number),
|
||||
Weekday::Name(name) => matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"mon"
|
||||
| "monday"
|
||||
| "tue"
|
||||
| "tues"
|
||||
| "tuesday"
|
||||
| "wed"
|
||||
| "wednesday"
|
||||
| "thu"
|
||||
| "thur"
|
||||
| "thurs"
|
||||
| "thursday"
|
||||
| "fri"
|
||||
| "friday"
|
||||
| "sat"
|
||||
| "saturday"
|
||||
| "sun"
|
||||
| "sunday"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_prices(path: &str, value: &Value) -> Result<(), EntryViolation> {
|
||||
let Some(object) = value.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
object.iter().try_for_each(|(key, field)| {
|
||||
let field_path = format!("{path}.{key}");
|
||||
if (key.contains("cost")
|
||||
|| path.ends_with(".guardrail_cost_per_unit")
|
||||
|| path.ends_with(".search_context_cost_per_query"))
|
||||
&& let Some(number) = field.as_f64()
|
||||
&& number < 0.0
|
||||
{
|
||||
return Err(EntryViolation::Negative {
|
||||
field: relative(&field_path),
|
||||
});
|
||||
}
|
||||
if key.contains("uplift_multiplier")
|
||||
&& let Some(number) = field.as_f64()
|
||||
&& number < 1.0
|
||||
{
|
||||
return Err(EntryViolation::UpliftBelowOne {
|
||||
field: relative(&field_path),
|
||||
});
|
||||
}
|
||||
if matches!(key.as_str(), "range" | "max_results_range")
|
||||
&& field.as_array().is_some_and(|values| {
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.as_f64().is_some_and(|n| n < 0.0))
|
||||
})
|
||||
{
|
||||
return Err(EntryViolation::Negative {
|
||||
field: relative(&field_path),
|
||||
});
|
||||
}
|
||||
if matches!(key.as_str(), "metadata" | "provider_specific_entry") {
|
||||
return Ok(());
|
||||
}
|
||||
match field.as_array() {
|
||||
Some(items) => items.iter().enumerate().try_for_each(|(index, item)| {
|
||||
check_prices(&format!("{field_path}[{index}]"), item)
|
||||
}),
|
||||
None => check_prices(&field_path, field),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn relative(field_path: &str) -> String {
|
||||
field_path.trim_start_matches('.').to_owned()
|
||||
}
|
||||
126
litellm-rust/crates/model-catalog/src/vocabulary.rs
Normal file
126
litellm-rust/crates/model-catalog/src/vocabulary.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/// Primary API surface / task type of the model.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Mode {
|
||||
AudioSpeech,
|
||||
AudioTranscription,
|
||||
Chat,
|
||||
Completion,
|
||||
Decisions,
|
||||
Embedding,
|
||||
Evaluation,
|
||||
Extraction,
|
||||
Guardrail,
|
||||
ImageEdit,
|
||||
ImageGeneration,
|
||||
Moderation,
|
||||
Ocr,
|
||||
Realtime,
|
||||
Rerank,
|
||||
Responses,
|
||||
Search,
|
||||
VectorStore,
|
||||
VideoGeneration,
|
||||
}
|
||||
|
||||
/// Gemini audio generation API the model is served through.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VertexAiAudioApi {
|
||||
LyriaPredict,
|
||||
LyriaInteractions,
|
||||
}
|
||||
|
||||
/// Audio container format the model can return.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AudioFormat {
|
||||
Mp3,
|
||||
Wav,
|
||||
}
|
||||
|
||||
/// Input modality the model accepts.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputModality {
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
}
|
||||
|
||||
/// Output modality the model can produce.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputModality {
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
Code,
|
||||
}
|
||||
|
||||
/// API route a model can be called through.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
pub enum Endpoint {
|
||||
#[serde(rename = "/v1/chat/completions")]
|
||||
ChatCompletions,
|
||||
#[serde(rename = "/v1/completions")]
|
||||
Completions,
|
||||
#[serde(rename = "/v1/responses")]
|
||||
Responses,
|
||||
#[serde(rename = "/v1/messages")]
|
||||
Messages,
|
||||
#[serde(rename = "/v1/batch")]
|
||||
Batch,
|
||||
#[serde(rename = "/v1/embeddings")]
|
||||
Embeddings,
|
||||
#[serde(rename = "/v1/images/generations")]
|
||||
ImageGenerations,
|
||||
#[serde(rename = "/v1/images/edits")]
|
||||
ImageEdits,
|
||||
#[serde(rename = "/v1/audio/speech")]
|
||||
AudioSpeech,
|
||||
#[serde(rename = "/v1/audio/transcriptions")]
|
||||
AudioTranscriptions,
|
||||
#[serde(rename = "/v1/realtime")]
|
||||
Realtime,
|
||||
#[serde(rename = "/v1/realtime/transcription_sessions")]
|
||||
RealtimeTranscriptionSessions,
|
||||
#[serde(rename = "/v1/videos")]
|
||||
Videos,
|
||||
#[serde(rename = "/v1/ocr")]
|
||||
Ocr,
|
||||
#[serde(rename = "/v1/listen")]
|
||||
Listen,
|
||||
#[serde(rename = "/v1/systemone")]
|
||||
SystemOne,
|
||||
#[serde(rename = "/v1/decisions")]
|
||||
Decisions,
|
||||
#[serde(rename = "/v1beta/interactions")]
|
||||
Interactions,
|
||||
#[serde(rename = "/v1beta/models")]
|
||||
Models,
|
||||
#[serde(rename = "/vertex_ai/live")]
|
||||
VertexAiLive,
|
||||
}
|
||||
|
||||
/// Reasoning effort level a model accepts or applies.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EffortLevel {
|
||||
None,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Xhigh,
|
||||
Max,
|
||||
}
|
||||
344
litellm-rust/crates/model-catalog/src/wire/attributes.rs
Normal file
344
litellm-rust/crates/model-catalog/src/wire/attributes.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use litellm_cost::money::UnitPrice;
|
||||
use serde::de::{self, Deserializer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::{DateCause, Error, Repr};
|
||||
use crate::vocabulary::{
|
||||
AudioFormat, EffortLevel, Endpoint, InputModality, Mode, OutputModality, VertexAiAudioApi,
|
||||
};
|
||||
use crate::wire::lax::Count;
|
||||
|
||||
macro_rules! attributes {
|
||||
($($(#[$doc:meta])* $name:ident: $ty:ty $(as $via:ty)?),* $(,)?) => {
|
||||
/// The named, non-price fields of a catalog entry.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
pub struct Attributes {
|
||||
$($(#[$doc])* pub $name: Option<$ty>,)*
|
||||
}
|
||||
|
||||
impl Attributes {
|
||||
pub(crate) fn read(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: &Value,
|
||||
) -> Option<Result<(), serde_json::Error>> {
|
||||
match key {
|
||||
$(stringify!($name) => Some(
|
||||
attributes!(@read value, $ty $(, $via)?).map(|read| self.$name = Some(read)),
|
||||
),)*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(@read $value:ident, $ty:ty) => {
|
||||
<$ty as Deserialize>::deserialize($value)
|
||||
};
|
||||
(@read $value:ident, $ty:ty, $via:ty) => {
|
||||
serde_with::de::DeserializeAsWrap::<$ty, $via>::deserialize($value)
|
||||
.map(serde_with::de::DeserializeAsWrap::into_inner)
|
||||
};
|
||||
}
|
||||
|
||||
attributes! {
|
||||
/// Other names this entry is served under.
|
||||
aliases: Vec<String>,
|
||||
audio_transcription_config: String,
|
||||
bedrock_converse_supports_strict_tools: bool,
|
||||
/// Highest reasoning effort the Bedrock output_config accepts for this model.
|
||||
bedrock_output_config_effort_ceiling: EffortLevel,
|
||||
comment: String,
|
||||
/// Reasoning effort the provider applies when the request omits reasoning_effort. Gates whether a non-default temperature or the top_p/logprobs sampling params are accepted, which hold only when the effort resolves to 'none'.
|
||||
default_reasoning_effort: EffortLevel,
|
||||
/// Date the provider deprecates the model, YYYY-MM-DD.
|
||||
deprecation_date: IsoDate,
|
||||
gemini_audio_only_live: bool,
|
||||
gemini_native_audio: bool,
|
||||
input_cost_per_token_cache_hit: UnitPrice,
|
||||
/// Databricks units, not USD.
|
||||
input_dbu_cost_per_token: Dbu,
|
||||
/// LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers.
|
||||
litellm_provider: String,
|
||||
/// Maximum prompt/context tokens the model accepts.
|
||||
max_input_tokens: u64 as Count,
|
||||
/// Maximum tokens the model can generate in one response.
|
||||
max_output_tokens: u64 as Count,
|
||||
/// Legacy field: max output tokens if the provider specifies it, else max input tokens.
|
||||
max_tokens: u64 as Count,
|
||||
/// Free-form notes about the entry (e.g. pricing derivation).
|
||||
metadata: BTreeMap<String, Value>,
|
||||
/// Primary API surface / task type of the model.
|
||||
mode: Mode,
|
||||
/// Databricks units, not USD.
|
||||
output_dbu_cost_per_token: Dbu,
|
||||
/// Embedding dimension for embedding models.
|
||||
output_vector_size: u64 as Count,
|
||||
/// Smallest prefix the provider will actually cache; absent means the provider default applies.
|
||||
prompt_cache_min_tokens: u64 as Count,
|
||||
/// Provider-internal routing hints (e.g. bedrock_invocation_schema).
|
||||
provider_specific_entry: BTreeMap<String, Value>,
|
||||
reasoning_contexts: Vec<String>,
|
||||
/// Exact reasoning_effort levels this deployment accepts; wins over supports_* flags.
|
||||
reasoning_effort_levels: Vec<EffortLevel>,
|
||||
reasoning_modes: Vec<String>,
|
||||
/// Provider default requests-per-minute limit.
|
||||
rpm: u64 as Count,
|
||||
/// Grounded search requests the provider includes for free before search is billed.
|
||||
search_free_requests: SearchFreeRequests,
|
||||
/// URL of the provider pricing/model page this entry was taken from.
|
||||
source: String,
|
||||
/// Audio container formats the model can return.
|
||||
supported_audio_formats: Vec<AudioFormat>,
|
||||
/// OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.
|
||||
supported_endpoints: Vec<Endpoint>,
|
||||
/// Input modalities the model accepts.
|
||||
supported_modalities: Vec<InputModality>,
|
||||
/// Output modalities the model can produce.
|
||||
supported_output_modalities: Vec<OutputModality>,
|
||||
/// Cloud regions the model is available in ('global' or region ids).
|
||||
supported_regions: Vec<String>,
|
||||
thinking_always_on: bool,
|
||||
token_count_framing: u64 as Count,
|
||||
token_count_model: TokenCountModel,
|
||||
token_count_tokenizer: TokenCountTokenizer,
|
||||
/// Provider default tokens-per-minute limit.
|
||||
tpm: u64 as Count,
|
||||
use_openai_responses_path: bool,
|
||||
uses_embed_content: bool,
|
||||
vertex_ai_audio_api: VertexAiAudioApi,
|
||||
/// Whether web search is billed per query or per prompt.
|
||||
web_search_billing_unit: WebSearchBillingUnit,
|
||||
}
|
||||
|
||||
/// The model whose provider token counter counts tokens for this entry.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Eq, Hash)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TokenCountModel {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub litellm_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// Free grounded search requests per period, shared across the models of one group.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Eq, Hash)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SearchFreeRequests {
|
||||
pub count: u64,
|
||||
pub period: FreeRequestPeriod,
|
||||
pub shared_group: String,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FreeRequestPeriod {
|
||||
Day,
|
||||
Month,
|
||||
}
|
||||
|
||||
/// Local tokenizer that counts tokens for this entry.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TokenCountTokenizer {
|
||||
KimiK3,
|
||||
}
|
||||
|
||||
/// Whether web search is billed per query or per prompt.
|
||||
#[macro_rules_attribute::apply(crate::wire_type)]
|
||||
#[derive(Copy, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebSearchBillingUnit {
|
||||
PerQuery,
|
||||
PerPrompt,
|
||||
}
|
||||
|
||||
/// A non-negative amount of Databricks units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Dbu(f64);
|
||||
|
||||
impl Dbu {
|
||||
pub fn new(units: f64) -> Option<Self> {
|
||||
(units.is_finite() && units >= 0.0).then_some(Self(units))
|
||||
}
|
||||
pub fn get(self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Dbu {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let units = f64::deserialize(deserializer)?;
|
||||
Self::new(units).ok_or_else(|| {
|
||||
de::Error::invalid_value(
|
||||
de::Unexpected::Float(units),
|
||||
&"a non-negative number of Databricks units",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A calendar date written YYYY-MM-DD.
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
serde_with::DeserializeFromStr,
|
||||
serde_with::SerializeDisplay,
|
||||
)]
|
||||
pub struct IsoDate(pub time::Date);
|
||||
|
||||
impl FromStr for IsoDate {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(date: &str) -> Result<Self, Self::Err> {
|
||||
let mut parts = date.split('-');
|
||||
let (Some(year), Some(month), Some(day), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return Err(Repr::InvalidDate { cause: None }.into());
|
||||
};
|
||||
if year.len() != 4 || month.len() != 2 || day.len() != 2 {
|
||||
return Err(Repr::InvalidDate { cause: None }.into());
|
||||
}
|
||||
let number = |part: &str| part.parse::<u8>().map_err(DateCause::from);
|
||||
let month = time::Month::try_from(number(month)?).map_err(DateCause::from)?;
|
||||
time::Date::from_calendar_date(year.parse().map_err(DateCause::from)?, month, number(day)?)
|
||||
.map(Self)
|
||||
.map_err(DateCause::from)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for IsoDate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:04}-{:02}-{:02}",
|
||||
self.0.year(),
|
||||
u8::from(self.0.month()),
|
||||
self.0.day()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
mod schema {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
|
||||
use super::{Dbu, IsoDate};
|
||||
|
||||
impl JsonSchema for IsoDate {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"IsoDate".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"pattern": r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Dbu {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Dbu".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "number", "minimum": 0})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::leap_day("2028-02-29", true)]
|
||||
#[case::not_a_leap_year("2026-02-29", false)]
|
||||
#[case::impossible_day("2026-02-31", false)]
|
||||
#[case::one_digit_month("2026-2-01", false)]
|
||||
#[case::extra_part("2026-02-01-01", false)]
|
||||
fn iso_dates_are_real_calendar_dates(#[case] text: &str, #[case] valid: bool) {
|
||||
assert_eq!(text.parse::<IsoDate>().is_ok(), valid);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::count_written_as_text("max_tokens", json!("8192"), true)]
|
||||
#[case::negative_count("max_tokens", json!(-1), false)]
|
||||
#[case::known_mode("mode", json!("chat"), true)]
|
||||
#[case::unknown_mode("mode", json!("chatt"), false)]
|
||||
#[case::negative_dbu("input_dbu_cost_per_token", json!(-1), false)]
|
||||
#[case::reasoning_contexts("reasoning_contexts", json!(["reasoning", "non_reasoning"]), true)]
|
||||
#[case::reasoning_modes("reasoning_modes", json!(["none", "adaptive"]), true)]
|
||||
#[case::reasoning_context_not_a_list("reasoning_contexts", json!("reasoning"), false)]
|
||||
#[case::reasoning_mode_not_a_string("reasoning_modes", json!([1]), false)]
|
||||
fn each_attribute_reads_its_own_type(
|
||||
#[case] key: &str,
|
||||
#[case] value: Value,
|
||||
#[case] valid: bool,
|
||||
) {
|
||||
assert_eq!(
|
||||
Attributes::default()
|
||||
.read(key, &value)
|
||||
.map(|read| read.is_ok()),
|
||||
Some(valid)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn keys_that_are_not_attributes_are_left_to_the_caller() {
|
||||
assert!(
|
||||
Attributes::default()
|
||||
.read("input_cost_per_token", &json!(1))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn reasoning_metadata_is_retained() {
|
||||
let mut attributes = Attributes::default();
|
||||
attributes
|
||||
.read("reasoning_contexts", &json!(["reasoning", "non_reasoning"]))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
attributes
|
||||
.read("reasoning_modes", &json!(["none", "adaptive"]))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attributes.reasoning_contexts.unwrap(),
|
||||
["reasoning", "non_reasoning"]
|
||||
);
|
||||
assert_eq!(attributes.reasoning_modes.unwrap(), ["none", "adaptive"]);
|
||||
}
|
||||
}
|
||||
182
litellm-rust/crates/model-catalog/src/wire/blocks.rs
Normal file
182
litellm-rust/crates/model-catalog/src/wire/blocks.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use litellm_cost::money::UnitPrice;
|
||||
use litellm_cost::schedule::{UtcWindow, Weekday};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
use serde_with::de::DeserializeAsWrap;
|
||||
|
||||
use crate::error::{Error, Repr, pointer_child, pointer_for_key};
|
||||
use crate::wire::flat_key::FlatKey;
|
||||
use crate::wire::lax::Count;
|
||||
|
||||
/// A flat price key with its spelling, kept so problems can name it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FlatPrice {
|
||||
pub key: String,
|
||||
pub path: String,
|
||||
pub flat: FlatKey,
|
||||
pub price: UnitPrice,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TierRow {
|
||||
pub path: String,
|
||||
pub range: Option<[u64; 2]>,
|
||||
pub max_results_range: Option<[u64; 2]>,
|
||||
pub prices: Vec<FlatPrice>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OffPeakBlock {
|
||||
pub hours_utc: Option<UtcHours>,
|
||||
pub windows: Option<Vec<OffPeakWindow>>,
|
||||
pub weekday_timezone: Option<String>,
|
||||
pub prices: Vec<FlatPrice>,
|
||||
}
|
||||
|
||||
/// One UTC window or a list of them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(untagged)]
|
||||
pub enum UtcHours {
|
||||
Single(UtcWindow),
|
||||
Multiple(Vec<UtcWindow>),
|
||||
}
|
||||
|
||||
/// One off-peak window entry inside `windows`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OffPeakWindow {
|
||||
pub hours_utc: UtcHours,
|
||||
pub weekdays: Option<Vec<Weekday>>,
|
||||
}
|
||||
|
||||
/// USD cost per web search query, keyed by search context size.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SearchContextCostPerQuery {
|
||||
pub search_context_size_low: Option<UnitPrice>,
|
||||
pub search_context_size_medium: Option<UnitPrice>,
|
||||
pub search_context_size_high: Option<UnitPrice>,
|
||||
}
|
||||
|
||||
impl UtcHours {
|
||||
pub fn windows(&self) -> &[UtcWindow] {
|
||||
match self {
|
||||
Self::Single(window) => std::slice::from_ref(window),
|
||||
Self::Multiple(windows) => windows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'a> {
|
||||
pub(crate) problems: &'a mut Vec<Error>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(crate) fn value<T: DeserializeOwned>(&mut self, path: String, value: &Value) -> Option<T> {
|
||||
T::deserialize(value)
|
||||
.map_err(|error| self.problems.push(Error::new(path, Repr::Invalid(error))))
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub(crate) fn price(
|
||||
&mut self,
|
||||
key: String,
|
||||
path: String,
|
||||
flat: FlatKey,
|
||||
value: &Value,
|
||||
) -> Option<FlatPrice> {
|
||||
let price = self.value(path.clone(), value)?;
|
||||
Some(FlatPrice {
|
||||
key,
|
||||
path,
|
||||
flat,
|
||||
price,
|
||||
})
|
||||
}
|
||||
|
||||
fn object<'v>(&mut self, path: &str, value: &'v Value) -> Option<&'v Map<String, Value>> {
|
||||
match value {
|
||||
Value::Object(fields) => Some(fields),
|
||||
other => self
|
||||
.value::<Map<String, Value>>(path.to_owned(), other)
|
||||
.and(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn block_price(
|
||||
&mut self,
|
||||
name: &str,
|
||||
key: String,
|
||||
path: String,
|
||||
value: &Value,
|
||||
) -> Option<FlatPrice> {
|
||||
let Some(flat) = FlatKey::parse(name) else {
|
||||
self.problems.push(Error::new(path, Repr::UnknownBlockKey));
|
||||
return None;
|
||||
};
|
||||
self.price(key, path, flat, value)
|
||||
}
|
||||
|
||||
pub(crate) fn tier_rows(&mut self, value: &Value) -> Option<Vec<TierRow>> {
|
||||
let path = pointer_for_key("tiered_pricing");
|
||||
let rows: Vec<Value> = self.value(path.clone(), value)?;
|
||||
Some(
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, row)| self.tier_row(path.clone(), index, row))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn tier_row(&mut self, parent: String, index: usize, value: &Value) -> Option<TierRow> {
|
||||
let path = format!("{parent}/{index}");
|
||||
let display_path = format!("tiered_pricing[{index}]");
|
||||
let fields = self.object(&path, value)?;
|
||||
let mut row = TierRow {
|
||||
path: path.clone(),
|
||||
..TierRow::default()
|
||||
};
|
||||
for (name, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
|
||||
let key = format!("{display_path}.{name}");
|
||||
let child_path = pointer_child(&path, name);
|
||||
match name.as_str() {
|
||||
"range" => row.range = self.bounds(child_path, value),
|
||||
"max_results_range" => row.max_results_range = self.bounds(child_path, value),
|
||||
_ => row
|
||||
.prices
|
||||
.extend(self.block_price(name, key, child_path, value)),
|
||||
}
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
|
||||
fn bounds(&mut self, path: String, value: &Value) -> Option<[u64; 2]> {
|
||||
DeserializeAsWrap::<[u64; 2], [Count; 2]>::deserialize(value)
|
||||
.map(DeserializeAsWrap::into_inner)
|
||||
.map_err(|error| self.problems.push(Error::new(path, Repr::Invalid(error))))
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub(crate) fn off_peak(&mut self, value: &Value) -> Option<OffPeakBlock> {
|
||||
let path = pointer_for_key("off_peak_pricing");
|
||||
let fields = self.object(&path, value)?;
|
||||
let mut block = OffPeakBlock::default();
|
||||
for (name, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
|
||||
let key = format!("off_peak_pricing.{name}");
|
||||
let child_path = pointer_child(&path, name);
|
||||
match name.as_str() {
|
||||
"hours_utc" => block.hours_utc = self.value(child_path, value),
|
||||
"windows" => block.windows = self.value(child_path, value),
|
||||
"weekday_timezone" => block.weekday_timezone = self.value(child_path, value),
|
||||
_ => block
|
||||
.prices
|
||||
.extend(self.block_price(name, key, child_path, value)),
|
||||
}
|
||||
}
|
||||
Some(block)
|
||||
}
|
||||
}
|
||||
193
litellm-rust/crates/model-catalog/src/wire/entry.rs
Normal file
193
litellm-rust/crates/model-catalog/src/wire/entry.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_cost::money::{Multiplier, UnitPrice};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{Error, Repr, pointer_for_key};
|
||||
use crate::wire::attributes::Attributes;
|
||||
use crate::wire::blocks::{FlatPrice, OffPeakBlock, Reader, SearchContextCostPerQuery, TierRow};
|
||||
use crate::wire::flat_key::FlatKey;
|
||||
|
||||
pub type Fields = Map<String, Value>;
|
||||
|
||||
/// One catalog entry as written. Every non-null key lands in exactly one field, and a known key
|
||||
/// with a bad value is kept out and listed in `problems`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EntryWire {
|
||||
pub attributes: Attributes,
|
||||
pub prices: Vec<FlatPrice>,
|
||||
pub capabilities: BTreeMap<String, bool>,
|
||||
pub tier_rows: Option<Vec<TierRow>>,
|
||||
pub off_peak: Option<OffPeakBlock>,
|
||||
pub search_context: Option<SearchContextCostPerQuery>,
|
||||
pub guardrail: Option<BTreeMap<String, UnitPrice>>,
|
||||
pub regional_endpoint_uplift: Option<Multiplier>,
|
||||
pub residency_uplift_eu: Option<Multiplier>,
|
||||
pub residency_uplift_us: Option<Multiplier>,
|
||||
pub unknown: Fields,
|
||||
pub problems: Vec<Error>,
|
||||
}
|
||||
|
||||
impl EntryWire {
|
||||
pub fn read(fields: &Fields) -> Self {
|
||||
let mut problems = Vec::new();
|
||||
let mut wire = Self::default();
|
||||
let mut reader = Reader {
|
||||
problems: &mut problems,
|
||||
};
|
||||
for (key, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
|
||||
wire.read_key(&mut reader, key, value);
|
||||
}
|
||||
Self { problems, ..wire }
|
||||
}
|
||||
|
||||
fn read_key(&mut self, reader: &mut Reader<'_>, key: &str, value: &Value) {
|
||||
let path = || pointer_for_key(key);
|
||||
match key {
|
||||
"tiered_pricing" => self.tier_rows = reader.tier_rows(value),
|
||||
"off_peak_pricing" => self.off_peak = reader.off_peak(value),
|
||||
"search_context_cost_per_query" => self.search_context = reader.value(path(), value),
|
||||
"guardrail_cost_per_unit" => self.guardrail = reader.value(path(), value),
|
||||
"regional_endpoint_uplift_multiplier" => {
|
||||
self.regional_endpoint_uplift = reader.value(path(), value)
|
||||
}
|
||||
"regional_processing_uplift_multiplier_eu" => {
|
||||
self.residency_uplift_eu = reader.value(path(), value)
|
||||
}
|
||||
"regional_processing_uplift_multiplier_us" => {
|
||||
self.residency_uplift_us = reader.value(path(), value)
|
||||
}
|
||||
_ => self.read_open_key(reader, key, value),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_open_key(&mut self, reader: &mut Reader<'_>, key: &str, value: &Value) {
|
||||
if let Some(read) = self.attributes.read(key, value) {
|
||||
if let Err(error) = read {
|
||||
reader
|
||||
.problems
|
||||
.push(Error::from_key(key, Repr::Invalid(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(flat) = FlatKey::parse(key) {
|
||||
self.prices
|
||||
.extend(reader.price(key.to_owned(), pointer_for_key(key), flat, value));
|
||||
return;
|
||||
}
|
||||
if key.starts_with("supports_") {
|
||||
if let Some(flag) = reader.value(pointer_for_key(key), value) {
|
||||
self.capabilities.insert(key.to_owned(), flag);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.unknown.insert(key.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read(entry: Value) -> EntryWire {
|
||||
let Value::Object(fields) = entry else {
|
||||
panic!("entry must be an object")
|
||||
};
|
||||
EntryWire::read(&fields)
|
||||
}
|
||||
|
||||
fn problem_keys(wire: &EntryWire) -> Vec<String> {
|
||||
wire.problems
|
||||
.iter()
|
||||
.map(|problem| problem.path().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn every_key_lands_in_exactly_one_place() {
|
||||
let wire = read(json!({
|
||||
"litellm_provider": "openai",
|
||||
"max_tokens": "8192",
|
||||
"input_cost_per_token": "0.000001",
|
||||
"tiered_pricing": [{"range": [0, 10], "output_cost_per_token": 2e-6}],
|
||||
"supports_vision": true,
|
||||
"id": "deployment-1",
|
||||
"mode": null
|
||||
}));
|
||||
|
||||
assert_eq!(wire.attributes.litellm_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(wire.attributes.max_tokens, Some(8192));
|
||||
assert_eq!(wire.attributes.mode, None);
|
||||
assert_eq!(
|
||||
wire.prices
|
||||
.iter()
|
||||
.map(|price| price.key.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["input_cost_per_token"]
|
||||
);
|
||||
assert_eq!(wire.tier_rows.unwrap()[0].prices.len(), 1);
|
||||
assert_eq!(
|
||||
wire.capabilities,
|
||||
BTreeMap::from([("supports_vision".to_owned(), true)])
|
||||
);
|
||||
assert_eq!(wire.unknown.keys().collect::<Vec<_>>(), ["id"]);
|
||||
assert!(wire.problems.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn every_bad_value_is_reported_by_its_key_at_once() {
|
||||
let wire = read(json!({
|
||||
"mode": "chatt",
|
||||
"max_tokens": -1,
|
||||
"input_cost_per_token": "x",
|
||||
"output_cost_per_image_0.5K": "x",
|
||||
"supports_vision": "yes",
|
||||
"regional_endpoint_uplift_multiplier": 0.5,
|
||||
"tiered_pricing": [
|
||||
{"range": [0, 10], "input_cost_per_token": 1e-6},
|
||||
{"range": [10.5, 20], "typo_cost": 1, "output_cost_per_token": -1}
|
||||
],
|
||||
"off_peak_pricing": {"hours_utc": "25:00-01:00", "input_cost_per_token": 1e-6}
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
problem_keys(&wire)
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
[
|
||||
"/input_cost_per_token",
|
||||
"/max_tokens",
|
||||
"/mode",
|
||||
"/off_peak_pricing/hours_utc",
|
||||
"/output_cost_per_image_0.5K",
|
||||
"/regional_endpoint_uplift_multiplier",
|
||||
"/supports_vision",
|
||||
"/tiered_pricing/1/output_cost_per_token",
|
||||
"/tiered_pricing/1/range",
|
||||
"/tiered_pricing/1/typo_cost",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tiers_not_a_list(json!({"tiered_pricing": {"range": [0, 1]}}), "/tiered_pricing")]
|
||||
#[case::row_not_an_object(json!({"tiered_pricing": [3]}), "/tiered_pricing/0")]
|
||||
#[case::off_peak_not_an_object(json!({"off_peak_pricing": true}), "/off_peak_pricing")]
|
||||
#[case::unknown_search_size(
|
||||
json!({"search_context_cost_per_query": {"search_context_size_huge": 1}}),
|
||||
"/search_context_cost_per_query"
|
||||
)]
|
||||
#[case::negative_guardrail(
|
||||
json!({"guardrail_cost_per_unit": {"contentPolicyUnits": -1}}),
|
||||
"/guardrail_cost_per_unit"
|
||||
)]
|
||||
fn a_bad_block_is_reported_at_the_block(#[case] entry: Value, #[case] expected: &str) {
|
||||
assert_eq!(problem_keys(&read(entry)), [expected]);
|
||||
}
|
||||
}
|
||||
359
litellm-rust/crates/model-catalog/src/wire/flat_key.rs
Normal file
359
litellm-rust/crates/model-catalog/src/wire/flat_key.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
use std::num::NonZeroU64;
|
||||
|
||||
use litellm_cost::price::{
|
||||
Band, CacheReadMode, CacheTtl, Channel, Feature, ImageSize, InputUnit, Meter, OutputUnit,
|
||||
Resolution, ServiceTier, TokenKind, When,
|
||||
};
|
||||
|
||||
/// A flat price key as the grammar reads it: `{meter}[_above_{N}k_tokens][_{tier}]`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FlatKey {
|
||||
pub meter: Meter,
|
||||
pub band: Band,
|
||||
pub tier: Option<TierSuffix>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TierSuffix {
|
||||
Service(ServiceTier),
|
||||
Batches,
|
||||
Decisions,
|
||||
}
|
||||
|
||||
impl FlatKey {
|
||||
pub fn parse(key: &str) -> Option<Self> {
|
||||
let (rest, tier) = split_tier(key);
|
||||
let (base, suffix) = split_above(rest)?;
|
||||
let (meter, band) = match suffix {
|
||||
Above::None => (meter(base)?, Band::Base),
|
||||
Above::PromptTokens(tokens) => (meter(base)?, Band::over_prompt_tokens(tokens)),
|
||||
Above::Seconds(seconds) => match meter(base)? {
|
||||
Meter::Input(InputUnit::VideoSecond { over_seconds: None }) => (
|
||||
Meter::Input(InputUnit::VideoSecond {
|
||||
over_seconds: NonZeroU64::new(seconds),
|
||||
}),
|
||||
Band::Base,
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(Self { meter, band, tier })
|
||||
}
|
||||
|
||||
pub fn has_suffix(&self) -> bool {
|
||||
self.band != Band::Base || self.tier.is_some()
|
||||
}
|
||||
|
||||
pub fn channel_and_when(&self) -> (Channel, When) {
|
||||
let (channel, tier) = match self.tier {
|
||||
None => (Channel::Online, ServiceTier::Standard),
|
||||
Some(TierSuffix::Service(tier)) => (Channel::Online, tier),
|
||||
Some(TierSuffix::Batches) => (Channel::Batch, ServiceTier::Standard),
|
||||
Some(TierSuffix::Decisions) => (Channel::Decisions, ServiceTier::Standard),
|
||||
};
|
||||
(
|
||||
channel,
|
||||
When {
|
||||
tier,
|
||||
band: self.band,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Above {
|
||||
None,
|
||||
PromptTokens(u64),
|
||||
Seconds(u64),
|
||||
}
|
||||
|
||||
fn split_tier(key: &str) -> (&str, Option<TierSuffix>) {
|
||||
let Some((head, tail)) = key.rsplit_once('_') else {
|
||||
return (key, None);
|
||||
};
|
||||
let tier = match tail {
|
||||
"priority" => TierSuffix::Service(ServiceTier::Priority),
|
||||
"flex" => TierSuffix::Service(ServiceTier::Flex),
|
||||
"ultrafast" => TierSuffix::Service(ServiceTier::Ultrafast),
|
||||
"balanced" => TierSuffix::Service(ServiceTier::Balanced),
|
||||
"batches" => TierSuffix::Batches,
|
||||
"decisions" => TierSuffix::Decisions,
|
||||
_ => return (key, None),
|
||||
};
|
||||
(head, Some(tier))
|
||||
}
|
||||
|
||||
fn split_above(key: &str) -> Option<(&str, Above)> {
|
||||
let Some((head, tail)) = key.rsplit_once("_above_") else {
|
||||
return Some((key, Above::None));
|
||||
};
|
||||
if tail == "1hr" || tail == "12hr" {
|
||||
return Some((key, Above::None));
|
||||
}
|
||||
if let Some(thousands) = tail.strip_suffix("k_tokens") {
|
||||
return Some((
|
||||
head,
|
||||
Above::PromptTokens(number(thousands)?.checked_mul(1000)?),
|
||||
));
|
||||
}
|
||||
if let Some(tokens) = tail.strip_suffix("_tokens") {
|
||||
return Some((head, Above::PromptTokens(number(tokens)?)));
|
||||
}
|
||||
let seconds = tail.strip_suffix("s_interval")?;
|
||||
Some((head, Above::Seconds(number(seconds)?)))
|
||||
}
|
||||
|
||||
fn number(digits: &str) -> Option<u64> {
|
||||
(!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.then(|| digits.parse().ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn meter(base: &str) -> Option<Meter> {
|
||||
let fixed = match base {
|
||||
"cost_per_second" => Some(Meter::WallClockSecond),
|
||||
"explicit_cache_read_input_token_cost" => Some(Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Explicit,
|
||||
}),
|
||||
"annotation_cost_per_page" => Some(Meter::Feature(Feature::AnnotationPage)),
|
||||
"citation_cost_per_token" => Some(Meter::Feature(Feature::CitationToken)),
|
||||
"code_interpreter_cost_per_session" => {
|
||||
Some(Meter::Feature(Feature::CodeInterpreterSession))
|
||||
}
|
||||
"google_maps_grounding_cost_per_query" => {
|
||||
Some(Meter::Feature(Feature::GoogleMapsGroundingQuery))
|
||||
}
|
||||
"ocr_cost_per_credit" => Some(Meter::Feature(Feature::OcrCredit)),
|
||||
"ocr_cost_per_page" => Some(Meter::Feature(Feature::OcrPage)),
|
||||
"browser_open_cost_per_request" => Some(Meter::Feature(Feature::BrowserOpen)),
|
||||
"cost_per_additional_result" => Some(Meter::Feature(Feature::AdditionalResult)),
|
||||
_ => None,
|
||||
};
|
||||
if fixed.is_some() {
|
||||
return fixed;
|
||||
}
|
||||
if let Some(unit) = base.strip_prefix("input_cost_per_") {
|
||||
return input_unit(unit).map(Meter::Input);
|
||||
}
|
||||
if let Some(unit) = base.strip_prefix("output_cost_per_") {
|
||||
return output_unit(unit).map(Meter::Output);
|
||||
}
|
||||
if let Some(kind) = base
|
||||
.strip_prefix("cache_read_input_")
|
||||
.and_then(|rest| rest.strip_suffix("_cost"))
|
||||
{
|
||||
return Some(Meter::CacheRead {
|
||||
kind: token_kind(kind)?,
|
||||
mode: CacheReadMode::Implicit,
|
||||
});
|
||||
}
|
||||
let rest = base.strip_prefix("cache_creation_input_")?;
|
||||
let (kind, ttl) = if let Some(kind) = rest.strip_suffix("_cost_above_1hr") {
|
||||
(kind, CacheTtl::OneHour)
|
||||
} else if let Some(kind) = rest.strip_suffix("_cost_above_12hr") {
|
||||
(kind, CacheTtl::TwelveHours)
|
||||
} else {
|
||||
(rest.strip_suffix("_cost")?, CacheTtl::Default)
|
||||
};
|
||||
Some(Meter::CacheWrite {
|
||||
kind: token_kind(kind)?,
|
||||
ttl,
|
||||
})
|
||||
}
|
||||
|
||||
fn token_kind(unit: &str) -> Option<TokenKind> {
|
||||
match unit {
|
||||
"token" => Some(TokenKind::Text),
|
||||
"audio_token" => Some(TokenKind::Audio),
|
||||
"image_token" => Some(TokenKind::Image),
|
||||
"video_token" => Some(TokenKind::Video),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_unit(unit: &str) -> Option<InputUnit> {
|
||||
if let Some(kind) = token_kind(unit) {
|
||||
return Some(InputUnit::Token(kind));
|
||||
}
|
||||
match unit {
|
||||
"character" => Some(InputUnit::Character),
|
||||
"image" => Some(InputUnit::Image),
|
||||
"pixel" => Some(InputUnit::Pixel),
|
||||
"query" => Some(InputUnit::Query { over_results: None }),
|
||||
"request" => Some(InputUnit::Request),
|
||||
"second" => Some(InputUnit::Second),
|
||||
"text_message" => Some(InputUnit::TextMessage),
|
||||
"audio_per_second" => Some(InputUnit::AudioSecond),
|
||||
"video_per_second" => Some(InputUnit::VideoSecond { over_seconds: None }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn output_unit(unit: &str) -> Option<OutputUnit> {
|
||||
if let Some(kind) = token_kind(unit) {
|
||||
return Some(OutputUnit::Token(kind));
|
||||
}
|
||||
match unit.split_once('_') {
|
||||
Some(("image", label)) => {
|
||||
return ImageSize::new(label).map(|size| OutputUnit::Image(Some(size)));
|
||||
}
|
||||
Some(("second", label)) => {
|
||||
return Resolution::new(label).map(|resolution| OutputUnit::Second(Some(resolution)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match unit {
|
||||
"reasoning_token" => Some(OutputUnit::ReasoningToken),
|
||||
"character" => Some(OutputUnit::Character),
|
||||
"image" => Some(OutputUnit::Image(None)),
|
||||
"pixel" => Some(OutputUnit::Pixel),
|
||||
"second" => Some(OutputUnit::Second(None)),
|
||||
"video_per_second" => Some(OutputUnit::VideoSecond),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
const TEXT_INPUT: Meter = Meter::Input(InputUnit::Token(TokenKind::Text));
|
||||
const TEXT_OUTPUT: Meter = Meter::Output(OutputUnit::Token(TokenKind::Text));
|
||||
|
||||
fn key(meter: Meter, band: Band, tier: Option<TierSuffix>) -> FlatKey {
|
||||
FlatKey { meter, band, tier }
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::input_token("input_cost_per_token", key(TEXT_INPUT, Band::Base, None))]
|
||||
#[case::band_and_tier(
|
||||
"output_cost_per_token_above_272k_tokens_priority",
|
||||
key(
|
||||
TEXT_OUTPUT,
|
||||
Band::over_prompt_tokens(272_000),
|
||||
Some(TierSuffix::Service(ServiceTier::Priority))
|
||||
)
|
||||
)]
|
||||
#[case::band_without_k(
|
||||
"input_cost_per_token_above_128_tokens",
|
||||
key(TEXT_INPUT, Band::over_prompt_tokens(128), None)
|
||||
)]
|
||||
#[case::zero_band_is_base(
|
||||
"input_cost_per_token_above_0k_tokens",
|
||||
key(TEXT_INPUT, Band::Base, None)
|
||||
)]
|
||||
#[case::batch(
|
||||
"input_cost_per_token_batches",
|
||||
key(TEXT_INPUT, Band::Base, Some(TierSuffix::Batches))
|
||||
)]
|
||||
#[case::reasoning(
|
||||
"output_cost_per_reasoning_token",
|
||||
key(Meter::Output(OutputUnit::ReasoningToken), Band::Base, None)
|
||||
)]
|
||||
#[case::cache_read_audio(
|
||||
"cache_read_input_audio_token_cost",
|
||||
key(Meter::CacheRead { kind: TokenKind::Audio, mode: CacheReadMode::Implicit }, Band::Base, None)
|
||||
)]
|
||||
#[case::explicit_cache_read(
|
||||
"explicit_cache_read_input_token_cost",
|
||||
key(Meter::CacheRead { kind: TokenKind::Text, mode: CacheReadMode::Explicit }, Band::Base, None)
|
||||
)]
|
||||
#[case::cache_write_one_hour_band(
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens",
|
||||
key(
|
||||
Meter::CacheWrite { kind: TokenKind::Text, ttl: CacheTtl::OneHour },
|
||||
Band::over_prompt_tokens(200_000),
|
||||
None
|
||||
)
|
||||
)]
|
||||
#[case::cache_write_twelve_hours_priority(
|
||||
"cache_creation_input_token_cost_above_12hr_above_272k_tokens_priority",
|
||||
key(
|
||||
Meter::CacheWrite { kind: TokenKind::Text, ttl: CacheTtl::TwelveHours },
|
||||
Band::over_prompt_tokens(272_000),
|
||||
Some(TierSuffix::Service(ServiceTier::Priority))
|
||||
)
|
||||
)]
|
||||
#[case::image_label(
|
||||
"output_cost_per_image_0.5K",
|
||||
key(
|
||||
Meter::Output(OutputUnit::Image(ImageSize::new("0.5K"))),
|
||||
Band::Base,
|
||||
None
|
||||
)
|
||||
)]
|
||||
#[case::image_token_is_a_token(
|
||||
"output_cost_per_image_token",
|
||||
key(Meter::Output(OutputUnit::Token(TokenKind::Image)), Band::Base, None)
|
||||
)]
|
||||
#[case::resolution(
|
||||
"output_cost_per_second_720p",
|
||||
key(
|
||||
Meter::Output(OutputUnit::Second(Resolution::new("720p"))),
|
||||
Band::Base,
|
||||
None
|
||||
)
|
||||
)]
|
||||
#[case::video_interval(
|
||||
"input_cost_per_video_per_second_above_8s_interval",
|
||||
key(Meter::Input(InputUnit::VideoSecond { over_seconds: NonZeroU64::new(8) }), Band::Base, None)
|
||||
)]
|
||||
#[case::feature_batch(
|
||||
"ocr_cost_per_page_batches",
|
||||
key(
|
||||
Meter::Feature(Feature::OcrPage),
|
||||
Band::Base,
|
||||
Some(TierSuffix::Batches)
|
||||
)
|
||||
)]
|
||||
#[case::wall_clock("cost_per_second", key(Meter::WallClockSecond, Band::Base, None))]
|
||||
#[case::browser_open(
|
||||
"browser_open_cost_per_request",
|
||||
key(Meter::Feature(Feature::BrowserOpen), Band::Base, None)
|
||||
)]
|
||||
#[case::additional_result(
|
||||
"cost_per_additional_result",
|
||||
key(Meter::Feature(Feature::AdditionalResult), Band::Base, None)
|
||||
)]
|
||||
fn parses_every_key_shape(#[case] text: &str, #[case] expected: FlatKey) {
|
||||
assert_eq!(FlatKey::parse(text), Some(expected));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::typo("input_cost_per_tokne")]
|
||||
#[case::unused_meter("cost_per_token")]
|
||||
#[case::other_currency("input_dbu_cost_per_token")]
|
||||
#[case::cache_hit_alias("input_cost_per_token_cache_hit")]
|
||||
#[case::non_numeric_band("input_cost_per_token_above_xk_tokens")]
|
||||
#[case::interval_on_a_token_meter("input_cost_per_token_above_8s_interval")]
|
||||
#[case::read_has_no_ttl("cache_read_input_token_cost_above_1hr")]
|
||||
#[case::cache_unit_must_be_token("cache_read_input_character_cost")]
|
||||
#[case::bad_image_size("output_cost_per_image_big")]
|
||||
#[case::bad_resolution("output_cost_per_second_hd")]
|
||||
#[case::unknown_feature("telepathy_cost_per_page")]
|
||||
#[case::empty("")]
|
||||
fn rejects_keys_outside_the_grammar(#[case] text: &str) {
|
||||
assert_eq!(FlatKey::parse(text), None);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::plain("input_cost_per_token", (Channel::Online, When::default()))]
|
||||
#[case::service_tier(
|
||||
"input_cost_per_token_flex",
|
||||
(Channel::Online, When { tier: ServiceTier::Flex, band: Band::Base })
|
||||
)]
|
||||
#[case::batch(
|
||||
"input_cost_per_token_above_200k_tokens_batches",
|
||||
(Channel::Batch, When { tier: ServiceTier::Standard, band: Band::over_prompt_tokens(200_000) })
|
||||
)]
|
||||
#[case::decisions("input_cost_per_token_decisions", (Channel::Decisions, When::default()))]
|
||||
fn batch_and_decisions_pick_a_channel_not_a_tier(
|
||||
#[case] text: &str,
|
||||
#[case] expected: (Channel, When),
|
||||
) {
|
||||
assert_eq!(FlatKey::parse(text).unwrap().channel_and_when(), expected);
|
||||
}
|
||||
}
|
||||
128
litellm-rust/crates/model-catalog/src/wire/lax.rs
Normal file
128
litellm-rust/crates/model-catalog/src/wire/lax.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use std::fmt;
|
||||
|
||||
use serde::Deserializer;
|
||||
use serde::de::{self, Visitor};
|
||||
use serde_with::DeserializeAs;
|
||||
|
||||
pub(crate) struct LaxI64;
|
||||
|
||||
pub(crate) struct Count;
|
||||
|
||||
impl<'de> DeserializeAs<'de, i64> for LaxI64 {
|
||||
fn deserialize_as<D: Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
|
||||
deserializer.deserialize_any(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Visitor<'_> for LaxI64 {
|
||||
type Value = i64;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("an integer in the i64 range")
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, value: i64) -> Result<i64, E> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, value: u64) -> Result<i64, E> {
|
||||
i64::try_from(value).map_err(E::custom)
|
||||
}
|
||||
|
||||
fn visit_f64<E: de::Error>(self, value: f64) -> Result<i64, E> {
|
||||
integral_float(value).ok_or_else(|| E::custom("expected an integer in the i64 range"))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<i64, E> {
|
||||
integer_string(value.trim())
|
||||
.ok_or_else(|| E::custom("expected an integer in the i64 range"))
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, value: bool) -> Result<i64, E> {
|
||||
Ok(i64::from(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, u64> for Count {
|
||||
fn deserialize_as<D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
|
||||
let count = LaxI64::deserialize_as(deserializer)?;
|
||||
u64::try_from(count).map_err(|_| {
|
||||
de::Error::invalid_value(de::Unexpected::Signed(count), &"a count of zero or more")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn integer_string(value: &str) -> Option<i64> {
|
||||
let integer = match value.split_once('.') {
|
||||
Some((integer, fraction)) => {
|
||||
if fraction.is_empty() || !fraction.bytes().all(|byte| byte == b'0') {
|
||||
return None;
|
||||
}
|
||||
integer
|
||||
}
|
||||
None => value,
|
||||
};
|
||||
if integer.starts_with('_') || integer.ends_with('_') || integer.contains("__") {
|
||||
return None;
|
||||
}
|
||||
let digits = integer.strip_prefix(['+', '-']).unwrap_or(integer);
|
||||
if digits.is_empty()
|
||||
|| digits.starts_with('_')
|
||||
|| !digits
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || byte == b'_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
integer.replace('_', "").parse().ok()
|
||||
}
|
||||
|
||||
fn integral_float(value: f64) -> Option<i64> {
|
||||
(value.is_finite()
|
||||
&& value.fract() == 0.0
|
||||
&& value >= i64::MIN as f64
|
||||
&& value < -(i64::MIN as f64))
|
||||
.then_some(value as i64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
use serde_with::de::DeserializeAsWrap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn count(value: Value) -> Option<u64> {
|
||||
DeserializeAsWrap::<u64, Count>::deserialize(value)
|
||||
.ok()
|
||||
.map(DeserializeAsWrap::into_inner)
|
||||
}
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[rstest]
|
||||
#[case::integer(json!(128000), Some(128_000))]
|
||||
#[case::whole_float(json!(128000.0), Some(128_000))]
|
||||
#[case::string(json!(" 8192 "), Some(8192))]
|
||||
#[case::separated(json!("1_000"), Some(1000))]
|
||||
#[case::trailing_zeros(json!("2.000"), Some(2))]
|
||||
#[case::boolean(json!(true), Some(1))]
|
||||
#[case::maximum(json!(i64::MAX.to_string()), Some(i64::MAX as u64))]
|
||||
#[case::negative(json!(-5), None)]
|
||||
#[case::fraction(json!(2.5), None)]
|
||||
#[case::precise_fraction(json!("1.0000000000000001"), None)]
|
||||
#[case::exponent(json!("1e3"), None)]
|
||||
#[case::missing_fraction(json!("2."), None)]
|
||||
#[case::repeated_separator(json!("2__0"), None)]
|
||||
#[case::above_i64(json!(u64::MAX), None)]
|
||||
#[case::float_above_i64(json!(9_223_372_036_854_775_808.0), None)]
|
||||
#[case::word(json!("8k"), None)]
|
||||
#[case::object(json!({}), None)]
|
||||
fn counts_accept_whole_numbers_however_they_are_written(
|
||||
#[case] written: Value,
|
||||
#[case] expected: Option<u64>,
|
||||
) {
|
||||
assert_eq!(count(written), expected);
|
||||
}
|
||||
}
|
||||
10
litellm-rust/crates/model-catalog/src/wire/mod.rs
Normal file
10
litellm-rust/crates/model-catalog/src/wire/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mod attributes;
|
||||
mod blocks;
|
||||
mod entry;
|
||||
mod flat_key;
|
||||
mod lax;
|
||||
|
||||
pub use attributes::*;
|
||||
pub use blocks::*;
|
||||
pub use entry::*;
|
||||
pub use flat_key::*;
|
||||
|
|
@ -1,303 +1,405 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use litellm_model_catalog::{AliasIssue, Catalog, Error, IntegrityLimits, Mode, Provenance};
|
||||
use litellm_model_catalog::{
|
||||
BUNDLED_REGISTRY, Catalog, Error, ErrorKind, IntegrityLimits, Mode, Rejects, Written,
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::json;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const ALPHA_FIXTURE: &[u8] = br#"{
|
||||
"sample_spec":{"explanation":"example"},
|
||||
"sample_spec":{"explanation":"example","input_cost_per_token":"not checked"},
|
||||
"fallback_generalizations":{"rules":[{"name":"family","pattern":"^new-","model_info":{"mode":"chat"}}]},
|
||||
"Alpha":{"litellm_provider":"test","aliases":["short"],"price":0,"enabled":false,
|
||||
"optional":null,"unknown":{"nested":[1,{"x":true}]}}
|
||||
}"#;
|
||||
|
||||
#[fixture]
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn current_catalog(repo_root: PathBuf) -> Catalog {
|
||||
let body = std::fs::read(repo_root.join("model_prices_and_context_window.json")).unwrap();
|
||||
Catalog::parse(&body, Provenance::default()).unwrap()
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn backup_catalog(repo_root: PathBuf) -> Catalog {
|
||||
let body = std::fs::read(repo_root.join("litellm/model_prices_and_context_window_backup.json"))
|
||||
.unwrap();
|
||||
Catalog::parse(&body, Provenance::default()).unwrap()
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn fixture_catalog() -> Catalog {
|
||||
Catalog::parse(
|
||||
ALPHA_FIXTURE,
|
||||
Provenance {
|
||||
source: Some("fixture".into()),
|
||||
revision: Some("rev".into()),
|
||||
etag: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
Catalog::parse(ALPHA_FIXTURE).unwrap().value
|
||||
}
|
||||
|
||||
fn loaded(document: Value) -> litellm_model_catalog::Loaded<Catalog> {
|
||||
Catalog::parse(document.to_string().as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn catalog(document: Value) -> Catalog {
|
||||
loaded(document).value
|
||||
}
|
||||
|
||||
fn written(catalog: &Catalog) -> String {
|
||||
let document: Map<String, Value> = catalog
|
||||
.written()
|
||||
.map(|(key, value)| {
|
||||
let value = match value {
|
||||
Written::Entry(fields) => serde_json::to_value(fields).unwrap(),
|
||||
Written::Section(value) => value.clone(),
|
||||
};
|
||||
(key.to_owned(), value)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&document).unwrap()
|
||||
}
|
||||
|
||||
fn report(error: &Error) -> String {
|
||||
litellm_error::Report::new(error).to_string()
|
||||
}
|
||||
|
||||
fn error_text(document: Value) -> Vec<String> {
|
||||
loaded(document).errors.iter().map(report).collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::decisions("decisions", Mode::Decisions)]
|
||||
#[case::legacy_evaluation("evaluation", Mode::Evaluation)]
|
||||
fn parses_decision_modes(#[case] mode: &str, #[case] expected: Mode) {
|
||||
let source = json!({"Test":{"litellm_provider":"test","mode":mode}}).to_string();
|
||||
let catalog = Catalog::parse(source.as_bytes(), Provenance::default()).unwrap();
|
||||
|
||||
let catalog = catalog(json!({"Test": {"litellm_provider": "test", "mode": mode}}));
|
||||
assert_eq!(
|
||||
catalog.lookup("Test").unwrap().entry.info().mode,
|
||||
catalog
|
||||
.lookup("Test")
|
||||
.unwrap()
|
||||
.entry
|
||||
.model()
|
||||
.unwrap()
|
||||
.attributes
|
||||
.mode,
|
||||
Some(expected)
|
||||
);
|
||||
assert_eq!(serde_json::to_value(expected).unwrap(), json!(mode));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn preserves_fields_and_metadata(fixture_catalog: Catalog) {
|
||||
let catalog = fixture_catalog;
|
||||
let entry = catalog.lookup("SHORT").unwrap();
|
||||
assert_eq!(entry.canonical_key, "Alpha");
|
||||
assert_eq!(entry.matched_key, "short");
|
||||
assert_eq!(entry.entry.field("price"), Some(&json!(0)));
|
||||
assert_eq!(entry.entry.field("enabled"), Some(&json!(false)));
|
||||
assert_eq!(entry.entry.field("optional"), Some(&json!(null)));
|
||||
assert_eq!(entry.entry.field("missing"), None);
|
||||
fn unknown_keys_are_kept_raw_and_listed_as_unrecognized(fixture_catalog: Catalog) {
|
||||
let entry = fixture_catalog.lookup("Alpha").unwrap().entry;
|
||||
assert_eq!(
|
||||
entry.entry.field("unknown"),
|
||||
entry.field("unknown"),
|
||||
Some(&json!({"nested":[1,{"x":true}]}))
|
||||
);
|
||||
assert_eq!(entry.entry.field("aliases"), None);
|
||||
assert_eq!(entry.entry.info().litellm_provider.as_deref(), Some("test"));
|
||||
assert_eq!(
|
||||
catalog.sample_spec(),
|
||||
Some(&json!({"explanation":"example"}))
|
||||
);
|
||||
assert_eq!(catalog.fallback_rules().unwrap().len(), 1);
|
||||
assert_eq!(catalog.provenance().revision.as_deref(), Some("rev"));
|
||||
assert_eq!(catalog.model_count(), 1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn snapshot_does_not_borrow_source() {
|
||||
let mut source = ALPHA_FIXTURE.to_vec();
|
||||
let catalog = Catalog::parse(&source, Provenance::default()).unwrap();
|
||||
source.fill(b' ');
|
||||
|
||||
let entry = catalog.lookup("short").unwrap();
|
||||
assert_eq!(entry.canonical_key, "Alpha");
|
||||
assert_eq!(entry.entry.field("price"), Some(&json!(0)));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("Shared", "First")]
|
||||
#[case("Second", "Second")]
|
||||
#[case("shared", "Second")]
|
||||
#[case("FIRST", "First")]
|
||||
#[case("sHaReD", "Second")]
|
||||
#[ignore]
|
||||
fn alias_collisions_and_case_fallback_follow_entry_order(
|
||||
#[case] lookup: &str,
|
||||
#[case] expected: &str,
|
||||
) {
|
||||
let catalog = Catalog::parse(
|
||||
br#"{
|
||||
"First":{"aliases":["Shared","Second","first"],"value":1},
|
||||
"Second":{"aliases":["Shared","sHaReD"],"value":2},
|
||||
"SHARED":{"value":3}
|
||||
}"#,
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(catalog.lookup(lookup).unwrap().canonical_key, expected);
|
||||
assert_eq!(catalog.alias_count(), 3);
|
||||
assert!(
|
||||
catalog
|
||||
.alias_issues()
|
||||
.contains(&AliasIssue::CanonicalCollision {
|
||||
model: "First".into(),
|
||||
alias: "Second".into(),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
catalog
|
||||
.alias_issues()
|
||||
.contains(&AliasIssue::AliasCollision {
|
||||
model: "Second".into(),
|
||||
alias: "Shared".into(),
|
||||
})
|
||||
fixture_catalog.unrecognized().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([
|
||||
("Alpha", "enabled"),
|
||||
("Alpha", "price"),
|
||||
("Alpha", "unknown")
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn json_entry_order_controls_alias_ownership_and_case_fallback() {
|
||||
let forward = Catalog::parse(
|
||||
br#"{
|
||||
"Alpha":{"aliases":["shared"]},
|
||||
"Beta":{"aliases":["shared"]},
|
||||
"Foo":{},
|
||||
"fOO":{}
|
||||
}"#,
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let reversed = Catalog::parse(
|
||||
br#"{
|
||||
"fOO":{},
|
||||
"Foo":{},
|
||||
"Beta":{"aliases":["shared"]},
|
||||
"Alpha":{"aliases":["shared"]}
|
||||
}"#,
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(forward.lookup("shared").unwrap().canonical_key, "Alpha");
|
||||
assert_eq!(reversed.lookup("shared").unwrap().canonical_key, "Beta");
|
||||
assert_eq!(forward.lookup("foo").unwrap().canonical_key, "fOO");
|
||||
assert_eq!(reversed.lookup("foo").unwrap().canonical_key, "Foo");
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ValidationOutcome {
|
||||
Ok,
|
||||
Shrunk,
|
||||
BelowMinimum,
|
||||
InvalidRatio,
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
IntegrityLimits {
|
||||
reference_model_count: 2,
|
||||
min_model_count: 1,
|
||||
min_reference_ratio: 0.5,
|
||||
},
|
||||
ValidationOutcome::Ok
|
||||
)]
|
||||
#[case(
|
||||
IntegrityLimits {
|
||||
reference_model_count: 3,
|
||||
min_model_count: 1,
|
||||
min_reference_ratio: 0.5,
|
||||
},
|
||||
ValidationOutcome::Shrunk
|
||||
)]
|
||||
#[case(
|
||||
IntegrityLimits {
|
||||
reference_model_count: 0,
|
||||
min_model_count: 2,
|
||||
min_reference_ratio: 0.5,
|
||||
},
|
||||
ValidationOutcome::BelowMinimum
|
||||
)]
|
||||
#[case(
|
||||
IntegrityLimits {
|
||||
reference_model_count: 0,
|
||||
min_model_count: 0,
|
||||
min_reference_ratio: f64::NAN,
|
||||
},
|
||||
ValidationOutcome::InvalidRatio
|
||||
)]
|
||||
#[ignore]
|
||||
fn integrity_uses_canonical_count_and_strict_shrink_boundary(
|
||||
#[case] limits: IntegrityLimits,
|
||||
#[case] expected: ValidationOutcome,
|
||||
) {
|
||||
let catalog = Catalog::parse(
|
||||
br#"{"sample_spec":{},"fallback_generalizations":{"rules":[]},"a":{"aliases":["b","c"]}}"#,
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let actual = catalog.validate(limits);
|
||||
match expected {
|
||||
ValidationOutcome::Ok => assert!(actual.is_ok()),
|
||||
ValidationOutcome::Shrunk => {
|
||||
assert!(matches!(actual, Err(Error::Shrunk { actual: 1, .. })))
|
||||
}
|
||||
ValidationOutcome::BelowMinimum => {
|
||||
assert!(matches!(actual, Err(Error::BelowMinimum { actual: 1, .. })))
|
||||
}
|
||||
ValidationOutcome::InvalidRatio => assert!(matches!(actual, Err(Error::InvalidRatio))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MalformedOutcome {
|
||||
Empty,
|
||||
Json,
|
||||
EntryNotObject,
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(b"{}", MalformedOutcome::Empty)]
|
||||
#[case::invalid_json(b"{", MalformedOutcome::Json)]
|
||||
#[case::entry_not_object(br#"{"a":1}"#, MalformedOutcome::EntryNotObject)]
|
||||
#[case::fallback_rules_missing(
|
||||
br#"{"fallback_generalizations":{},"a":{}}"#,
|
||||
MalformedOutcome::Json
|
||||
)]
|
||||
#[ignore]
|
||||
fn malformed_input_and_aliases_have_typed_outcomes(
|
||||
#[case] body: &[u8],
|
||||
#[case] expected: MalformedOutcome,
|
||||
) {
|
||||
let actual = Catalog::parse(body, Provenance::default());
|
||||
match expected {
|
||||
MalformedOutcome::Empty => assert!(matches!(actual, Err(Error::Empty))),
|
||||
MalformedOutcome::Json => assert!(matches!(actual, Err(Error::Json(_)))),
|
||||
MalformedOutcome::EntryNotObject => {
|
||||
assert!(matches!(actual, Err(Error::EntryNotObject { .. })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn invalid_aliases_are_reported_not_fatal() {
|
||||
let catalog = Catalog::parse(
|
||||
br#"{"a":{"aliases":"bad"},"b":{"aliases":[9,"ok"]}}"#,
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
fn sections_aliases_and_raw_fields_are_read_in_their_document_views(fixture_catalog: Catalog) {
|
||||
let found = fixture_catalog.lookup("SHORT").unwrap();
|
||||
assert_eq!(found.canonical_key, "Alpha");
|
||||
assert_eq!(found.matched_key, "short");
|
||||
assert_eq!(found.entry.field("optional"), Some(&json!(null)));
|
||||
assert_eq!(found.entry.field("aliases"), Some(&json!(["short"])));
|
||||
assert_eq!(
|
||||
catalog.alias_issues(),
|
||||
&[
|
||||
AliasIssue::InvalidList { model: "a".into() },
|
||||
AliasIssue::InvalidName { model: "b".into() },
|
||||
]
|
||||
fixture_catalog.sample_spec(),
|
||||
Some(&json!({"explanation":"example","input_cost_per_token":"not checked"}))
|
||||
);
|
||||
assert_eq!(fixture_catalog.fallback_rules().unwrap()[0].name, "family");
|
||||
assert_eq!(fixture_catalog.model_count(), 1);
|
||||
assert_eq!(
|
||||
fixture_catalog
|
||||
.served()
|
||||
.map(|(key, _)| key)
|
||||
.collect::<Vec<_>>(),
|
||||
["Alpha", "short"]
|
||||
);
|
||||
assert_eq!(catalog.lookup("ok").unwrap().canonical_key, "b");
|
||||
assert!(catalog.lookup("missing").is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn parses_current_and_packaged_catalogs_against_independent_baseline(
|
||||
current_catalog: Catalog,
|
||||
backup_catalog: Catalog,
|
||||
) {
|
||||
assert!(current_catalog.model_count() > 0);
|
||||
assert!(backup_catalog.model_count() > 0);
|
||||
assert!(current_catalog.sample_spec().is_some());
|
||||
assert!(backup_catalog.sample_spec().is_some());
|
||||
// Snapshot from 2026-09-23; the backup file mirrors the current file and cannot detect shrinkage.
|
||||
const REFERENCE_MODEL_COUNT: usize = 4303;
|
||||
current_catalog
|
||||
.validate(IntegrityLimits {
|
||||
reference_model_count: REFERENCE_MODEL_COUNT,
|
||||
min_model_count: 50,
|
||||
min_reference_ratio: 0.9,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(current_catalog.model_names().all(|name| {
|
||||
let entry = current_catalog.lookup(name).unwrap().entry;
|
||||
entry.info().litellm_provider.is_some() == entry.field("litellm_provider").is_some()
|
||||
fn a_bad_entry_is_reported_by_model_and_key_and_kept_raw() {
|
||||
let parsed = loaded(json!({
|
||||
"Good": {"litellm_provider": "test"},
|
||||
"Bad": {"litellm_provider": "test", "mode": "chatt", "input_cost_per_token": "free"},
|
||||
"Worse": {"mode": "chat"}
|
||||
}));
|
||||
let catalog = parsed.value;
|
||||
let problems: Vec<String> = parsed.errors.iter().map(report).collect();
|
||||
assert_eq!(problems.len(), 3, "{problems:?}");
|
||||
assert!(problems[0].starts_with("Bad.mode: invalid value: unknown variant `chatt`"));
|
||||
assert!(problems[1].starts_with("Bad.input_cost_per_token: invalid value"));
|
||||
assert_eq!(problems[2], "Worse.litellm_provider: required");
|
||||
assert!(catalog.lookup("Good").unwrap().entry.model().is_some());
|
||||
assert!(catalog.lookup("Bad").unwrap().entry.model().is_none());
|
||||
assert_eq!(catalog.model_count(), 3);
|
||||
assert!(
|
||||
parsed
|
||||
.errors
|
||||
.iter()
|
||||
.find(|error| error.rejects() == Rejects::Document)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::alias_names_a_model(
|
||||
json!({"First": {"litellm_provider": "t", "aliases": ["Second"]}, "Second": {"litellm_provider": "t"}}),
|
||||
r#"First: alias "Second" is already a model name"#
|
||||
)]
|
||||
#[case::alias_claimed_twice(
|
||||
json!({"First": {"litellm_provider": "t", "aliases": ["shared"]}, "Second": {"litellm_provider": "t", "aliases": ["shared"]}}),
|
||||
r#"Second: alias "shared" already belongs to First"#
|
||||
)]
|
||||
#[case::aliases_not_a_list(
|
||||
json!({"a": {"litellm_provider": "t", "aliases": "bad"}}),
|
||||
"a.aliases: invalid value: invalid type: string \"bad\", expected a sequence"
|
||||
)]
|
||||
#[case::entry_not_object(
|
||||
json!({"a": 1, "valid": {"litellm_provider": "test"}}),
|
||||
"a: must be a JSON object"
|
||||
)]
|
||||
#[case::fallback_rules_missing(
|
||||
json!({"fallback_generalizations": {}, "a": {"litellm_provider": "t"}}),
|
||||
"fallback_generalizations: invalid value: missing field `rules`"
|
||||
)]
|
||||
#[case::unknown_timezone(
|
||||
json!({"a": {"litellm_provider": "t", "off_peak_pricing": {
|
||||
"windows": [{"hours_utc": "00:00-01:00"}],
|
||||
"weekday_timezone": "Not/AZone",
|
||||
"input_cost_per_token": 1e-6
|
||||
}}}),
|
||||
r#"a.off_peak_pricing.weekday_timezone: "Not/AZone" is not an IANA time zone"#
|
||||
)]
|
||||
fn document_level_problems_are_reported(#[case] document: Value, #[case] expected: &str) {
|
||||
assert_eq!(error_text(document), [expected]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn escaped_row_paths_and_row_validation_share_the_parse_rules() {
|
||||
let document = json!({
|
||||
"open/router~v1": {"litellm_provider": "test", "input_cost_per_token": -1}
|
||||
});
|
||||
let parsed = loaded(document);
|
||||
let fields = parsed
|
||||
.value
|
||||
.lookup("open/router~v1")
|
||||
.unwrap()
|
||||
.entry
|
||||
.fields();
|
||||
let row_errors = Catalog::row_errors("open/router~v1", fields);
|
||||
assert_eq!(
|
||||
row_errors
|
||||
.iter()
|
||||
.map(|error| (error.path(), report(error)))
|
||||
.collect::<Vec<_>>(),
|
||||
parsed
|
||||
.errors
|
||||
.iter()
|
||||
.map(|error| (error.path(), report(error)))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
row_errors[0].path(),
|
||||
"/open~1router~0v1/input_cost_per_token"
|
||||
);
|
||||
assert!(row_errors[0].kind() == ErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn malformed_fallback_is_collected_while_models_are_read() {
|
||||
let parsed = loaded(json!({
|
||||
"fallback_generalizations": {"rules": "bad"},
|
||||
"a": {"litellm_provider": "test"}
|
||||
}));
|
||||
assert_eq!(parsed.value.model_count(), 1);
|
||||
assert_eq!(parsed.errors.len(), 1);
|
||||
assert_eq!(parsed.errors[0].path(), "/fallback_generalizations");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn rejected_rows_and_their_aliases_are_removed_without_disturbing_written_order() {
|
||||
let body = r#"{"before":{"litellm_provider":"test","aliases":["before-alias"]},"bad":{"litellm_provider":"test","aliases":["bad-alias"],"mode":"chatt"},"not-object":4,"sample_spec":{"keep":true},"after":{"litellm_provider":"test"}}"#;
|
||||
let parsed = Catalog::parse(body.as_bytes()).unwrap();
|
||||
let (catalog, removed) = parsed.value.without_rejected(&parsed.errors);
|
||||
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert_eq!(
|
||||
catalog.lookup("before-alias").unwrap().canonical_key,
|
||||
"before"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.lookup("BEFORE-ALIAS").unwrap().matched_key,
|
||||
"before-alias"
|
||||
);
|
||||
assert!(catalog.lookup("bad").is_none());
|
||||
assert!(catalog.lookup("BAD").is_none());
|
||||
assert!(catalog.lookup("Bad-Alias").is_none());
|
||||
assert_eq!(catalog.model_count(), 2);
|
||||
assert_eq!(
|
||||
written(&catalog),
|
||||
r#"{"before":{"litellm_provider":"test","aliases":["before-alias"]},"sample_spec":{"keep":true},"after":{"litellm_provider":"test"}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn invalid_empty_key_rows_are_removed() {
|
||||
let parsed = loaded(json!({
|
||||
"": {"litellm_provider": "test", "mode": "chatt"},
|
||||
"good": {"litellm_provider": "test"}
|
||||
}));
|
||||
assert_eq!(parsed.errors[0].path(), "//mode");
|
||||
|
||||
let (catalog, removed) = parsed.value.without_rejected(&parsed.errors);
|
||||
assert_eq!(catalog.model_count(), 1);
|
||||
assert!(catalog.lookup("").is_none());
|
||||
assert!(catalog.lookup("good").is_some());
|
||||
assert_eq!(removed.len(), 1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn timezone_errors_reject_providerless_rows_without_rejecting_other_providerless_rows() {
|
||||
let parsed = loaded(json!({
|
||||
"good": {"mode": "chat"},
|
||||
"bad": {"off_peak_pricing": {
|
||||
"windows": [{"hours_utc": "00:00-01:00"}],
|
||||
"weekday_timezone": "Not/AZone",
|
||||
"input_cost_per_token": 1e-6
|
||||
}}
|
||||
}));
|
||||
assert_eq!(parsed.errors.len(), 3);
|
||||
assert!(
|
||||
parsed
|
||||
.errors
|
||||
.iter()
|
||||
.any(|error| error.kind() == ErrorKind::UnknownTimezone)
|
||||
);
|
||||
|
||||
let (catalog, removed) = parsed.value.without_rejected(&parsed.errors);
|
||||
assert_eq!(catalog.model_count(), 1);
|
||||
assert!(catalog.lookup("good").is_some());
|
||||
assert!(catalog.lookup("bad").is_none());
|
||||
assert_eq!(removed.len(), 2);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn bundled_per_row_errors_match_document_errors() {
|
||||
let parsed = Catalog::parse(BUNDLED_REGISTRY).unwrap();
|
||||
let row_errors: Vec<_> = parsed
|
||||
.value
|
||||
.written()
|
||||
.flat_map(|(key, written)| match written {
|
||||
Written::Entry(fields) => Catalog::row_errors(key, fields),
|
||||
Written::Section(_) => Vec::new(),
|
||||
})
|
||||
.map(|error| (error.path().to_owned(), report(&error)))
|
||||
.collect();
|
||||
let document_errors: Vec<_> = parsed
|
||||
.errors
|
||||
.iter()
|
||||
.map(|error| (error.path().to_owned(), report(error)))
|
||||
.collect();
|
||||
|
||||
assert_eq!(row_errors, document_errors);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn three_hundred_rows_filter_one_row_with_two_invalid_prices() {
|
||||
let valid_rows: Map<String, Value> = (0..299)
|
||||
.map(|index| {
|
||||
(
|
||||
format!("valid-{index:03}"),
|
||||
json!({"litellm_provider": "test"}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut document = valid_rows;
|
||||
document.insert(
|
||||
"bad".to_owned(),
|
||||
json!({
|
||||
"litellm_provider": "test",
|
||||
"input_cost_per_token": -1,
|
||||
"output_cost_per_token": -1
|
||||
}),
|
||||
);
|
||||
let parsed = Catalog::parse(serde_json::to_vec(&document).unwrap().as_slice()).unwrap();
|
||||
assert_eq!(parsed.value.model_count(), 300);
|
||||
assert_eq!(parsed.errors.len(), 2);
|
||||
|
||||
let (catalog, removed) = parsed.value.without_rejected(&parsed.errors);
|
||||
assert_eq!(catalog.model_count(), 299);
|
||||
assert_eq!(removed.len(), 2);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn integrity_limits_count_canonical_models_not_aliases() {
|
||||
let fetched = catalog(json!({"a": {"litellm_provider": "t", "aliases": ["b", "c"]}}));
|
||||
assert!(
|
||||
fetched
|
||||
.check_size(2, IntegrityLimits::new(1, 0.5).unwrap())
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
fetched
|
||||
.check_size(3, IntegrityLimits::new(1, 0.5).unwrap())
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Shrunk
|
||||
);
|
||||
assert_eq!(
|
||||
fetched
|
||||
.check_size(2, IntegrityLimits::new(2, 0.5).unwrap())
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::BelowMinimum
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty(b"{}", "empty")]
|
||||
#[case::only_sections(br#"{"sample_spec":{}}"#, "empty")]
|
||||
#[case::invalid_json(b"{", "json")]
|
||||
#[case::array_root_is_json_error(b"[]", "json")]
|
||||
fn malformed_documents_are_rejected_whole(#[case] body: &[u8], #[case] kind: &str) {
|
||||
let error = Catalog::parse(body).unwrap_err();
|
||||
assert_eq!(error.rejects(), Rejects::Document);
|
||||
assert_eq!(<&str>::from(error.kind()), kind);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn written_is_the_document_in_order_including_sections_and_bad_entries() {
|
||||
let body = concat!(
|
||||
r#"{"b":{"litellm_provider":"t","z":1,"a":null},"sample_spec":{"x":"y"},"n":4,"#,
|
||||
r#""fallback_generalizations":{"rules":"bad"},"a":{"mode":"chatt","price":1.0}}"#
|
||||
);
|
||||
let catalog = Catalog::parse(body.as_bytes()).unwrap().value;
|
||||
assert_eq!(written(&catalog), body);
|
||||
assert_eq!(catalog.model_count(), 2);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_bundled_registry_parses_with_no_problems_and_every_key_understood() {
|
||||
let loaded = Catalog::parse(BUNDLED_REGISTRY).unwrap();
|
||||
let catalog = loaded.value;
|
||||
assert!(loaded.errors.is_empty(), "{:#?}", loaded.errors);
|
||||
let unrecognized: Vec<_> = catalog.unrecognized().collect();
|
||||
assert!(unrecognized.is_empty(), "{unrecognized:#?}");
|
||||
assert!(
|
||||
catalog
|
||||
.fallback_rules()
|
||||
.is_some_and(|rules| !rules.is_empty())
|
||||
);
|
||||
const REFERENCE_MODEL_COUNT: usize = 4303;
|
||||
assert!(catalog.model_count() as f64 >= REFERENCE_MODEL_COUNT as f64 * 0.9);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_bundled_registry_is_written_back_as_it_was_read() {
|
||||
let document: Value = serde_json::from_slice(BUNDLED_REGISTRY).unwrap();
|
||||
assert_eq!(
|
||||
written(&Catalog::parse(BUNDLED_REGISTRY).unwrap().value),
|
||||
serde_json::to_string(&document).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_bad_value_keeps_its_json_error_as_the_source_and_names_its_key_and_field() {
|
||||
let parsed =
|
||||
loaded(json!({"open/router": {"litellm_provider": "t", "input_cost_per_token": "free"}}));
|
||||
let error = &parsed.errors[0];
|
||||
let cause = litellm_error::find::<serde_json::Error>(error)
|
||||
.expect("the JSON error is in the chain")
|
||||
.to_string();
|
||||
assert_eq!(report(error).matches(&cause).count(), 1);
|
||||
assert!(!error.to_string().contains(&cause));
|
||||
assert_eq!(error.key().as_deref(), Some("open/router"));
|
||||
assert_eq!(error.field().as_deref(), Some("input_cost_per_token"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import itertools
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import _get_token_base_cost
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
REPO_ROOT: Final = Path(__file__).resolve().parents[4]
|
||||
TOKEN_RATE_KEY: Final = re.compile(
|
||||
r"^(input_cost_per_token|output_cost_per_token|cache_read_input_token_cost"
|
||||
r"|cache_creation_input_token_cost(_above_1hr)?)"
|
||||
r"(_above_(\d+)(k?)_tokens)?(_(priority|flex|ultrafast|balanced|batches|decisions))?$"
|
||||
)
|
||||
SERVICE_TIERS: Final = ("priority", "flex", "ultrafast", "balanced")
|
||||
LARGEST_PROMPT: Final = 10_000_000
|
||||
INCLUSIVE_PROVIDERS: Final = frozenset({"xai"})
|
||||
|
||||
|
||||
def token_rates(entry: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in entry.items()
|
||||
if value is not None and (key == "tiered_pricing" or TOKEN_RATE_KEY.match(key))
|
||||
}
|
||||
|
||||
|
||||
def band_floors(info: dict[str, object]) -> frozenset[int]:
|
||||
matches: Final = (TOKEN_RATE_KEY.match(key) for key in info)
|
||||
flat: Final = frozenset(
|
||||
int(match.group(4)) * (1000 if match.group(5) else 1)
|
||||
for match in matches
|
||||
if match is not None and match.group(4) is not None
|
||||
)
|
||||
rows: Final = info.get("tiered_pricing")
|
||||
tiers: Final = (
|
||||
frozenset(int(row["range"][0]) for row in rows if isinstance(row, dict) and "range" in row)
|
||||
if isinstance(rows, list)
|
||||
else frozenset()
|
||||
)
|
||||
return flat | tiers
|
||||
|
||||
|
||||
def tiers_in(info: dict[str, object]) -> tuple[str | None, ...]:
|
||||
return (None, *(tier for tier in SERVICE_TIERS if any(key.endswith(f"_{tier}") for key in info)))
|
||||
|
||||
|
||||
def prompts(info: dict[str, object]) -> tuple[int, ...]:
|
||||
floors: Final = band_floors(info)
|
||||
return tuple(sorted(({1, LARGEST_PROMPT} | floors | {floor + 1 for floor in floors}) - {0}))
|
||||
|
||||
|
||||
def check(info: dict[str, object], tier: str | None, prompt: int, inclusive: bool) -> list[object]:
|
||||
usage: Final = Usage(prompt_tokens=prompt, completion_tokens=1, total_tokens=prompt + 1)
|
||||
rates: Final = _get_token_base_cost(info, usage, service_tier=tier, threshold_is_inclusive=inclusive)
|
||||
one_hour: Final = rates.cache_creation_rates_by_ttl.get("1hr", rates.cache_creation_rate)
|
||||
return [tier, prompt, [rates.input_rate, rates.output_rate, rates.cache_creation_rate, one_hour, rates.cache_read_rate]]
|
||||
|
||||
|
||||
def shape(info: dict[str, object], inclusive: bool) -> str:
|
||||
rows: Final = info.get("tiered_pricing")
|
||||
row_keys: Final = [sorted(row) for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||
return json.dumps([sorted(info), row_keys, inclusive])
|
||||
|
||||
|
||||
def checks(info: dict[str, object], inclusive: bool) -> list[list[object]]:
|
||||
return [check(info, tier, prompt, inclusive) for tier, prompt in itertools.product(tiers_in(info), prompts(info))]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
registry: Final = json.loads((REPO_ROOT / "model_prices_and_context_window.json").read_text())
|
||||
entries: Final = tuple(
|
||||
(token_rates(entry), entry.get("litellm_provider") in INCLUSIVE_PROVIDERS)
|
||||
for name, entry in registry.items()
|
||||
if name not in ("sample_spec", "fallback_generalizations") and isinstance(entry, dict)
|
||||
)
|
||||
by_shape: Final = {shape(info, inclusive): (info, inclusive) for info, inclusive in reversed(entries) if info}
|
||||
models: Final = tuple(
|
||||
json.dumps({"info": info, "inclusive": inclusive, "checks": checks(info, inclusive)})
|
||||
for info, inclusive in by_shape.values()
|
||||
)
|
||||
revision: Final = subprocess.check_output(("git", "rev-parse", "HEAD"), text=True, cwd=REPO_ROOT).strip()
|
||||
Path(__file__).with_name("price_lookup_reference.json").write_text(
|
||||
f'{{"python_reference_commit": "{revision}", "models": [\n' + ",\n".join(models) + "\n]}\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
89
litellm-rust/crates/model-catalog/tests/price_lookup.rs
Normal file
89
litellm-rust/crates/model-catalog/tests/price_lookup.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use litellm_cost::money::UnitPrice;
|
||||
use litellm_cost::price::{
|
||||
CacheReadMode, CacheTtl, InputUnit, Meter, OutputUnit, Prices, ServiceTier, TokenKind,
|
||||
};
|
||||
use litellm_model_catalog::wire::{EntryWire, Fields};
|
||||
use rstest::rstest;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reference {
|
||||
models: Vec<ReferenceModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReferenceModel {
|
||||
info: Fields,
|
||||
inclusive: bool,
|
||||
checks: Vec<(Option<String>, u64, [f64; 5])>,
|
||||
}
|
||||
|
||||
const METERS: [Meter; 5] = [
|
||||
Meter::Input(InputUnit::Token(TokenKind::Text)),
|
||||
Meter::Output(OutputUnit::Token(TokenKind::Text)),
|
||||
Meter::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::Default,
|
||||
},
|
||||
Meter::CacheWrite {
|
||||
kind: TokenKind::Text,
|
||||
ttl: CacheTtl::OneHour,
|
||||
},
|
||||
Meter::CacheRead {
|
||||
kind: TokenKind::Text,
|
||||
mode: CacheReadMode::Implicit,
|
||||
},
|
||||
];
|
||||
|
||||
fn service_tier(name: Option<&str>) -> ServiceTier {
|
||||
match name {
|
||||
None => ServiceTier::Standard,
|
||||
Some("priority") => ServiceTier::Priority,
|
||||
Some("flex") => ServiceTier::Flex,
|
||||
Some("ultrafast") => ServiceTier::Ultrafast,
|
||||
Some("balanced") => ServiceTier::Balanced,
|
||||
Some(other) => panic!("the reference uses an unknown service tier {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn mismatch(
|
||||
prices: &Prices,
|
||||
(tier_name, prompt, python): &(Option<String>, u64, [f64; 5]),
|
||||
) -> Option<String> {
|
||||
let tier = service_tier(tier_name.as_deref());
|
||||
let band = prices.band(tier, *prompt);
|
||||
let rust = METERS
|
||||
.each_ref()
|
||||
.map(|meter| prices.rate(meter, tier, band).map_or(0.0, UnitPrice::get));
|
||||
(rust != *python)
|
||||
.then(|| format!("tier={tier_name:?} prompt={prompt}: rust {rust:?}, python {python:?}"))
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn band_and_rate_match_python_token_base_cost() {
|
||||
let reference: Reference =
|
||||
serde_json::from_str(include_str!("price_lookup_reference.json")).unwrap();
|
||||
|
||||
let mismatches: Vec<String> = reference
|
||||
.models
|
||||
.iter()
|
||||
.flat_map(|model| {
|
||||
let provider = if model.inclusive { "xai" } else { "openai" };
|
||||
let fields: Fields = model
|
||||
.info
|
||||
.clone()
|
||||
.into_iter()
|
||||
.chain([("litellm_provider".to_owned(), provider.into())])
|
||||
.collect();
|
||||
let prices = Prices::try_from(&EntryWire::read(&fields)).unwrap();
|
||||
model
|
||||
.checks
|
||||
.iter()
|
||||
.filter_map(move |check| mismatch(&prices, check))
|
||||
.map(move |found| format!("{:?} {found}", model.info))
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(reference.models.len() > 50);
|
||||
assert!(mismatches.is_empty(), "{mismatches:#?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
{"python_reference_commit": "c7c895db456eda349a7be9661b4b21f578f9b589", "models": [
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-05, "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, "cache_read_input_token_cost": 1e-06, "cache_read_input_token_cost_above_272k_tokens": 2e-06, "input_cost_per_token": 1e-05, "input_cost_per_token_above_272k_tokens": 2e-05, "output_cost_per_token": 5e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05}, "inclusive": false, "checks": [[null, 1, [1e-05, 5e-05, 1.25e-05, 1.25e-05, 1e-06]], [null, 272000, [1e-05, 5e-05, 1.25e-05, 1.25e-05, 1e-06]], [null, 272001, [2e-05, 7.5e-05, 2.5e-05, 2.5e-05, 2e-06]], [null, 10000000, [2e-05, 7.5e-05, 2.5e-05, 2.5e-05, 2e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "output_cost_per_token": 3e-06}, "inclusive": false, "checks": [[null, 1, [1e-06, 3e-06, 1e-06, 1e-06, 1e-07]], [null, 10000000, [1e-06, 3e-06, 1e-06, 1e-06, 1e-07]]]},
|
||||
{"info": {"input_cost_per_token": 1.25e-05, "output_cost_per_token": 1.25e-05}, "inclusive": false, "checks": [[null, 1, [1.25e-05, 1.25e-05, 1.25e-05, 1.25e-05, 1.25e-05]], [null, 10000000, [1.25e-05, 1.25e-05, 1.25e-05, 1.25e-05, 1.25e-05]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 2.5e-06, "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-06, "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, "cache_creation_input_token_cost_flex": 1.25e-06, "cache_creation_input_token_cost_priority": 5e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_272k_tokens": 4e-07, "cache_read_input_token_cost_above_272k_tokens_flex": 2e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_above_272k_tokens_batches": 2e-07, "cache_creation_input_token_cost_batches": 1.25e-06, "cache_creation_input_token_cost_above_272k_tokens_batches": 2.5e-06, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "input_cost_per_token_above_272k_tokens_flex": 2e-06, "input_cost_per_token_above_272k_tokens_priority": 8e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_above_272k_tokens_batches": 2e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 4e-06, "output_cost_per_token": 1e-05, "output_cost_per_token_above_272k_tokens": 1.5e-05, "output_cost_per_token_above_272k_tokens_flex": 7.5e-06, "output_cost_per_token_above_272k_tokens_priority": 3e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_above_272k_tokens_batches": 7.5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05}, "inclusive": false, "checks": [[null, 1, [2e-06, 1e-05, 2.5e-06, 2.5e-06, 2e-07]], [null, 272000, [2e-06, 1e-05, 2.5e-06, 2.5e-06, 2e-07]], [null, 272001, [4e-06, 1.5e-05, 5e-06, 5e-06, 4e-07]], [null, 10000000, [4e-06, 1.5e-05, 5e-06, 5e-06, 4e-07]], ["priority", 1, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], ["priority", 272000, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], ["priority", 272001, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], ["priority", 10000000, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], ["flex", 1, [1e-06, 5e-06, 1.25e-06, 1.25e-06, 1e-07]], ["flex", 272000, [1e-06, 5e-06, 1.25e-06, 1.25e-06, 1e-07]], ["flex", 272001, [2e-06, 7.5e-06, 2.5e-06, 2.5e-06, 2e-07]], ["flex", 10000000, [2e-06, 7.5e-06, 2.5e-06, 2.5e-06, 2e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_creation_input_token_cost_batches": 6.25e-07, "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_batches": 5e-08, "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, "output_cost_per_token": 5e-06, "output_cost_per_token_batches": 2.5e-06}, "inclusive": false, "checks": [[null, 1, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]], [null, 10000000, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]]]},
|
||||
{"info": {"input_cost_per_token_above_100k_tokens_batches": 2.5e-07, "output_cost_per_token_above_100k_tokens_batches": 1.25e-06, "cache_creation_input_token_cost_above_100k_tokens_batches": 3.125e-07, "cache_read_input_token_cost_above_100k_tokens_batches": 2.5e-08, "cache_creation_input_token_cost": 1.25e-07, "cache_creation_input_token_cost_above_1hr": 2e-07, "cache_creation_input_token_cost_batches": 6.25e-08, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_batches": 5e-09, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "output_cost_per_token": 5e-07, "output_cost_per_token_batches": 2.5e-07, "input_cost_per_token_above_100k_tokens": 5e-07, "output_cost_per_token_above_100k_tokens": 2.5e-06, "cache_creation_input_token_cost_above_100k_tokens": 6.25e-07, "cache_creation_input_token_cost_above_1hr_above_100k_tokens": 1e-06, "cache_read_input_token_cost_above_100k_tokens": 5e-08}, "inclusive": false, "checks": [[null, 1, [1e-07, 5e-07, 1.25e-07, 2e-07, 1e-08]], [null, 100000, [1e-07, 5e-07, 1.25e-07, 2e-07, 1e-08]], [null, 100001, [5e-07, 2.5e-06, 6.25e-07, 1e-06, 5e-08]], [null, 10000000, [5e-07, 2.5e-06, 6.25e-07, 1e-06, 5e-08]]]},
|
||||
{"info": {"input_cost_per_token": 1.0000000000000001e-07, "output_cost_per_token": 5e-07, "cache_read_input_token_cost": 1e-08, "cache_creation_input_token_cost": 1.25e-07, "tiered_pricing": [{"range": [0, 272000], "input_cost_per_token": 1.0000000000000001e-07, "output_cost_per_token": 5e-07, "cache_read_input_token_cost": 1e-08, "cache_creation_input_token_cost": 1.25e-07}, {"range": [272000, 1050000], "input_cost_per_token": 2.0000000000000002e-07, "output_cost_per_token": 7.5e-07, "cache_read_input_token_cost": 2e-08, "cache_creation_input_token_cost": 2.5e-07}]}, "inclusive": false, "checks": [[null, 1, [1.0000000000000001e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 272000, [1.0000000000000001e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 272001, [2.0000000000000002e-07, 7.5e-07, 2.5e-07, 2.5e-07, 2e-08]], [null, 10000000, [2.0000000000000002e-07, 7.5e-07, 2.5e-07, 2.5e-07, 2e-08]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 8e-07, "output_cost_per_token": 4e-06}, "inclusive": false, "checks": [[null, 1, [8e-07, 4e-06, 1e-06, 1e-06, 8e-08]], [null, 10000000, [8e-07, 4e-06, 1e-06, 1e-06, 8e-08]]]},
|
||||
{"info": {"input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "cache_read_input_token_cost": 5e-07, "tiered_pricing": [{"range": [0, 200000], "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, "cache_read_input_token_cost": 5e-07}, {"range": [200000, 500000], "input_cost_per_token": 4e-06, "output_cost_per_token": 1.2e-05, "cache_read_input_token_cost": 1e-06}]}, "inclusive": false, "checks": [[null, 1, [2e-06, 6e-06, 2e-06, 2e-06, 5e-07]], [null, 200000, [2e-06, 6e-06, 2e-06, 2e-06, 5e-07]], [null, 200001, [4e-06, 1.2e-05, 4e-06, 4e-06, 1e-06]], [null, 10000000, [4e-06, 1.2e-05, 4e-06, 4e-06, 1e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_priority": 1.5e-07, "input_cost_per_token": 7.5e-07, "input_cost_per_token_priority": 1.5e-06, "output_cost_per_token": 4.5e-06, "output_cost_per_token_priority": 9e-06}, "inclusive": false, "checks": [[null, 1, [7.5e-07, 4.5e-06, 7.5e-07, 7.5e-07, 7.5e-08]], [null, 10000000, [7.5e-07, 4.5e-06, 7.5e-07, 7.5e-07, 7.5e-08]], ["priority", 1, [1.5e-06, 9e-06, 1.5e-06, 1.5e-06, 1.5e-07]], ["priority", 10000000, [1.5e-06, 9e-06, 1.5e-06, 1.5e-06, 1.5e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token": 2.5e-06, "output_cost_per_token_above_200k_tokens": 5e-06}, "inclusive": false, "checks": [[null, 1, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200000, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200001, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]], [null, 10000000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "output_cost_per_token": 5e-06}, "inclusive": false, "checks": [[null, 1, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]], [null, 10000000, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 8.25e-08, "cache_read_input_token_cost_flex": 4.125e-08, "cache_read_input_token_cost_priority": 1.44375e-07, "input_cost_per_token": 3.3e-07, "input_cost_per_token_batches": 1.65e-07, "input_cost_per_token_flex": 1.65e-07, "input_cost_per_token_priority": 5.775e-07, "output_cost_per_token": 2.75e-06, "output_cost_per_token_batches": 1.375e-06, "output_cost_per_token_flex": 1.375e-06, "output_cost_per_token_priority": 4.8125e-06}, "inclusive": false, "checks": [[null, 1, [3.3e-07, 2.75e-06, 3.3e-07, 3.3e-07, 8.25e-08]], [null, 10000000, [3.3e-07, 2.75e-06, 3.3e-07, 3.3e-07, 8.25e-08]], ["priority", 1, [5.775e-07, 4.8125e-06, 5.775e-07, 5.775e-07, 1.44375e-07]], ["priority", 10000000, [5.775e-07, 4.8125e-06, 5.775e-07, 5.775e-07, 1.44375e-07]], ["flex", 1, [1.65e-07, 1.375e-06, 1.65e-07, 1.65e-07, 4.125e-08]], ["flex", 10000000, [1.65e-07, 1.375e-06, 1.65e-07, 1.65e-07, 4.125e-08]]]},
|
||||
{"info": {"cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "cache_read_input_token_cost_priority": 1.5e-07, "input_cost_per_token": 7.5e-07, "output_cost_per_token": 4.5e-06, "input_cost_per_token_batches": 3.75e-07, "input_cost_per_token_flex": 3.75e-07, "input_cost_per_token_priority": 1.5e-06, "output_cost_per_token_batches": 2.25e-06, "output_cost_per_token_flex": 2.25e-06, "output_cost_per_token_priority": 9e-06}, "inclusive": false, "checks": [[null, 1, [7.5e-07, 4.5e-06, 7.5e-07, 7.5e-07, 7.5e-08]], [null, 10000000, [7.5e-07, 4.5e-06, 7.5e-07, 7.5e-07, 7.5e-08]], ["priority", 1, [1.5e-06, 9e-06, 1.5e-06, 1.5e-06, 1.5e-07]], ["priority", 10000000, [1.5e-06, 9e-06, 1.5e-06, 1.5e-06, 1.5e-07]], ["flex", 1, [3.75e-07, 2.25e-06, 3.75e-07, 3.75e-07, 3.75e-08]], ["flex", 10000000, [3.75e-07, 2.25e-06, 3.75e-07, 3.75e-07, 3.75e-08]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5.5e-09, "input_cost_per_token": 5.5e-08, "input_cost_per_token_batches": 2.75e-08, "output_cost_per_token": 4.4e-07, "output_cost_per_token_batches": 2.2e-07, "cache_read_input_token_cost_batches": 2.75e-09}, "inclusive": false, "checks": [[null, 1, [5.5e-08, 4.4e-07, 5.5e-08, 5.5e-08, 5.5e-09]], [null, 10000000, [5.5e-08, 4.4e-07, 5.5e-08, 5.5e-08, 5.5e-09]]]},
|
||||
{"info": {"output_cost_per_token": 0.0}, "inclusive": false, "checks": [[null, 1, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 10000000, [0.0, 0.0, 0.0, 0.0, 0.0]]]},
|
||||
{"info": {"input_cost_per_token_above_100k_tokens": 5e-07, "output_cost_per_token_above_100k_tokens": 2.5e-06, "cache_creation_input_token_cost_above_100k_tokens": 6.25e-07, "cache_creation_input_token_cost_above_1hr_above_100k_tokens": 1e-06, "cache_read_input_token_cost_above_100k_tokens": 5e-08, "cache_creation_input_token_cost": 1.25e-07, "cache_creation_input_token_cost_above_1hr": 2e-07, "cache_creation_input_token_cost_batches": 6.25e-08, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_batches": 5e-09, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "output_cost_per_token": 5e-07, "output_cost_per_token_batches": 2.5e-07}, "inclusive": false, "checks": [[null, 1, [1e-07, 5e-07, 1.25e-07, 2e-07, 1e-08]], [null, 100000, [1e-07, 5e-07, 1.25e-07, 2e-07, 1e-08]], [null, 100001, [5e-07, 2.5e-06, 6.25e-07, 1e-06, 5e-08]], [null, 10000000, [5e-07, 2.5e-06, 6.25e-07, 1e-06, 5e-08]]]},
|
||||
{"info": {"input_cost_per_token_above_100k_tokens": 5.5e-07, "output_cost_per_token_above_100k_tokens": 2.75e-06, "cache_creation_input_token_cost_above_100k_tokens": 6.875e-07, "cache_creation_input_token_cost_above_1hr_above_100k_tokens": 1.1e-06, "cache_read_input_token_cost_above_100k_tokens": 5.5e-08, "cache_creation_input_token_cost": 1.375e-07, "cache_creation_input_token_cost_above_1hr": 2.2e-07, "cache_read_input_token_cost": 1.1e-08, "input_cost_per_token": 1.1e-07, "output_cost_per_token": 5.5e-07}, "inclusive": false, "checks": [[null, 1, [1.1e-07, 5.5e-07, 1.375e-07, 2.2e-07, 1.1e-08]], [null, 100000, [1.1e-07, 5.5e-07, 1.375e-07, 2.2e-07, 1.1e-08]], [null, 100001, [5.5e-07, 2.75e-06, 6.875e-07, 1.1e-06, 5.5e-08]], [null, 10000000, [5.5e-07, 2.75e-06, 6.875e-07, 1.1e-06, 5.5e-08]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 4.125e-06, "cache_creation_input_token_cost_flex": 2.0625e-06, "cache_creation_input_token_cost_priority": 7.21875e-06, "cache_read_input_token_cost": 3.3e-07, "cache_read_input_token_cost_flex": 1.65e-07, "cache_read_input_token_cost_priority": 5.775e-07, "input_cost_per_token": 3.3e-06, "input_cost_per_token_flex": 1.65e-06, "input_cost_per_token_priority": 5.775e-06, "output_cost_per_token": 1.65e-05, "output_cost_per_token_flex": 8.25e-06, "output_cost_per_token_priority": 2.8875e-05}, "inclusive": false, "checks": [[null, 1, [3.3e-06, 1.65e-05, 4.125e-06, 4.125e-06, 3.3e-07]], [null, 10000000, [3.3e-06, 1.65e-05, 4.125e-06, 4.125e-06, 3.3e-07]], ["priority", 1, [5.775e-06, 2.8875e-05, 7.21875e-06, 7.21875e-06, 5.775e-07]], ["priority", 10000000, [5.775e-06, 2.8875e-05, 7.21875e-06, 7.21875e-06, 5.775e-07]], ["flex", 1, [1.65e-06, 8.25e-06, 2.0625e-06, 2.0625e-06, 1.65e-07]], ["flex", 10000000, [1.65e-06, 8.25e-06, 2.0625e-06, 2.0625e-06, 1.65e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1.375e-07, "cache_read_input_token_cost_priority": 2.75e-07, "input_cost_per_token": 1.375e-06, "input_cost_per_token_batches": 6.875e-07, "input_cost_per_token_priority": 2.75e-06, "output_cost_per_token": 1.1e-05, "output_cost_per_token_batches": 5.5e-06, "output_cost_per_token_priority": 2.2e-05, "cache_read_input_token_cost_batches": 6.875e-08}, "inclusive": false, "checks": [[null, 1, [1.375e-06, 1.1e-05, 1.375e-06, 1.375e-06, 1.375e-07]], [null, 10000000, [1.375e-06, 1.1e-05, 1.375e-06, 1.375e-06, 1.375e-07]], ["priority", 1, [2.75e-06, 2.2e-05, 2.75e-06, 2.75e-06, 2.75e-07]], ["priority", 10000000, [2.75e-06, 2.2e-05, 2.75e-06, 2.75e-06, 2.75e-07]]]},
|
||||
{"info": {"input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06}, "inclusive": false, "checks": [[null, 1, [5e-06, 1.5e-05, 5e-06, 5e-06, 5e-06]], [null, 10000000, [5e-06, 1.5e-05, 5e-06, 5e-06, 5e-06]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 2.1e-06, "cache_creation_input_token_cost_batches": 1.05e-06, "cache_creation_input_token_cost_flex": 1.05e-06, "cache_creation_input_token_cost_priority": 3.675e-06, "cache_read_input_token_cost": 3.12e-07, "cache_read_input_token_cost_batches": 1.56e-07, "cache_read_input_token_cost_flex": 1.56e-07, "cache_read_input_token_cost_priority": 5.46e-07, "input_cost_per_token": 1.68e-06, "input_cost_per_token_batches": 8.4e-07, "input_cost_per_token_flex": 8.4e-07, "input_cost_per_token_priority": 2.94e-06, "output_cost_per_token": 5.28e-06, "output_cost_per_token_batches": 2.64e-06, "output_cost_per_token_flex": 2.64e-06, "output_cost_per_token_priority": 9.24e-06}, "inclusive": false, "checks": [[null, 1, [1.68e-06, 5.28e-06, 2.1e-06, 2.1e-06, 3.12e-07]], [null, 10000000, [1.68e-06, 5.28e-06, 2.1e-06, 2.1e-06, 3.12e-07]], ["priority", 1, [2.94e-06, 9.24e-06, 3.675e-06, 3.675e-06, 5.46e-07]], ["priority", 10000000, [2.94e-06, 9.24e-06, 3.675e-06, 3.675e-06, 5.46e-07]], ["flex", 1, [8.4e-07, 2.64e-06, 1.05e-06, 1.05e-06, 1.56e-07]], ["flex", 10000000, [8.4e-07, 2.64e-06, 1.05e-06, 1.05e-06, 1.56e-07]]]},
|
||||
{"info": {"input_cost_per_token": 1.1e-05, "input_cost_per_token_above_272k_tokens": 2.2e-05, "cache_creation_input_token_cost": 1.375e-05, "cache_creation_input_token_cost_above_272k_tokens": 2.75e-05, "cache_creation_input_token_cost_above_272k_tokens_ultrafast": 0.000165, "cache_creation_input_token_cost_ultrafast": 8.25e-05, "cache_read_input_token_cost": 1.1e-06, "cache_read_input_token_cost_above_272k_tokens": 2.2e-06, "cache_read_input_token_cost_above_272k_tokens_ultrafast": 1.32e-05, "cache_read_input_token_cost_ultrafast": 6.6e-06, "input_cost_per_token_above_272k_tokens_ultrafast": 0.000132, "input_cost_per_token_ultrafast": 6.6e-05, "output_cost_per_token": 5.5e-05, "output_cost_per_token_above_272k_tokens": 8.25e-05, "output_cost_per_token_above_272k_tokens_ultrafast": 0.000495, "output_cost_per_token_ultrafast": 0.00033}, "inclusive": false, "checks": [[null, 1, [1.1e-05, 5.5e-05, 1.375e-05, 1.375e-05, 1.1e-06]], [null, 272000, [1.1e-05, 5.5e-05, 1.375e-05, 1.375e-05, 1.1e-06]], [null, 272001, [2.2e-05, 8.25e-05, 2.75e-05, 2.75e-05, 2.2e-06]], [null, 10000000, [2.2e-05, 8.25e-05, 2.75e-05, 2.75e-05, 2.2e-06]], ["ultrafast", 1, [6.6e-05, 0.00033, 8.25e-05, 8.25e-05, 6.6e-06]], ["ultrafast", 272000, [6.6e-05, 0.00033, 8.25e-05, 8.25e-05, 6.6e-06]], ["ultrafast", 272001, [0.000132, 0.000495, 0.000165, 0.000165, 1.32e-05]], ["ultrafast", 10000000, [0.000132, 0.000495, 0.000165, 0.000165, 1.32e-05]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-05, "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, "cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-05, "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-05, "cache_creation_input_token_cost_flex": 6.25e-06, "cache_creation_input_token_cost_priority": 2.5e-05, "cache_read_input_token_cost": 1e-06, "cache_read_input_token_cost_above_272k_tokens": 2e-06, "cache_read_input_token_cost_above_272k_tokens_flex": 1e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 4e-06, "cache_read_input_token_cost_batches": 5e-07, "cache_read_input_token_cost_above_272k_tokens_batches": 1e-06, "cache_creation_input_token_cost_batches": 6.25e-06, "cache_creation_input_token_cost_above_272k_tokens_batches": 1.25e-05, "cache_creation_input_token_cost_above_272k_tokens_ultrafast": 0.00015, "cache_creation_input_token_cost_ultrafast": 7.5e-05, "cache_read_input_token_cost_above_272k_tokens_ultrafast": 1.2e-05, "cache_read_input_token_cost_flex": 5e-07, "cache_read_input_token_cost_priority": 2e-06, "cache_read_input_token_cost_ultrafast": 6e-06, "input_cost_per_token": 1e-05, "input_cost_per_token_above_272k_tokens": 2e-05, "input_cost_per_token_above_272k_tokens_flex": 1e-05, "input_cost_per_token_above_272k_tokens_priority": 4e-05, "input_cost_per_token_batches": 5e-06, "input_cost_per_token_above_272k_tokens_batches": 1e-05, "input_cost_per_token_above_272k_tokens_ultrafast": 0.00012, "input_cost_per_token_flex": 5e-06, "input_cost_per_token_priority": 2e-05, "input_cost_per_token_ultrafast": 6e-05, "output_cost_per_token": 5e-05, "output_cost_per_token_above_272k_tokens": 7.5e-05, "output_cost_per_token_above_272k_tokens_flex": 3.75e-05, "output_cost_per_token_above_272k_tokens_priority": 0.00015, "output_cost_per_token_batches": 2.5e-05, "output_cost_per_token_above_272k_tokens_batches": 3.75e-05, "output_cost_per_token_above_272k_tokens_ultrafast": 0.00045, "output_cost_per_token_flex": 2.5e-05, "output_cost_per_token_priority": 0.0001, "output_cost_per_token_ultrafast": 0.0003}, "inclusive": false, "checks": [[null, 1, [1e-05, 5e-05, 1.25e-05, 1.25e-05, 1e-06]], [null, 272000, [1e-05, 5e-05, 1.25e-05, 1.25e-05, 1e-06]], [null, 272001, [2e-05, 7.5e-05, 2.5e-05, 2.5e-05, 2e-06]], [null, 10000000, [2e-05, 7.5e-05, 2.5e-05, 2.5e-05, 2e-06]], ["priority", 1, [2e-05, 0.0001, 2.5e-05, 2.5e-05, 2e-06]], ["priority", 272000, [2e-05, 0.0001, 2.5e-05, 2.5e-05, 2e-06]], ["priority", 272001, [4e-05, 0.00015, 5e-05, 5e-05, 4e-06]], ["priority", 10000000, [4e-05, 0.00015, 5e-05, 5e-05, 4e-06]], ["flex", 1, [5e-06, 2.5e-05, 6.25e-06, 6.25e-06, 5e-07]], ["flex", 272000, [5e-06, 2.5e-05, 6.25e-06, 6.25e-06, 5e-07]], ["flex", 272001, [1e-05, 3.75e-05, 1.25e-05, 1.25e-05, 1e-06]], ["flex", 10000000, [1e-05, 3.75e-05, 1.25e-05, 1.25e-05, 1e-06]], ["ultrafast", 1, [6e-05, 0.0003, 7.5e-05, 7.5e-05, 6e-06]], ["ultrafast", 272000, [6e-05, 0.0003, 7.5e-05, 7.5e-05, 6e-06]], ["ultrafast", 272001, [0.00012, 0.00045, 0.00015, 0.00015, 1.2e-05]], ["ultrafast", 10000000, [0.00012, 0.00045, 0.00015, 0.00015, 1.2e-05]]]},
|
||||
{"info": {"input_cost_per_token": 2.2e-06, "output_cost_per_token": 6.6e-06, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_flex": 2.75e-07, "cache_read_input_token_cost_priority": 9.625e-07, "input_cost_per_token_flex": 1.1e-06, "input_cost_per_token_priority": 3.85e-06, "output_cost_per_token_flex": 3.3e-06, "output_cost_per_token_priority": 1.155e-05}, "inclusive": false, "checks": [[null, 1, [2.2e-06, 6.6e-06, 2.2e-06, 2.2e-06, 5.5e-07]], [null, 10000000, [2.2e-06, 6.6e-06, 2.2e-06, 2.2e-06, 5.5e-07]], ["priority", 1, [3.85e-06, 1.155e-05, 3.85e-06, 3.85e-06, 9.625e-07]], ["priority", 10000000, [3.85e-06, 1.155e-05, 3.85e-06, 3.85e-06, 9.625e-07]], ["flex", 1, [1.1e-06, 3.3e-06, 1.1e-06, 1.1e-06, 2.75e-07]], ["flex", 10000000, [1.1e-06, 3.3e-06, 1.1e-06, 1.1e-06, 2.75e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 3e-06, "cache_read_input_token_cost_above_272k_tokens": 6e-06, "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027}, "inclusive": false, "checks": [[null, 1, [3e-05, 0.00018, 3e-05, 3e-05, 3e-06]], [null, 272000, [3e-05, 0.00018, 3e-05, 3e-05, 3e-06]], [null, 272001, [6e-05, 0.00027, 6e-05, 6e-05, 6e-06]], [null, 10000000, [6e-05, 0.00027, 6e-05, 6e-05, 6e-06]]]},
|
||||
{"info": {"input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 1.2e-07, "output_cost_per_token": 0}, "inclusive": false, "checks": [[null, 1, [1.5e-07, 0.0, 1.5e-07, 1.5e-07, 1.5e-07]], [null, 10000000, [1.5e-07, 0.0, 1.5e-07, 1.5e-07, 1.5e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost_priority": 6e-07, "input_cost_per_token_priority": 1.2e-06, "output_cost_per_token_priority": 1.2e-06}, "inclusive": false, "checks": [[null, 1, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 10000000, [0.0, 0.0, 0.0, 0.0, 0.0]], ["priority", 1, [1.2e-06, 1.2e-06, 1.2e-06, 1.2e-06, 6e-07]], ["priority", 10000000, [1.2e-06, 1.2e-06, 1.2e-06, 1.2e-06, 6e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 5e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07}, "inclusive": true, "checks": [[null, 1, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]], [null, 200001, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]], [null, 10000000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_batches": 1.6e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_batches": 1e-06, "output_cost_per_token": 2.5e-06, "output_cost_per_token_batches": 2e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "input_cost_per_token_above_200k_tokens_batches": 2e-06, "output_cost_per_token_above_200k_tokens": 5e-06, "output_cost_per_token_above_200k_tokens_batches": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_batches": 3.2e-07}, "inclusive": true, "checks": [[null, 1, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]], [null, 200001, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]], [null, 10000000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 4e-07]]]},
|
||||
{"info": {"input_cost_per_token": 5e-06}, "inclusive": false, "checks": [[null, 1, [5e-06, 0.0, 5e-06, 5e-06, 5e-06]], [null, 10000000, [5e-06, 0.0, 5e-06, 5e-06, 5e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 6e-08, "input_cost_per_token_batches": 3e-08, "output_cost_per_token": 2.4e-07, "output_cost_per_token_batches": 1.2e-07}, "inclusive": false, "checks": [[null, 1, [6e-08, 2.4e-07, 6e-08, 6e-08, 1.5e-08]], [null, 10000000, [6e-08, 2.4e-07, 6e-08, 6e-08, 1.5e-08]]]},
|
||||
{"info": {"input_cost_per_token": 3.3e-05, "input_cost_per_token_above_272k_tokens": 6.6e-05, "input_cost_per_token_above_272k_tokens_batches": 3.3e-05, "input_cost_per_token_batches": 1.65e-05, "output_cost_per_token": 0.000198, "output_cost_per_token_above_272k_tokens": 0.000297, "output_cost_per_token_above_272k_tokens_batches": 0.0001485, "output_cost_per_token_batches": 9.9e-05}, "inclusive": false, "checks": [[null, 1, [3.3e-05, 0.000198, 3.3e-05, 3.3e-05, 3.3e-05]], [null, 272000, [3.3e-05, 0.000198, 3.3e-05, 3.3e-05, 3.3e-05]], [null, 272001, [6.6e-05, 0.000297, 6.6e-05, 6.6e-05, 6.6e-05]], [null, 10000000, [6.6e-05, 0.000297, 6.6e-05, 6.6e-05, 6.6e-05]]]},
|
||||
{"info": {"cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_priority": 1.3125e-07, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_priority": 5.25e-07, "output_cost_per_token": 2.5e-06, "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_priority": 4.375e-06}, "inclusive": false, "checks": [[null, 1, [3e-07, 2.5e-06, 3e-07, 3e-07, 7.5e-08]], [null, 10000000, [3e-07, 2.5e-06, 3e-07, 3e-07, 7.5e-08]], ["priority", 1, [5.25e-07, 4.375e-06, 5.25e-07, 5.25e-07, 1.3125e-07]], ["priority", 10000000, [5.25e-07, 4.375e-06, 5.25e-07, 5.25e-07, 1.3125e-07]]]},
|
||||
{"info": {"input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "cache_read_input_token_cost": 2e-07, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 5e-06}, "inclusive": false, "checks": [[null, 1, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200000, [1.25e-06, 2.5e-06, 1.25e-06, 1.25e-06, 2e-07]], [null, 200001, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 2e-07]], [null, 10000000, [2.5e-06, 5e-06, 2.5e-06, 2.5e-06, 2e-07]]]},
|
||||
{"info": {"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07, "input_cost_per_token_above_272k_tokens": 1e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05}, "inclusive": false, "checks": [[null, 1, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272000, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 5e-07]], [null, 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 5e-07]]]},
|
||||
{"info": {"input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "input_cost_per_token_flex": 7.5e-08, "input_cost_per_token_priority": 2.625e-07, "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, "output_cost_per_token_flex": 3e-07, "output_cost_per_token_priority": 1.05e-06}, "inclusive": false, "checks": [[null, 1, [1.5e-07, 6e-07, 1.5e-07, 1.5e-07, 1.5e-07]], [null, 10000000, [1.5e-07, 6e-07, 1.5e-07, 1.5e-07, 1.5e-07]], ["priority", 1, [2.625e-07, 1.05e-06, 2.625e-07, 2.625e-07, 2.625e-07]], ["priority", 10000000, [2.625e-07, 1.05e-06, 2.625e-07, 2.625e-07, 2.625e-07]], ["flex", 1, [7.5e-08, 3e-07, 7.5e-08, 7.5e-08, 7.5e-08]], ["flex", 10000000, [7.5e-08, 3e-07, 7.5e-08, 7.5e-08, 7.5e-08]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_token": 1.2e-06, "output_cost_per_token": 6e-06, "range": [0, 32000.0]}, {"input_cost_per_token": 2.4e-06, "output_cost_per_token": 1.2e-05, "range": [32000.0, 128000.0]}, {"input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, "range": [128000.0, 252000.0]}]}, "inclusive": false, "checks": [[null, 1, [1.2e-06, 6e-06, 1.2e-06, 1.2e-06, 1.2e-06]], [null, 32000, [1.2e-06, 6e-06, 1.2e-06, 1.2e-06, 1.2e-06]], [null, 32001, [2.4e-06, 1.2e-05, 2.4e-06, 2.4e-06, 2.4e-06]], [null, 128000, [2.4e-06, 1.2e-05, 2.4e-06, 2.4e-06, 2.4e-06]], [null, 128001, [3e-06, 1.5e-05, 3e-06, 3e-06, 3e-06]], [null, 10000000, [3e-06, 1.5e-05, 3e-06, 3e-06, 3e-06]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "output_cost_per_token": 5e-06, "input_cost_per_token_batches": 5e-07, "output_cost_per_token_batches": 2.5e-06}, "inclusive": false, "checks": [[null, 1, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]], [null, 10000000, [1e-06, 5e-06, 1.25e-06, 2e-06, 1e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_priority": 3.6e-06, "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06, "output_cost_per_token_priority": 2.16e-05, "cache_read_input_token_cost_flex": 2e-07, "input_cost_per_token_flex": 1e-06, "output_cost_per_token_flex": 6e-06}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], ["priority", 1, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200000, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200001, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]], ["priority", 10000000, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]], ["flex", 1, [1e-06, 6e-06, 1e-06, 1e-06, 2e-07]], ["flex", 200000, [1e-06, 6e-06, 1e-06, 1e-06, 2e-07]], ["flex", 200001, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], ["flex", 10000000, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]]]},
|
||||
{"info": {"input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_priority": 8.75e-06, "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 2.625e-05}, "inclusive": false, "checks": [[null, 1, [5e-06, 1.5e-05, 5e-06, 5e-06, 5e-06]], [null, 10000000, [5e-06, 1.5e-05, 5e-06, 5e-06, 5e-06]], ["priority", 1, [8.75e-06, 2.625e-05, 8.75e-06, 8.75e-06, 8.75e-06]], ["priority", 10000000, [8.75e-06, 2.625e-05, 8.75e-06, 8.75e-06, 8.75e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "cache_read_input_token_cost_batches": 1e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_priority": 3.6e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_priority": 2.16e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], ["priority", 1, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200000, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200001, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]], ["priority", 10000000, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.6e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], ["priority", 1, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200000, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200001, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]], ["priority", 10000000, [7.2e-06, 3.24e-05, 7.2e-06, 7.2e-06, 7.2e-07]], ["flex", 1, [1e-06, 6e-06, 1e-06, 1e-06, 1e-07]], ["flex", 200000, [1e-06, 6e-06, 1e-06, 1e-06, 1e-07]], ["flex", 200001, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]], ["flex", 10000000, [4e-06, 1.8e-05, 4e-06, 4e-06, 4e-07]]]},
|
||||
{"info": {"input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, "output_cost_per_token": 2.5e-06, "output_cost_per_token_batches": 1.25e-06, "output_cost_per_token_flex": 1.25e-06}, "inclusive": false, "checks": [[null, 1, [3e-07, 2.5e-06, 3e-07, 3e-07, 3e-07]], [null, 10000000, [3e-07, 2.5e-06, 3e-07, 3e-07, 3e-07]], ["flex", 1, [1.5e-07, 1.25e-06, 1.5e-07, 1.5e-07, 1.5e-07]], ["flex", 10000000, [1.5e-07, 1.25e-06, 1.5e-07, 1.5e-07, 1.5e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "cache_read_input_token_cost_batches": 1.5e-07, "input_cost_per_token_batches": 1.5e-06, "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "cache_creation_input_token_cost_batches": 1.88e-06}, "inclusive": false, "checks": [[null, 1, [3e-06, 1.5e-05, 3.75e-06, 6e-06, 3e-07]], [null, 200000, [3e-06, 1.5e-05, 3.75e-06, 6e-06, 3e-07]], [null, 200001, [6e-06, 2.25e-05, 7.5e-06, 1.2e-05, 6e-07]], [null, 10000000, [6e-06, 2.25e-05, 7.5e-06, 1.2e-05, 6e-07]]]},
|
||||
{"info": {"input_cost_per_token": 7.4e-07, "input_cost_per_token_flex": 3.7e-07, "output_cost_per_token": 2.22e-06, "output_cost_per_token_flex": 1.11e-06}, "inclusive": false, "checks": [[null, 1, [7.4e-07, 2.22e-06, 7.4e-07, 7.4e-07, 7.4e-07]], [null, 10000000, [7.4e-07, 2.22e-06, 7.4e-07, 7.4e-07, 7.4e-07]], ["flex", 1, [3.7e-07, 1.11e-06, 3.7e-07, 3.7e-07, 3.7e-07]], ["flex", 10000000, [3.7e-07, 1.11e-06, 3.7e-07, 3.7e-07, 3.7e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "output_cost_per_token": 1.5e-05}, "inclusive": false, "checks": [[null, 1, [3e-06, 1.5e-05, 3.75e-06, 3.75e-06, 3e-07]], [null, 200000, [3e-06, 1.5e-05, 3.75e-06, 3.75e-06, 3e-07]], [null, 200001, [6e-06, 2.25e-05, 7.5e-06, 7.5e-06, 6e-07]], [null, 10000000, [6e-06, 2.25e-05, 7.5e-06, 7.5e-06, 6e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, "cache_read_input_token_cost": 3.6e-07, "input_cost_per_token": 3.6e-06, "input_cost_per_token_above_200k_tokens": 7.2e-06, "output_cost_per_token_above_200k_tokens": 2.7e-05, "cache_creation_input_token_cost_above_200k_tokens": 9e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.44e-05, "cache_read_input_token_cost_above_200k_tokens": 7.2e-07, "output_cost_per_token": 1.8e-05}, "inclusive": false, "checks": [[null, 1, [3.6e-06, 1.8e-05, 4.5e-06, 7.2e-06, 3.6e-07]], [null, 200000, [3.6e-06, 1.8e-05, 4.5e-06, 7.2e-06, 3.6e-07]], [null, 200001, [7.2e-06, 2.7e-05, 9e-06, 1.44e-05, 7.2e-07]], [null, 10000000, [7.2e-06, 2.7e-05, 9e-06, 1.44e-05, 7.2e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "output_cost_per_token": 1.5e-05, "input_cost_per_token_batches": 1.5e-06, "output_cost_per_token_batches": 7.5e-06}, "inclusive": false, "checks": [[null, 1, [3e-06, 1.5e-05, 3.75e-06, 6e-06, 3e-07]], [null, 200000, [3e-06, 1.5e-05, 3.75e-06, 6e-06, 3e-07]], [null, 200001, [6e-06, 2.25e-05, 7.5e-06, 1.2e-05, 6e-07]], [null, 10000000, [6e-06, 2.25e-05, 7.5e-06, 1.2e-05, 6e-07]]]},
|
||||
{"info": {"input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05}, "inclusive": false, "checks": [[null, 1, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 1.25e-06]], [null, 200000, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 1.25e-06]], [null, 200001, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-06]], [null, 10000000, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 5e-06}, "inclusive": false, "checks": [[null, 1, [5e-06, 0.0, 5e-06, 5e-06, 1.25e-06]], [null, 10000000, [5e-06, 0.0, 5e-06, 5e-06, 1.25e-06]]]},
|
||||
{"info": {"input_cost_per_token": 3e-07, "input_cost_per_token_above_512k_tokens": 6e-07, "output_cost_per_token": 1.2e-06, "output_cost_per_token_above_512k_tokens": 2.4e-06, "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_above_512k_tokens": 1.2e-07}, "inclusive": false, "checks": [[null, 1, [3e-07, 1.2e-06, 3e-07, 3e-07, 6e-08]], [null, 512000, [3e-07, 1.2e-06, 3e-07, 3e-07, 6e-08]], [null, 512001, [6e-07, 2.4e-06, 6e-07, 6e-07, 1.2e-07]], [null, 10000000, [6e-07, 2.4e-06, 6e-07, 6e-07, 1.2e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_batches": 6.25e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06}, "inclusive": false, "checks": [[null, 1, [5e-06, 0.0, 5e-06, 5e-06, 1.25e-06]], [null, 10000000, [5e-06, 0.0, 5e-06, 5e-06, 1.25e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5e-09, "cache_read_input_token_cost_batches": 2.5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, "input_cost_per_token_batches": 2.5e-08, "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "output_cost_per_token_flex": 2e-07}, "inclusive": false, "checks": [[null, 1, [5e-08, 4e-07, 5e-08, 5e-08, 5e-09]], [null, 10000000, [5e-08, 4e-07, 5e-08, 5e-08, 5e-09]], ["priority", 1, [2.5e-06, 4e-07, 2.5e-06, 2.5e-06, 5e-09]], ["priority", 10000000, [2.5e-06, 4e-07, 2.5e-06, 2.5e-06, 5e-09]], ["flex", 1, [2.5e-08, 2e-07, 2.5e-08, 2.5e-08, 2.5e-09]], ["flex", 10000000, [2.5e-08, 2e-07, 2.5e-08, 2.5e-08, 2.5e-09]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_batches": 1e-08, "cache_read_input_token_cost_flex": 1e-08, "input_cost_per_token": 2e-07, "output_cost_per_token": 1.25e-06, "input_cost_per_token_batches": 1e-07, "input_cost_per_token_flex": 1e-07, "output_cost_per_token_batches": 6.25e-07, "output_cost_per_token_flex": 6.25e-07}, "inclusive": false, "checks": [[null, 1, [2e-07, 1.25e-06, 2e-07, 2e-07, 2e-08]], [null, 10000000, [2e-07, 1.25e-06, 2e-07, 2e-07, 2e-08]], ["flex", 1, [1e-07, 6.25e-07, 1e-07, 1e-07, 1e-08]], ["flex", 10000000, [1e-07, 6.25e-07, 1e-07, 1e-07, 1e-08]]]},
|
||||
{"info": {"input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, "input_cost_per_token_flex": 1.5e-05, "input_cost_per_token_batches": 1.5e-05, "input_cost_per_token_above_272k_tokens_batches": 3e-05, "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, "output_cost_per_token_above_272k_tokens_batches": 0.000135, "input_cost_per_token_above_272k_tokens_flex": 3e-05, "output_cost_per_token_above_272k_tokens_flex": 0.000135}, "inclusive": false, "checks": [[null, 1, [3e-05, 0.00018, 3e-05, 3e-05, 3e-05]], [null, 272000, [3e-05, 0.00018, 3e-05, 3e-05, 3e-05]], [null, 272001, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]], [null, 10000000, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]], ["flex", 1, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 1.5e-05]], ["flex", 272000, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 1.5e-05]], ["flex", 272001, [3e-05, 0.000135, 3e-05, 3e-05, 3e-05]], ["flex", 10000000, [3e-05, 0.000135, 3e-05, 3e-05, 3e-05]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_batches": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens_batches": 5e-07, "cache_read_input_token_cost_flex": 2.5e-07, "cache_read_input_token_cost_priority": 1.25e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_flex": 2.5e-06, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_above_272k_tokens_batches": 5e-06, "input_cost_per_token_priority": 1.25e-05, "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_flex": 1.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_above_272k_tokens_batches": 2.25e-05, "output_cost_per_token_priority": 7.5e-05, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "output_cost_per_token_above_272k_tokens_flex": 2.25e-05, "cache_read_input_token_cost_above_272k_tokens_flex": 5e-07}, "inclusive": false, "checks": [[null, 1, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272000, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], [null, 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["priority", 1, [1.25e-05, 7.5e-05, 1.25e-05, 1.25e-05, 1.25e-06]], ["priority", 272000, [1.25e-05, 7.5e-05, 1.25e-05, 1.25e-05, 1.25e-06]], ["priority", 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["priority", 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["flex", 1, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 272000, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 272001, [5e-06, 2.25e-05, 5e-06, 5e-06, 5e-07]], ["flex", 10000000, [5e-06, 2.25e-05, 5e-06, 5e-06, 5e-07]]]},
|
||||
{"info": {"input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, "input_cost_per_token_flex": 1.5e-05, "input_cost_per_token_batches": 1.5e-05, "input_cost_per_token_above_272k_tokens_batches": 3e-05, "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, "output_cost_per_token_above_272k_tokens_batches": 0.000135}, "inclusive": false, "checks": [[null, 1, [3e-05, 0.00018, 3e-05, 3e-05, 3e-05]], [null, 272000, [3e-05, 0.00018, 3e-05, 3e-05, 3e-05]], [null, 272001, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]], [null, 10000000, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]], ["flex", 1, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 1.5e-05]], ["flex", 272000, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 1.5e-05]], ["flex", 272001, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]], ["flex", 10000000, [6e-05, 0.00027, 6e-05, 6e-05, 6e-05]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-07, "cache_creation_input_token_cost_decisions": 1.25e-07, "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_272k_tokens_decisions": 2.5e-07, "cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-07, "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07, "cache_creation_input_token_cost_flex": 6.25e-08, "cache_creation_input_token_cost_priority": 2.5e-07, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_decisions": 1e-08, "cache_read_input_token_cost_above_272k_tokens": 2e-08, "cache_read_input_token_cost_above_272k_tokens_decisions": 2e-08, "cache_read_input_token_cost_above_272k_tokens_flex": 1e-08, "cache_read_input_token_cost_above_272k_tokens_priority": 4e-08, "cache_read_input_token_cost_batches": 5e-09, "cache_read_input_token_cost_above_272k_tokens_batches": 1e-08, "cache_creation_input_token_cost_batches": 6.25e-08, "cache_creation_input_token_cost_above_272k_tokens_batches": 1.25e-07, "cache_read_input_token_cost_flex": 5e-09, "cache_read_input_token_cost_priority": 2e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_decisions": 1e-07, "input_cost_per_token_above_272k_tokens": 2e-07, "input_cost_per_token_above_272k_tokens_decisions": 2e-07, "input_cost_per_token_above_272k_tokens_flex": 1e-07, "input_cost_per_token_above_272k_tokens_priority": 4e-07, "input_cost_per_token_batches": 5e-08, "input_cost_per_token_above_272k_tokens_batches": 1e-07, "input_cost_per_token_flex": 5e-08, "input_cost_per_token_priority": 2e-07, "output_cost_per_token": 5e-07, "output_cost_per_token_decisions": 0.0, "output_cost_per_token_above_272k_tokens": 7.5e-07, "output_cost_per_token_above_272k_tokens_decisions": 0.0, "output_cost_per_token_above_272k_tokens_flex": 3.75e-07, "output_cost_per_token_above_272k_tokens_priority": 1.5e-06, "output_cost_per_token_batches": 2.5e-07, "output_cost_per_token_above_272k_tokens_batches": 3.75e-07, "output_cost_per_token_flex": 2.5e-07, "output_cost_per_token_priority": 1e-06}, "inclusive": false, "checks": [[null, 1, [1e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 272000, [1e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 272001, [2e-07, 7.5e-07, 2.5e-07, 2.5e-07, 2e-08]], [null, 10000000, [2e-07, 7.5e-07, 2.5e-07, 2.5e-07, 2e-08]], ["priority", 1, [2e-07, 1e-06, 2.5e-07, 2.5e-07, 2e-08]], ["priority", 272000, [2e-07, 1e-06, 2.5e-07, 2.5e-07, 2e-08]], ["priority", 272001, [4e-07, 1.5e-06, 5e-07, 5e-07, 4e-08]], ["priority", 10000000, [4e-07, 1.5e-06, 5e-07, 5e-07, 4e-08]], ["flex", 1, [5e-08, 2.5e-07, 6.25e-08, 6.25e-08, 5e-09]], ["flex", 272000, [5e-08, 2.5e-07, 6.25e-08, 6.25e-08, 5e-09]], ["flex", 272001, [1e-07, 3.75e-07, 1.25e-07, 1.25e-07, 1e-08]], ["flex", 10000000, [1e-07, 3.75e-07, 1.25e-07, 1.25e-07, 1e-08]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.25e-07, "cache_creation_input_token_cost_above_100k_tokens": 6.25e-07, "cache_read_input_token_cost": 1e-08, "cache_read_input_token_cost_above_100k_tokens": 5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_above_100k_tokens": 5e-07, "output_cost_per_token": 5e-07, "output_cost_per_token_above_100k_tokens": 2.5e-06}, "inclusive": false, "checks": [[null, 1, [1e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 100000, [1e-07, 5e-07, 1.25e-07, 1.25e-07, 1e-08]], [null, 100001, [5e-07, 2.5e-06, 6.25e-07, 6.25e-07, 5e-08]], [null, 10000000, [5e-07, 2.5e-06, 6.25e-07, 6.25e-07, 5e-08]]]},
|
||||
{"info": {"input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0}, "inclusive": false, "checks": [[null, 1, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 128000, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 128001, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 10000000, [0.0, 0.0, 0.0, 0.0, 0.0]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5e-08, "cache_read_input_token_cost_priority": 9e-08, "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, "input_cost_per_token_flex": 2.5e-07, "input_cost_per_token_priority": 9e-07, "output_cost_per_token": 3e-06, "output_cost_per_token_batches": 1.5e-06, "output_cost_per_token_flex": 1.5e-06, "output_cost_per_token_priority": 5.4e-06}, "inclusive": false, "checks": [[null, 1, [5e-07, 3e-06, 5e-07, 5e-07, 5e-08]], [null, 10000000, [5e-07, 3e-06, 5e-07, 5e-07, 5e-08]], ["priority", 1, [9e-07, 5.4e-06, 9e-07, 9e-07, 9e-08]], ["priority", 10000000, [9e-07, 5.4e-06, 9e-07, 9e-07, 9e-08]], ["flex", 1, [2.5e-07, 1.5e-06, 2.5e-07, 2.5e-07, 5e-08]], ["flex", 10000000, [2.5e-07, 1.5e-06, 2.5e-07, 2.5e-07, 5e-08]]]},
|
||||
{"info": {"cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "input_cost_per_token_flex": 1.5e-07, "input_cost_per_token_priority": 5.4e-07, "output_cost_per_token": 2.5e-06}, "inclusive": false, "checks": [[null, 1, [3e-07, 2.5e-06, 3e-07, 3e-07, 3e-08]], [null, 10000000, [3e-07, 2.5e-06, 3e-07, 3e-07, 3e-08]], ["priority", 1, [5.4e-07, 2.5e-06, 5.4e-07, 5.4e-07, 3e-08]], ["priority", 10000000, [5.4e-07, 2.5e-06, 5.4e-07, 5.4e-07, 3e-08]], ["flex", 1, [1.5e-07, 2.5e-06, 1.5e-07, 1.5e-07, 3e-08]], ["flex", 10000000, [1.5e-07, 2.5e-06, 1.5e-07, 1.5e-07, 3e-08]]]},
|
||||
{"info": {"cache_read_input_token_cost": 8.3e-08, "input_cost_per_token": 1.65e-07, "input_cost_per_token_batches": 8.3e-08, "output_cost_per_token": 6.6e-07}, "inclusive": false, "checks": [[null, 1, [1.65e-07, 6.6e-07, 1.65e-07, 1.65e-07, 8.3e-08]], [null, 10000000, [1.65e-07, 6.6e-07, 1.65e-07, 1.65e-07, 8.3e-08]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "input_cost_per_token_batches": 1e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06, "input_cost_per_token_priority": 3.6e-06, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "output_cost_per_token_priority": 2.16e-05, "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], ["priority", 1, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200000, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200001, [7.2e-06, 3.24e-05, 2.5e-07, 2.5e-07, 7.2e-07]], ["priority", 10000000, [7.2e-06, 3.24e-05, 2.5e-07, 2.5e-07, 7.2e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "cache_read_input_token_cost_priority": 3.6e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.6e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "output_cost_per_token_batches": 6e-06, "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], ["priority", 1, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200000, [3.6e-06, 2.16e-05, 3.6e-06, 3.6e-06, 3.6e-07]], ["priority", 200001, [7.2e-06, 3.24e-05, 2.5e-07, 2.5e-07, 7.2e-07]], ["priority", 10000000, [7.2e-06, 3.24e-05, 2.5e-07, 2.5e-07, 7.2e-07]], ["flex", 1, [1e-06, 6e-06, 1e-06, 1e-06, 2e-07]], ["flex", 200000, [1e-06, 6e-06, 1e-06, 1e-06, 2e-07]], ["flex", 200001, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], ["flex", 10000000, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_above_200k_tokens": 4e-06, "input_cost_per_token_batches": 1e-06, "output_cost_per_token": 1.2e-05, "output_cost_per_token_above_200k_tokens": 1.8e-05, "output_cost_per_token_batches": 6e-06}, "inclusive": false, "checks": [[null, 1, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200000, [2e-06, 1.2e-05, 2e-06, 2e-06, 2e-07]], [null, 200001, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]], [null, 10000000, [4e-06, 1.8e-05, 2.5e-07, 2.5e-07, 4e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, "cache_read_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "input_cost_per_token_above_200k_tokens_priority": 4.5e-06, "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.25e-06, "output_cost_per_token_above_200k_tokens_priority": 2.7e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 1.8e-05}, "inclusive": false, "checks": [[null, 1, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 1.25e-07]], [null, 200000, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 1.25e-07]], [null, 200001, [2.5e-06, 1.5e-05, 2.5e-07, 2.5e-07, 2.5e-07]], [null, 10000000, [2.5e-06, 1.5e-05, 2.5e-07, 2.5e-07, 2.5e-07]], ["priority", 1, [2.25e-06, 1.8e-05, 2.25e-06, 2.25e-06, 2.25e-07]], ["priority", 200000, [2.25e-06, 1.8e-05, 2.25e-06, 2.25e-06, 2.25e-07]], ["priority", 200001, [4.5e-06, 2.7e-05, 2.5e-07, 2.5e-07, 4.5e-07]], ["priority", 10000000, [4.5e-06, 2.7e-05, 2.5e-07, 2.5e-07, 4.5e-07]], ["flex", 1, [6.25e-07, 5e-06, 6.25e-07, 6.25e-07, 1.25e-07]], ["flex", 200000, [6.25e-07, 5e-06, 6.25e-07, 6.25e-07, 1.25e-07]], ["flex", 200001, [2.5e-06, 1.5e-05, 2.5e-07, 2.5e-07, 2.5e-07]], ["flex", 10000000, [2.5e-06, 1.5e-05, 2.5e-07, 2.5e-07, 2.5e-07]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, "output_cost_per_token": 1.5e-05}, "inclusive": false, "checks": [[null, 1, [3.75e-06, 1.5e-05, 1.875e-06, 1.875e-06, 3.75e-06]], [null, 10000000, [3.75e-06, 1.5e-05, 1.875e-06, 1.875e-06, 3.75e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2.1e-07, "cache_read_input_token_cost_flex": 1.05e-07, "input_cost_per_token": 8.4e-07, "input_cost_per_token_flex": 4.2e-07, "output_cost_per_token": 3.36e-06, "output_cost_per_token_flex": 1.68e-06}, "inclusive": false, "checks": [[null, 1, [8.4e-07, 3.36e-06, 8.4e-07, 8.4e-07, 2.1e-07]], [null, 10000000, [8.4e-07, 3.36e-06, 8.4e-07, 8.4e-07, 2.1e-07]], ["flex", 1, [4.2e-07, 1.68e-06, 4.2e-07, 4.2e-07, 1.05e-07]], ["flex", 10000000, [4.2e-07, 1.68e-06, 4.2e-07, 4.2e-07, 1.05e-07]]]},
|
||||
{"info": {"input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.25e-05, "cache_read_input_token_cost": 2.5e-07, "input_cost_per_token_balanced": 2e-06, "output_cost_per_token_balanced": 1e-05, "cache_read_input_token_cost_balanced": 2e-07, "input_cost_per_token_flex": 1.25e-06, "output_cost_per_token_flex": 6.25e-06, "cache_read_input_token_cost_flex": 1.5e-07}, "inclusive": false, "checks": [[null, 1, [2.5e-06, 1.25e-05, 2.5e-06, 2.5e-06, 2.5e-07]], [null, 10000000, [2.5e-06, 1.25e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 1, [1.25e-06, 6.25e-06, 1.25e-06, 1.25e-06, 1.5e-07]], ["flex", 10000000, [1.25e-06, 6.25e-06, 1.25e-06, 1.25e-06, 1.5e-07]], ["balanced", 1, [2e-06, 1e-05, 2e-06, 2e-06, 2e-07]], ["balanced", 10000000, [2e-06, 1e-05, 2e-06, 2e-06, 2e-07]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_query": 0.00166, "max_results_range": [1, 10]}, {"input_cost_per_query": 0.00332, "max_results_range": [11, 20]}, {"input_cost_per_query": 0.00498, "max_results_range": [21, 30]}, {"input_cost_per_query": 0.00664, "max_results_range": [31, 40]}, {"input_cost_per_query": 0.0083, "max_results_range": [41, 50]}, {"input_cost_per_query": 0.00996, "max_results_range": [51, 60]}, {"input_cost_per_query": 0.01162, "max_results_range": [61, 70]}, {"input_cost_per_query": 0.01328, "max_results_range": [71, 80]}, {"input_cost_per_query": 0.01494, "max_results_range": [81, 90]}, {"input_cost_per_query": 0.0166, "max_results_range": [91, 100]}]}, "inclusive": false, "checks": [[null, 1, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 10000000, [0.0, 0.0, 0.0, 0.0, 0.0]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_query": 0.005, "max_results_range": [0, 25]}, {"input_cost_per_query": 0.025, "max_results_range": [26, 100]}]}, "inclusive": false, "checks": [[null, 1, [0.0, 0.0, 0.0, 0.0, 0.0]], [null, 10000000, [0.0, 0.0, 0.0, 0.0, 0.0]]]},
|
||||
{"info": {"tiered_pricing": [{"cache_read_input_token_cost": 8e-08, "input_cost_per_token": 4e-07, "output_cost_per_token": 1.6e-06, "range": [0, 256000.0]}, {"cache_read_input_token_cost": 2.4e-07, "input_cost_per_token": 1.2e-06, "output_cost_per_token": 4.8e-06, "range": [256000.0, 1000000.0]}]}, "inclusive": false, "checks": [[null, 1, [4e-07, 1.6e-06, 4e-07, 4e-07, 8e-08]], [null, 256000, [4e-07, 1.6e-06, 4e-07, 4e-07, 8e-08]], [null, 256001, [1.2e-06, 4.8e-06, 1.2e-06, 1.2e-06, 2.4e-07]], [null, 10000000, [1.2e-06, 4.8e-06, 1.2e-06, 1.2e-06, 2.4e-07]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07, "range": [0, 256000.0]}, {"input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "range": [256000.0, 1000000.0]}]}, "inclusive": false, "checks": [[null, 1, [5e-08, 4e-07, 5e-08, 5e-08, 5e-08]], [null, 256000, [5e-08, 4e-07, 5e-08, 5e-08, 5e-08]], [null, 256001, [2.5e-07, 2e-06, 2.5e-07, 2.5e-07, 2.5e-07]], [null, 10000000, [2.5e-07, 2e-06, 2.5e-07, 2.5e-07, 2.5e-07]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_token": 3e-07, "output_cost_per_token": 1.5e-06, "range": [0, 32000.0]}, {"input_cost_per_token": 5e-07, "output_cost_per_token": 2.5e-06, "range": [32000.0, 128000.0]}, {"input_cost_per_token": 8e-07, "output_cost_per_token": 4e-06, "range": [128000.0, 256000.0]}, {"input_cost_per_token": 1.6e-06, "output_cost_per_token": 9.6e-06, "range": [256000.0, 1000000.0]}]}, "inclusive": false, "checks": [[null, 1, [3e-07, 1.5e-06, 3e-07, 3e-07, 3e-07]], [null, 32000, [3e-07, 1.5e-06, 3e-07, 3e-07, 3e-07]], [null, 32001, [5e-07, 2.5e-06, 5e-07, 5e-07, 5e-07]], [null, 128000, [5e-07, 2.5e-06, 5e-07, 5e-07, 5e-07]], [null, 128001, [8e-07, 4e-06, 8e-07, 8e-07, 8e-07]], [null, 256000, [8e-07, 4e-06, 8e-07, 8e-07, 8e-07]], [null, 256001, [1.6e-06, 9.6e-06, 1.6e-06, 1.6e-06, 1.6e-06]], [null, 10000000, [1.6e-06, 9.6e-06, 1.6e-06, 1.6e-06, 1.6e-06]]]},
|
||||
{"info": {"tiered_pricing": [{"cache_read_input_token_cost": 8e-08, "input_cost_per_token": 3e-07, "output_cost_per_token": 1.5e-06, "range": [0, 32000.0]}, {"cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 5e-07, "output_cost_per_token": 2.5e-06, "range": [32000.0, 128000.0]}, {"cache_read_input_token_cost": 2e-07, "input_cost_per_token": 8e-07, "output_cost_per_token": 4e-06, "range": [128000.0, 256000.0]}, {"cache_read_input_token_cost": 4e-07, "input_cost_per_token": 1.6e-06, "output_cost_per_token": 9.6e-06, "range": [256000.0, 1000000.0]}]}, "inclusive": false, "checks": [[null, 1, [3e-07, 1.5e-06, 3e-07, 3e-07, 8e-08]], [null, 32000, [3e-07, 1.5e-06, 3e-07, 3e-07, 8e-08]], [null, 32001, [5e-07, 2.5e-06, 5e-07, 5e-07, 1.2e-07]], [null, 128000, [5e-07, 2.5e-06, 5e-07, 5e-07, 1.2e-07]], [null, 128001, [8e-07, 4e-06, 8e-07, 8e-07, 2e-07]], [null, 256000, [8e-07, 4e-06, 8e-07, 8e-07, 2e-07]], [null, 256001, [1.6e-06, 9.6e-06, 1.6e-06, 1.6e-06, 4e-07]], [null, 10000000, [1.6e-06, 9.6e-06, 1.6e-06, 1.6e-06, 4e-07]]]},
|
||||
{"info": {"tiered_pricing": [{"input_cost_per_token": 4e-07, "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "range": [0, 256000.0]}, {"input_cost_per_token": 1.2e-06, "output_cost_per_reasoning_token": 1.2e-05, "output_cost_per_token": 3.6e-06, "range": [256000.0, 1000000.0]}]}, "inclusive": false, "checks": [[null, 1, [4e-07, 1.2e-06, 4e-07, 4e-07, 4e-07]], [null, 256000, [4e-07, 1.2e-06, 4e-07, 4e-07, 4e-07]], [null, 256001, [1.2e-06, 3.6e-06, 1.2e-06, 1.2e-06, 1.2e-06]], [null, 10000000, [1.2e-06, 3.6e-06, 1.2e-06, 1.2e-06, 1.2e-06]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "output_cost_per_token": 5.5e-06, "input_cost_per_token_batches": 5.5e-07, "output_cost_per_token_batches": 2.75e-06}, "inclusive": false, "checks": [[null, 1, [1.1e-06, 5.5e-06, 1.375e-06, 1.375e-06, 1.1e-07]], [null, 10000000, [1.1e-06, 5.5e-06, 1.375e-06, 1.375e-06, 1.1e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2.75e-07, "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens_batches": 2.75e-07, "cache_read_input_token_cost_batches": 1.43e-07, "cache_read_input_token_cost_priority": 5.5e-07, "input_cost_per_token": 2.75e-06, "input_cost_per_token_priority": 5.5e-06, "output_cost_per_token": 1.65e-05, "output_cost_per_token_priority": 3.3e-05, "input_cost_per_token_above_272k_tokens": 5.5e-06, "input_cost_per_token_above_272k_tokens_batches": 2.75e-06, "input_cost_per_token_batches": 1.375e-06, "output_cost_per_token_above_272k_tokens": 2.475e-05, "output_cost_per_token_above_272k_tokens_batches": 1.2375e-05, "output_cost_per_token_batches": 8.25e-06}, "inclusive": false, "checks": [[null, 1, [2.75e-06, 1.65e-05, 2.75e-06, 2.75e-06, 2.75e-07]], [null, 272000, [2.75e-06, 1.65e-05, 2.75e-06, 2.75e-06, 2.75e-07]], [null, 272001, [5.5e-06, 2.475e-05, 5.5e-06, 5.5e-06, 5.5e-07]], [null, 10000000, [5.5e-06, 2.475e-05, 5.5e-06, 5.5e-06, 5.5e-07]], ["priority", 1, [5.5e-06, 3.3e-05, 5.5e-06, 5.5e-06, 5.5e-07]], ["priority", 272000, [5.5e-06, 3.3e-05, 5.5e-06, 5.5e-06, 5.5e-07]], ["priority", 272001, [5.5e-06, 2.475e-05, 5.5e-06, 5.5e-06, 5.5e-07]], ["priority", 10000000, [5.5e-06, 2.475e-05, 5.5e-06, 5.5e-06, 5.5e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_above_272k_tokens_batches": 5e-07, "cache_read_input_token_cost_priority": 1.25e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, "cache_read_input_token_cost_batches": 2.5e-07, "cache_read_input_token_cost_flex": 2.5e-07, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_above_272k_tokens_batches": 5e-06, "input_cost_per_token_above_272k_tokens_flex": 5e-06, "input_cost_per_token_priority": 1.25e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_above_272k_tokens_batches": 2.25e-05, "output_cost_per_token_priority": 7.5e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05, "output_cost_per_token_batches": 1.5e-05}, "inclusive": false, "checks": [[null, 1, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272000, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], [null, 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["priority", 1, [1.25e-05, 7.5e-05, 1.25e-05, 1.25e-05, 1.25e-06]], ["priority", 272000, [1.25e-05, 7.5e-05, 1.25e-05, 1.25e-05, 1.25e-06]], ["priority", 272001, [2e-05, 9e-05, 2e-05, 2e-05, 2e-06]], ["priority", 10000000, [2e-05, 9e-05, 2e-05, 2e-05, 2e-06]], ["flex", 1, [2.5e-06, 3e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 272000, [2.5e-06, 3e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 272001, [5e-06, 4.5e-05, 5e-06, 5e-06, 1e-06]], ["flex", 10000000, [5e-06, 4.5e-05, 5e-06, 5e-06, 1e-06]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 5e-06, "cache_creation_input_token_cost_above_272k_tokens": 1e-05, "cache_creation_input_token_cost_priority": 1e-05, "cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05, "cache_read_input_token_cost": 4e-07, "cache_read_input_token_cost_above_272k_tokens": 8e-07, "cache_read_input_token_cost_priority": 8e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06, "input_cost_per_token": 4e-06, "input_cost_per_token_above_272k_tokens": 8e-06, "input_cost_per_token_priority": 8e-06, "input_cost_per_token_above_272k_tokens_priority": 1.6e-05, "output_cost_per_token": 2e-05, "output_cost_per_token_above_272k_tokens": 3e-05, "output_cost_per_token_priority": 4e-05, "output_cost_per_token_above_272k_tokens_priority": 6e-05}, "inclusive": false, "checks": [[null, 1, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], [null, 272000, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], [null, 272001, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], [null, 10000000, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], ["priority", 1, [8e-06, 4e-05, 1e-05, 1e-05, 8e-07]], ["priority", 272000, [8e-06, 4e-05, 1e-05, 1e-05, 8e-07]], ["priority", 272001, [1.6e-05, 6e-05, 2e-05, 2e-05, 1.6e-06]], ["priority", 10000000, [1.6e-05, 6e-05, 2e-05, 2e-05, 1.6e-06]]]},
|
||||
{"info": {"cache_creation_input_token_cost": 5e-06, "cache_creation_input_token_cost_above_272k_tokens": 1e-05, "cache_creation_input_token_cost_above_272k_tokens_flex": 5e-06, "cache_creation_input_token_cost_priority": 1e-05, "cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05, "cache_creation_input_token_cost_flex": 2.5e-06, "cache_read_input_token_cost": 4e-07, "cache_read_input_token_cost_above_272k_tokens": 8e-07, "cache_read_input_token_cost_above_272k_tokens_flex": 4e-07, "cache_read_input_token_cost_priority": 8e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06, "cache_read_input_token_cost_flex": 2e-07, "input_cost_per_token": 4e-06, "input_cost_per_token_above_272k_tokens": 8e-06, "input_cost_per_token_above_272k_tokens_flex": 4e-06, "input_cost_per_token_priority": 8e-06, "input_cost_per_token_above_272k_tokens_priority": 1.6e-05, "input_cost_per_token_flex": 2e-06, "output_cost_per_token": 2e-05, "output_cost_per_token_above_272k_tokens": 3e-05, "output_cost_per_token_above_272k_tokens_flex": 1.5e-05, "output_cost_per_token_priority": 4e-05, "output_cost_per_token_above_272k_tokens_priority": 6e-05, "output_cost_per_token_flex": 1e-05}, "inclusive": false, "checks": [[null, 1, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], [null, 272000, [4e-06, 2e-05, 5e-06, 5e-06, 4e-07]], [null, 272001, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], [null, 10000000, [8e-06, 3e-05, 1e-05, 1e-05, 8e-07]], ["priority", 1, [8e-06, 4e-05, 1e-05, 1e-05, 8e-07]], ["priority", 272000, [8e-06, 4e-05, 1e-05, 1e-05, 8e-07]], ["priority", 272001, [1.6e-05, 6e-05, 2e-05, 2e-05, 1.6e-06]], ["priority", 10000000, [1.6e-05, 6e-05, 2e-05, 2e-05, 1.6e-06]], ["flex", 1, [2e-06, 1e-05, 2.5e-06, 2.5e-06, 2e-07]], ["flex", 272000, [2e-06, 1e-05, 2.5e-06, 2.5e-06, 2e-07]], ["flex", 272001, [4e-06, 1.5e-05, 5e-06, 5e-06, 4e-07]], ["flex", 10000000, [4e-06, 1.5e-05, 5e-06, 5e-06, 4e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 3e-06, "cache_read_input_token_cost_above_272k_tokens": 6e-06, "input_cost_per_token": 3e-05, "input_cost_per_token_above_272k_tokens": 6e-05, "input_cost_per_token_above_272k_tokens_batches": 3e-05, "input_cost_per_token_above_272k_tokens_flex": 3e-05, "input_cost_per_token_batches": 1.5e-05, "input_cost_per_token_flex": 1.5e-05, "output_cost_per_token": 0.00018, "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_above_272k_tokens_batches": 0.000135, "output_cost_per_token_above_272k_tokens_flex": 0.000135, "output_cost_per_token_batches": 9e-05, "output_cost_per_token_flex": 9e-05}, "inclusive": false, "checks": [[null, 1, [3e-05, 0.00018, 3e-05, 3e-05, 3e-06]], [null, 272000, [3e-05, 0.00018, 3e-05, 3e-05, 3e-06]], [null, 272001, [6e-05, 0.00027, 6e-05, 6e-05, 6e-06]], [null, 10000000, [6e-05, 0.00027, 6e-05, 6e-05, 6e-06]], ["flex", 1, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 3e-06]], ["flex", 272000, [1.5e-05, 9e-05, 1.5e-05, 1.5e-05, 3e-06]], ["flex", 272001, [3e-05, 0.000135, 3e-05, 3e-05, 6e-06]], ["flex", 10000000, [3e-05, 0.000135, 3e-05, 3e-05, 6e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, "cache_read_input_token_cost_above_272k_tokens_batches": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07, "cache_read_input_token_cost_priority": 5e-07, "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, "cache_read_input_token_cost_batches": 1.3e-07, "input_cost_per_token": 2.5e-06, "input_cost_per_token_above_272k_tokens": 5e-06, "input_cost_per_token_priority": 5e-06, "input_cost_per_token_above_272k_tokens_priority": 1e-05, "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_272k_tokens": 2.25e-05, "output_cost_per_token_priority": 3e-05, "output_cost_per_token_above_272k_tokens_priority": 4.5e-05, "input_cost_per_token_above_272k_tokens_batches": 2.5e-06, "input_cost_per_token_above_272k_tokens_flex": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "input_cost_per_token_flex": 1.25e-06, "output_cost_per_token_above_272k_tokens_batches": 1.125e-05, "output_cost_per_token_above_272k_tokens_flex": 1.125e-05, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_flex": 7.5e-06}, "inclusive": false, "checks": [[null, 1, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-07]], [null, 272000, [2.5e-06, 1.5e-05, 2.5e-06, 2.5e-06, 2.5e-07]], [null, 272001, [5e-06, 2.25e-05, 5e-06, 5e-06, 5e-07]], [null, 10000000, [5e-06, 2.25e-05, 5e-06, 5e-06, 5e-07]], ["priority", 1, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], ["priority", 272000, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], ["priority", 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["priority", 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["flex", 1, [1.25e-06, 7.5e-06, 1.25e-06, 1.25e-06, 2.5e-07]], ["flex", 272000, [1.25e-06, 7.5e-06, 1.25e-06, 1.25e-06, 2.5e-07]], ["flex", 272001, [2.5e-06, 1.125e-05, 2.5e-06, 2.5e-06, 2.5e-07]], ["flex", 10000000, [2.5e-06, 1.125e-05, 2.5e-06, 2.5e-06, 2.5e-07]]]},
|
||||
{"info": {"cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_priority": 1e-06, "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_priority": 1e-05, "input_cost_per_token_above_272k_tokens_priority": 2e-05, "output_cost_per_token": 3e-05, "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_priority": 6e-05, "output_cost_per_token_above_272k_tokens_priority": 9e-05}, "inclusive": false, "checks": [[null, 1, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272000, [5e-06, 3e-05, 5e-06, 5e-06, 5e-07]], [null, 272001, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], [null, 10000000, [1e-05, 4.5e-05, 1e-05, 1e-05, 1e-06]], ["priority", 1, [1e-05, 6e-05, 1e-05, 1e-05, 1e-06]], ["priority", 272000, [1e-05, 6e-05, 1e-05, 1e-05, 1e-06]], ["priority", 272001, [2e-05, 9e-05, 2e-05, 2e-05, 2e-06]], ["priority", 10000000, [2e-05, 9e-05, 2e-05, 2e-05, 2e-06]]]},
|
||||
{"info": {"cache_read_input_token_cost": 3.125e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_batches": 6.25e-07, "input_cost_per_token_priority": 2.1875e-06, "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_priority": 1.75e-05}, "inclusive": false, "checks": [[null, 1, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 3.125e-07]], [null, 10000000, [1.25e-06, 1e-05, 1.25e-06, 1.25e-06, 3.125e-07]], ["priority", 1, [2.1875e-06, 1.75e-05, 2.1875e-06, 2.1875e-06, 3.125e-07]], ["priority", 10000000, [2.1875e-06, 1.75e-05, 2.1875e-06, 2.1875e-06, 3.125e-07]]]}
|
||||
]}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use litellm_model_catalog::{
|
||||
Catalog, FallbackGeneralizations, Provenance, validate_model_entry, validate_registry,
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[fixture]
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("model_prices_and_context_window.json")]
|
||||
#[case("litellm/model_prices_and_context_window_backup.json")]
|
||||
#[ignore]
|
||||
fn checked_in_registry_passes_strict_validation(repo_root: PathBuf, #[case] filename: &str) {
|
||||
let body = std::fs::read(repo_root.join(filename)).unwrap();
|
||||
let catalog = Catalog::parse(&body, Provenance::default()).unwrap();
|
||||
validate_registry(&catalog).unwrap();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn fallback_generalizations_are_typed(repo_root: PathBuf) {
|
||||
let body = std::fs::read(repo_root.join("model_prices_and_context_window.json")).unwrap();
|
||||
let document: Map<String, Value> = serde_json::from_slice(&body).unwrap();
|
||||
let Some(raw_rules) = document.get("fallback_generalizations") else {
|
||||
return;
|
||||
};
|
||||
let _: FallbackGeneralizations = serde_json::from_value(raw_rules.clone()).unwrap();
|
||||
let catalog = Catalog::parse(&body, Provenance::default()).unwrap();
|
||||
assert!(
|
||||
catalog
|
||||
.fallback_rules()
|
||||
.is_some_and(|rules| !rules.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_provider(serde_json::json!({"mode": "chat"}), "litellm_provider")]
|
||||
#[case::unknown_field(serde_json::json!({"litellm_provider": "test", "typo": true}), "unknown field")]
|
||||
#[case::negative_price(serde_json::json!({"litellm_provider": "test", "input_cost_per_token": -1}), "nonnegative")]
|
||||
#[case::negative_nested_price(serde_json::json!({"litellm_provider": "test", "guardrail_cost_per_unit": {"unit": -1}}), "nonnegative")]
|
||||
#[case::invalid_mode(serde_json::json!({"litellm_provider": "test", "mode": "invalid"}), "unknown variant")]
|
||||
#[case::invalid_date(serde_json::json!({"litellm_provider": "test", "deprecation_date": "2026-02-31"}), "deprecation_date")]
|
||||
#[case::invalid_hours(serde_json::json!({"litellm_provider": "test", "off_peak_pricing": {"hours_utc": "25:00-01:00"}}), "hours_utc")]
|
||||
#[case::empty_windows(serde_json::json!({"litellm_provider": "test", "off_peak_pricing": {"windows": []}}), "windows is empty")]
|
||||
#[case::invalid_weekday(serde_json::json!({"litellm_provider": "test", "off_peak_pricing": {"windows": [{"hours_utc": "00:00-01:00", "weekdays": [0]}]}}), "weekdays is invalid")]
|
||||
#[case::invalid_aliases(serde_json::json!({"litellm_provider": "test", "aliases": ["good", 7]}), "aliases must contain strings")]
|
||||
#[case::null_aliases(serde_json::json!({"litellm_provider": "test", "aliases": null}), "aliases must be an array")]
|
||||
#[ignore]
|
||||
fn registry_validation_rejects_malformed_entries(#[case] entry: Value, #[case] expected: &str) {
|
||||
let error = validate_model_entry("test", &entry).unwrap_err();
|
||||
assert!(
|
||||
litellm_error::Report::new(&error)
|
||||
.to_string()
|
||||
.contains(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn checked_in_catalog_and_backup_match() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..");
|
||||
let current = std::fs::read(root.join("model_prices_and_context_window.json")).unwrap();
|
||||
let backup =
|
||||
std::fs::read(root.join("litellm/model_prices_and_context_window_backup.json")).unwrap();
|
||||
assert_eq!(current, backup);
|
||||
let catalog = Catalog::parse(¤t, Provenance::default()).unwrap();
|
||||
assert!(
|
||||
catalog.alias_issues().is_empty(),
|
||||
"invalid registry aliases"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::input("input_cost_per_token_above_100k_tokens")]
|
||||
#[case::input_batches("input_cost_per_token_above_100k_tokens_batches")]
|
||||
#[case::output("output_cost_per_token_above_100k_tokens")]
|
||||
#[case::output_batches("output_cost_per_token_above_100k_tokens_batches")]
|
||||
#[case::cache_creation("cache_creation_input_token_cost_above_100k_tokens")]
|
||||
#[case::cache_creation_batches("cache_creation_input_token_cost_above_100k_tokens_batches")]
|
||||
#[case::cache_creation_1hr("cache_creation_input_token_cost_above_1hr_above_100k_tokens")]
|
||||
#[case::cache_read("cache_read_input_token_cost_above_100k_tokens")]
|
||||
#[case::cache_read_batches("cache_read_input_token_cost_above_100k_tokens_batches")]
|
||||
fn registry_validation_keeps_above_100k_tier_rates(#[case] field: &str) {
|
||||
let mut entry = Map::new();
|
||||
entry.insert("litellm_provider".into(), "anthropic".into());
|
||||
entry.insert(field.into(), 5e-7.into());
|
||||
validate_model_entry("test", &Value::Object(entry)).unwrap();
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
#![cfg(feature = "schema")]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use litellm_model_catalog::{model_entry_json_schema, registry_json_schema};
|
||||
use litellm_model_catalog::{BUNDLED_REGISTRY, model_entry_json_schema, registry_json_schema};
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn schema() -> Value {
|
||||
serde_json::to_value(model_entry_json_schema()).expect("generated schema serializes")
|
||||
}
|
||||
|
||||
fn checked_in_catalog() -> Value {
|
||||
serde_json::from_slice(BUNDLED_REGISTRY).unwrap()
|
||||
}
|
||||
|
||||
fn registry_validator() -> jsonschema::Validator {
|
||||
let schema = serde_json::to_value(registry_json_schema()).unwrap();
|
||||
jsonschema::options()
|
||||
|
|
@ -19,26 +20,21 @@ fn registry_validator() -> jsonschema::Validator {
|
|||
.expect("generated registry schema is valid")
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("model_prices_and_context_window.json")]
|
||||
#[case("litellm/model_prices_and_context_window_backup.json")]
|
||||
#[ignore]
|
||||
fn generated_registry_schema_validates_checked_in_catalog(#[case] path: &str) {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..");
|
||||
let catalog: Value = serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
|
||||
#[test]
|
||||
fn generated_registry_schema_validates_checked_in_catalog() {
|
||||
let catalog = checked_in_catalog();
|
||||
let validator = registry_validator();
|
||||
let errors: Vec<_> = validator
|
||||
.iter_errors(&catalog)
|
||||
.map(|error| error.to_string())
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{path}: {errors:?}");
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(json!({"example": {"litellm_provider": "test"}}))]
|
||||
#[case(json!({"example": {"litellm_provider": "test", "future_field": true}}))]
|
||||
#[case(json!({"sample_spec": {"litellm_provider": "placeholder"}}))]
|
||||
#[ignore]
|
||||
fn generated_registry_schema_keeps_reader_compatibility(#[case] document: Value) {
|
||||
assert!(registry_validator().is_valid(&document));
|
||||
}
|
||||
|
|
@ -59,47 +55,55 @@ fn generated_registry_schema_keeps_reader_compatibility(#[case] document: Value)
|
|||
#[case::invalid_weekday(json!({"litellm_provider": "test", "off_peak_pricing": {"windows": [{"hours_utc": "00:00-01:00", "weekdays": [0]}]}}))]
|
||||
#[case::invalid_aliases(json!({"litellm_provider": "test", "aliases": "wrong"}))]
|
||||
#[case::non_object_model(json!(4))]
|
||||
#[ignore]
|
||||
fn generated_registry_schema_rejects_invalid_entries(#[case] entry: Value) {
|
||||
assert!(!registry_validator().is_valid(&json!({"example": entry})));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("model_prices_and_context_window.json")]
|
||||
#[case("litellm/model_prices_and_context_window_backup.json")]
|
||||
#[ignore]
|
||||
fn generated_schema_covers_catalog_fields(#[case] path: &str) {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..");
|
||||
let catalog: Value = serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
|
||||
#[test]
|
||||
fn generated_schema_covers_catalog_fields() {
|
||||
let catalog = checked_in_catalog();
|
||||
let schema = schema();
|
||||
let properties = schema["properties"]
|
||||
let any_value = |keys: &Value| -> Map<String, Value> {
|
||||
keys.as_object()
|
||||
.expect("schema keys are an object")
|
||||
.keys()
|
||||
.map(|key| (key.clone(), json!({})))
|
||||
.collect()
|
||||
};
|
||||
let keys_only = json!({
|
||||
"type": "object",
|
||||
"properties": any_value(&schema["properties"]),
|
||||
"patternProperties": any_value(&schema["patternProperties"]),
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let registry = serde_json::to_value(registry_json_schema()).unwrap();
|
||||
let sections = registry["properties"]
|
||||
.as_object()
|
||||
.expect("ModelInfo schema has properties");
|
||||
let fields: BTreeSet<&str> = catalog
|
||||
.expect("registry has sections");
|
||||
let fields: Map<String, Value> = catalog
|
||||
.as_object()
|
||||
.expect("catalog is an object")
|
||||
.iter()
|
||||
.filter(|(name, _)| *name != "sample_spec" && *name != "fallback_generalizations")
|
||||
.filter(|(name, _)| !sections.contains_key(*name))
|
||||
.flat_map(|(_, entry)| entry.as_object().expect("model entry is an object").keys())
|
||||
.map(String::as_str)
|
||||
.filter(|name| *name != "aliases")
|
||||
.map(|name| (name.clone(), Value::Null))
|
||||
.collect();
|
||||
let missing: Vec<_> = fields
|
||||
.into_iter()
|
||||
.filter(|name| !properties.contains_key(*name))
|
||||
let missing: Vec<_> = jsonschema::validator_for(&keys_only)
|
||||
.unwrap()
|
||||
.iter_errors(&Value::Object(fields))
|
||||
.map(|error| error.to_string())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{path}: fields missing from schema: {missing:?}"
|
||||
"fields missing from schema: {missing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("Mode", "chat")]
|
||||
#[case("ReasoningEffort", "high")]
|
||||
#[case("EffortLevel", "high")]
|
||||
#[case("InputModality", "image")]
|
||||
#[ignore]
|
||||
fn generated_schema_includes_enum_values(#[case] definition: &str, #[case] value: &str) {
|
||||
let schema = schema();
|
||||
let variants = schema["$defs"][definition]["enum"]
|
||||
|
|
@ -110,7 +114,6 @@ fn generated_schema_includes_enum_values(#[case] definition: &str, #[case] value
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn generated_schema_includes_nested_pricing_types() {
|
||||
let schema = schema();
|
||||
let definitions = schema["$defs"].as_object().expect("schema has definitions");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -591,7 +591,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 +619,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 +637,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 +684,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 +836,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 +884,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 +980,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 +1116,7 @@ model_list = list(
|
|||
| text_completion_inception_models
|
||||
| xai_models
|
||||
| zai_models
|
||||
| bigmodel_models
|
||||
| fal_ai_models
|
||||
| deepseek_models
|
||||
| modelscope_models
|
||||
|
|
@ -1145,6 +1158,7 @@ model_list = list(
|
|||
| publicai_models
|
||||
| darkbloom_models
|
||||
| openinfer_models
|
||||
| streamlake_models
|
||||
| zerogpu_models
|
||||
| v0_models
|
||||
| morph_models
|
||||
|
|
@ -1200,7 +1214,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 +1240,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 +1289,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,
|
||||
|
|
|
|||
|
|
@ -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,6 +24,15 @@ 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 (
|
||||
|
|
@ -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
|
||||
|
|
@ -667,15 +731,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 +860,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 +883,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 +915,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 +983,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 +1024,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 +1151,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 +1236,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 +1247,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 +1271,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 +1280,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 +1309,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 +1322,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 +1382,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 +1526,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 +1594,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]:
|
||||
|
|
@ -1890,8 +1983,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 +2032,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 +2041,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 +2253,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 +2270,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 +2326,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):
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue