chore: merge main into litellm_gemini_tool_call_id_length

This commit is contained in:
mateo-berri 2026-10-28 21:47:30 -07:00
commit 9be6f4cfc7
597 changed files with 38194 additions and 6636 deletions

View file

@ -1,6 +1,8 @@
---
name: database-access
description: Add database queries, extract existing Prisma calls into repositories, or explicitly port queries to Rust and SQLx without expanding migration scope
metadata:
internal: true
---
# Database access

1
.github/CODEOWNERS vendored
View file

@ -2,3 +2,4 @@
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
/helm/ @yassin-berriai
/terraform/ @yassin-berriai
/.github/workflows/zizmor.yml @yuneng-berri

View file

@ -1,7 +1,7 @@
name: Cost map guard
on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's cost map files are read as data and never executed
pull_request_target:
on:
pull_request:
branches:
- main
- "litellm_**"
@ -19,6 +19,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Fetch the pull request head and its merge base
id: revisions

View file

@ -19,7 +19,7 @@ jobs:
name: interactions-spec-canary
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 30
permissions:
contents: read
outputs:
@ -39,8 +39,11 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
uses: ./.github/actions/rust-bridge
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy --no-install-project
run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy
- name: Run the compliance tests against Google's live spec
id: compliance

View file

@ -1,13 +1,9 @@
name: Spend logs guard
on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's diff is read as data and never executed
pull_request_target:
on:
pull_request:
branches:
- main
pull_request_review:
types:
- submitted
- dismissed
permissions:
contents: read
@ -23,7 +19,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ github.event.pull_request.base.ref }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Fetch the pull request head, its merge base and its reviews
id: revisions

View file

@ -561,6 +561,9 @@ jobs:
- name: router_code_coverage
run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py
- name: router_surface_check
run: uv run --no-sync python ./tests/code_coverage_tests/router_surface_check.py
- name: test_chat_completion_imports
run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py

View file

@ -132,7 +132,7 @@ jobs:
windows-sdk:
name: windows-sdk
runs-on: windows-2025
timeout-minutes: 10
timeout-minutes: 30
env:
LITELLM_LOCAL_MODEL_COST_MAP: "True"
defaults:
@ -154,8 +154,11 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
uses: ./.github/actions/rust-bridge
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --group dev --python 3.11
run: .github/scripts/uv_sync_with_retries.sh --frozen --group dev --python 3.11
- name: Run the Windows tests
run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests

View file

@ -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/**"

View file

@ -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
@ -137,6 +137,7 @@ jobs:
include:
- shard: core-utils
test-path: |-
tests/unit/accounting
tests/unit/batch_completion
tests/unit/decisions
tests/unit/extraction
@ -244,6 +245,7 @@ jobs:
tests/unit/llms/clarifai
tests/unit/llms/claude_code
tests/unit/llms/cloudflare
tests/unit/llms/codestral
tests/unit/llms/codex
tests/unit/llms/cohere
tests/unit/llms/cometapi
@ -1306,6 +1308,8 @@ jobs:
done
openai-sdk-compat:
name: OpenAI ${{ matrix.openai-version }} compatibility
needs: rust-bridge
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: read
@ -1342,10 +1346,18 @@ jobs:
with:
version: "0.10.9"
- name: Set up the Rust bridge
if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 5
uses: ./.github/actions/rust-bridge
with:
artifact: ${{ needs.rust-bridge.outputs.artifact }}
- name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group dev --group proxy-dev --extra proxy --no-install-workspace
cp rust-bridge-dist/litellm/rust_bridge/_native.* litellm/rust_bridge/
uv pip install "openai==${OPENAI_VERSION:?}" --exclude-newer-package openai=2026-10-07T00:00:00Z
uv run --no-sync python -c 'import os, openai; assert openai.__version__ == os.environ["OPENAI_VERSION"], openai.__version__'

View file

@ -27,10 +27,15 @@ jobs:
with:
persist-credentials: false
- name: Install zizmor
run: |
curl -fsSL --retry 3 -o "$RUNNER_TEMP/zizmor.tar.gz" \
https://github.com/zizmorcore/zizmor/releases/download/v1.31.1/zizmor-x86_64-unknown-linux-gnu.tar.gz
echo "d185f726bdaf92df78273217dfdd24ebcd6b0ef48cf3ce776ff5762b6e138c99 $RUNNER_TEMP/zizmor.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/zizmor.tar.gz" -C "$RUNNER_TEMP" zizmor
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
version: "1.24.1"
min-severity: medium
advanced-security: false
annotations: true
env:
GH_TOKEN: ${{ github.token }}
run: |
"$RUNNER_TEMP/zizmor" --no-ignores --no-config --min-severity=medium --format=github .

View file

@ -427,6 +427,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Watsonx Text (`watsonx_text`)](https://docs.litellm.ai/docs/providers/watsonx) | ✅ | ✅ | ✅ | | | | | | | |
| [xAI (`xai`)](https://docs.litellm.ai/docs/providers/xai) | ✅ | ✅ | ✅ | | | | | | | |
| [Xinference (`xinference`)](https://docs.litellm.ai/docs/providers/xinference) | | | | ✅ | | | | | | |
| [ZeroGPU (`zerogpu`)](https://docs.litellm.ai/docs/providers/zerogpu) | ✅ | ✅ | ✅ | | | | | | | |
[**Read the Docs**](https://docs.litellm.ai/docs/)

View file

@ -2,9 +2,12 @@
needs an approving review from the spend-log owner before it can merge.
CODEOWNERS can only match file paths, and every schema change edits the same schema.prisma copies,
so a path rule would put every migration on the owner. This gate reads the diff instead: when no
added line in the watched files mentions the table it passes at once, otherwise it passes only once
the owner's latest review on the pull request is an approval. The workflow re-runs on every review.
so a path rule would put every migration on the owner. This gate reads the diff instead: a schema.prisma
hunk counts when it adds or removes a line inside the `model LiteLLM_SpendLogs { ... }` block, a migration
hunk counts when an added line names the table. Without such a hunk it passes at once, otherwise it passes
only once the owner's latest review on the pull request is an approval that still covers every spend-log line
in the head (approvals survive pushes, so a later commit may not add spend-log lines the owner never saw).
Reviews do not trigger the workflow, so the check is re-run by hand after approving.
"""
from __future__ import annotations
@ -13,8 +16,10 @@ import argparse
import re
import subprocess
import sys
from collections.abc import Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final
from pydantic import BaseModel, ConfigDict, TypeAdapter
@ -25,7 +30,10 @@ WATCHED: Final = (
"litellm-proxy-extras/litellm_proxy_extras/schema.prisma",
)
MIGRATIONS_PREFIX: Final = "litellm-proxy-extras/litellm_proxy_extras/migrations/"
TABLE: Final = re.compile(r"(?<![A-Za-z0-9])LiteLLM_SpendLogs", re.IGNORECASE)
TABLE: Final = re.compile(r"(?<![A-Za-z0-9])LiteLLM_SpendLogs(?![A-Za-z0-9])", re.IGNORECASE)
DECIDING_STATES: Final = frozenset({"APPROVED", "CHANGES_REQUESTED", "DISMISSED"})
MODEL_START: Final = re.compile(r"^model\s+LiteLLM_SpendLogs\s*\{")
HUNK: Final = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@[^\n]*\n", re.MULTILINE)
class Reviewer(BaseModel):
@ -37,31 +45,77 @@ class Review(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
state: str
user: Reviewer
commit_id: str | None = None
_REVIEWS: Final = TypeAdapter(list[Review])
@dataclass(frozen=True, slots=True)
class Block:
"""Lines of `model LiteLLM_SpendLogs { ... }` in one schema.prisma copy at the merge base and at the head."""
base: range
head: range
@dataclass(frozen=True, slots=True)
class Hunk:
removed: tuple[tuple[int, str], ...]
added: tuple[tuple[int, str], ...]
def watched(path: str) -> bool:
return path in WATCHED or (path.startswith(MIGRATIONS_PREFIX) and path.endswith("/migration.sql"))
def _added_lines(section: str) -> tuple[str, ...]:
body: Final = section.split("\n@@", 1)
return tuple(line[1:] for line in body[1].splitlines() if line.startswith("+")) if len(body) == 2 else ()
def model_block(schema: str) -> range:
"""1-based line range of `model LiteLLM_SpendLogs { ... }` including both braces, empty when the schema has none."""
lines: Final = schema.splitlines()
starts: Final = tuple(number for number, line in enumerate(lines, 1) if MODEL_START.match(line))
if not starts:
return range(0)
ends: Final = tuple(number for number, line in enumerate(lines, 1) if number > starts[0] and line.startswith("}"))
return range(starts[0], (ends[0] if ends else len(lines)) + 1)
def spend_log_hits(diff: str) -> tuple[str, ...]:
"""`path: added line` for every added line in a watched file that names the table."""
def _hunks(section: str) -> tuple[Hunk, ...]:
headers: Final = tuple(HUNK.finditer(section))
ends: Final = tuple(header.start() for header in headers[1:]) + (len(section),)
return tuple(_hunk(header, section[header.end() : end]) for header, end in zip(headers, ends))
def _hunk(header: re.Match[str], body: str) -> Hunk:
lines: Final = body.splitlines()
return Hunk(
removed=tuple(enumerate((line[1:] for line in lines if line.startswith("-")), int(header.group(1)))),
added=tuple(enumerate((line[1:] for line in lines if line.startswith("+")), int(header.group(2)))),
)
def spend_log_hits(diff: str, blocks: Mapping[str, Block]) -> tuple[str, ...]:
"""`path: +line` or `path: -line` for every changed line that touches the table.
A schema.prisma line counts when its number falls inside that copy's `blocks` entry (head for added lines,
merge base for removed ones), a migration line when it is added and names the table."""
sections: Final = re.split(r"^diff --git [^\n]*\n", diff, flags=re.MULTILINE)
return tuple(f"{path}: {line.strip()}" for path, line in _watched_added_lines(sections) if TABLE.search(line))
def _watched_added_lines(sections: Sequence[str]) -> tuple[tuple[str, str], ...]:
pairs: Final = tuple((_path_of(section), section) for section in sections)
paths: Final = tuple((_path_of(section), section) for section in sections)
return tuple(
(path, line) for path, section in pairs if watched(path) for line in _added_lines(section)
) # comprehension-ok: path then its lines
hit for path, section in paths if watched(path) for hit in _section_hits(path, _hunks(section), blocks)
) # comprehension-ok: path then its hits
def _section_hits(path: str, hunks: Sequence[Hunk], blocks: Mapping[str, Block]) -> tuple[str, ...]:
if path not in blocks:
return tuple(f"{path}: +{line.strip()}" for hunk in hunks for _, line in hunk.added if TABLE.search(line))
block: Final = blocks[path]
removed: Final = tuple(
f"{path}: -{line.strip()}" for hunk in hunks for number, line in hunk.removed if number in block.base
) # comprehension-ok: hunk then its lines
added: Final = tuple(
f"{path}: +{line.strip()}" for hunk in hunks for number, line in hunk.added if number in block.head
) # comprehension-ok: hunk then its lines
return removed + added
def _path_of(section: str) -> str:
@ -70,11 +124,33 @@ def _path_of(section: str) -> str:
def approved_by(reviews: Sequence[Review], login: str) -> bool:
"""True when `login`'s most recent review on the pull request is an approval."""
states: Final = tuple(review.state for review in reviews if review.user.login == login)
"""True when `login`'s most recent review that changes GitHub's approval state is an approval.
COMMENTED and PENDING reviews leave an earlier approval in force on GitHub, so they are skipped here too.
"""
own: Final = tuple(review.state for review in reviews if review.user.login == login)
states: Final = tuple(state for state in own if state in DECIDING_STATES)
return bool(states) and states[-1] == "APPROVED"
def latest_approval(reviews: Sequence[Review], login: str) -> Review | None:
"""`login`'s most recent APPROVED review on the pull request, the one GitHub keeps in force after pushes."""
own: Final = tuple(review for review in reviews if review.user.login == login and review.state == "APPROVED")
return own[-1] if own else None
def approval_covers(
approval: Review, head: str, hits: Sequence[str], hits_at: Callable[[str], Sequence[str] | None]
) -> bool:
"""True when the approval is on the head, or the head adds no spend-log line the approved commit did not."""
if approval.commit_id is None:
return False
if approval.commit_id == head:
return True
reviewed: Final = hits_at(approval.commit_id)
return reviewed is not None and set(hits) <= set(reviewed)
def added_diff(base: str, head: str) -> str:
result: Final = subprocess.run(
("git", "diff", "--unified=0", "--no-color", f"{base}..{head}", "--", *WATCHED, MIGRATIONS_PREFIX),
@ -85,6 +161,27 @@ def added_diff(base: str, head: str) -> str:
return result.stdout
def file_at(revision: str, path: str) -> str:
"""Contents of `path` at `revision`, empty when the file does not exist there."""
result: Final = subprocess.run(("git", "show", f"{revision}:{path}"), check=False, capture_output=True, text=True)
return result.stdout if result.returncode == 0 else ""
def schema_blocks(base: str, head: str) -> Mapping[str, Block]:
return MappingProxyType(
{path: Block(base=model_block(file_at(base, path)), head=model_block(file_at(head, path))) for path in WATCHED}
)
def fetched(commit: str) -> bool:
result: Final = subprocess.run(("git", "fetch", "--no-tags", "--depth=1", "origin", commit), check=False)
return result.returncode == 0
def reviewed_hits(base: str, commit: str) -> tuple[str, ...] | None:
return spend_log_hits(added_diff(base, commit), schema_blocks(base, commit)) if fetched(commit) else None
class Arguments(BaseModel):
model_config = ConfigDict(frozen=True)
base: str
@ -100,18 +197,28 @@ def main(argv: Sequence[str]) -> int:
parser.add_argument("--approver", required=True, help="GitHub login whose approval unlocks the merge")
parser.add_argument("--reviews", required=True, help="JSON file with the pull request's reviews")
args: Final = Arguments.model_validate(vars(parser.parse_args(argv)))
hits: Final = spend_log_hits(added_diff(args.base, args.head))
hits: Final = spend_log_hits(added_diff(args.base, args.head), schema_blocks(args.base, args.head))
if not hits:
sys.stdout.write("No added line in schema.prisma or a migration names LiteLLM_SpendLogs; nothing to review.\n")
sys.stdout.write(
"No change inside model LiteLLM_SpendLogs in schema.prisma and no added migration line names it; "
"nothing to review.\n"
)
return 0
if approved_by(_REVIEWS.validate_json(args.reviews.read_bytes()), args.approver):
reviews: Final = _REVIEWS.validate_json(args.reviews.read_bytes())
approval: Final = latest_approval(reviews, args.approver) if approved_by(reviews, args.approver) else None
if approval is not None and approval_covers(
approval, args.head, hits, lambda commit: reviewed_hits(args.base, commit)
):
sys.stdout.write(f"LiteLLM_SpendLogs change approved by {args.approver}: {', '.join(hits)}\n")
return 0
listed: Final = "".join(f" {hit}\n" for hit in hits)
sys.stdout.write(
reason: Final = (
f"This pull request changes LiteLLM_SpendLogs and needs an approving review from @{args.approver}:\n"
f"{listed}The check re-runs when a review is submitted.\n"
if approval is None
else f"LiteLLM_SpendLogs lines changed after @{args.approver} approved {approval.commit_id}, "
f"a new approving review is needed:\n"
)
sys.stdout.write(f"{reason}{listed}After approving, re-run this job.\n")
return 1

View file

@ -32,6 +32,7 @@ from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.llms.base_llm.guardrail_translation.utils import effective_skip_tool_message_for_guardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails._content_utils import iter_tool_json_fields, map_tool_json_text, walk_user_text
from litellm.types.guardrails import GuardrailEventHooks
@ -753,7 +754,12 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
_masked_entity_count.set({})
# Covers multimodal list content + Responses-API input.
inspected = walk_user_text(data, self.redact_text, self.redact_tool_payload) + self._redact_prompt(data)
inspected = walk_user_text(
data,
self.redact_text,
self.redact_tool_payload,
skip_tool_message=effective_skip_tool_message_for_guardrail(self),
) + self._redact_prompt(data)
if inspected == 0:
# Image-only, empty-text, and unsupported payloads inspected

View file

@ -5,9 +5,9 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t
import asyncio
import json
import re
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import replace as dataclasses_replace
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Protocol, Tuple, cast
@ -28,6 +28,7 @@ from litellm.constants import (
from litellm.repositories.table_repositories import ManagedObjectRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.utils import get_utc_datetime
if TYPE_CHECKING:
from prisma import models as prisma_models
@ -42,11 +43,14 @@ if TYPE_CHECKING:
def _error_names_resource(error: Exception, resource_id: str) -> bool:
return resource_id in re.findall(r"""[^\s"'`]+""", str(error))
tokens: Final[list[str]] = re.findall(r"""[^\s"'`<>(]+""", str(error))
return any(token.rstrip(".,:;)") == resource_id for token in tokens)
CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost"
RECENT_BATCH_POLL_WINDOW: Final = timedelta(hours=48)
GIVE_UP_POLLING_AFTER: Final = timedelta(days=2 * MANAGED_OBJECT_STALENESS_CUTOFF_DAYS)
PROVIDER_TERMINAL_BATCH_STATUSES: Final[Tuple[str, ...]] = (
"completed",
@ -91,6 +95,10 @@ def _team_table(prisma_client: "PrismaClient") -> "TableActions[prisma_models.Li
return TeamRepository(prisma_client).table
def _unique_jobs(jobs: Sequence[ManagedObjectRow]) -> tuple[ManagedObjectRow, ...]:
return tuple({job.id: job for job in jobs}.values())
class CheckBatchCost:
def __init__(
self,
@ -98,6 +106,7 @@ class CheckBatchCost:
prisma_client: "PrismaClient",
llm_router: "Router",
track_unmanaged_batch_cost: bool = False,
clock: Callable[[], datetime] = get_utc_datetime,
):
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
@ -106,13 +115,14 @@ class CheckBatchCost:
self.prisma_client: PrismaClient = prisma_client
self.llm_router: Router = llm_router
self._track_unmanaged_batch_cost = track_unmanaged_batch_cost
self._clock: Final = clock
self._creator_attribution: Final = ManagedObjectCreatorAttribution(prisma_client, "CheckBatchCost")
# Cached after the first poll cycle. Once we know the column is absent we skip
# the guaranteed-failing primary query on every subsequent cycle.
self._has_batch_processed_column: bool = True
self.batch_processed_support_confirmed: bool = False
self._poll_cursor: tuple[datetime, str] | None = None
self._poll_before: datetime = datetime.now(timezone.utc)
self._poll_before: datetime = clock()
self._arrival_cursor: tuple[datetime, str] | None = None
self._arrival_before: datetime = self._poll_before
self._poll_arrivals: bool = False
@ -147,27 +157,6 @@ class CheckBatchCost:
return
self.batch_processed_support_confirmed = True
async def _cleanup_stale_managed_objects(self) -> None:
"""
Mark managed objects older than MANAGED_OBJECT_STALENESS_CUTOFF_DAYS days
in non-terminal states as 'stale_expired'. These will never complete and
should not be polled.
"""
cutoff: Final = datetime.now(timezone.utc) - timedelta(days=MANAGED_OBJECT_STALENESS_CUTOFF_DAYS)
result: Final = await _managed_object_table(self.prisma_client).update_many(
where={
"file_purpose": "batch",
"status": {"not_in": list(TERMINAL_MANAGED_OBJECT_STATUSES)},
"created_at": {"lt": cutoff},
},
data={"status": "stale_expired"},
)
if result > 0:
verbose_proxy_logger.warning(
f"CheckBatchCost: marked {result} stale managed objects "
f"(older than {MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days) as stale_expired"
)
async def _fallback_find_jobs(self) -> "Sequence[ManagedObjectRow]":
"""Query batch jobs without the batch_processed filter (for older schemas)."""
return await self._find_jobs(
@ -187,20 +176,43 @@ class CheckBatchCost:
)
async def _find_jobs(self, where: Mapping[str, object]) -> Sequence[ManagedObjectRow]:
arrivals: Final = self._poll_arrivals
page: Final = await self._find_job_page(where, arrivals=arrivals)
self._poll_arrivals = not arrivals if page else arrivals
if page:
return page
return await self._find_job_page(where, arrivals=not arrivals)
lead_arrivals: Final = self._poll_arrivals
self._poll_arrivals = not lead_arrivals
lead_share: Final = MAX_OBJECTS_PER_POLL_CYCLE - MAX_OBJECTS_PER_POLL_CYCLE // 2
lead: Final = await self._find_job_page(where, arrivals=lead_arrivals, take=lead_share)
trail: Final = await self._find_job_page(
where, arrivals=not lead_arrivals, take=MAX_OBJECTS_PER_POLL_CYCLE - len(lead)
)
spill: Final = (
await self._find_job_page(
where, arrivals=lead_arrivals, take=MAX_OBJECTS_PER_POLL_CYCLE - len(lead) - len(trail)
)
if len(lead) == lead_share
else ()
)
return _unique_jobs((*lead, *trail, *spill))
async def _find_job_page(self, where: Mapping[str, object], *, arrivals: bool) -> Sequence[ManagedObjectRow]:
async def _find_job_page(
self, where: Mapping[str, object], *, arrivals: bool, take: int
) -> Sequence[ManagedObjectRow]:
if take == 0:
return ()
resumed_mid_lane: Final = (self._arrival_cursor if arrivals else self._poll_cursor) is not None
page: Final = await self._find_job_page_from_cursor(where, arrivals=arrivals, take=take)
if len(page) == take or not resumed_mid_lane:
return page
wrapped: Final = await self._find_job_page_from_cursor(where, arrivals=arrivals, take=take - len(page))
return _unique_jobs((*page, *wrapped))
async def _find_job_page_from_cursor(
self, where: Mapping[str, object], *, arrivals: bool, take: int
) -> Sequence[ManagedObjectRow]:
cursor: Final = self._arrival_cursor if arrivals else self._poll_cursor
if cursor is None:
if arrivals:
self._arrival_before = datetime.now(timezone.utc)
self._arrival_before = self._clock()
else:
self._poll_before = datetime.now(timezone.utc) - RECENT_BATCH_POLL_WINDOW
self._poll_before = self._clock() - RECENT_BATCH_POLL_WINDOW
before: Final = self._arrival_before if arrivals else self._poll_before
page: Final = await _managed_object_table(self.prisma_client).find_many(
where={
@ -220,10 +232,10 @@ class CheckBatchCost:
else {}
),
},
take=MAX_OBJECTS_PER_POLL_CYCLE,
take=take,
order=[{"created_at": "asc"}, {"id": "asc"}],
)
next_cursor: Final = (page[-1].created_at, page[-1].id) if len(page) == MAX_OBJECTS_PER_POLL_CYCLE else None
next_cursor: Final = (page[-1].created_at, page[-1].id) if len(page) == take else None
if arrivals:
self._arrival_cursor = next_cursor
else:
@ -231,11 +243,47 @@ class CheckBatchCost:
return page
def _unprocessed_snapshot(self, job: ManagedObjectRow) -> Mapping[str, object]:
return {
"id": job.id,
"updated_at": job.updated_at,
**({"batch_processed": False} if self._has_batch_processed_column else {}),
}
if self._has_batch_processed_column:
return {"id": job.id, "batch_processed": False}
return {"id": job.id, "updated_at": job.updated_at}
def _is_past_give_up_age(self, job: ManagedObjectRow) -> bool:
return job.created_at < self._clock() - GIVE_UP_POLLING_AFTER
async def _expire_if_old_and_unfinished(self, job: ManagedObjectRow) -> None:
if job.status in TERMINAL_MANAGED_OBJECT_STATUSES or not self._is_past_give_up_age(job):
return
await self._expire_unfinished(job)
@staticmethod
def _is_completed_with_output_id_pending(response: "LiteLLMBatch") -> bool:
from litellm.proxy.openai_files_endpoints.common_utils import (
_completed_batch_safe_to_retire,
)
return (
response.status in ("completed", "complete")
and response.output_file_id is None
and not _completed_batch_safe_to_retire(response)
)
async def _expire_unfinished(self, job: ManagedObjectRow) -> None:
try:
expired: Final = await _managed_object_table(self.prisma_client).update_many(
where={
**self._unprocessed_snapshot(job),
"status": {"not_in": list(TERMINAL_MANAGED_OBJECT_STATUSES)},
},
data={"status": "stale_expired"},
)
except Exception as db_err:
verbose_proxy_logger.error(f"CheckBatchCost: failed to mark stale job {job.id} as stale_expired: {db_err}")
return
if expired > 0:
verbose_proxy_logger.warning(
f"CheckBatchCost: job {job.id} is older than {GIVE_UP_POLLING_AFTER.days} days "
"and still not finished, so it is marked stale_expired and no longer polled"
)
async def _retire_job(self, job: "ManagedObjectRow", reason: str) -> None:
"""
@ -356,15 +404,21 @@ class CheckBatchCost:
def _is_output_file_gone_at_provider(error: Exception, output_file_id: Optional[str]) -> bool:
"""A 404 naming the output file means there is nothing to fetch on this or any
later poll: providers like Vertex AI advertise an output path for every batch,
including terminal ones that never wrote it. Any other failure may be
transient, so it keeps retrying until the staleness sweep bounds it."""
including terminal ones that never wrote it. GCS names a gs:// object without its scheme,
and S3 names an s3:// object by its key alone. Any other failure keeps retrying."""
import openai
from litellm.exceptions import NotFoundError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
if not output_file_id:
return False
return isinstance(error, (NotFoundError, openai.NotFoundError)) and _error_names_resource(error, output_file_id)
is_not_found: Final = isinstance(error, (NotFoundError, openai.NotFoundError)) or (
isinstance(error, BaseLLMException) and error.status_code == 404
)
object_path: Final = output_file_id.split("://", 1)[-1]
names: Final = (output_file_id, object_path, object_path.split("/", 1)[-1])
return is_not_found and any(_error_names_resource(error, name) for name in names)
async def _finalize_unbilled_terminal_job(
self, job: "ManagedObjectRow", response: "LiteLLMBatch"
@ -1094,13 +1148,6 @@ class CheckBatchCost:
processed_models: List[Tuple[Optional[str], Optional[str]]] = []
try:
await self._cleanup_stale_managed_objects()
except Exception as cleanup_err:
verbose_proxy_logger.warning(
f"CheckBatchCost: stale cleanup failed (poll will continue): {cleanup_err}"
)
# Look for all batches that have not yet been processed by CheckBatchCost.
# self._has_batch_processed_column is cached after the first probe so that
# older schemas don't pay a guaranteed-failing primary query + warning on
@ -1142,6 +1189,8 @@ class CheckBatchCost:
if routing is None:
if self._has_unified_id_without_model(job):
await self._retire_job(job, "unified object id has no model id")
else:
await self._expire_if_old_and_unfinished(job)
continue
model_id, batch_id = routing
@ -1166,6 +1215,14 @@ class CheckBatchCost:
prom_logger.record_check_batch_cost_error("provider_retrieval_error")
if self._is_batch_gone_at_provider(e, batch_id) and self._batch_deployment_exists(model_id):
await self._retire_job(job, f"batch {batch_id} no longer exists at the provider")
else:
await self._expire_if_old_and_unfinished(job)
continue
if self._is_completed_with_output_id_pending(response):
verbose_proxy_logger.info(
f"CheckBatchCost: batch {batch_id} is completed but its output file id "
f"has not appeared yet; leaving job {job.id} for the next poll cycle"
)
continue
## RETRIEVE THE BATCH JOB OUTPUT FILE
@ -1217,17 +1274,9 @@ class CheckBatchCost:
self._store_batch_line_items_in_background(job, response, model_id, batch_id, billed=True)
elif response.status in PROVIDER_TERMINAL_BATCH_STATUSES:
from litellm.proxy.openai_files_endpoints.common_utils import (
_completed_batch_safe_to_retire,
)
if response.status in ("completed", "complete") and not _completed_batch_safe_to_retire(response):
verbose_proxy_logger.info(
f"CheckBatchCost: batch {batch_id} is completed but its output file id "
f"has not appeared yet; leaving job {job.id} for the next poll cycle"
)
continue
await self._retire_unbilled_terminal_job(job, response, model_id, batch_id)
else:
await self._expire_if_old_and_unfinished(job)
# Record polling run metrics (always, even if nothing was processed)
if prom_logger:

View file

@ -6,6 +6,7 @@ import base64
import json
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from datetime import datetime, timedelta
from itertools import chain
from types import MappingProxyType
from typing import (
@ -27,7 +28,7 @@ import httpx
from fastapi import HTTPException
from openai import APIConnectionError, AsyncOpenAI
from openai.types.file_deleted import FileDeleted
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, Unpack
import litellm
@ -39,6 +40,7 @@ from litellm.constants import (
BATCH_OUTPUT_FILE_FALLBACK_RETRY_AFTER_SECONDS,
BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY,
MANAGED_OBJECT_STALENESS_CUTOFF_DAYS,
MAX_FILE_LIST_LIMIT,
PENDING_RESPONSE_STATUSES,
)
@ -51,7 +53,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_metadata,
)
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints, FileListModelScope
from litellm.llms.base_llm.managed_resources.isolation import (
build_list_page,
build_owner_filter,
@ -113,6 +115,7 @@ from litellm.types.utils import (
LLMResponseTypes,
SpecialEnums,
)
from litellm.utils import get_utc_datetime
class _ManagedFileRetrieve(Protocol):
@ -306,6 +309,7 @@ def _is_xai_batch_output(
class _ManagedFileRow(Protocol):
unified_file_id: str
file_object: OpenAIFileObject
model_mappings: Mapping[str, str]
flat_model_file_ids: Sequence[str]
storage_backend: Optional[str]
storage_url: Optional[str]
@ -359,6 +363,8 @@ class _ManagedResourceDatabase(Protocol):
@property
def litellm_managedfiletable(self) -> _ManagedFileTableActions: ...
async def query_raw(self, query: str, *args: object) -> object: ...
@property
def litellm_managedobjecttable(self) -> _ManagedObjectTableActions: ...
@ -382,10 +388,83 @@ class _RouterFileCallKwargs(TypedDict, total=False):
custom_llm_provider: ReadOnly[str | None]
def _managed_database(prisma_client: _ManagedResourcePrismaClient) -> _ManagedResourceDatabase:
return prisma_client.db
def _managed_file_table(prisma_client: _ManagedResourcePrismaClient) -> _ManagedFileTableActions:
return prisma_client.db.litellm_managedfiletable
class _ListedFileRow(Protocol):
@property
def unified_file_id(self) -> str: ...
@property
def file_object(self) -> object: ...
@property
def model_mappings(self) -> Mapping[str, object]: ...
@property
def storage_backend(self) -> str | None: ...
class _ScopedFileRow(BaseModel):
model_config = ConfigDict(frozen=True)
unified_file_id: str
file_object: JsonValue = None
model_mappings: Mapping[str, JsonValue]
storage_backend: str | None = None
_SCOPED_FILE_ROWS: Final = TypeAdapter(tuple[_ScopedFileRow, ...])
def _owner_condition(owner_filter: Mapping[str, object]) -> tuple[str, tuple[str, ...]]:
match owner_filter:
case {"OR": [{"created_by": str() as user_id}, {"team_id": str() as team_id}]}:
return '("created_by" = $1 OR "team_id" = $2)', (user_id, team_id)
case {"created_by": str() as user_id}:
return '"created_by" = $1', (user_id,)
case {"team_id": str() as team_id}:
return '"team_id" = $1', (team_id,)
case _:
return ("FALSE", ()) if owner_filter else ("TRUE", ())
async def _scoped_file_rows(
database: _ManagedResourceDatabase,
owner_filter: Mapping[str, object],
model_mapping_keys: frozenset[str],
cursor_id: str | None,
direction: Literal["asc", "desc"],
take: int,
) -> tuple[_ScopedFileRow, ...]:
owner_condition, owner_params = _owner_condition(owner_filter)
keys_param: Final = len(owner_params) + 1
comparison: Final = "<" if direction == "desc" else ">"
cursor_condition: Final = (
f'AND ("created_at", "unified_file_id") {comparison} (SELECT "created_at", "unified_file_id" '
f'FROM "LiteLLM_ManagedFileTable" WHERE "unified_file_id" = ${keys_param + 1}) '
if cursor_id
else ""
)
order: Final = direction.upper()
rows: Final = await database.query_raw(
'SELECT "unified_file_id", "file_object", "model_mappings", "storage_backend" FROM "LiteLLM_ManagedFileTable" '
f'WHERE {owner_condition} AND ("model_mappings" ?| ${keys_param}::text[] OR "storage_backend" IS NOT NULL) '
f'{cursor_condition}ORDER BY "created_at" {order}, "unified_file_id" {order} '
f"LIMIT ${keys_param + (2 if cursor_id else 1)}",
*owner_params,
sorted(model_mapping_keys),
*((cursor_id,) if cursor_id else ()),
take,
)
return _SCOPED_FILE_ROWS.validate_python(rows)
def _iter_provider_file_id_pairs(
rows: Sequence[_ManagedFileRow],
requested_provider_file_ids: frozenset[str],
@ -507,10 +586,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
prisma_client: PrismaClient,
*,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
clock: Callable[[], datetime] = get_utc_datetime,
):
self.internal_usage_cache = internal_usage_cache
self.prisma_client = prisma_client
self._sleep = sleep
self._clock = clock
@staticmethod
def _get_prometheus_logger():
@ -2260,14 +2341,23 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
user_api_key_dict: UserAPIKeyAuth,
limit: Optional[int] = None,
after: Optional[str] = None,
model_scope: Optional[FileListModelScope] = None,
before: Optional[str] = None,
**data: Dict,
) -> FileListPage:
"""List the managed files the caller owns, newest first.
Pagination is keyset based on ``unified_file_id`` so a key that owns
every file on the proxy still reads one bounded page at a time.
``purpose`` is applied after parsing, because the managed file table
keeps it inside the ``file_object`` blob instead of a column, and rows
``after`` pages toward older files and ``before`` toward newer ones,
each page still ordered newest first.
``model_scope`` is applied in the query, so only rows whose
``model_mappings`` name one of its deployment ids or model names, and
storage-backed rows, leave the database; a storage-backed row then
stays only when its model name is one the router sends to the listed
provider. ``purpose`` is applied after parsing, because the managed
file table keeps it inside the ``file_object`` blob instead of a
column. Rows
whose blob will not parse drop out there too, so a chunk of rows can
yield fewer matches than the page holds. Successive chunks are read
until the page is full or the caller's rows run out, which keeps
@ -2276,9 +2366,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
scan has to continue past it, the chunk widens to
``FILE_LIST_CONTINUATION_CHUNK_SIZE``, so the walk costs one query per
that many rows instead of one per page. That bound is per query, not
per request: the work is still linear in the rows the caller owns, and
a filter matching nothing reads every one of them, with no index
covering either the owner filter or the sort.
per request: a ``purpose`` filter matching nothing still reads every
row that passes the query.
"""
validate_file_list_limit(limit)
validate_file_list_purpose(purpose)
@ -2287,36 +2376,60 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if owner_filter is None:
return FileListPage.model_validate(build_list_page([]))
if after:
cursor_row = await _managed_file_table(self.prisma_client).find_first(
where={**owner_filter, "unified_file_id": after}
if after and before:
raise ProxyException(
message="Pass either 'after' (or 'after_id') or 'before_id' when listing files, not both.",
type="invalid_request_error",
param="before_id",
code=400,
openai_code="invalid_value",
)
cursor_param: Final = "before_id" if before else "after"
cursor: Final = before or after
database: Final = _managed_database(self.prisma_client)
if cursor:
cursor_row = await database.litellm_managedfiletable.find_first(
where={**owner_filter, "unified_file_id": cursor}
)
if cursor_row is None:
raise ProxyException(
message=f"Invalid 'after' cursor: no file found with id '{after}'.",
message=f"Invalid '{cursor_param}' cursor: no file found with id '{cursor}'.",
type="invalid_request_error",
param="after",
param=cursor_param,
code=400,
openai_code="invalid_value",
)
if model_scope is not None and not model_scope.model_mapping_keys:
return FileListPage.model_validate(build_list_page([]))
page_size: Final = min(limit or MAX_FILE_LIST_LIMIT, MAX_FILE_LIST_LIMIT)
matches: Final[List[OpenAIFileObject]] = []
cursor_id = after
cursor_id = cursor
chunk_size = page_size + 1
direction: Final[Literal["asc", "desc"]] = "asc" if before else "desc"
while len(matches) <= page_size:
cursor_args: _CursorPageArgs = {"cursor": {"unified_file_id": cursor_id}, "skip": 1} if cursor_id else {}
chunk = await _managed_file_table(self.prisma_client).find_many(
where=owner_filter,
take=chunk_size,
order=[{"created_at": "desc"}, {"unified_file_id": "desc"}],
**cursor_args,
chunk: Sequence[_ListedFileRow] = (
await _scoped_file_rows(
database, owner_filter, model_scope.model_mapping_keys, cursor_id, direction, chunk_size
)
if model_scope is not None
else await database.litellm_managedfiletable.find_many(
where=owner_filter,
take=chunk_size,
order=[{"created_at": direction}, {"unified_file_id": direction}],
**cursor_args,
)
)
matches.extend(
_public_file_object(parsed_file_object, row.unified_file_id)
for row in chunk
if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
if (
model_scope is None or model_scope.holds(row.model_mappings, stored=row.storage_backend is not None)
)
and (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
and (purpose is None or parsed_file_object.purpose == purpose)
)
if len(chunk) < chunk_size:
@ -2324,7 +2437,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
cursor_id = chunk[-1].unified_file_id
chunk_size = max(chunk_size, FILE_LIST_CONTINUATION_CHUNK_SIZE)
return FileListPage.model_validate(build_list_page(matches[:page_size], has_more=len(matches) > page_size))
page: Final = matches[:page_size]
return FileListPage.model_validate(
build_list_page(page[::-1] if before else page, has_more=len(matches) > page_size)
)
def _is_batch_polling_enabled(self) -> bool:
"""
@ -2387,12 +2503,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
where={
"file_purpose": "batch",
"batch_processed": False,
"status": {"not_in": ["failed", "expired", "cancelled"]},
"status": {"not_in": ["failed", "expired", "cancelled", "stale_expired"]},
},
take=MAX_MATCHES_TO_RETURN,
order={"created_at": "desc"},
)
input_file_cutoff: Final = self._clock() - timedelta(days=MANAGED_OBJECT_STALENESS_CUTOFF_DAYS)
referencing_batches: Final[list[dict[str, object]]] = []
for batch in batches:
try:
@ -2405,7 +2522,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
# Extract file IDs from batch
# Batches typically reference the unified file ID in input_file_id
# Output and error files are generated by the provider
input_file_id = batch_data.get("input_file_id")
input_file_id = batch_data.get("input_file_id") if batch.created_at >= input_file_cutoff else None
output_file_id = batch_data.get("output_file_id")
error_file_id = batch_data.get("error_file_id")

View file

@ -153,7 +153,7 @@ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths" && \
python -c "import litellm; from litellm.rust_bridge.loader import native_bridge_available; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__; assert native_bridge_available()"
python -c "import litellm, litellm.rust_bridge._native; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__"
USER nonroot

View file

@ -343,11 +343,16 @@ def btree_max_entry_bytes(block_size: int) -> int:
def _value_bytes(column: ColumnType, identifier: "sql.Identifier") -> "sql.Composed":
"""The bytes a value takes in the index entry: its length, or its stored size when
Postgres compressed it, since `index_form_tuple` keeps the compressed bytes."""
from psycopg import sql
if column.type_name in _OCTET_LENGTH_TYPES:
return sql.SQL("octet_length({})").format(identifier)
return sql.SQL("octet_length({}::text)").format(identifier)
length: Final = (
sql.SQL("octet_length({})").format(identifier)
if column.type_name in _OCTET_LENGTH_TYPES
else sql.SQL("octet_length({}::text)").format(identifier)
)
return sql.SQL("least({}, pg_column_size({}))").format(length, identifier)
def _aligned(offset: "sql.Composable", align: str) -> "sql.Composed":

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_ManagedFileTable_created_by_created_at_idx"
ON "LiteLLM_ManagedFileTable" ("created_by", "created_at" DESC);

View file

@ -0,0 +1,47 @@
SET LOCAL lock_timeout = '10s';
CREATE TYPE "AccountingEventKind" AS ENUM ('inbound_usage', 'outbound_usage', 'cost');
CREATE TYPE "AccountingDeliveryState" AS ENUM ('pending', 'applied', 'parked');
CREATE TYPE "AccountingDeliveryTarget" AS ENUM ('LiteLLM_VerificationToken', 'LiteLLM_UserTable', 'LiteLLM_TeamTable', 'LiteLLM_TeamMembership', 'LiteLLM_OrganizationTable', 'LiteLLM_OrganizationMembership', 'LiteLLM_EndUserTable', 'LiteLLM_AgentsTable', 'LiteLLM_TagTable', 'LiteLLM_ProjectTable', 'LiteLLM_ModelAccessGroupBudgetTable', 'LiteLLM_BudgetTable', 'LiteLLM_BudgetWindowSpend', 'LiteLLM_DailyUserSpend', 'LiteLLM_DailyTeamSpend', 'LiteLLM_DailyOrganizationSpend', 'LiteLLM_DailyEndUserSpend', 'LiteLLM_DailyAgentSpend', 'LiteLLM_DailyTagSpend', 'LiteLLM_DailyModelUsage', 'LiteLLM_DailyToolSpend', 'LiteLLM_AutoRouterDailySpend');
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingEvent" (
"event_id" TEXT NOT NULL,
"kind" "AccountingEventKind" NOT NULL,
"source_event_id" TEXT,
"schema_version" INTEGER NOT NULL,
"payload_hash" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"recorded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"payload" JSONB NOT NULL,
CONSTRAINT "LiteLLM_AccountingEvent_schema_version_check" CHECK ("schema_version" > 0),
CONSTRAINT "LiteLLM_AccountingEvent_pkey" PRIMARY KEY ("event_id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingDelivery" (
"event_id" TEXT NOT NULL,
"target" "AccountingDeliveryTarget" NOT NULL,
"version" INTEGER NOT NULL,
"state" "AccountingDeliveryState" NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"applied_at" TIMESTAMP(3),
"last_error" TEXT,
CONSTRAINT "LiteLLM_AccountingDelivery_version_check" CHECK ("version" > 0),
CONSTRAINT "LiteLLM_AccountingDelivery_attempts_check" CHECK ("attempts" >= 0),
CONSTRAINT "LiteLLM_AccountingDelivery_pkey" PRIMARY KEY ("event_id","target","version")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_recorded_at_idx" ON "LiteLLM_AccountingEvent"("recorded_at");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_kind_source_event_id_idx" ON "LiteLLM_AccountingEvent"("kind", "source_event_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingDelivery_target_state_next_attempt_at_idx" ON "LiteLLM_AccountingDelivery"("target", "state", "next_attempt_at");

View file

@ -1163,6 +1163,7 @@ model LiteLLM_ManagedFileTable {
@@index([unified_file_id])
@@index([flat_model_file_ids], type: Gin)
@@index([team_id, created_at(sort: Desc)])
@@index([created_by, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -2060,3 +2061,73 @@ model LiteLLM_TelemetryReport {
@@index([window_end])
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}

View file

@ -1,6 +1,8 @@
---
name: rust-error
description: Define, wrap, convert, classify, report or test Rust error types in litellm-rust with thiserror. Use when adding an error type or variant, mapping one crate's error into another, turning an error into a gateway response or Python exception, deciding retry or fault from an error, or replacing call sites that build errors from strings
metadata:
internal: true
---
# Rust errors

View file

@ -1,6 +1,8 @@
---
name: rust-gateway
description: Add or change an HTTP route, extractor, response envelope, stream or router mount in the litellm-rust gateway crates (crates/gateway and crates/gateway-*). Use when adding an endpoint, parsing a request body, choosing an error envelope for an API surface, relaying SSE, or writing a router-level test
metadata:
internal: true
---
# Rust gateway

View file

@ -1,6 +1,8 @@
---
name: rust-string-enums
description: Define or refactor Rust string-valued enums and their Serde adapters in litellm-rust, using Strum and serde_with while preserving parsing, wire values, and schemas
metadata:
internal: true
---
# Rust string enums

View file

@ -1,6 +1,8 @@
---
name: rust-tracing
description: Add or change Rust diagnostic tracing in litellm-rust, including route spans, subscriber layers, and Python logger delivery
metadata:
internal: true
---
# Rust tracing

View file

@ -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"
@ -4387,12 +4415,17 @@ name = "litellm-router"
version = "0.1.0"
dependencies = [
"litellm-config",
"litellm-core-utils",
"litellm-inference",
"litellm-inference-messages",
"litellm-router-types",
"rstest",
]
[[package]]
name = "litellm-router-core"
version = "0.1.0"
[[package]]
name = "litellm-router-types"
version = "0.1.0"

View file

@ -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"] }

View file

@ -13,6 +13,7 @@ const FINISH_REASONS: &[(&str, &str)] = &[
("max_tokens", "length"),
("refusal", "content_filter"),
("compaction", "length"),
("model_context_window_exceeded", "length"),
("guardrail_intervened", "content_filter"),
("content_filtered", "content_filter"),
("content_filter", "content_filter"),
@ -74,6 +75,7 @@ mod tests {
#[case::end_turn("end_turn", "stop")]
#[case::stop_sequence("stop_sequence", "stop")]
#[case::max_tokens("max_tokens", "length")]
#[case::model_context_window_exceeded("model_context_window_exceeded", "length")]
#[case::refusal("refusal", "content_filter")]
#[case::guardrail_intervened("guardrail_intervened", "content_filter")]
#[case::content_filtered("content_filtered", "content_filter")]

View file

@ -113,6 +113,7 @@ const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[
"meta",
"cognition",
"scx-ai",
"zerogpu",
];
impl ExceptionFamily {

View file

@ -1,13 +1,25 @@
pub fn parse_str_bool(value: &str) -> Option<bool> {
let token = value.trim_matches(|character: char| {
fn python_strip(value: &str) -> &str {
value.trim_matches(|character: char| {
character.is_whitespace() || matches!(character, '\u{1c}'..='\u{1f}')
});
})
}
pub fn parse_str_bool(value: &str) -> Option<bool> {
let token = python_strip(value);
if token.eq_ignore_ascii_case("true") {
return Some(true);
}
token.eq_ignore_ascii_case("false").then_some(false)
}
pub fn parse_pydantic_bool(value: &str) -> Option<bool> {
match python_strip(value).to_ascii_lowercase().as_str() {
"1" | "on" | "t" | "true" | "y" | "yes" => Some(true),
"0" | "off" | "f" | "false" | "n" | "no" => Some(false),
_ => None,
}
}
/// `redis-py` string Booleans: only `1`, `true`, and `yes` (case-insensitive) are true.
pub fn parse_redis_bool(value: &str) -> bool {
value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
@ -34,4 +46,27 @@ mod tests {
) {
assert_eq!(parse_str_bool(input), expected, "{input:?}");
}
#[rstest]
#[case::yes(" YES ", Some(true))]
#[case::on("On", Some(true))]
#[case::one("1", Some(true))]
#[case::t("t", Some(true))]
#[case::y("y", Some(true))]
#[case::control_whitespace_true("\u{1c}TRUE\u{1f}", Some(true))]
#[case::no("No", Some(false))]
#[case::off("OFF", Some(false))]
#[case::zero("0", Some(false))]
#[case::f("F", Some(false))]
#[case::n("n", Some(false))]
#[case::false_word("false", Some(false))]
#[case::two("2", None)]
#[case::empty("", None)]
#[case::unknown("maybe", None)]
fn boolean_words_follow_pydantic_lax_bool_parsing(
#[case] input: &str,
#[case] expected: Option<bool>,
) {
assert_eq!(parse_pydantic_bool(input), expected, "{input:?}");
}
}

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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()
);
}

View 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
}
}

View file

@ -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);
}
}

View file

@ -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::*;

View 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
);
}
}

View 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);
}
}

View 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);
}
}

View 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,
}

View 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);
}
}

View 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,
}

View file

@ -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), &regional).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));
}

View file

@ -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()

View file

@ -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.

View file

@ -37,6 +37,7 @@ pub fn build_inference(config: &Config) -> Result<Arc<Gateway>, Error> {
.map(|value| value.expose().to_owned())
.or_else(|| std::env::var(name).ok())
};
let router = ModelRouter::from_config(config, &lookup);
let settings = &config.litellm_settings;
let http_settings = HttpSettings::from_layers([
HttpSettingsLayer::from_environment(&lookup),
@ -64,12 +65,7 @@ pub fn build_inference(config: &Config) -> Result<Arc<Gateway>, Error> {
Arc::new(EnvironmentSecrets::python_compatible(client)),
));
let resources = CoreResources::new(pool);
Ok(Arc::new(Gateway::new(
resources,
http,
secrets,
ModelRouter::from_model_list(&config.model_list),
)?))
Ok(Arc::new(Gateway::new(resources, http, secrets, router)?))
}
pub async fn build_redis(config: &Config) -> Result<RedisClients, Error> {

View file

@ -141,9 +141,13 @@ async fn provider_call(
&env_lookup,
)?;
let body = config.transform_anthropic_messages_request(
trimmed,
&MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params),
let body = config.request_body(
config.transform_anthropic_messages_request(
trimmed,
&MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params),
)?,
&validated.headers,
shaping.settings.drop_params,
)?;
let environment = ValidatedEnvironment {
headers: config.request_headers(
@ -514,6 +518,84 @@ mod tests {
);
}
fn vertex_call_requesting(
shaping: MessagesShaping,
requested: &str,
drop_params: bool,
) -> MessagesCall {
MessagesCall {
body: body(
json!({"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16}),
),
api_key: None,
api_base: Some("https://vertex.test".into()),
custom_llm_provider: Some("vertex_ai".into()),
litellm_params: Default::default(),
extra_headers: Some(Map::from_iter([
("authorization".to_string(), json!("Bearer vertex-token")),
("anthropic-version".to_string(), json!(requested)),
])),
provider_specific_header: None,
timeout: None,
shaping: MessagesShaping {
settings: MessagesSettings {
drop_params,
..shaping.settings
},
..shaping
},
}
}
fn vertex_project(name: &str) -> Option<String> {
(name == "VERTEXAI_PROJECT").then(|| "version-project".to_string())
}
#[rstest]
#[case::sdk_2("2026-10-21", false, "2026-10-21")]
#[case::sdk_1("2023-06-01", false, "vertex-2023-10-16")]
#[case::unknown_under_drop_params("latest", true, "vertex-2023-10-16")]
#[tokio::test]
async fn vertex_gets_the_requested_version_in_the_body_alone(
shaping: MessagesShaping,
#[case] requested: &str,
#[case] drop_params: bool,
#[case] body_version: &str,
) {
let call = provider_call_with_secrets(
vertex_call_requesting(shaping, requested, drop_params),
&vertex_project,
)
.await
.unwrap();
let version_headers: Vec<&str> = call
.wire
.headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("anthropic-version"))
.map(|(_, value)| value.as_str())
.collect();
assert_eq!(
(version_headers, &call.wire.body["anthropic_version"]),
(Vec::<&str>::new(), &json!(body_version))
);
}
#[rstest]
#[tokio::test]
async fn vertex_refuses_an_unsupported_version_without_drop_params(shaping: MessagesShaping) {
assert!(matches!(
provider_call_with_secrets(
vertex_call_requesting(shaping, "latest", false),
&vertex_project
)
.await,
Err(Error::Llm(litellm_llms::Error::InvalidRequest(
litellm_llms::ErrorDetail::UnsupportedParameter { .. }
)))
));
}
#[rstest]
#[tokio::test]
async fn missing_credentials_are_reported_before_the_request_is_transformed(

View file

@ -77,6 +77,15 @@ pub trait BaseMessagesConfig: Sync {
headers
}
fn request_body(
&self,
request: MessagesRequest,
_headers: &Headers,
_drop_params: bool,
) -> Result<MessagesRequest, Error> {
Ok(request)
}
/// The JSON that goes on the wire, for a host whose body differs from the typed request:
/// Python's configs `pop("model")` when the model is addressed by the URL.
fn wire_body(&self, body: Value) -> Value {

View file

@ -1,6 +1,7 @@
# rules
- Claude on Vertex AI: reuses `anthropic/messages` shaping, moves the model into the publisher URL, puts the Vertex `anthropic_version` in the body, and authenticates with a Google access token
- Claude on Vertex AI: reuses `anthropic/messages` shaping, moves the model into the publisher URL, and authenticates with a Google access token
- Vertex reads the API version only from the body's `anthropic_version` and answers 400 when an `anthropic-version` header names a different one, so the caller's header never reaches Vertex. A version Vertex accepts in the body is carried there, `2023-06-01`, `2023-01-01`, or no header get `vertex-2023-10-16`, and any other version drops under `drop_params` and is refused without it
- Applies Vertex's beta policy and strips `output_config.effort` for models that do not accept it
# references

View file

@ -3,10 +3,10 @@ use litellm_auth_gcp::{
VertexConfig, get_vertex_ai_location, get_vertex_ai_project,
get_vertex_ai_project_from_credentials, secret_names,
};
use litellm_http::request::has_header;
use litellm_http::request::{has_header, header_value, without_headers};
use litellm_llms_types::{
formats::messages::{MessagesOptionalParams, MessagesRequest, MessagesTool, OutputConfig},
providers::anthropic::{AnthropicBeta, BetaProvider, BetaSet},
providers::anthropic::{AnthropicBeta, BetaProvider, BetaSet, VERSION_HEADER},
recognized::Recognized,
};
use litellm_router_types::LitellmParams;
@ -36,6 +36,8 @@ use litellm_llms_types::providers::vertex_ai::{
};
const WEB_SEARCH_TOOL_PREFIX: &str = "web_search";
const VERSIONS_SERVED_AS_VERTEX_DEFAULT: [&str; 3] = ["", "2023-01-01", "2023-06-01"];
const VERSIONS_VERTEX_ACCEPTS_IN_BODY: [&str; 2] = [ANTHROPIC_VERSION, "2026-10-21"];
/// Claude on Vertex AI, Python's `VertexAIPartnerModelsAnthropicMessagesConfig`: the Anthropic
/// payload addressed to a project and location, authenticated with a Google access token.
@ -90,12 +92,6 @@ impl BaseMessagesConfig for VertexAiPartnerModelsAnthropicMessagesConfig {
.params
.output_config
.and_then(|config| sanitize_output_config(config, accepts_effort)),
extra: request
.params
.extra
.into_iter()
.chain([("anthropic_version".to_string(), json!(ANTHROPIC_VERSION))])
.collect(),
..request.params
},
..request
@ -133,7 +129,35 @@ impl BaseMessagesConfig for VertexAiPartnerModelsAnthropicMessagesConfig {
}
fn request_headers(&self, headers: Headers, request: &MessagesRequest) -> Headers {
merge_beta_headers(headers, vertex_feature_betas(request))
merge_beta_headers(
without_headers(headers, &[VERSION_HEADER]),
vertex_feature_betas(request),
)
}
fn request_body(
&self,
request: MessagesRequest,
headers: &Headers,
drop_params: bool,
) -> Result<MessagesRequest, Error> {
let version = vertex_version(
&request.model,
header_value(headers, VERSION_HEADER),
drop_params,
)?;
Ok(MessagesRequest {
params: MessagesOptionalParams {
extra: request
.params
.extra
.into_iter()
.chain([("anthropic_version".to_string(), json!(version))])
.collect(),
..request.params
},
..request
})
}
/// Vertex addresses the model in the URL and rejects it in the body.
@ -225,6 +249,26 @@ fn vertex_feature_betas(request: &MessagesRequest) -> BetaSet {
)
}
fn vertex_version<'a>(
model: &str,
requested: Option<&'a str>,
drop_params: bool,
) -> Result<&'a str, Error> {
match requested.map(str::trim).unwrap_or_default() {
version if VERSIONS_SERVED_AS_VERTEX_DEFAULT.contains(&version) => Ok(ANTHROPIC_VERSION),
version if VERSIONS_VERTEX_ACCEPTS_IN_BODY.contains(&version) => Ok(version),
_ if drop_params => Ok(ANTHROPIC_VERSION),
version => Err(Error::InvalidRequest(
crate::ErrorDetail::UnsupportedParameter {
model: model.into(),
param: VERSION_HEADER.into(),
value: version.into(),
hint: String::new(),
},
)),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
@ -471,6 +515,28 @@ mod tests {
);
}
#[rstest]
#[case::sdk_spelling("anthropic-version")]
#[case::any_case("Anthropic-Version")]
fn a_forwarded_anthropic_version_never_reaches_vertex(#[case] name: &str) {
let request: MessagesRequest = serde_json::from_value(json!({
"model": "claude-sonnet-4-5",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap();
assert_eq!(
VERTEX_ANTHROPIC_MESSAGES_CONFIG.request_headers(
vec![
(name.to_string(), "2026-10-21".to_string()),
("x-request-id".to_string(), "req-1".to_string()),
],
&request,
),
vec![("x-request-id".to_string(), "req-1".to_string())]
);
}
fn transformed(value: Value, context: &MessagesTransformContext) -> Value {
let request: MessagesRequest = serde_json::from_value(value).unwrap();
serde_json::to_value(
@ -482,16 +548,87 @@ mod tests {
}
#[rstest]
fn the_body_carries_the_vertex_version_and_the_wire_drops_the_model() {
let body = transformed(
json!({
"model": "claude-sonnet-4-5@20250929",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]
}),
&MessagesTransformContext::default(),
#[case::sdk_2(&[("anthropic-version", "2026-10-21")], false, "2026-10-21")]
#[case::any_case(&[("Anthropic-Version", "2026-10-21")], false, "2026-10-21")]
#[case::vertex_name(&[("anthropic-version", "vertex-2023-10-16")], false, "vertex-2023-10-16")]
#[case::sdk_1(&[("anthropic-version", "2023-06-01")], false, "vertex-2023-10-16")]
#[case::first_version(&[("anthropic-version", "2023-01-01")], false, "vertex-2023-10-16")]
#[case::blank(&[("anthropic-version", " ")], false, "vertex-2023-10-16")]
#[case::no_header(&[], false, "vertex-2023-10-16")]
#[case::unknown_under_drop_params(&[("anthropic-version", "latest")], true, "vertex-2023-10-16")]
fn the_requested_version_goes_in_the_body(
#[case] forwarded: &[(&str, &str)],
#[case] drop_params: bool,
#[case] body_version: &str,
) {
let request: MessagesRequest = serde_json::from_value(json!({
"model": "claude-sonnet-4-5",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap();
let headers: Headers = forwarded
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect();
let body = serde_json::to_value(
VERTEX_ANTHROPIC_MESSAGES_CONFIG
.request_body(request, &headers, drop_params)
.unwrap(),
)
.unwrap();
assert_eq!(body["anthropic_version"], json!(body_version));
}
#[rstest]
#[case::unreleased("2025-01-01")]
#[case::alias("latest")]
fn an_unsupported_version_is_refused_without_drop_params(#[case] requested: &str) {
let request: MessagesRequest = serde_json::from_value(json!({
"model": "claude-sonnet-4-5",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}],
}))
.unwrap();
let headers: Headers = vec![("anthropic-version".to_string(), requested.to_string())];
let Error::InvalidRequest(detail) = VERTEX_ANTHROPIC_MESSAGES_CONFIG
.request_body(request, &headers, false)
.unwrap_err()
else {
panic!("expected an invalid request");
};
assert_eq!(
detail.to_string(),
format!(
"claude-sonnet-4-5 does not support anthropic-version={requested}. \
To drop unsupported params, set `litellm.drop_params = True`."
)
);
assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16"));
}
#[rstest]
fn the_body_carries_the_vertex_version_and_the_wire_drops_the_model() {
let request: MessagesRequest = serde_json::from_value(json!({
"model": "claude-sonnet-4-5@20250929",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]
}))
.unwrap();
let body = serde_json::to_value(
VERTEX_ANTHROPIC_MESSAGES_CONFIG
.request_body(
VERTEX_ANTHROPIC_MESSAGES_CONFIG
.transform_anthropic_messages_request(
request,
&MessagesTransformContext::default(),
)
.unwrap(),
&Vec::new(),
false,
)
.unwrap(),
)
.unwrap();
assert_eq!(body["model"], json!("claude-sonnet-4-5@20250929"));
let wire = VERTEX_ANTHROPIC_MESSAGES_CONFIG.wire_body(body);
assert_eq!(wire.get("model"), None);

View 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

View 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}");
}
}

View 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::*;

View 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";

View 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,
}

View 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 },
}

View file

@ -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

View file

@ -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

View 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");

View file

@ -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,
}

View file

@ -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(&section.value)
.map_err(|error| {
problems.push(Error::from_key(&section.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(&section.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(&section.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(&section.key) || !rejected.contains(&section.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| &section.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(),
},
)
})
}

View file

@ -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));
}
}

View file

@ -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>,

View 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,
}

View file

@ -1,18 +1,65 @@
mod capabilities;
//! The model catalog: per-model pricing, context limits and capabilities.
//!
//! This is the Rust form of `model_prices_and_context_window.json`, the map Python reads as
//! `litellm.model_cost` and through `litellm.get_model_info`. It answers "what does this model
//! cost and what can it do"; it knows nothing about configured deployments. The router's view of
//! deployments (groups, aliases, wildcard patterns, team mappings) is the router registry, defined
//! in `litellm-router-core`, which reads this catalog through its dynamic config.
//!
//! # Structure
//! - `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::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 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::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
//! returns a `ModelMatch` with both the key that matched and the canonical key of the entry. Provider
//! prefixes are not stripped here; callers decide which keys to try.
//!
//! # Readers
//! 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.
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;

View 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);
}
}

View 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))
}
}

View file

@ -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>,
}

View file

@ -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>,
}

View 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>>,
}

View 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,
}

View 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>,
},
}

View file

@ -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()
);
}
}

View 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

View 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);
}
}

View 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)
}
}

View file

@ -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()
}

View 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,
}

View 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"]);
}
}

View 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)
}
}

View 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]);
}
}

View 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);
}
}

View 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);
}
}

View 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::*;

View file

@ -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"));
}

View file

@ -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()

View 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:#?}");
}

View file

@ -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]]]}
]}

View file

@ -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(&current, 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();
}

View file

@ -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");

View file

@ -187,6 +187,7 @@ fn native_events_reach_python_with_levels_context_reentry_and_http_deduplication
locals
.set_item("http_warning", wrap_pyfunction!(http_warning, py).unwrap())
.unwrap();
locals.set_item("native", crate::native_module(py)).unwrap();
let importable = py
.eval(
c"__import__('importlib.util', fromlist=['util']).find_spec('dotenv') is not None",
@ -205,6 +206,7 @@ import asyncio
import logging
import sys
sys.path.insert(0, repo_root)
sys.modules['litellm.rust_bridge._native'] = native
import litellm
from litellm._logging import verbose_logger, session_id_var, trace_id_var

View file

@ -0,0 +1,9 @@
[package]
name = "litellm-router-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "The router core behind litellm.Router: registry, settings, health, selection and the call loop"
[dependencies]

View file

@ -0,0 +1,63 @@
//! Routing for LiteLLM: decide which deployment serves a request, and drive retries, fallbacks
//! and cooldowns around the attempts.
//!
//! This crate defines the boundary `litellm.Router` is moving behind. Today the Python router in
//! `litellm/router.py` implements all of it; this crate is where the Rust core will implement the
//! same interface. Until then it holds this overview and nothing else, so the interface is written
//! down in code before either side changes.
//!
//! # The public surface
//! Code outside the router uses `litellm.Router` through two kinds of members and nothing else:
//! - The endpoint methods (`acompletion`, `aembedding`, `aresponses`, `aanthropic_messages`, the
//! files, batches, assistants and vector store wrappers, 37 in all). Each is a thin wrapper over
//! `call(op, request)`.
//! - Eight entry points: `registry()`, `settings()`, `health()`, `select()`, `apply()`,
//! `observe()`, `register_plugin()` and `retire()`.
//!
//! Every other member that outside code uses today has a named replacement among those. The old
//! names stay available for about a month through a `DeprecatedRouterMembers` mixin, which
//! implements them over the entry points and warns on use. A CI check
//! (`tests/code_coverage_tests/router_surface_check.py`) keeps an allowlist of the members outside
//! code touches; the list may only shrink.
//!
//! # Structure
//! - `Registry`: the configured deployments, indexed by id, model group, alias, wildcard pattern
//! and team. Immutable and versioned. Seven queries: `deployment(id)`, `deployments(filter)`,
//! `names(filter)`, `resolve(name, scope)`, `group(name)`, `access_groups()` and
//! `credentials(id)`. "Registry" is the router's view of what is configured; per-model pricing
//! and capabilities are the model catalog (`litellm-model-catalog`), which the router reads
//! through its dynamic config.
//! - `RouterConfig` / `RouterDynamicConfig`: everything the router needs to know, passed in. The
//! router never reads process globals. Static config (model list, strategy, retry, timeout and
//! cooldown policy, Redis settings, plugins) is fixed for the router's life. Dynamic config
//! (pricing, model aliases, fallbacks, retry counts, callbacks) is a versioned snapshot read
//! once per call.
//! - `Settings`: one typed, frozen view of the configuration a caller may read.
//! - `Health`: cooldowns, blocked deployments and usage, kept in shared cache keys so Python and
//! Rust workers see the same state. `report(id, outcome)` records outcomes the router did not
//! see itself (a health check, an interceptor that marks a deployment failed).
//! - `select(model, request)`: resolve a name, filter eligible deployments, pick one with the
//! configured strategy, without calling it.
//! - `apply(changes)`: one `ChangeSet` (upserts, deletes, settings), applied atomically, producing
//! a new registry version.
//! - `call(op, request)`: select, run the attempt, and retry or fall back until it succeeds or the
//! policy is exhausted.
//!
//! # Execution
//! Each attempt resolves an `Executor` after a deployment is picked. A Rust-native operation runs
//! its `inference-*` route machine in-process; anything else goes through `Host::invoke`. A
//! started attempt is never replayed on a different executor.
//!
//! # Hosts
//! `Host` is what the embedding process provides: `invoke` for non-native operations, `select`
//! for strategies and sub-routers not yet ported, `allow_fallback` for access and budget checks,
//! `sleep`, `observe` for routing events, and `cache`, a DualCache-style store that holds all
//! routing state under the same keys, encodings and TTLs the Python router uses. `PythonHost`
//! serves them from the existing Python code when the router runs inside Python; `NativeHost`
//! serves them in the Rust server, where an operation without a Rust implementation makes the
//! config fail at build time, never partway through a request.
//!
//! # Plugins
//! Routing strategies, pre-call checks and sub-routers (auto, complexity, quality, adaptive)
//! register through `register_plugin` and see a `RoutingContext` (registry, settings, health and
//! the current request), never the router itself.

View file

@ -7,6 +7,7 @@ repository.workspace = true
[dependencies]
litellm-config.workspace = true
litellm-core-utils.workspace = true
litellm-inference.workspace = true
litellm-inference-messages.workspace = true
litellm-router-types.workspace = true

View file

@ -1,4 +1,4 @@
`litellm-router` scaffolds the model-list setup and deployment lookup portion of Python's `litellm.Router`. `Router::from_model_list(&config.model_list)` maps configured public names to provider deployments. Programmatic callers can collect `(String, Deployment)` entries into a `Router`
`litellm-router` scaffolds the model-list setup and deployment lookup portion of Python's `litellm.Router`. `Router::from_config(&config, &env)` maps configured public names to provider deployments and reads `drop_params` from `litellm_settings` and each deployment, resolving an `os.environ/NAME` value through `env`. Programmatic callers can collect `(String, Deployment)` entries into a `Router`
Lookup is exact and returns `None` for an unknown name. This extraction preserves the gateway's existing behavior: the last entry wins when public names repeat. Multiple deployments per model group, routing strategies, retries, cooldowns, and fallbacks are not implemented yet

View file

@ -2,16 +2,33 @@ mod deployment;
use std::collections::HashMap;
use litellm_config::Model;
use litellm_config::{Config, Spelled};
use litellm_core_utils::{serde_compat::parse_pydantic_bool, settings::Lookup};
use litellm_inference_messages::{MessagesSettings, MessagesShaping};
pub use deployment::Deployment;
#[derive(Clone, Debug, Default)]
pub struct Router(HashMap<String, Deployment>);
fn is_on(flag: Option<&Spelled<bool>>, env: &impl Lookup) -> bool {
match flag {
Some(Spelled::Value(on)) => *on,
Some(Spelled::Text(text)) => match text.strip_prefix("os.environ/") {
Some(name) => env
.get(name)
.is_some_and(|value| parse_pydantic_bool(&value) == Some(true)),
None => parse_pydantic_bool(text) == Some(true),
},
None => false,
}
}
impl Router {
pub fn from_model_list(model_list: &[Model]) -> Self {
model_list
pub fn from_config(config: &Config, env: &impl Lookup) -> Self {
let drops_params_globally = is_on(config.litellm_settings.drop_params.as_ref(), env);
config
.model_list
.iter()
.map(|model| {
(
@ -26,6 +43,14 @@ impl Router {
api_base: model.litellm_params.api_base.clone(),
custom_llm_provider: model.litellm_params.custom_llm_provider.clone(),
litellm_params: model.litellm_params.clone(),
shaping: MessagesShaping {
settings: MessagesSettings {
drop_params: drops_params_globally
|| is_on(model.litellm_params.drop_params.as_ref(), env),
..MessagesSettings::default()
},
..MessagesShaping::default()
},
..Deployment::default()
},
)
@ -43,3 +68,60 @@ impl FromIterator<(String, Deployment)> for Router {
Self(entries.into_iter().collect())
}
}
#[cfg(test)]
mod tests {
use litellm_config::Config;
use rstest::rstest;
use super::Router;
#[rstest]
#[case::unset("", "", false)]
#[case::deployment_flag("", "\n drop_params: true", true)]
#[case::deployment_word("", "\n drop_params: \" True \"", true)]
#[case::deployment_off("", "\n drop_params: false", false)]
#[case::deployment_unknown_word("", "\n drop_params: maybe", false)]
#[case::deployment_yes("", "\n drop_params: yes", true)]
#[case::deployment_no("", "\n drop_params: \"no\"", false)]
#[case::deployment_env_reference("", "\n drop_params: os.environ/DROP_PARAMS_FLAG", true)]
#[case::deployment_unset_env_reference(
"",
"\n drop_params: os.environ/MISSING_FLAG",
false
)]
#[case::global_env_reference(
"litellm_settings:\n drop_params: os.environ/DROP_PARAMS_FLAG\n",
"",
true
)]
#[case::global_flag("litellm_settings:\n drop_params: true\n", "", true)]
#[case::global_word("litellm_settings:\n drop_params: \"true\"\n", "", true)]
#[case::global_off("litellm_settings:\n drop_params: false\n", "", false)]
#[case::global_over_deployment_off(
"litellm_settings:\n drop_params: true\n",
"\n drop_params: false",
true
)]
fn configured_drop_params_reach_the_deployment(
#[case] settings: &str,
#[case] deployment: &str,
#[case] expected: bool,
) {
let config = Config::from_yaml(&format!(
"{settings}model_list:\n - model_name: public-model\n litellm_params:\n model: provider/model{deployment}"
))
.unwrap();
assert_eq!(
Router::from_config(&config, &|name: &str| (name == "DROP_PARAMS_FLAG")
.then(|| " Yes ".to_owned()))
.get("public-model")
.unwrap()
.shaping
.settings
.drop_params,
expected
);
}
}

View file

@ -16,7 +16,7 @@ fn configuration_preserves_deployment_parameters(#[case] parameters: &str) {
"model_list:\n - model_name: public-model\n litellm_params:\n model: provider/model\n {parameters}"
))
.unwrap();
let router = Router::from_model_list(&config.model_list);
let router = Router::from_config(&config, &|_: &str| None);
let deployment = router.get(&config.model_list[0].model_name).unwrap();
let params = &config.model_list[0].litellm_params;
@ -44,7 +44,7 @@ fn configuration_types_the_provider_params_of_a_deployment() {
azure_ad_token: ignored-by-every-group",
)
.unwrap();
let router = Router::from_model_list(&config.model_list);
let router = Router::from_config(&config, &|_: &str| None);
let params = &router.get("public-model").unwrap().litellm_params;
assert_eq!(
@ -74,7 +74,7 @@ fn lookup_uses_public_names(#[case] name: &str, #[case] expected: Option<&str>)
model: provider/b",
)
.unwrap();
let router = Router::from_model_list(&config.model_list);
let router = Router::from_config(&config, &|_: &str| None);
assert_eq!(router.get(name).map(|entry| entry.model.as_str()), expected);
}
@ -84,7 +84,7 @@ fn empty_configuration_has_no_deployment() {
let config = Config::from_yaml("model_list: []").unwrap();
assert!(
Router::from_model_list(&config.model_list)
Router::from_config(&config, &|_: &str| None)
.get("")
.is_none()
);

View file

@ -681,6 +681,7 @@ moonshot_models: Set = set()
publicai_models: Set = set()
darkbloom_models: Set = set()
openinfer_models: Final[Set[str]] = set()
zerogpu_models: Final[Set[str]] = set()
v0_models: Set = set()
morph_models: Set = set()
opencode_go_models: Final[set[str]] = set() # mutable-ok: populated when the shared model catalog loads
@ -969,6 +970,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") == "zerogpu":
zerogpu_models.add(key)
elif value.get("litellm_provider") == "v0":
v0_models.add(key)
elif value.get("litellm_provider") == "morph":
@ -1142,6 +1145,7 @@ model_list = list(
| publicai_models
| darkbloom_models
| openinfer_models
| zerogpu_models
| v0_models
| morph_models
| opencode_go_models
@ -1261,6 +1265,7 @@ def _build_models_by_provider() -> dict:
"publicai": publicai_models,
"darkbloom": darkbloom_models,
"openinfer": openinfer_models,
"zerogpu": zerogpu_models,
"v0": v0_models,
"morph": morph_models,
"opencode_go": opencode_go_models,
@ -1331,7 +1336,6 @@ all_embedding_models = (
)
####### IMAGE GENERATION MODELS ###################
openai_image_generation_models = ["dall-e-2", "dall-e-3"]
####### VIDEO GENERATION MODELS ###################
openai_video_generation_models = ["sora-2"]
@ -1449,6 +1453,7 @@ from .exceptions import (
Timeout,
APIConnectionError,
UnsupportedParamsError,
UnsupportedEndpointError as UnsupportedEndpointError,
APIResponseValidationError,
UnprocessableEntityError,
InternalServerError,
@ -1738,6 +1743,9 @@ if TYPE_CHECKING:
from .llms.lm_studio.decisions.transformation import (
LMStudioDecisionsConfig as LMStudioDecisionsConfig,
)
from .llms.ollama.decisions.transformation import (
OllamaDecisionsConfig as OllamaDecisionsConfig,
)
from .llms.laya.decisions.transformation import (
LayaDecisionsConfig as LayaDecisionsConfig,
)

View file

@ -171,6 +171,7 @@ LLM_CONFIG_NAMES: Final = (
"DatabricksDecisionsConfig",
"HostedVLLMDecisionsConfig",
"LMStudioDecisionsConfig",
"OllamaDecisionsConfig",
"LayaDecisionsConfig",
"BespokeDecisionsConfig",
"OpenAIDecisionsConfig",
@ -749,6 +750,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
"HostedVLLMDecisionsConfig",
),
"LMStudioDecisionsConfig": (".llms.lm_studio.decisions.transformation", "LMStudioDecisionsConfig"),
"OllamaDecisionsConfig": (".llms.ollama.decisions.transformation", "OllamaDecisionsConfig"),
"LayaDecisionsConfig": (".llms.laya.decisions.transformation", "LayaDecisionsConfig"),
"BespokeDecisionsConfig": (".llms.bespoke.decisions.transformation", "BespokeDecisionsConfig"),
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),

View file

@ -19,6 +19,7 @@ from litellm.a2a_protocol.utils import (
)
from litellm.exceptions import BadRequestError
from litellm.llms.bedrock.chat.agentcore.transformation import AmazonAgentCoreConfig
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
RUNTIME_SESSION_ID_MIN_LENGTH: Final = 33
RUNTIME_SESSION_ID_MAX_LENGTH: Final = 256
@ -50,7 +51,7 @@ _RESERVED_PREFIX_HEADERS: Final[tuple[str, ...]] = (
class _SSELineSource(Protocol):
"""Minimal streaming-response surface used to read SSE lines."""
def aiter_lines(self) -> AsyncIterator[str]: ...
def aiter_text(self) -> AsyncIterator[str]: ...
def _filter_reserved_headers(
@ -244,7 +245,7 @@ class BedrockAgentCoreA2ATransformation:
Yields:
Parsed JSON dicts from SSE data lines
"""
async for line in response.aiter_lines():
async for line in aiter_sse_lines(response.aiter_text()):
line = line.strip()
if not line:
continue

Some files were not shown because too many files have changed in this diff Show more