Merge remote-tracking branch 'origin/main' into litellm-providers/price-sync-aws-bedrock

This commit is contained in:
devin-ai-integration[bot] 2026-10-27 19:25:50 +00:00
commit 64d3144c23
48 changed files with 9397 additions and 2184 deletions

View file

@ -524,6 +524,12 @@ jobs:
- name: check_provider_folders_documented
run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py
- name: check_provider_endpoints_support_generated
run: uv run --no-sync python scripts/generate_provider_endpoints_support.py --check
- name: test_generate_provider_endpoints_support
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_generate_provider_endpoints_support.py
- name: check_prisma_binary_cache
run: uv run --no-sync python ./tests/code_coverage_tests/check_prisma_binary_cache.py

View file

@ -0,0 +1,639 @@
"""Read-only checks that the rows already in the database can take the pending schema
changes, run before `prisma migrate deploy` and before each request-log index build.
Postgres has a short list of schema statements that fail because of existing rows: a
btree index whose entry for some row would not fit in a third of a page (2704 bytes on
the default 8 KB page, counting every indexed column), UNIQUE (duplicate values), SET NOT NULL and ADD COLUMN NOT NULL with no default (nulls, any row at all),
FOREIGN KEY (orphans) and CHECK (rows failing the expression). Each pending statement
of one of those kinds becomes one count query derived from the statement text and the
column types in the catalog. A count above zero names the table, the column, the
statement it blocks and the SQL that clears it, and the migration is refused before
anything is applied. Every other statement a migration can contain cannot fail on
existing data and gets no query. A statement the derivation cannot read gets none
either, so the migration keeps today's behavior for it.
"""
import os
import re
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from functools import reduce
from itertools import accumulate
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal
from litellm_proxy_extras._logging import logger
if TYPE_CHECKING:
import psycopg
from psycopg import sql
DEFAULT_BLOCK_SIZE: Final = 8192
INDEX_TUPLE_HEADER_BYTES: Final = 8
NULL_BITMAP_BYTES: Final = 8
VARLENA_HEADER_BYTES: Final = 4
SHORT_VARLENA_MAX_BYTES: Final = 126
_PAGE_HEADER_WITH_THREE_LINE_POINTERS_BYTES: Final = 40
_BTREE_PAGE_OPAQUE_BYTES: Final = 16
_HEAP_TID_BYTES: Final = 8
_ALIGNMENT: Final = MappingProxyType({"c": 1, "s": 2, "i": 4, "d": 8})
_OCTET_LENGTH_TYPES: Final = frozenset({"text", "varchar", "bpchar", "bytea", "name"})
MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR: Final = "LITELLM_MIGRATION_DATA_CHECK_TIMEOUT"
DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS: Final = 600.0
REFUSAL: Final = "Existing rows would make a pending schema change fail:"
LEGACY_SPEND_LOG_KEY_REMEDY: Final = (
"Rows written before v1.99.0 hold the raw key. Hash them in place (one-way, idempotent):\n"
' UPDATE "LiteLLM_SpendLogs" SET api_key = encode(sha256(convert_to('
"regexp_replace(api_key, '^[Bb][Ee][Aa][Rr][Ee][Rr] ', ''), 'UTF8')), 'hex')\n"
" WHERE api_key <> '' AND api_key !~ '^[0-9a-fA-F]{64}$' AND api_key !~ '^hashed-jwt-[0-9A-Fa-f]{64}$';"
)
Kind = Literal["too_long", "duplicates", "nulls", "any_row", "orphans", "check_violations"]
@dataclass(frozen=True, slots=True)
class Check:
kind: Kind
table: str
columns: tuple[str, ...]
statement: str
source: str
index: str | None = None
referenced_table: str | None = None
referenced_columns: tuple[str, ...] = ()
expression: str | None = None
@dataclass(frozen=True, slots=True)
class BlockedChange:
check: Check
rows: int
detail: str
@dataclass(frozen=True, slots=True)
class CouldNotVerify:
check: Check
timeout_seconds: float
Finding = BlockedChange | CouldNotVerify
@dataclass(frozen=True, slots=True)
class ColumnType:
"""One column as `pg_attribute` and `pg_type` describe it: `length` is `attlen` (-1 for a
variable-length value), `align` is `attalign` (c, s, i or d), `typmod` is `atttypmod`."""
type_name: str
length: int
align: str
typmod: int
@property
def is_variable_length(self) -> bool:
return self.length == -1
@property
def can_overflow_btree(self) -> bool:
"""Whether one value of this column alone can push a btree entry past the limit: a
variable-length type with no declared cap, or a cap of more bytes than the limit."""
if not self.is_variable_length:
return False
return self.typmod == -1 or (self.typmod - VARLENA_HEADER_BYTES) * 4 > btree_max_entry_bytes(DEFAULT_BLOCK_SIZE)
ColumnTypeLookup = Callable[[str, str], ColumnType | None]
_IDENT: Final = r'"?(\w+)"?'
_QUALIFIED: Final = r'(?:"?\w+"?\s*\.\s*)?"?(\w+)"?'
_CREATE_INDEX: Final = re.compile(
rf"^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?{_IDENT}\s+ON\s+(?:ONLY\s+)?"
rf"{_QUALIFIED}\s*(?:USING\s+(\w+)\s*)?\((.*?)\)",
re.IGNORECASE | re.DOTALL,
)
_ALTER_TABLE: Final = re.compile(
rf"^\s*ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?{_QUALIFIED}\s+(.*)$", re.IGNORECASE | re.DOTALL
)
_SET_NOT_NULL: Final = re.compile(rf"^ALTER\s+(?:COLUMN\s+)?{_IDENT}\s+SET\s+NOT\s+NULL$", re.IGNORECASE)
_ADD_COLUMN: Final = re.compile(
rf"^ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?!(?:CONSTRAINT|FOREIGN|UNIQUE|CHECK|PRIMARY|EXCLUDE)\b){_IDENT}\s+(.*)$",
re.IGNORECASE | re.DOTALL,
)
_CONSTRAINT: Final = rf"^ADD\s+(?:CONSTRAINT\s+{_IDENT}\s+)?"
_ADD_FOREIGN_KEY: Final = re.compile(
rf"{_CONSTRAINT}FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+{_QUALIFIED}\s*\(([^)]*)\)(.*)$",
re.IGNORECASE | re.DOTALL,
)
_ADD_UNIQUE: Final = re.compile(rf"{_CONSTRAINT}UNIQUE\s*\(([^)]*)\)", re.IGNORECASE | re.DOTALL)
_ADD_CHECK: Final = re.compile(rf"{_CONSTRAINT}CHECK\s*\((.*)\)\s*(NOT\s+VALID)?\s*$", re.IGNORECASE | re.DOTALL)
_PLAIN_COLUMN: Final = re.compile(rf"^{_IDENT}(?:\s+(?:ASC|DESC|NULLS\s+(?:FIRST|LAST)))*$", re.IGNORECASE)
_COMMENT: Final = re.compile(r"--[^\n]*")
_NOT_NULL: Final = re.compile(r"\bNOT\s+NULL\b", re.IGNORECASE)
_HAS_DEFAULT: Final = re.compile(r"\bDEFAULT\b|\bGENERATED\b", re.IGNORECASE)
def migration_data_check_timeout_seconds() -> float:
"""Seconds one count query may run before the preflight gives up on verifying it."""
raw: Final = os.getenv(MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR)
if raw is None:
return DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS
try:
return float(raw)
except ValueError:
logger.warning(
"%s=%r is not a number, using %s",
MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR,
raw,
DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS,
)
return DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS
def statements(migration_sql: str) -> tuple[str, ...]:
"""The statements of one migration file, comments stripped, empty ones dropped."""
parts: Final = _COMMENT.sub("", migration_sql).split(";")
return tuple(part.strip() for part in parts if part.strip())
def _split_top_level(text: str) -> tuple[str, ...]:
"""Split on the commas outside parentheses."""
depth: Final = accumulate((1 if char == "(" else -1 if char == ")" else 0) for char in text)
cuts: Final = tuple(
position for position, (char, level) in enumerate(zip(text, depth, strict=True)) if char == "," and level == 0
)
starts: Final = (0, *(cut + 1 for cut in cuts))
stops: Final = (*cuts, len(text))
pieces: Final = tuple(text[start:stop].strip() for start, stop in zip(starts, stops, strict=True))
return tuple(piece for piece in pieces if piece)
def _plain_columns(column_list: str) -> tuple[str, ...] | None:
"""The column names of an index or constraint column list, None when one entry is an
expression the derivation cannot turn into a count."""
matches: Final = tuple(_PLAIN_COLUMN.match(entry) for entry in _split_top_level(column_list))
if any(match is None for match in matches):
return None
return tuple(match.group(1) for match in matches if match is not None)
def _index_checks(match: "re.Match[str]", statement_text: str, source: str) -> Iterator[Check]:
unique, name, table, method, column_list = match.groups()
columns: Final = _plain_columns(column_list)
if columns is None:
return
label: Final = f'CREATE {"UNIQUE " if unique else ""}INDEX "{name}"'
if method is None or method.lower() == "btree":
yield Check("too_long", table, columns, label, source, index=name)
if unique:
yield Check("duplicates", table, columns, label, source, index=name)
def _alter_table_checks(table: str, action: str, source: str) -> Iterator[Check]:
if (set_not_null := _SET_NOT_NULL.match(action)) is not None:
yield Check("nulls", table, (set_not_null.group(1),), f'ALTER TABLE "{table}" {action}', source)
return
if (add_column := _ADD_COLUMN.match(action)) is not None:
column, body = add_column.groups()
if _NOT_NULL.search(body) is not None and _HAS_DEFAULT.search(body) is None:
yield Check(
"any_row", table, (column,), f'ALTER TABLE "{table}" ADD COLUMN "{column}" ... NOT NULL', source
)
return
if (foreign_key := _ADD_FOREIGN_KEY.match(action)) is not None:
constraint, columns, referenced_table, referenced_columns, tail = foreign_key.groups()
own: Final = _plain_columns(columns)
theirs: Final = _plain_columns(referenced_columns)
if own is None or theirs is None or len(own) != len(theirs) or "NOT VALID" in tail.upper():
return
yield Check(
"orphans",
table,
own,
_constraint_label(table, constraint, "FOREIGN KEY"),
source,
referenced_table=referenced_table,
referenced_columns=theirs,
)
return
if (unique := _ADD_UNIQUE.match(action)) is not None:
constraint, columns = unique.groups()
unique_columns: Final = _plain_columns(columns)
if unique_columns is not None:
yield Check("duplicates", table, unique_columns, _constraint_label(table, constraint, "UNIQUE"), source)
return
if (check := _ADD_CHECK.match(action)) is not None:
constraint, expression, not_valid = check.groups()
if not_valid is None:
yield Check(
"check_violations",
table,
(),
_constraint_label(table, constraint, "CHECK"),
source,
expression=expression,
)
def _constraint_label(table: str, constraint: str | None, kind: str) -> str:
named: Final = f'CONSTRAINT "{constraint}" ' if constraint else ""
return f'ALTER TABLE "{table}" ADD {named}{kind}'
def checks_for_statement(statement_text: str, source: str) -> tuple[Check, ...]:
"""The count queries one SQL statement needs before it can be applied, empty for a
statement that cannot fail on existing rows or that the derivation cannot read."""
if (index := _CREATE_INDEX.match(statement_text)) is not None:
return tuple(_index_checks(index, statement_text, source))
if (alter := _ALTER_TABLE.match(statement_text)) is not None:
table, actions = alter.groups()
return tuple(
check for action in _split_top_level(actions) for check in _alter_table_checks(table, action, source)
) # comprehension-ok: flattening the per-action checks of one statement
return ()
def checks_for_migration(name: str, migration_sql: str) -> tuple[Check, ...]:
return tuple(
check
for statement_text in statements(migration_sql)
for check in checks_for_statement(statement_text, f"migration {name}")
) # comprehension-ok: flattening the per-statement checks of one migration
def checks_for_index(table: str, name: str, columns: tuple[str, ...]) -> tuple[Check, ...]:
"""The entry-size check a request-log index needs before `CREATE INDEX CONCURRENTLY`."""
return (Check("too_long", table, columns, f'CREATE INDEX "{name}"', "request-log index", index=name),)
def _applied_migrations(connection: "psycopg.Connection[tuple[object, ...]]", schema: str) -> frozenset[str]:
import psycopg
from psycopg import sql
try:
rows: Final = connection.execute(
sql.SQL("SELECT migration_name FROM {} WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL").format(
sql.Identifier(schema, "_prisma_migrations")
)
).fetchall()
except psycopg.errors.UndefinedTable:
return frozenset()
return frozenset(str(row[0]) for row in rows)
def pending_migrations(
connection: "psycopg.Connection[tuple[object, ...]]", schema: str, migrations_dir: Path
) -> tuple[tuple[str, str], ...]:
"""(name, sql) of every shipped migration the ledger has not finished, oldest first.
Without a ledger every migration is pending, and the checks skip tables that do not exist yet."""
applied: Final = _applied_migrations(connection, schema)
folders: Final = tuple(folder for folder in sorted(migrations_dir.iterdir()) if folder.is_dir())
return tuple(
(folder.name, (folder / "migration.sql").read_text())
for folder in folders
if folder.name not in applied and (folder / "migration.sql").is_file()
)
def _int(value: object, default: int) -> int:
return value if isinstance(value, int) else default
def column_type_lookup(connection: "psycopg.Connection[tuple[object, ...]]", schema: str) -> ColumnTypeLookup:
def lookup(table: str, column: str) -> ColumnType | None:
row: Final = connection.execute(
"SELECT t.typname, a.attlen, a.attalign, a.atttypmod FROM pg_attribute a "
"JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace "
"JOIN pg_type t ON t.oid = a.atttypid "
"WHERE n.nspname = %s AND c.relname = %s AND a.attname = %s AND a.attnum > 0 AND NOT a.attisdropped",
(schema, table, column),
).fetchone()
if row is None:
return None
return ColumnType(str(row[0]), _int(row[1], -1), str(row[2]), _int(row[3], -1))
return lookup
def _column_types(check: Check, column_type: ColumnTypeLookup) -> tuple[ColumnType, ...] | None:
"""The catalog types of the check's columns, None when one of them is not there yet."""
types: Final = tuple(column_type(check.table, column) for column in check.columns)
if any(column is None for column in types):
return None
return tuple(column for column in types if column is not None)
def _table_exists(connection: "psycopg.Connection[tuple[object, ...]]", schema: str, table: str) -> bool:
row: Final = connection.execute(
"SELECT 1 FROM information_schema.tables WHERE table_schema = %s AND table_name = %s", (schema, table)
).fetchone()
return row is not None
def _index_is_valid(connection: "psycopg.Connection[tuple[object, ...]]", schema: str, index: str) -> bool:
row: Final = connection.execute(
"SELECT x.indisvalid FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid "
"JOIN pg_namespace n ON n.oid = i.relnamespace WHERE n.nspname = %s AND i.relname = %s",
(schema, index),
).fetchone()
return row is not None and bool(row[0])
def btree_max_entry_bytes(block_size: int) -> int:
"""`BTMaxItemSize` from nbtree.h for btree version 4: a third of the page left after the page
header, three line pointers and the btree opaque area, rounded down to 8, minus the heap TID a
pivot tuple carries. 2704 on the default 8 KB page, the number in Postgres's error."""
usable: Final = block_size - _PAGE_HEADER_WITH_THREE_LINE_POINTERS_BYTES - _BTREE_PAGE_OPAQUE_BYTES
return (usable // 3) // 8 * 8 - _HEAP_TID_BYTES
def _value_bytes(column: ColumnType, identifier: "sql.Identifier") -> "sql.Composed":
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)
def _aligned(offset: "sql.Composable", align: str) -> "sql.Composed":
from psycopg import sql
unit: Final = _ALIGNMENT.get(align, 8)
return sql.SQL("(({off} + {pad}) / {unit}) * {unit}").format(
off=offset, pad=sql.Literal(unit - 1), unit=sql.Literal(unit)
)
def _value_end(column: ColumnType, identifier: "sql.Identifier", offset: "sql.Composable") -> "sql.Composed":
"""Where this column's value ends when laid down from `offset` the way `index_form_tuple`
does it: NULL takes no room, a fixed-length value is aligned to its type, a variable-length
value of up to 126 bytes gets a 1-byte header and no alignment, a longer one a 4-byte header."""
from psycopg import sql
length: Final = _value_bytes(column, identifier)
placed: Final = (
sql.SQL("{} + {}").format(_aligned(offset, column.align), sql.Literal(column.length))
if not column.is_variable_length
else sql.SQL("CASE WHEN {len} <= {short} THEN {off} + 1 + {len} ELSE {aligned} + {hdr} + {len} END").format(
len=length,
short=sql.Literal(SHORT_VARLENA_MAX_BYTES),
off=offset,
aligned=_aligned(offset, column.align),
hdr=sql.Literal(VARLENA_HEADER_BYTES),
)
)
return sql.SQL("CASE WHEN {col} IS NULL THEN {off} ELSE {placed} END").format(
col=identifier, off=offset, placed=placed
)
def _entry_size_query(check: Check, schema: str, types: tuple[ColumnType, ...], limit_bytes: int) -> "sql.Composed":
"""count(*) and the largest entry over the rows whose btree entry would not fit, plus the
longest value of each variable-length column: an 8-byte tuple header, 8 more for the null
bitmap when a column is NULL, each value laid down in index order, the total rounded up to 8."""
from psycopg import sql
identifiers: Final = tuple(sql.Identifier(column) for column in check.columns)
any_null: Final = sql.SQL(" OR ").join(sql.SQL("{} IS NULL").format(identifier) for identifier in identifiers)
header: Final = sql.SQL("{} + CASE WHEN {} THEN {} ELSE 0 END").format(
sql.Literal(INDEX_TUPLE_HEADER_BYTES), any_null, sql.Literal(NULL_BITMAP_BYTES)
)
innermost: Final = sql.SQL("SELECT {cols}, {hdr} AS off0 FROM {t}").format(
cols=sql.SQL(", ").join(identifiers), hdr=header, t=sql.Identifier(schema, check.table)
)
def lay_down(inner: "sql.Composed", step: tuple[int, tuple[ColumnType, "sql.Identifier"]]) -> "sql.Composed":
position, (column, identifier) = step
return sql.SQL("SELECT *, {end} AS {next} FROM ({inner}) AS {alias}").format(
end=_value_end(column, identifier, sql.Identifier(f"off{position}")),
next=sql.Identifier(f"off{position + 1}"),
inner=inner,
alias=sql.Identifier(f"s{position}"),
)
laid_out: Final = reduce(lay_down, enumerate(zip(types, identifiers, strict=True)), innermost)
entry: Final = _aligned(sql.Identifier(f"off{len(types)}"), "d")
longest: Final = sql.SQL("").join(
sql.SQL(", max({})").format(_value_bytes(column, identifier))
for column, identifier in zip(types, identifiers, strict=True)
if column.is_variable_length
)
return sql.SQL("SELECT count(*), max({entry}){longest} FROM ({rows}) AS entries WHERE {entry} > {limit}").format(
entry=entry, longest=longest, rows=laid_out, limit=sql.Literal(limit_bytes)
)
def _count_query(check: Check, schema: str, types: tuple[ColumnType, ...], limit_bytes: int) -> "sql.Composed":
from psycopg import sql
table: Final = sql.Identifier(schema, check.table)
columns: Final = sql.SQL(", ").join(sql.Identifier(column) for column in check.columns)
match check.kind:
case "too_long":
return _entry_size_query(check, schema, types, limit_bytes)
case "duplicates":
not_null: Final = sql.SQL(" AND ").join(
sql.SQL("{} IS NOT NULL").format(sql.Identifier(column)) for column in check.columns
)
return sql.SQL(
"SELECT count(*) FROM (SELECT 1 FROM {t} WHERE {nn} GROUP BY {cols} HAVING count(*) > 1) AS d"
).format(t=table, nn=not_null, cols=columns)
case "nulls":
return sql.SQL("SELECT count(*) FROM {t} WHERE {col} IS NULL").format(
t=table, col=sql.Identifier(check.columns[0])
)
case "any_row":
return sql.SQL("SELECT count(*) FROM {t}").format(t=table)
case "orphans":
pairs: Final = sql.SQL(" AND ").join(
sql.SQL("parent.{} = child.{}").format(sql.Identifier(theirs), sql.Identifier(own))
for own, theirs in zip(check.columns, check.referenced_columns, strict=True)
)
present: Final = sql.SQL(" AND ").join(
sql.SQL("child.{} IS NOT NULL").format(sql.Identifier(column)) for column in check.columns
)
return sql.SQL(
"SELECT count(*) FROM {t} AS child WHERE {present} AND NOT EXISTS (SELECT 1 FROM {ref} AS parent WHERE {pairs})"
).format(t=table, present=present, ref=sql.Identifier(schema, check.referenced_table or ""), pairs=pairs)
case "check_violations":
return sql.SQL("SELECT count(*) FROM {t} WHERE NOT ({expr})").format(
t=table,
expr=sql.SQL(check.expression or "true"), # pyright: ignore[reportArgumentType] # copied from our own migration file
)
def _applies(
connection: "psycopg.Connection[tuple[object, ...]]",
schema: str,
check: Check,
types: tuple[ColumnType, ...] | None,
) -> bool:
"""False when the check has nothing to count yet: the table or a column is not there
(a pending migration adds it), the index is already valid, or no column can overflow."""
if not _table_exists(connection, schema, check.table):
return False
if check.index is not None and _index_is_valid(connection, schema, check.index):
return False
if check.kind == "any_row":
return types is None
if types is None:
return False
if check.kind == "too_long":
return any(column.can_overflow_btree for column in types)
if check.kind == "orphans":
return check.referenced_table is not None and _table_exists(connection, schema, check.referenced_table)
return True
def _too_long_detail(check: Check, types: tuple[ColumnType, ...], values: tuple[object, ...], limit_bytes: int) -> str:
variable: Final = tuple(
column for column, column_type in zip(check.columns, types, strict=True) if column_type.is_variable_length
)
longest: Final = ", ".join(
f"{column} up to {length} bytes"
for column, length in zip(variable, values[2:], strict=True)
if length is not None
)
return f"with an index entry over {limit_bytes} bytes, largest {values[1]} bytes ({longest})"
def _run_check(
connection: "psycopg.Connection[tuple[object, ...]]",
schema: str,
check: Check,
types: tuple[ColumnType, ...],
limit_bytes: int,
timeout_seconds: float,
) -> Finding | None:
import psycopg
try:
row: Final = connection.execute(_count_query(check, schema, types, limit_bytes)).fetchone()
except psycopg.errors.QueryCanceled:
return CouldNotVerify(check, timeout_seconds)
values: Final[tuple[object, ...]] = row if row is not None else ()
rows: Final = _int(values[0], 0) if values else 0
if rows == 0:
return None
match check.kind:
case "too_long":
return BlockedChange(check, rows, _too_long_detail(check, types, values, limit_bytes))
case "duplicates":
return BlockedChange(check, rows, "duplicated value groups")
case "nulls":
return BlockedChange(check, rows, "NULL")
case "any_row":
return BlockedChange(
check, rows, "rows, and a NOT NULL column with no DEFAULT cannot be added to a table with rows"
)
case "orphans":
return BlockedChange(check, rows, f'with no matching row in "{check.referenced_table}"')
case "check_violations":
return BlockedChange(check, rows, f"failing CHECK ({check.expression})")
def find_blockers(
connection: "psycopg.Connection[tuple[object, ...]]",
schema: str,
checks: tuple[Check, ...],
timeout_seconds: float = DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS,
) -> tuple[Finding, ...]:
"""Run every applicable check read-only, each under `timeout_seconds`. Never writes
and never takes a table lock; a check that times out is a finding, not a pass."""
from psycopg import sql
previous_timeout: Final = connection.execute("SHOW statement_timeout").fetchone()
previous_read_only: Final = connection.execute("SHOW default_transaction_read_only").fetchone()
block_size: Final = connection.execute("SHOW block_size").fetchone()
limit_bytes: Final = btree_max_entry_bytes(int(str(block_size[0])) if block_size else DEFAULT_BLOCK_SIZE)
connection.execute("SET default_transaction_read_only = on")
connection.execute(sql.SQL("SET statement_timeout = {}").format(sql.Literal(int(timeout_seconds * 1000))))
column_type: Final = column_type_lookup(connection, schema)
typed: Final = tuple((check, _column_types(check, column_type)) for check in checks)
applicable: Final = tuple(
(check, types if types is not None else ())
for check, types in typed
if _applies(connection, schema, check, types)
)
results: Final = tuple(
_run_check(connection, schema, check, types, limit_bytes, timeout_seconds) for check, types in applicable
)
connection.execute(
sql.SQL("SET statement_timeout = {}").format(sql.Literal(str(previous_timeout[0]) if previous_timeout else "0"))
)
connection.execute(
sql.SQL("SET default_transaction_read_only = {}").format(
sql.Literal(str(previous_read_only[0]) if previous_read_only else "off")
)
)
return tuple(result for result in results if result is not None)
def _remedy(check: Check) -> str:
if check.kind == "too_long" and check.table == "LiteLLM_SpendLogs" and "api_key" in check.columns:
return LEGACY_SPEND_LOG_KEY_REMEDY
joined: Final = ", ".join(check.columns)
match check.kind:
case "too_long":
return f'Shorten or hash the long "{check.table}" ({joined}) values so every row\'s index entry fits.'
case "duplicates":
return f'Remove or merge the duplicate "{check.table}" ({joined}) values.'
case "nulls":
return f'Fill the NULL "{check.table}"."{joined}" values, or give the column a default first.'
case "any_row":
return "Give the new column a DEFAULT in the migration, or add it nullable and fill it afterwards."
case "orphans":
return f'Delete or repoint the "{check.table}" ({joined}) values with no "{check.referenced_table}" row.'
case "check_violations":
return f'Fix the "{check.table}" rows that fail the expression before adding the constraint.'
def _describe(finding: Finding) -> str:
check: Final = finding.check
where: Final = f'"{check.table}"' + (f".{', '.join(check.columns)}" if check.columns else "")
match finding:
case CouldNotVerify(timeout_seconds=seconds):
return (
f" {where}: could not be checked within {seconds:g} s, refusing rather than guessing\n"
f" blocks: {check.statement} ({check.source})\n"
f" raise {MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR} to give the scan more time"
)
case BlockedChange(rows=rows, detail=detail):
return (
f" {where}: {rows} row(s) {detail}\n"
f" blocks: {check.statement} ({check.source})\n"
f" fix: {_remedy(check).replace(chr(10), chr(10) + ' ' * 8)}"
)
def report(findings: tuple[Finding, ...]) -> str:
"""The operator-facing refusal: one block per finding, then what to do next."""
lines: Final = "\n".join(_describe(finding) for finding in findings)
return (
f"{REFUSAL}\n{lines}\n"
"No schema change was made. Fix the rows and start again, or run `litellm --check_migrations_against_existing_data` with the new image to check again."
)
def pending_change_checks(
connection: "psycopg.Connection[tuple[object, ...]]",
schema: str,
migrations_dir: Path | None,
indexes: tuple[tuple[str, str, tuple[str, ...]], ...],
) -> tuple[Check, ...]:
"""Every check the pending migrations and the request-log indexes need. `migrations_dir`
is None when the schema is pushed rather than migrated, so only the indexes are checked."""
migration_checks: Final = (
tuple(
check
for name, migration_sql in pending_migrations(connection, schema, migrations_dir)
for check in checks_for_migration(name, migration_sql)
) # comprehension-ok: flattening the per-migration checks
if migrations_dir is not None
else ()
)
index_checks: Final = tuple(
check for table, name, columns in indexes for check in checks_for_index(table, name, columns)
) # comprehension-ok: flattening the per-index checks
return migration_checks + index_checks

View file

@ -18,6 +18,12 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.migration_data_checks import (
checks_for_index,
find_blockers,
migration_data_check_timeout_seconds,
report,
)
from litellm_proxy_extras.migration_lock import held_migration_lock
if TYPE_CHECKING:
@ -355,6 +361,12 @@ def _build_leaf_index(
connection.execute(sql.SQL("DROP INDEX CONCURRENTLY {}").format(sql.Identifier(schema, name)))
elif _adopt_equivalent_index(connection, schema, table, name, index):
return True
blockers: Final = find_blockers(
connection, schema, checks_for_index(table, name, index.columns), migration_data_check_timeout_seconds()
)
if blockers:
logger.warning("Not building index %s on %s. %s", name, table, report(blockers))
return False
logger.info("Building index %s on %s concurrently", name, table)
prefix: Final = sql.SQL("CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} ").format(
sql.Identifier(name), sql.Identifier(schema, table)

View file

@ -16,6 +16,13 @@ from typing import TYPE_CHECKING, Final, Optional, Protocol, Union
from litellm_proxy_extras import prisma_toolchain
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras._redaction import _redact_credentials
from litellm_proxy_extras.migration_data_checks import (
Finding,
find_blockers,
migration_data_check_timeout_seconds,
pending_change_checks,
report,
)
from litellm_proxy_extras.migration_lock import held_migration_lock
from litellm_proxy_extras.prisma_toolchain import (
PRISMA_COMMAND_TIMEOUT_ENV_VAR,
@ -28,7 +35,11 @@ from litellm_proxy_extras.replica_identity import (
REPLICA_IDENTITY_FULL_ENV_VAR,
apply_replica_identity_full,
)
from litellm_proxy_extras.request_log_indexes import ensure_request_log_indexes, filter_request_log_index_diff
from litellm_proxy_extras.request_log_indexes import (
REQUEST_LOG_INDEXES,
ensure_request_log_indexes,
filter_request_log_index_diff,
)
if TYPE_CHECKING:
import psycopg
@ -53,9 +64,7 @@ def _get_prisma_env() -> dict:
if str_to_bool(os.getenv("PRISMA_OFFLINE_MODE")):
# These env vars prevent Prisma from attempting downloads
prisma_env["NPM_CONFIG_PREFER_OFFLINE"] = "true"
prisma_env["NPM_CONFIG_CACHE"] = os.getenv(
"NPM_CONFIG_CACHE", "/app/.cache/npm"
)
prisma_env["NPM_CONFIG_CACHE"] = os.getenv("NPM_CONFIG_CACHE", "/app/.cache/npm")
return prisma_env
@ -86,6 +95,7 @@ class _InvalidIndex:
name: str
table_size: str
MAX_MIGRATE_DEPLOY_ATTEMPTS = 4
LIBPQ_URL_PARAMS: Final = frozenset(
{
@ -135,9 +145,7 @@ class _MigrateAttemptBudget:
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
)
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE)
_SPEND_LOGS_PK_CLAUSE_RE = re.compile(
r'^(?:DROP\s+CONSTRAINT\s+"[^"]*_pkey"'
r'|ADD\s+(?:CONSTRAINT\s+"[^"]*"\s+)?PRIMARY\s+KEY\s*\([^)]*\))$',
@ -146,9 +154,9 @@ _SPEND_LOGS_PK_CLAUSE_RE = re.compile(
PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
"LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), "
"so its primary key must include the partition key (\"startTime\"). `prisma db push` "
'so its primary key must include the partition key ("startTime"). `prisma db push` '
"reconciles the database against schema.prisma, which declares the unpartitioned "
"primary key (\"request_id\"), and Postgres rejects that rewrite with: unique "
'primary key ("request_id"), and Postgres rejects that rewrite with: unique '
"constraint on partitioned table must include all partitioning columns. Start the "
"proxy without --use_prisma_db_push so it uses `prisma migrate deploy`, which only "
"applies shipped migrations and leaves the partitioned primary key alone."
@ -157,9 +165,7 @@ PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
def _without_sql_comments(statement: str) -> str:
return "\n".join(
line
for line in statement.splitlines()
if line.strip() and not line.strip().startswith("--")
line for line in statement.splitlines() if line.strip() and not line.strip().startswith("--")
).strip()
@ -169,7 +175,7 @@ def _without_spend_logs_pk_clauses(statement: str) -> Optional[str]:
return statement
kept = tuple(
clause.strip()
for clause in statement[prefix_match.end():].split(",\n")
for clause in statement[prefix_match.end() :].split(",\n")
if not _SPEND_LOGS_PK_CLAUSE_RE.match(clause.strip())
)
if not kept:
@ -243,8 +249,7 @@ def _get_prisma_command() -> str:
# If not found, log warning and fall back
logger.warning(
f"Prisma CLI not found at {default_cli_path}. "
"Falling back to Python wrapper (may attempt downloads)"
f"Prisma CLI not found at {default_cli_path}. Falling back to Python wrapper (may attempt downloads)"
)
# Fall back to the Python wrapper (will work in online mode)
@ -331,9 +336,7 @@ class ProxyExtrasDBManager:
return True
except subprocess.TimeoutExpired:
logger.warning(
"Migration timed out - the database might be under heavy load."
)
logger.warning("Migration timed out - the database might be under heavy load.")
return False
except subprocess.CalledProcessError as e:
logger.warning(
@ -386,10 +389,12 @@ class ProxyExtrasDBManager:
except ImportError:
return None
from psycopg import sql
cleaned_url: Final = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
ledger_table: Final = psycopg.sql.SQL("{}.{}").format(
psycopg.sql.Identifier(ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"),
psycopg.sql.Identifier("_prisma_migrations"),
ledger_table: Final = sql.SQL("{}.{}").format(
sql.Identifier(ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"),
sql.Identifier("_prisma_migrations"),
)
try:
with psycopg.connect(cleaned_url, connect_timeout=10, autocommit=True) as conn:
@ -500,9 +505,7 @@ class ProxyExtrasDBManager:
return filter_partitioned_spend_logs_diff(without_indexes)
@staticmethod
def _resolve_all_migrations(
migrations_dir: str, schema_path: str, mark_all_applied: bool = True
):
def _resolve_all_migrations(migrations_dir: str, schema_path: str, mark_all_applied: bool = True):
"""
1. Compare the current database state to schema.prisma and generate a migration for the diff.
2. Run prisma migrate deploy to apply any pending migrations.
@ -571,9 +574,7 @@ class ProxyExtrasDBManager:
)
logger.info(f"Applied migration: {mig_file.parent.name}")
except subprocess.CalledProcessError as e:
logger.warning(
f"Failed to apply migration {mig_file.parent.name}: {e.stderr}"
)
logger.warning(f"Failed to apply migration {mig_file.parent.name}: {e.stderr}")
except subprocess.TimeoutExpired:
logger.warning(f"Migration {mig_file.parent.name} timed out.")
return
@ -645,9 +646,7 @@ class ProxyExtrasDBManager:
logger.debug(f"Resolved migration: {migration_name}")
except subprocess.CalledProcessError as e:
if "is already recorded as applied in the database." not in e.stderr:
logger.warning(
f"Failed to resolve migration {migration_name}: {e.stderr}"
)
logger.warning(f"Failed to resolve migration {migration_name}: {e.stderr}")
@staticmethod
def raise_if_lens_rename_pending(connect: LensCheckConnect | None = None) -> None:
@ -707,9 +706,7 @@ class ProxyExtrasDBManager:
cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
try:
with psycopg.connect(
cleaned_url, connect_timeout=10, autocommit=True
) as conn:
with psycopg.connect(cleaned_url, connect_timeout=10, autocommit=True) as conn:
row = conn.execute(
"SELECT 1 "
"FROM pg_partitioned_table pt "
@ -717,10 +714,7 @@ class ProxyExtrasDBManager:
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname = 'LiteLLM_SpendLogs' "
" AND n.nspname = %s",
(
ProxyExtrasDBManager._prisma_schema_param(database_url)
or "public",
),
(ProxyExtrasDBManager._prisma_schema_param(database_url) or "public",),
).fetchone()
except (psycopg.OperationalError, psycopg.DatabaseError):
return False
@ -815,9 +809,7 @@ class ProxyExtrasDBManager:
# it, psycopg3's `with conn` calls COMMIT on clean exit — which
# fails after `UndefinedTable` (fresh DB) leaves the transaction
# in an aborted state.
with psycopg.connect(
cleaned_url, connect_timeout=10, autocommit=True
) as conn:
with psycopg.connect(cleaned_url, connect_timeout=10, autocommit=True) as conn:
try:
rows = conn.execute(
"SELECT migration_name FROM _prisma_migrations "
@ -838,9 +830,7 @@ class ProxyExtrasDBManager:
return
head_newest_ts = _max_migration_timestamp(known)
hostile = {
name for name in unknown if _migration_timestamp(name) > head_newest_ts
}
hostile = {name for name in unknown if _migration_timestamp(name) > head_newest_ts}
if not hostile:
return
@ -931,8 +921,7 @@ class ProxyExtrasDBManager:
if not found:
return True
logger.warning(
"Found %d invalid index(es) left by an interrupted CREATE INDEX "
"CONCURRENTLY, rebuilding: %s",
"Found %d invalid index(es) left by an interrupted CREATE INDEX CONCURRENTLY, rebuilding: %s",
len(found),
", ".join(f"{index.name} (table size {index.table_size})" for index in found),
)
@ -1403,8 +1392,7 @@ class ProxyExtrasDBManager:
prisma_env = _get_prisma_env()
except OSError as e:
logger.error(
"Could not resolve the migrations directory for the REPLICA "
"IDENTITY FULL step, skipping it. Error: %s",
"Could not resolve the migrations directory for the REPLICA IDENTITY FULL step, skipping it. Error: %s",
e,
)
return False
@ -1435,18 +1423,57 @@ class ProxyExtrasDBManager:
Returns:
bool: True if setup was successful, False otherwise
"""
ensure_prisma_toolchain(
prisma_command=_get_prisma_command(), prisma_env=_get_prisma_env()
)
migrated = ProxyExtrasDBManager._run_migrations(
use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
)
ensure_prisma_toolchain(prisma_command=_get_prisma_command(), prisma_env=_get_prisma_env())
ProxyExtrasDBManager.raise_if_rows_block_pending_changes(use_migrate=use_migrate)
migrated = ProxyExtrasDBManager._run_migrations(use_migrate=use_migrate, use_v2_resolver=use_v2_resolver)
if not migrated:
return False
ProxyExtrasDBManager.repair_invalid_indexes()
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
return True
@staticmethod
def migration_data_check_findings(use_migrate: bool = True) -> tuple[Finding, ...]:
"""What in the existing rows would make a pending migration or request-log index
fail, read with count queries only: no write, no table lock. Empty when nothing
blocks, when no database is configured, or when it cannot be reached, so the
migration step that follows reports the connection problem the way it does today."""
import psycopg
database_url: Final = os.environ.get("DATABASE_URL")
if not database_url:
return ()
direct_url: Final = ProxyExtrasDBManager._strip_prisma_query_params(
os.environ.get("DIRECT_URL") or database_url
)
schema: Final = ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"
migrations_dir: Final = Path(ProxyExtrasDBManager._get_prisma_dir()) / "migrations" if use_migrate else None
indexes: Final = tuple((index.table, index.name, index.columns) for index in REQUEST_LOG_INDEXES)
try:
with psycopg.connect(direct_url, connect_timeout=10, autocommit=True) as connection:
checks: Final = pending_change_checks(connection, schema, migrations_dir, indexes)
return find_blockers(connection, schema, checks, migration_data_check_timeout_seconds())
except psycopg.OperationalError as exc:
logger.warning(
"Could not reach the database for the pre-migration row check: %s", _redact_credentials(str(exc))
)
return ()
@staticmethod
def migration_data_check_report(use_migrate: bool = True) -> Optional[str]:
"""The operator-facing refusal for `migration_data_check_findings`, None when nothing blocks."""
findings: Final = ProxyExtrasDBManager.migration_data_check_findings(use_migrate=use_migrate)
return report(findings) if findings else None
@staticmethod
def raise_if_rows_block_pending_changes(use_migrate: bool) -> None:
"""Refuse to run migrations that the existing rows would make fail, before any is
applied. The RuntimeError carries the report and reaches the operator through the
proxy's "Database migration cannot proceed" exit."""
refusal: Final = ProxyExtrasDBManager.migration_data_check_report(use_migrate=use_migrate)
if refusal is not None:
raise RuntimeError(refusal)
@staticmethod
def build_request_log_indexes(build: Callable[[str, str], bool] = ensure_request_log_indexes) -> bool:
"""Build the indexes in `REQUEST_LOG_INDEXES` on the writer, in the schema the
@ -1513,9 +1540,7 @@ class ProxyExtrasDBManager:
# Skip sanity check when deploy reports no pending migrations —
# DB already matches schema, no drift to correct.
if "No pending migrations to apply" in result.stdout:
logger.info(
"No pending migrations — skipping post-migration sanity check"
)
logger.info("No pending migrations — skipping post-migration sanity check")
return True
# Run sanity check to ensure DB matches schema
@ -1530,9 +1555,7 @@ class ProxyExtrasDBManager:
logger.info(f"prisma db error: {stderr}, e: {e.stdout}")
if "P3009" in stderr:
# Extract the failed migration name from the error message
migration_match = re.search(
r"`(\d+_.*)` migration", stderr
)
migration_match = re.search(r"`(\d+_.*)` migration", stderr)
if migration_match:
failed_migration = migration_match.group(1)
if ProxyExtrasDBManager._is_idempotent_error(stderr):
@ -1540,9 +1563,7 @@ class ProxyExtrasDBManager:
f"Migration {failed_migration} failed due to idempotent error (e.g., column already exists), resolving as applied"
)
try:
ProxyExtrasDBManager._roll_back_migration(
failed_migration
)
ProxyExtrasDBManager._roll_back_migration(failed_migration)
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
@ -1552,9 +1573,7 @@ class ProxyExtrasDBManager:
f"It may already be in a rolled-back state."
)
try:
ProxyExtrasDBManager._resolve_specific_migration(
failed_migration
)
ProxyExtrasDBManager._resolve_specific_migration(failed_migration)
logger.info(
f"✅ Migration {failed_migration} resolved, retrying to apply remaining migrations"
)
@ -1562,9 +1581,7 @@ class ProxyExtrasDBManager:
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
) as resolve_err:
logger.warning(
f"Failed to resolve migration {failed_migration}: {resolve_err}"
)
logger.warning(f"Failed to resolve migration {failed_migration}: {resolve_err}")
# Apply any schema drift not covered by the marked-as-applied migration
ProxyExtrasDBManager._resolve_all_migrations(
migrations_dir,
@ -1572,9 +1589,7 @@ class ProxyExtrasDBManager:
mark_all_applied=False,
)
else:
logger.info(
f"Found failed migration: {failed_migration}, marking as rolled back"
)
logger.info(f"Found failed migration: {failed_migration}, marking as rolled back")
# Mark the failed migration as rolled back
prisma_toolchain.run_prisma(
[
@ -1587,23 +1602,14 @@ class ProxyExtrasDBManager:
timeout=prisma_command_timeout(),
env=_get_prisma_env(),
)
logger.info(
f"✅ Migration {failed_migration} marked as rolled back... retrying"
)
elif (
"P3005" in stderr
and "database schema is not empty" in stderr
):
logger.info(f"✅ Migration {failed_migration} marked as rolled back... retrying")
elif "P3005" in stderr and "database schema is not empty" in stderr:
logger.info(
"Database schema is not empty, creating baseline migration. In read-only file system, please set an environment variable `LITELLM_MIGRATION_DIR` to a writable directory to enable migrations. Learn more - https://docs.litellm.ai/docs/proxy/prod#read-only-file-system"
)
ProxyExtrasDBManager._create_baseline_migration(schema_path)
logger.info(
"Baseline migration created, resolving all migrations"
)
ProxyExtrasDBManager._resolve_all_migrations(
migrations_dir, schema_path
)
logger.info("Baseline migration created, resolving all migrations")
ProxyExtrasDBManager._resolve_all_migrations(migrations_dir, schema_path)
logger.info("✅ All migrations resolved.")
return True
elif "P3018" in stderr:
@ -1611,14 +1617,8 @@ class ProxyExtrasDBManager:
if ProxyExtrasDBManager._is_permission_error(stderr):
# Permission errors should NOT be marked as applied
# Extract migration name for logging
migration_match = re.search(
r"Migration name: (\d+_.*)", stderr
)
migration_name = (
migration_match.group(1)
if migration_match
else "unknown"
)
migration_match = re.search(r"Migration name: (\d+_.*)", stderr)
migration_name = migration_match.group(1) if migration_match else "unknown"
logger.error(
f"❌ Migration {migration_name} failed due to insufficient permissions. "
@ -1628,16 +1628,10 @@ class ProxyExtrasDBManager:
# Mark as rolled back and exit with error
if migration_match:
try:
ProxyExtrasDBManager._roll_back_migration(
migration_name
)
logger.info(
f"Migration {migration_name} marked as rolled back"
)
ProxyExtrasDBManager._roll_back_migration(migration_name)
logger.info(f"Migration {migration_name} marked as rolled back")
except Exception as rollback_error:
logger.warning(
f"Failed to mark migration as rolled back: {rollback_error}"
)
logger.warning(f"Failed to mark migration as rolled back: {rollback_error}")
# Re-raise the error to prevent silent failures
raise RuntimeError(
@ -1652,18 +1646,12 @@ class ProxyExtrasDBManager:
"resolving as applied"
)
# Extract the migration name from the error message
migration_match = re.search(
r"Migration name: (\d+_.*)", stderr
)
migration_match = re.search(r"Migration name: (\d+_.*)", stderr)
if migration_match:
migration_name = migration_match.group(1)
try:
logger.info(
f"Rolling back migration {migration_name}"
)
ProxyExtrasDBManager._roll_back_migration(
migration_name
)
logger.info(f"Rolling back migration {migration_name}")
ProxyExtrasDBManager._roll_back_migration(migration_name)
except (
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
@ -1677,9 +1665,7 @@ class ProxyExtrasDBManager:
f"Resolving migration {migration_name} that failed "
f"due to existing schema objects"
)
ProxyExtrasDBManager._resolve_specific_migration(
migration_name
)
ProxyExtrasDBManager._resolve_specific_migration(migration_name)
logger.info(
f"✅ Migration {migration_name} resolved, "
f"retrying to apply remaining migrations"
@ -1688,9 +1674,7 @@ class ProxyExtrasDBManager:
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
) as resolve_err:
logger.warning(
f"Failed to resolve migration {migration_name}: {resolve_err}"
)
logger.warning(f"Failed to resolve migration {migration_name}: {resolve_err}")
# Apply any schema drift not covered by the marked-as-applied migration
ProxyExtrasDBManager._resolve_all_migrations(
migrations_dir,
@ -1732,14 +1716,8 @@ class ProxyExtrasDBManager:
time.sleep(random.randrange(5, 15))
except subprocess.CalledProcessError as e:
attempts_left = 3 - attempt
retry_msg = (
f" Retrying... ({attempts_left} attempts left)"
if attempts_left > 0
else ""
)
stderr_detail: Final = (
f" stderr: {_redact_credentials(str(e.stderr))}" if e.stderr else ""
)
retry_msg = f" Retrying... ({attempts_left} attempts left)" if attempts_left > 0 else ""
stderr_detail: Final = f" stderr: {_redact_credentials(str(e.stderr))}" if e.stderr else ""
logger.error(
f"The process failed to execute. Details: {_redact_command_error(e)}.{stderr_detail}{retry_msg}"
)

View file

@ -97,8 +97,13 @@ from litellm.litellm_core_utils.internal_call_metadata import (
is_unbilled_non_inference_call,
)
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
bedrock_guardrail_trace,
cost_breakdown_with_guardrail,
guardrail_information_cost,
is_str_keyed_dict,
native_bedrock_guardrail_information,
object_mapping,
with_guardrail_entry,
)
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
@ -2725,6 +2730,31 @@ class Logging(LiteLLMLoggingBaseClass):
return
self.model_call_details["response_headers"] = headers
def record_native_bedrock_guardrail(self, result: object) -> None:
"""Bill the guardrail trace a Bedrock response carries when the deployment's ``guardrailConfig`` ran it, by
recording it as guardrail information on this call, the same entry the proxy-side Bedrock guardrail writes.
A cache hit carries the original response's trace but made no Bedrock call, so it bills nothing."""
trace: Final = bedrock_guardrail_trace(result)
if trace is None or served_from_cache(result) or self.model_call_details.get("cache_hit") is True:
return
optional_params: Final = object_mapping(getattr(self, "optional_params", None)) or {}
guardrail_config: Final = object_mapping(optional_params.get("guardrailConfig")) or {}
identifier: Final = guardrail_config.get("guardrailIdentifier")
litellm_params_in: Final = object_mapping(getattr(self, "litellm_params", None)) or {}
region: Final = optional_params.get("aws_region_name") or litellm_params_in.get("aws_region_name")
entry: Final = native_bedrock_guardrail_information(
trace,
aws_region_name=region if isinstance(region, str) else None,
guardrail_identifier=identifier if isinstance(identifier, str) else None,
)
litellm_params: Final[object] = self.model_call_details.get("litellm_params")
if entry is None or not is_str_keyed_dict(litellm_params):
return
metadata: Final[object] = litellm_params.get("metadata")
bucket: Final = metadata if is_str_keyed_dict(metadata) else {}
litellm_params["metadata"] = bucket
with_guardrail_entry(bucket, entry)
def _merge_hidden_params_from_response_into_metadata(self, logging_result: object) -> None:
"""
Copy response._hidden_params into litellm_params.metadata['hidden_params'].
@ -7111,12 +7141,12 @@ class StandardLoggingPayloadSetup:
request_headers: Final = cast( # cast-ok: request headers are untyped framework data
dict[str, str], headers
)
header_tags: Final = []
for header_name in extra_headers:
header_value = request_headers.get(header_name)
if header_value:
header_tags.append(f"{header_name}: {header_value}")
lowercase_request_headers: Final = {str(name).lower(): value for name, value in request_headers.items()}
header_tags: Final = [
f"{header_name}: {header_value}"
for header_name in extra_headers
if (header_value := lowercase_request_headers.get(header_name.lower()))
]
return header_tags if header_tags else None

View file

@ -1,15 +1,24 @@
import math
from collections.abc import Mapping
from collections.abc import Iterator, Mapping, Sequence
from functools import partial
from itertools import chain
from typing import Annotated, Final
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import (
redact_nested_match_and_regex_keys, # pyright: ignore[reportUnknownVariableType] # signature uses bare dict
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import CostBreakdown
from litellm.types.utils import CostBreakdown, GuardrailStatus, StandardLoggingGuardrailInformation
BEDROCK_GUARDRAIL_PRICING_KEY: Final = "bedrock/guardrails"
BEDROCK_TRACE_FIELDS: Final = ("trace", "amazon-bedrock-trace")
_INTERVENING_ACTIONS: Final = frozenset({"BLOCKED", "ANONYMIZED"})
class GuardrailPricing(LiteLLMBaseModel):
@ -96,6 +105,161 @@ def bedrock_guardrail_cost(usage_units: Mapping[str, int], aws_region_name: str
return guardrail_cost_total(bedrock_guardrail_cost_by_unit(usage_units, aws_region_name))
_OBJECT_MAPPING: Final = TypeAdapter(Mapping[str, object])
_OBJECT_ITEMS: Final = TypeAdapter(tuple[object, ...])
def object_mapping(value: object) -> Mapping[str, object] | None:
"""``value`` as a str-keyed mapping, None for anything else. Vendor JSON arrives untyped, so this is the one place
that turns it into something the rest of the module can read without guessing."""
try:
return _OBJECT_MAPPING.validate_python(value)
except ValidationError:
return None
def object_items(value: object) -> tuple[object, ...]:
if isinstance(value, (str, bytes, Mapping)):
return ()
try:
return _OBJECT_ITEMS.validate_python(value)
except ValidationError:
return ()
def is_str_keyed_dict(
value: object,
) -> TypeIs[dict[str, object]]: # guard-ok: JSON object keys are str # mutable-ok: the caller edits this dict in place
return isinstance(value, dict)
def _trace_field(response: object, field: str) -> Mapping[str, object] | None:
source: Final = object_mapping(response)
trace: Final = object_mapping(source.get(field) if source is not None else getattr(response, field, None))
if trace is None or object_mapping(trace.get("guardrail")) is None:
return None
return trace
def bedrock_guardrail_trace(response: object) -> Mapping[str, object] | None:
"""The guardrail trace a Bedrock response carries: Converse puts it under ``trace``, Invoke under
``amazon-bedrock-trace``. Returned as is, or None when the response has no guardrail section."""
traces: Final = map(partial(_trace_field, response), BEDROCK_TRACE_FIELDS)
return next((trace for trace in traces if trace is not None), None)
class _Assessment(BaseModel):
model_config = ConfigDict(frozen=True, extra="allow")
invocation_metrics: Mapping[str, JsonValue] = Field(default_factory=dict, alias="invocationMetrics")
@property
def billable_usage(self) -> Mapping[str, int]:
usage: Final = self.invocation_metrics.get("usage")
if not isinstance(usage, dict):
return {}
return {
counter: units
for counter, units in usage.items()
if counter.endswith("Units") and isinstance(units, int) and not isinstance(units, bool)
}
class _GuardrailTrace(BaseModel):
"""Both trace shapes: Converse ``inputAssessment`` {id: a} and ``outputAssessments`` {id: [a, ...]}, Invoke
``input`` {id: a} and ``outputs`` [{id: a}, ...]."""
model_config = ConfigDict(frozen=True, extra="allow")
input_assessment: Mapping[str, _Assessment] = Field(default_factory=dict, alias="inputAssessment")
input: Mapping[str, _Assessment] = Field(default_factory=dict)
output_assessments: Mapping[str, tuple[_Assessment, ...]] = Field(default_factory=dict, alias="outputAssessments")
outputs: tuple[Mapping[str, _Assessment], ...] = ()
@property
def assessments(self) -> tuple[tuple[str, _Assessment], ...]:
listed: Final = (
(gid, a) for gid, items in self.output_assessments.items() for a in items
) # comprehension-ok: id then its list
return (
*self.input_assessment.items(),
*self.input.items(),
*listed,
*chain.from_iterable(by_id.items() for by_id in self.outputs),
)
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
def _summed_usage(usages: Sequence[Mapping[str, int]]) -> Mapping[str, int]:
counters: Final = tuple(dict.fromkeys(chain.from_iterable(usages)))
return {counter: sum(usage.get(counter, 0) for usage in usages) for counter in counters}
def _actions(node: JsonValue) -> Iterator[str]:
if isinstance(node, list):
yield from chain.from_iterable(map(_actions, node))
return
if not isinstance(node, dict):
return
action: Final = node.get("action")
if isinstance(action, str):
yield action
yield from chain.from_iterable(map(_actions, node.values()))
def native_bedrock_guardrail_information(
trace: Mapping[str, object], aws_region_name: str | None, guardrail_identifier: str | None
) -> StandardLoggingGuardrailInformation | None:
"""The guardrail entry for a trace Bedrock returned because the deployment's ``guardrailConfig`` ran with
``trace: enabled``: every assessment's ``invocationMetrics.usage`` summed and priced like the proxy-side Bedrock
guardrail. None when the trace carries no usage, which is what ``trace: disabled`` leaves."""
try:
guardrail: Final = _JSON_OBJECT.validate_python(trace.get("guardrail"))
pairs: Final = _GuardrailTrace.model_validate(guardrail).assessments
except ValidationError:
return None
usage: Final = _summed_usage(tuple(assessment.billable_usage for _, assessment in pairs))
if not usage:
return None
cost_by_unit: Final = bedrock_guardrail_cost_by_unit(usage, aws_region_name)
name: Final = guardrail_identifier or pairs[0][0]
status: Final[GuardrailStatus] = (
"guardrail_intervened" if any(action in _INTERVENING_ACTIONS for action in _actions(guardrail)) else "success"
)
priced: Final[StandardLoggingGuardrailInformation] = (
{"guardrail_cost_by_unit": cost_by_unit} if cost_by_unit is not None else {}
)
return {
"guardrail_name": name,
"guardrail_id": name,
"guardrail_provider": "bedrock",
"guardrail_mode": GuardrailEventHooks.during_call,
"guardrail_response": redact_nested_match_and_regex_keys(guardrail),
"guardrail_status": status,
"guardrail_usage": usage,
"guardrail_cost": guardrail_cost_total(cost_by_unit),
**priced,
}
def guardrail_entries(raw: object) -> tuple[object, ...]:
"""The entries under ``standard_logging_guardrail_information``, which older writers stored as one dict."""
single: Final = object_mapping(raw)
return (single,) if single is not None else object_items(raw)
def with_guardrail_entry(
metadata: dict[str, object], # mutable-ok: the proxy's own request metadata, read later for headers and spend
entry: StandardLoggingGuardrailInformation,
) -> None:
"""Append ``entry`` to the metadata's ``standard_logging_guardrail_information`` once, so the same trace recorded
from the response headers path and again from the success handler bills a single time."""
existing: Final = guardrail_entries(metadata.get("standard_logging_guardrail_information"))
if entry in existing:
return
metadata["standard_logging_guardrail_information"] = [*existing, entry] # rebind-ok: appending here is the job
AZURE_PROMPT_SHIELD_TEXT_RECORD_UNIT: Final = "text_records"

View file

@ -14,7 +14,7 @@ from collections.abc import Mapping, Sequence
from dataclasses import replace
from typing import TYPE_CHECKING, Any, Final
from pydantic import TypeAdapter
from pydantic import JsonValue, TypeAdapter, ValidationError
import litellm
from litellm.constants import REDACTED_BY_LITELLM
@ -120,11 +120,55 @@ def _redacted_streaming_response_copy(streaming_response: object) -> object:
return _redact_streaming_response(redacted_response)
BEDROCK_GUARDRAIL_TRACE_FIELDS: Final = ("trace", "amazon-bedrock-trace")
_MATCHED_TEXT_KEYS: Final = frozenset({"match", "regex"})
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
def _without_matched_text(trace: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
redacted: Final = dict(copy.deepcopy(trace))
stack: Final[list[JsonValue]] = [redacted] # mutable-ok: iterative walk, recursive_detector bans recursion here
while stack:
node = stack.pop()
if isinstance(node, dict):
for key in _MATCHED_TEXT_KEYS & node.keys():
node[key] = "[REDACTED]"
stack.extend(node.values())
elif isinstance(node, list):
stack.extend(node)
return redacted
def _redacted_bedrock_trace(trace: object) -> object:
try:
return _without_matched_text(_JSON_OBJECT.validate_python(trace))
except ValidationError:
return trace
def _redact_bedrock_guardrail_trace(response: object) -> None:
"""A Bedrock guardrail trace echoes the matched text of its sensitive-information and word findings back."""
mapping: Final = _as_mapping(response)
if isinstance(response, dict):
traces: Final = {
field: _redacted_bedrock_trace(value)
for field, value in (mapping or {}).items()
if field in BEDROCK_GUARDRAIL_TRACE_FIELDS
}
response.update(traces)
return
for field, value in ((name, getattr(response, name, None)) for name in BEDROCK_GUARDRAIL_TRACE_FIELDS):
if value is not None:
setattr(response, field, _redacted_bedrock_trace(value))
def _redact_streaming_response(streaming_response):
if hasattr(streaming_response, "choices"):
for choice in streaming_response.choices:
_redact_choice_content(choice)
redact_vertex_ai_metadata_from_logged_object(streaming_response)
if isinstance(streaming_response, litellm.ModelResponse):
_redact_bedrock_guardrail_trace(streaming_response)
elif isinstance(streaming_response, litellm.ResponsesAPIResponse):
return perform_redaction({}, streaming_response)
elif hasattr(streaming_response, "output"):
@ -351,6 +395,7 @@ def _redact_standard_logging_object(
if isinstance(response.get("choices"), list):
_redact_model_response_dict_choices(response["choices"], redacted_str)
redact_vertex_ai_metadata_from_logged_object(response)
_redact_bedrock_guardrail_trace(response)
elif isinstance(response, str):
standard_logging_object["response"] = redacted_str
else:
@ -523,11 +568,13 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
for choice in _result.choices:
_redact_choice_content(choice)
redact_vertex_ai_metadata_from_logged_object(_result)
_redact_bedrock_guardrail_trace(_result)
elif isinstance(_result, dict) and "choices" in _result:
# Handle dict representation of ModelResponse (e.g., from model_dump())
if _result.get("choices") is not None:
_redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM)
redact_vertex_ai_metadata_from_logged_object(_result)
_redact_bedrock_guardrail_trace(_result)
elif isinstance(_result, dict) and "output" in _result:
return _redacted_responses_api_response(_result, redact_instructions=redact_streaming_responses)
elif isinstance(_result, litellm.ResponsesAPIResponse):

View file

@ -14,6 +14,7 @@ import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import chunk_usage, provider_reported_usage_cost
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRACE_FIELDS, object_items, object_mapping
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
)
@ -1484,6 +1485,35 @@ def _simple_text_part(choices: Sequence[object]) -> str | None:
return content if isinstance(content, str) else ""
_NO_TRACE_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
def _carry_bedrock_guardrail_trace(chunks: Sequence[object], response: ModelResponse) -> None:
"""Bedrock reports a guardrail trace once per stream, on the final metadata chunk, so the built response carries it
under the same field as the non-streaming response (``trace`` on Converse, ``amazon-bedrock-trace`` on Invoke)."""
traced: Final = map(_bedrock_trace_fields, reversed(chunks))
carried: Final = next((fields for fields in traced if fields), _NO_TRACE_FIELDS)
for field, trace in carried.items():
setattr(response, field, trace)
def _record_bedrock_guardrail_trace(
chunks: Sequence[object], response: ModelResponse, logging_obj: Optional["Logging"]
) -> None:
_carry_bedrock_guardrail_trace(chunks, response)
if logging_obj is not None:
logging_obj.record_native_bedrock_guardrail(response)
def _bedrock_trace_fields(chunk: object) -> Mapping[str, object]:
as_mapping: Final = object_mapping(chunk)
raw: Final = (
as_mapping.get("provider_specific_fields") if as_mapping else getattr(chunk, "provider_specific_fields", None)
)
fields: Final[Mapping[str, object]] = object_mapping(raw) or {}
return {field: fields[field] for field in BEDROCK_TRACE_FIELDS if field in fields}
def stream_chunk_builder(
chunks: list,
messages: Sequence | None = None,
@ -1565,6 +1595,11 @@ def stream_chunk_builder(
)
break
_record_bedrock_guardrail_trace(
object_items(chunks), # pyright: ignore[reportUnknownArgumentType] # chunks is an untyped list param
response,
logging_obj,
)
_stamp_streaming_usage_cost(usage, response, logging_obj)
_set_stream_builder_response_cost(
response,
@ -1760,6 +1795,11 @@ def stream_chunk_builder(
chunks, # pyright: ignore[reportUnknownArgumentType] # legacy chunk list is untyped
)
_record_bedrock_guardrail_trace(
object_items(chunks), # pyright: ignore[reportUnknownArgumentType] # chunks is an untyped list param
response,
logging_obj,
)
processor.apply_provider_assembled_streaming_metadata(response, chunks, logging_obj)
return response
except Exception as e:

View file

@ -1610,6 +1610,8 @@ class CustomStreamWrapper:
return _ProviderChunkParsed(response_obj)
def chunk_creator(self, chunk: Any):
if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields:
self.logging_obj.record_native_bedrock_guardrail(chunk.provider_specific_fields)
if self.custom_llm_provider != "cached_response" and isinstance(
chunk, (ChatCompletionChunk, ModelResponseStream)
):
@ -1717,6 +1719,8 @@ class CustomStreamWrapper:
model_response.usage = Usage(**chunk_usage.model_dump())
## RETURN ARG
if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields:
self.copy_model_response_level_provider_specific_fields(chunk, model_response)
result: Final = self.return_processed_chunk_logic(
completion_obj=completion_obj,
model_response=model_response,

View file

@ -806,6 +806,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
tool_name_mapping=tool_name_mapping,
polyfill_result=polyfill_result,
is_async=True,
completion_request=completion_kwargs,
litellm_logging_obj=logging_obj,
safeguards_evaluator=safeguards_evaluator,
)
@ -952,6 +953,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
tool_name_mapping=tool_name_mapping,
polyfill_result=polyfill_result,
is_async=False,
completion_request=completion_kwargs,
litellm_logging_obj=logging_obj,
)
if transformed_stream is not None:

View file

@ -22,6 +22,7 @@ from typing import (
from pydantic import TypeAdapter
from typing_extensions import assert_never
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.exceptions import ContextWindowExceededError, MidStreamFallbackError
@ -41,19 +42,25 @@ from litellm.types.llms.anthropic import (
UsageDelta,
UsageIteration,
)
from litellm.types.llms.openai import ChatCompletionRedactedThinkingBlock, ChatCompletionThinkingBlock
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionRedactedThinkingBlock,
ChatCompletionThinkingBlock,
ChatCompletionToolParam,
)
from litellm.types.utils import AdapterCompletionStreamWrapper, ChatCompletionDeltaToolCall, Delta, Function
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.llms.anthropic.messages.safeguards import SafeguardsEvaluator
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponseStream
_STREAMING_DELTA_TYPES: Final = frozenset(get_args(StreamingContentBlockDeltaType))
_SSE_EVENT: Final = TypeAdapter(Mapping[str, object])
_THINKING_BLOCK: Final = TypeAdapter(ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock)
_PROMPT_MESSAGES: Final = TypeAdapter(list[AllMessageValues])
_PROMPT_TOOLS: Final = TypeAdapter(list[ChatCompletionToolParam])
class _UsageDeltaWithIterations(UsageDelta, total=False):
@ -548,6 +555,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
iterations_usage: list[UsageIteration] | None = None,
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
completion_request: Mapping[str, object] | None = None,
):
# Wrap the upstream stream so chunks that carry both content and a
# finish_reason (fake-streamed providers) are split into two — see
@ -556,6 +564,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
super().__init__(combined_chunk_splitter)
self._combined_chunk_splitter: Final = combined_chunk_splitter
self.model = model
self.completion_request: Final = completion_request
self._message_id: str = f"msg_{uuid.uuid4()}"
if litellm_logging_obj is not None:
litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id)
@ -752,25 +761,38 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
def _create_initial_usage_delta(self) -> UsageDelta:
"""
Create the initial UsageDelta for the message_start event.
Estimate prompt tokens for message_start without consuming upstream chunks.
Initializes cache token fields (cache_creation_input_tokens, cache_read_input_tokens)
to 0 to indicate to clients (like Claude Code) that prompt caching is supported.
The actual cache token values will be provided in the message_delta event at the
end of the stream, since Bedrock Converse API only returns usage data in the final
response chunk.
Returns:
UsageDelta with all token counts initialized to 0.
Output and cache counts start at zero. Provider usage in message_delta
remains authoritative. Failed estimates fall back to zero.
"""
return UsageDelta(
input_tokens=0,
input_tokens=self._estimate_initial_input_tokens(),
output_tokens=0,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
)
def _estimate_initial_input_tokens(self) -> int:
try:
request: Final = self.completion_request or {}
messages: Final = _PROMPT_MESSAGES.validate_python(request.get("messages", self.messages))
if not messages:
return 0
materialized_messages: Final = cast( # cast-ok: JSON-mode serialization preserves validated message types and materializes iterable content
list[AllMessageValues], _PROMPT_MESSAGES.dump_python(messages, mode="json")
)
model: Final = request.get("model", self.model)
tools: Final = request.get("tools")
return litellm.token_counter( # pyright: ignore[reportUnknownMemberType] # token_counter's public signature is partially untyped
model=model if isinstance(model, str) else self.model,
messages=materialized_messages,
tools=_PROMPT_TOOLS.validate_python(tools) if tools is not None else None,
use_default_image_token_count=True,
)
except Exception: # noqa: BLE001 # prompt estimation must not interrupt the stream
return 0
def __next__(self):
from .transformation import LiteLLMAnthropicMessagesAdapter

View file

@ -322,6 +322,7 @@ class AnthropicAdapter:
is_async: bool = True,
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
completion_request: Mapping[str, object] | None = None,
) -> AsyncIterator[bytes] | Iterator[bytes] | None:
"""
Translate OpenAI streaming response to Anthropic format.
@ -350,6 +351,7 @@ class AnthropicAdapter:
iterations_usage=iterations_usage,
litellm_logging_obj=litellm_logging_obj,
safeguards_evaluator=safeguards_evaluator,
completion_request=completion_request,
)
# Return the SSE-wrapped version for proper event formatting.
if is_async:

View file

@ -2859,6 +2859,8 @@ class AmazonConverseConfig(BaseConfig):
# Add "trace" from Bedrock guardrails - if user has opted in to returning it
if "trace" in completion_response:
setattr(model_response, "trace", completion_response["trace"])
if logging_obj is not None:
logging_obj.record_native_bedrock_guardrail(model_response)
# Add service_tier if present in Bedrock response
# Map Bedrock serviceTier (object) to OpenAI service_tier (string)

View file

@ -13,6 +13,7 @@ from litellm.caching.caching import InMemoryCache
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping
from litellm.litellm_core_utils.llm_response_utils.get_headers import provider_request_id_headers
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
from litellm.litellm_core_utils.prompt_templates.common_utils import native_redacted_thinking_data
@ -22,6 +23,7 @@ from litellm.llms.anthropic.chat.handler import (
from litellm.llms.anthropic.chat.transformation import JsonModeRequest
from litellm.llms.base_llm import base_model_iterator
from litellm.llms.base_llm.base_utils import ResponseFormatSchema
from litellm.llms.bedrock.common_utils import BEDROCK_INVOKE_TRACE_FIELD
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
@ -962,8 +964,19 @@ class AmazonAnthropicClaudeStreamDecoder(AWSEventStreamDecoder):
response_format_schema=response_format_schema,
)
def _chunk_parser(self, chunk_data: dict) -> ModelResponseStream:
return self.anthropic_model_response_iterator.chunk_parser(chunk=chunk_data)
def _chunk_parser(self, chunk_data: dict[str, object]) -> ModelResponseStream:
parsed: Final = self.anthropic_model_response_iterator.chunk_parser(chunk=chunk_data)
trace: Final = object_mapping(chunk_data.get(BEDROCK_INVOKE_TRACE_FIELD))
if trace is None:
return parsed
return parsed.model_copy(
update={
"provider_specific_fields": {
**(parsed.provider_specific_fields or {}),
BEDROCK_INVOKE_TRACE_FIELD: trace,
}
}
)
def _stream_end_chunk(self) -> ModelResponseStream | None:
return self.anthropic_model_response_iterator.cut_stream_chunk()

View file

@ -5,6 +5,7 @@ import httpx
from pydantic import TypeAdapter
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_anthropic_image_obj,
)
@ -22,6 +23,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
AmazonInvokeConfig,
)
from litellm.llms.bedrock.common_utils import (
BEDROCK_INVOKE_TRACE_FIELD,
apply_bedrock_invoke_structured_output,
bedrock_chat_uses_native_structured_output,
bedrock_cleaned_output_schema,
@ -406,7 +408,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
return AnthropicConfig.transform_response(
response: Final = AnthropicConfig.transform_response(
self,
model=model,
raw_response=raw_response,
@ -420,3 +422,9 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
api_key=api_key,
json_mode=json_mode,
)
body: Final = object_mapping(raw_response.json()) or {}
trace: Final = object_mapping(body.get(BEDROCK_INVOKE_TRACE_FIELD))
if trace is not None:
setattr(response, BEDROCK_INVOKE_TRACE_FIELD, trace)
logging_obj.record_native_bedrock_guardrail(response)
return response

View file

@ -45,6 +45,8 @@ if TYPE_CHECKING:
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.llms.openai import AllMessageValues
BEDROCK_INVOKE_TRACE_FIELD: Final = "amazon-bedrock-trace"
_ERROR_REQUEST_URL: Final = "https://docs.litellm.ai/docs"
_OPENAI_FAMILY_MODEL_RE: Final = re.compile(r"(^|[./])openai\.")

View file

@ -33,6 +33,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
AmazonInvokeConfig,
bedrock_invoke_guardrail_headers,
)
from litellm.llms.bedrock.common_utils import (
BedrockError,
@ -151,8 +152,14 @@ class AmazonAnthropicClaudeMessagesConfig(
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict, str | None]:
raw_guardrail_config: Final = litellm_params.get("guardrailConfig") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # litellm_params is an untyped dict param
guardrail_headers: Final = (
()
if raw_guardrail_config is None
else tuple(bedrock_invoke_guardrail_headers(raw_guardrail_config).items()) # pyright: ignore[reportUnknownArgumentType] # read from the untyped dict param
)
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, (), metadata_headers, owned_names), api_base
return merge_bedrock_invoke_headers(headers, guardrail_headers, metadata_headers, owned_names), api_base # pyright: ignore[reportUnknownArgumentType] # headers is an untyped dict param
def sign_request(
self,

View file

@ -2705,6 +2705,7 @@ class BaseLLMHTTPHandler:
raw_response=response,
logging_obj=logging_obj,
)
logging_obj.record_native_bedrock_guardrail(initial_response)
return await self._finalize_anthropic_messages_response(
initial_response=initial_response,

View file

@ -6326,7 +6326,7 @@ def completion(
response = _complete_vercel_ai_gateway(_dispatch_ctx)
elif custom_llm_provider == "palm":
raise ValueError(
"Palm was decommisioned on October 2024. Please use the `gemini/` route for Gemini Google AI Studio Models. Announcement: https://ai.google.dev/palm_docs/palm?hl=en"
"Palm was decommissioned on October 2024. Please use the `gemini/` route for Gemini Google AI Studio Models. Announcement: https://ai.google.dev/palm_docs/palm?hl=en"
)
elif custom_llm_provider == "vertex_ai_beta" or custom_llm_provider == "gemini":
response = _complete_vertex_ai_beta(_dispatch_ctx)

View file

@ -28402,7 +28402,6 @@
"cache_read_input_token_cost_batches": 1.25e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
"deprecation_date": "2027-06-28",
"input_cost_per_image": 0.00028,
"input_cost_per_token": 2.5e-07,
"input_cost_per_token_batches": 1.25e-07,
@ -50878,7 +50877,6 @@
"cache_read_input_token_cost_batches": 1.25e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
"deprecation_date": "2027-06-28",
"input_cost_per_image": 0.00028,
"input_cost_per_token": 2.5e-07,
"input_cost_per_token_batches": 1.25e-07,

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import math
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence
from datetime import datetime
from functools import lru_cache
from itertools import chain
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -69,7 +70,11 @@ from litellm.litellm_core_utils.get_supported_openai_params import (
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
from litellm.litellm_core_utils.litellm_logging import BilledRoundsDispatch
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
guardrail_entries,
guardrail_information_cost,
object_mapping,
)
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
get_response_headers,
)
@ -306,6 +311,23 @@ async def close_guarded_stream(stream: object) -> None:
)
def _guardrail_entries_for_headers(
request_metadata_bucket: object, logging_obj: LiteLLMLoggingObj
) -> tuple[object, ...]:
"""Guardrail entries the proxy hooks wrote on the request plus the ones the call recorded on its own logging
object (a Bedrock deployment's native guardrail trace), each once: the hooks copy theirs into the logging object."""
litellm_params: Final = object_mapping(logging_obj.model_call_details.get("litellm_params")) or {}
buckets: Final = (object_mapping(request_metadata_bucket), object_mapping(litellm_params.get("metadata")))
entries: Final = tuple(
chain.from_iterable(
guardrail_entries(metadata.get("standard_logging_guardrail_information"))
for metadata in buckets
if metadata is not None
)
)
return tuple(entry for index, entry in enumerate(entries) if entry not in entries[:index])
def resolve_litellm_call_id(client_call_id: str | None) -> str:
if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH:
return client_call_id
@ -3238,9 +3260,9 @@ class ProxyBaseLLMRequestProcessing:
if is_unbilled_non_inference_call_from_params(logging_obj.call_type, logging_obj.litellm_params, response)
else computed_cost_for_headers
)
_, request_metadata_bucket = get_or_create_metadata_bucket(self.data)
request_metadata_bucket: Final[object] = get_or_create_metadata_bucket(self.data)[1] # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # self.data is an untyped dict
guardrail_cost_for_headers: Final = guardrail_information_cost(
request_metadata_bucket.get("standard_logging_guardrail_information")
_guardrail_entries_for_headers(request_metadata_bucket, logging_obj) # pyright: ignore[reportUnknownArgumentType] # bucket comes from the untyped request dict
)
response_cost_for_headers: Final = (
(llm_cost_for_headers if isinstance(llm_cost_for_headers, (int, float)) else 0.0)

View file

@ -1126,6 +1126,18 @@ class PrismaManager:
return False
return ProxyExtrasDBManager.build_request_log_indexes()
@staticmethod
def migration_data_check_report(use_migrate: bool = True) -> str | None:
"""The read-only pre-migration row check on its own, for `--check_migrations_against_existing_data`: the
refusal the migration step would print, or None when the pending schema changes
can be applied to the rows the database has."""
from litellm_proxy_extras.utils import (
ProxyExtrasDBManager, # pyright: ignore[reportUnknownVariableType] # litellm-proxy-extras is an optional install
)
refusal: Final[object] = ProxyExtrasDBManager.migration_data_check_report(use_migrate=use_migrate) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # litellm-proxy-extras is an optional install
return refusal if isinstance(refusal, str) else None
@staticmethod
def start_request_log_index_build() -> None:
"""Build the request-log indexes on a daemon thread, for a serving proxy that ran the

View file

@ -28,6 +28,7 @@ from litellm.llms.anthropic.messages.stream_assembly import (
collapse_pure_text_chunks,
split_sse_chunk_into_events,
)
from litellm.llms.bedrock.common_utils import BEDROCK_INVOKE_TRACE_FIELD
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
@ -70,6 +71,17 @@ def _event_payload(event: str) -> Mapping[str, object] | None:
return None
def _bedrock_trace_payload(all_chunks: Sequence[str | bytes]) -> Mapping[str, object] | None:
payloads: Final = (
_event_payload(event)
for chunk in reversed(all_chunks)
for event in reversed(split_sse_chunk_into_events(chunk))
) # comprehension-ok: chunk then event, read newest first
return next(
(payload for payload in payloads if payload is not None and BEDROCK_INVOKE_TRACE_FIELD in payload), None
)
def _message_delta_usages(all_chunks: Sequence[str | bytes]) -> Iterator[object]:
for chunk in reversed(all_chunks):
yield from (
@ -141,6 +153,7 @@ class AnthropicPassthroughLoggingHandler:
json_mode=False,
litellm_params={},
)
logging_obj.record_native_bedrock_guardrail(response_body) # pyright: ignore[reportUnknownArgumentType] # response_body is an untyped dict param
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=litellm_model_response,
@ -546,6 +559,7 @@ class AnthropicPassthroughLoggingHandler:
"result": None,
"kwargs": {},
}
litellm_logging_obj.record_native_bedrock_guardrail(_bedrock_trace_payload(all_chunks))
kwargs: Final = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=complete_streaming_response,
model=model,

View file

@ -218,6 +218,23 @@ class ProxyInitializationHelpers:
_run_health_check = run_health_check
@staticmethod
def run_migration_data_check(use_migrate: bool) -> int:
"""`--check_migrations_against_existing_data`: the pre-migration row check against DATABASE_URL, nothing applied.
0 when the pending schema changes fit the rows, 2 with the refusal when they do not."""
if os.getenv("DATABASE_URL") is None:
raise click.UsageError("--check_migrations_against_existing_data requires DATABASE_URL")
from litellm.proxy.db.prisma_client import PrismaManager
refusal: Final[str | None] = PrismaManager.migration_data_check_report(use_migrate=use_migrate) # pyright: ignore[reportUnknownVariableType] # litellm-proxy-extras is an optional install
if refusal is None:
print("LiteLLM Proxy: the pending migrations and indexes can be applied to the rows this database has.")
return 0
print(
f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {refusal}\033[0m", file=sys.stderr, flush=True
)
return 2
@staticmethod
def run_config_validation(config: str | None) -> None:
if config is None:
@ -978,6 +995,13 @@ class ProxyInitializationHelpers:
default=False,
help="Load and validate the config file (including mcp_servers) without starting the server, then exit. Exit code 1 on any config error.",
)
@click.option(
"--check_migrations_against_existing_data",
is_flag=True,
default=False,
help="Check, read-only, that the rows in DATABASE_URL can take this version's pending migrations and indexes, "
"print what would block them, then exit without applying anything. Exit code 2 when something blocks.",
)
@click.option(
"--keepalive_timeout",
default=None,
@ -1127,6 +1151,7 @@ def run_server(
use_prisma_db_push: bool,
skip_server_startup,
validate_config: bool,
check_migrations_against_existing_data: bool,
keepalive_timeout,
timeout_worker_healthcheck,
max_requests_before_restart: int | None,
@ -1172,6 +1197,8 @@ def run_server(
if validate_config is True:
ProxyInitializationHelpers.run_config_validation(config)
return
if check_migrations_against_existing_data is True:
sys.exit(ProxyInitializationHelpers.run_migration_data_check(use_migrate=not use_prisma_db_push))
if enforce_prisma_migration_check:
print(
"\033[1;33mLiteLLM Proxy: --enforce_prisma_migration_check is "

View file

@ -133,7 +133,6 @@ class LiteLLMCompletionTransformationHandler:
litellm_completion_response: Final[ModelResponse | litellm.CustomStreamWrapper] = await litellm.acompletion(
**acompletion_args,
)
if isinstance(litellm_completion_response, ModelResponse):
responses_api_response: Final[ResponsesAPIResponse] = (
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(

View file

@ -2062,7 +2062,19 @@ class LiteLLMCompletionResponsesConfig:
- ItemReference
"""
if input_item.get("type") == "web_search_call":
search: Final = ResponseFunctionWebSearch.model_validate(input_item)
try:
search: Final = ResponseFunctionWebSearch.model_validate(input_item)
except ValidationError as exc:
errors: Final = exc.errors(include_url=False, include_input=False)
if len(errors) != 1 or errors[0]["type"] != "missing" or errors[0]["loc"] != ("action",):
raise
replay: Final = ResponseFunctionWebSearch.model_construct(None, **input_item)
return [
GenericChatCompletionMessage(
role="assistant",
content="Hosted web search: " + replay.model_dump_json(exclude_none=True),
)
]
return [
GenericChatCompletionMessage(
role="assistant",

View file

@ -28402,7 +28402,6 @@
"cache_read_input_token_cost_batches": 1.25e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
"deprecation_date": "2027-06-28",
"input_cost_per_image": 0.00028,
"input_cost_per_token": 2.5e-07,
"input_cost_per_token_batches": 1.25e-07,
@ -50878,7 +50877,6 @@
"cache_read_input_token_cost_batches": 1.25e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
"deprecation_date": "2027-06-28",
"input_cost_per_image": 0.00028,
"input_cost_per_token": 2.5e-07,
"input_cost_per_token_batches": 1.25e-07,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Iterator, Mapping, Sequence
from itertools import chain
from pathlib import Path
from types import MappingProxyType
from typing import Final
from pydantic import JsonValue, TypeAdapter
ROOT: Final = Path(__file__).resolve().parents[1]
COST_MAP_PATH: Final = ROOT / "model_prices_and_context_window.json"
SOURCE_PATH: Final = ROOT / "provider_endpoints_support.json"
OUTPUT_PATHS: Final = (
SOURCE_PATH,
ROOT / "litellm/provider_endpoints_support_backup.json",
)
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
MODE_TO_FLAG: Final[Mapping[str, str]] = MappingProxyType(
{
"chat": "chat_completions",
"responses": "responses",
"completion": "text_completion",
"embedding": "embeddings",
"image_generation": "image_generations",
"image_edit": "image_edits",
"audio_transcription": "audio_transcriptions",
"audio_speech": "audio_speech",
"rerank": "rerank",
"ocr": "ocr",
"search": "search",
"moderation": "moderations",
"realtime": "realtime",
"video_generation": "video_generations",
}
)
SUPPORTED_ENDPOINT_TO_FLAG: Final[Mapping[str, str]] = MappingProxyType(
{
"/v1/chat/completions": "chat_completions",
"/v1/responses": "responses",
"/v1/completions": "text_completion",
"/v1/embeddings": "embeddings",
"/v1/images/generations": "image_generations",
"/v1/images/edits": "image_edits",
"/v1/audio/transcriptions": "audio_transcriptions",
"/v1/audio/speech": "audio_speech",
"/v1/ocr": "ocr",
"/v1/realtime": "realtime",
"/v1/videos": "video_generations",
"/v1/batch": "batches",
"/v1/messages": "messages",
"/v1beta/interactions": "interactions",
"/v1/systemone": "systemone",
}
)
def _json_object(value: JsonValue, name: str) -> Mapping[str, JsonValue]:
if not isinstance(value, dict):
raise ValueError(f"{name} must be a JSON object")
return value
def _provider_slug(provider: str) -> str:
if provider.startswith("vertex_ai-"):
return "vertex_ai"
if provider == "fireworks_ai-embedding-models":
return "fireworks_ai"
if provider == "bedrock_converse":
return "bedrock"
if provider == "text-completion-inception":
return "inception"
return provider
def _model_provider_slug(model: JsonValue) -> str | None:
if not isinstance(model, dict):
return None
provider: Final = model.get("litellm_provider")
if not isinstance(provider, str) or not provider:
return None
return _provider_slug(provider)
def _model_flags(model: Mapping[str, JsonValue]) -> tuple[str, ...]:
mode: Final = model.get("mode")
mode_flag: Final = MODE_TO_FLAG.get(mode) if isinstance(mode, str) else None
supported_endpoints: Final = model.get("supported_endpoints")
endpoint_flags: Final = (
tuple(
SUPPORTED_ENDPOINT_TO_FLAG[endpoint]
for endpoint in supported_endpoints
if isinstance(endpoint, str) and endpoint in SUPPORTED_ENDPOINT_TO_FLAG
)
if isinstance(supported_endpoints, list)
else ()
)
return ((mode_flag,) if mode_flag is not None else ()) + endpoint_flags
def _bridge_field(endpoint: JsonValue) -> str | None:
if not isinstance(endpoint, dict) or endpoint.get("bridges_to_chat_completion") is not True:
return None
provider_json_field: Final = endpoint.get("provider_json_field")
return provider_json_field if isinstance(provider_json_field, str) else None
def _bridged_fields(endpoints: Mapping[str, JsonValue]) -> tuple[str, ...]:
return tuple(
dict.fromkeys(
provider_json_field
for endpoint in endpoints.values()
if (provider_json_field := _bridge_field(endpoint)) is not None
)
)
def _derived_keys(bridged_fields: tuple[str, ...]) -> tuple[str, ...]:
return tuple(dict.fromkeys((*MODE_TO_FLAG.values(), *SUPPORTED_ENDPOINT_TO_FLAG.values(), *bridged_fields)))
def _model_evidence(
model: JsonValue, bridged_fields: tuple[str, ...]
) -> Iterator[tuple[str, str]]:
provider: Final = _model_provider_slug(model)
if provider is None or not isinstance(model, dict):
return
flags: Final = _model_flags(model)
yield from ((provider, flag) for flag in flags)
if "chat_completions" not in flags:
return
yield from ((provider, flag) for flag in bridged_fields)
def _provider_evidence(
cost_map: Mapping[str, JsonValue], bridged_fields: tuple[str, ...]
) -> Iterator[tuple[str, str]]:
return chain.from_iterable(
_model_evidence(model, bridged_fields)
for model_name, model in cost_map.items()
if model_name != "sample_spec"
)
def _provider_slugs(cost_map: Mapping[str, JsonValue]) -> frozenset[str]:
return frozenset(
provider_slug
for model_name, model in cost_map.items()
if model_name != "sample_spec" and (provider_slug := _model_provider_slug(model)) is not None
)
def _generated_provider(
slug: str,
value: JsonValue,
derived_keys: tuple[str, ...],
derived_key_set: frozenset[str],
evidence: frozenset[tuple[str, str]],
) -> dict[str, JsonValue]:
provider: Final = _json_object(value, f"provider {slug}")
current_flags_value: Final = provider.get("endpoints")
current_flags: Final = (
_json_object(current_flags_value, f"provider {slug} endpoints")
if current_flags_value is not None
else {}
)
derived_flags: Final = {flag: (slug, flag) in evidence for flag in derived_keys}
handwritten_flags: Final = {
flag: value for flag, value in current_flags.items() if flag not in derived_key_set
}
return {
**provider,
"endpoints": {**derived_flags, **handwritten_flags},
}
def generate_provider_endpoints_support(
cost_map: Mapping[str, JsonValue],
current_json: Mapping[str, JsonValue],
) -> dict[str, JsonValue]:
current_providers: Final = _json_object(
current_json.get("providers"), "provider_endpoints_support providers"
)
endpoints: Final = _json_object(current_json.get("endpoints"), "provider_endpoints_support endpoints")
bridged_fields: Final = _bridged_fields(endpoints)
derived_keys: Final = _derived_keys(bridged_fields)
derived_key_set: Final = frozenset(derived_keys)
provider_slugs: Final = _provider_slugs(cost_map)
missing_slugs: Final = tuple(sorted(provider_slugs - frozenset(current_providers)))
if missing_slugs:
raise ValueError(f"Cost map providers missing from provider_endpoints_support.json: {', '.join(missing_slugs)}")
evidence: Final = frozenset(_provider_evidence(cost_map, bridged_fields))
providers: Final = {
slug: _generated_provider(slug, value, derived_keys, derived_key_set, evidence)
for slug, value in current_providers.items()
}
return {**current_json, "providers": providers}
def generate_files(
cost_map: Mapping[str, JsonValue],
current_json: Mapping[str, JsonValue],
output_paths: tuple[Path, ...],
check: bool,
) -> int:
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
content: Final = json.dumps(generated, indent=2, ensure_ascii=False) + "\n"
stale_paths: Final = tuple(
path for path in output_paths if not path.exists() or path.read_text(encoding="utf-8") != content
)
if check:
if not stale_paths:
return 0
sys.stderr.write(
"provider endpoint support is stale in "
+ ", ".join(str(path) for path in stale_paths)
+ "\nRun `uv run --no-sync python scripts/generate_provider_endpoints_support.py` to regenerate it\n"
)
return 1
for path in output_paths:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return 0
def main(argv: Sequence[str] | None = None, *, repo_root: Path = ROOT) -> int:
parser: Final = argparse.ArgumentParser(description="Generate provider endpoint support from the model cost map")
parser.add_argument("--check", action="store_true", help="fail if either generated file is stale")
args: Final = parser.parse_args(argv)
cost_map: Final = JSON_OBJECT.validate_json((repo_root / COST_MAP_PATH.name).read_bytes())
current_json: Final = JSON_OBJECT.validate_json((repo_root / SOURCE_PATH.name).read_bytes())
return generate_files(
cost_map,
current_json,
(
repo_root / SOURCE_PATH.name,
repo_root / OUTPUT_PATHS[1].relative_to(ROOT),
),
args.check,
)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,179 @@
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Final
import pytest
from pydantic import JsonValue
from scripts.generate_provider_endpoints_support import (
generate_provider_endpoints_support,
main,
)
ENDPOINTS: Final = {
"a2a": {"provider_json_field": "a2a", "bridges_to_chat_completion": True},
"messages": {"provider_json_field": "messages", "bridges_to_chat_completion": True},
"responses": {"provider_json_field": "responses", "bridges_to_chat_completion": True},
"generateContent": {"provider_json_field": "generateContent", "bridges_to_chat_completion": True},
"text_completion": {"provider_json_field": "text_completion", "bridges_to_chat_completion": True},
"count_tokens": {"provider_json_field": "count_tokens"},
}
def _current_json(providers: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {
"_comment": "retained",
"_schema": {"version": 1},
"providers": dict(providers),
"endpoints": ENDPOINTS,
}
def _provider(flags: Mapping[str, JsonValue] | None = None) -> dict[str, JsonValue]:
return {
"display_name": "Example (`example`)",
"url": "https://docs.litellm.ai/docs/providers/example",
"endpoints": dict(flags or {}),
}
def _flags(generated: Mapping[str, JsonValue], slug: str) -> Mapping[str, JsonValue]:
providers: Final = generated["providers"]
assert isinstance(providers, dict)
provider: Final = providers[slug]
assert isinstance(provider, dict)
flags: Final = provider["endpoints"]
assert isinstance(flags, dict)
return flags
def test_mode_derives_chat_flag() -> None:
cost_map: Final = {"example/model": {"litellm_provider": "example", "mode": "chat"}}
current_json: Final = _current_json({"example": _provider()})
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
assert _flags(generated, "example")["chat_completions"] is True
def test_supported_endpoint_derives_embedding_flag() -> None:
cost_map: Final = {
"example/model": {
"litellm_provider": "example",
"mode": "completion",
"supported_endpoints": ["/v1/embeddings"],
}
}
current_json: Final = _current_json({"example": _provider()})
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
assert _flags(generated, "example")["embeddings"] is True
def test_chat_models_enable_bridge_flags_but_other_models_do_not() -> None:
cost_map: Final = {
"example/chat": {"litellm_provider": "has_chat", "mode": "chat"},
"example/endpoint-chat": {
"litellm_provider": "endpoint_chat",
"mode": "image_generation",
"supported_endpoints": ["/v1/chat/completions"],
},
"example/image": {"litellm_provider": "no_chat", "mode": "image_generation"},
}
current_json: Final = _current_json(
{
"has_chat": _provider(),
"endpoint_chat": _provider(),
"no_chat": _provider(),
}
)
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
assert _flags(generated, "has_chat")["a2a"] is True
assert _flags(generated, "has_chat")["generateContent"] is True
assert _flags(generated, "endpoint_chat")["a2a"] is True
assert _flags(generated, "no_chat")["a2a"] is False
assert _flags(generated, "no_chat")["generateContent"] is False
def test_unbacked_derived_flags_reset_and_handwritten_flags_remain() -> None:
cost_map: Final = {"example/model": {"litellm_provider": "example", "mode": "image_generation"}}
current_json: Final = _current_json(
{
"example": _provider({"responses": True, "count_tokens": True, "files": False}),
"without_models": _provider({"responses": True}),
}
)
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
flags: Final = _flags(generated, "example")
assert flags["responses"] is False
assert flags["count_tokens"] is True
assert flags["files"] is False
assert _flags(generated, "without_models")["chat_completions"] is False
def test_provider_aliases_share_their_mapped_slugs() -> None:
cost_map: Final = {
"vertex/model-a": {"litellm_provider": "vertex_ai-chat", "mode": "chat"},
"vertex/model-b": {"litellm_provider": "vertex_ai-embedding", "mode": "embedding"},
"bedrock/model": {"litellm_provider": "bedrock_converse", "mode": "responses"},
}
current_json: Final = _current_json({"vertex_ai": _provider(), "bedrock": _provider()})
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
assert _flags(generated, "vertex_ai")["chat_completions"] is True
assert _flags(generated, "vertex_ai")["embeddings"] is True
assert _flags(generated, "bedrock")["responses"] is True
def test_cost_map_provider_without_file_entry_is_an_error() -> None:
cost_map: Final = {"example/model": {"litellm_provider": "missing_provider", "mode": "chat"}}
current_json: Final = _current_json({"example": _provider()})
with pytest.raises(ValueError, match="missing_provider"):
generate_provider_endpoints_support(cost_map, current_json)
def test_check_reports_stale_output_without_writing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
cost_map: Final = {"example/model": {"litellm_provider": "example", "mode": "chat"}}
current_json: Final = _current_json({"example": _provider()})
(tmp_path / "model_prices_and_context_window.json").write_text(json.dumps(cost_map), encoding="utf-8")
source_path: Final = tmp_path / "provider_endpoints_support.json"
source_content: Final = json.dumps(current_json)
source_path.write_text(source_content, encoding="utf-8")
result: Final = main(["--check"], repo_root=tmp_path)
assert result == 1
assert source_path.read_text(encoding="utf-8") == source_content
assert not (tmp_path / "litellm/provider_endpoints_support_backup.json").exists()
error_output: Final = capsys.readouterr().err
assert "provider endpoint support is stale" in error_output
assert "scripts/generate_provider_endpoints_support.py" in error_output
def test_check_reports_stale_backup_without_writing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
cost_map: Final = {"example/model": {"litellm_provider": "example", "mode": "chat"}}
current_json: Final = _current_json({"example": _provider()})
generated: Final = generate_provider_endpoints_support(cost_map, current_json)
generated_content: Final = json.dumps(generated, indent=2, ensure_ascii=False) + "\n"
source_path: Final = tmp_path / "provider_endpoints_support.json"
backup_path: Final = tmp_path / "litellm/provider_endpoints_support_backup.json"
backup_content: Final = "stale\n"
(tmp_path / "model_prices_and_context_window.json").write_text(json.dumps(cost_map), encoding="utf-8")
source_path.write_text(generated_content, encoding="utf-8")
backup_path.parent.mkdir()
backup_path.write_text(backup_content, encoding="utf-8")
result: Final = main(["--check"], repo_root=tmp_path)
assert result == 1
assert source_path.read_text(encoding="utf-8") == generated_content
assert backup_path.read_text(encoding="utf-8") == backup_content
assert str(backup_path) in capsys.readouterr().err

View file

@ -1,5 +1,6 @@
import os
import queue
import secrets
import shutil
import subprocess
import sys
@ -14,7 +15,7 @@ from typing import Final
import psycopg
import pytest
from litellm_proxy_extras import request_log_indexes
from litellm_proxy_extras import migration_data_checks, request_log_indexes
from litellm_proxy_extras.migration_lock import MIGRATION_LOCK_KEY, migration_lock
from litellm_proxy_extras.migration_recovery import roll_back_failed_inert_migration
from litellm_proxy_extras.request_log_indexes import (
@ -408,6 +409,12 @@ def test_a_migration_without_a_failed_row_is_not_touched(partitioned_database: s
assert _ledger(partitioned_database) == before
def _backend_pid(connection: "psycopg.Connection[tuple[object, ...]]") -> int:
row: Final = connection.execute("SELECT pg_backend_pid()").fetchone()
assert row is not None and isinstance(row[0], int), row
return row[0]
def _pin_a_snapshot_on(database_url: str, table: str) -> "psycopg.Connection[tuple[object, ...]]":
pin: Final = psycopg.connect(database_url)
pin.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
@ -532,7 +539,9 @@ def test_an_invalid_child_index_left_by_an_interrupted_build_is_rebuilt_and_atta
@requires_db
def test_partitioning_a_table_the_index_job_already_covered_moves_its_call_id_index_aside(scratch_database: str) -> None:
def test_partitioning_a_table_the_index_job_already_covered_moves_its_call_id_index_aside(
scratch_database: str,
) -> None:
"""The runbook renames the Prisma indexes off the legacy table so the partitioned table can take their
names. The call-id index is built by the index job, not Prisma, and a deployment that partitions after
that job ran kept the name on the legacy table, so every later job run reported it as on another table."""
@ -541,7 +550,9 @@ def test_partitioning_a_table_the_index_job_already_covered_moves_its_call_id_in
_partition_spend_logs(scratch_database)
assert _indexed_table(scratch_database, "LiteLLM_SpendLogs_legacy_litellm_call_id_idx") == "LiteLLM_SpendLogs_legacy"
assert (
_indexed_table(scratch_database, "LiteLLM_SpendLogs_legacy_litellm_call_id_idx") == "LiteLLM_SpendLogs_legacy"
)
assert ensure_request_log_indexes(scratch_database, "public") is True
_assert_index_covers_every_partition(scratch_database, CALL_ID_INDEX, "litellm_call_id_idx")
@ -737,7 +748,7 @@ def test_inserts_are_never_held_back_while_attach_partition_waits_for_a_reader_o
reader.execute(
sql.SQL('SELECT count(*) FROM {} WHERE "litellm_call_id" IS NULL').format(sql.Identifier(partition))
).fetchone()
reader_pid: Final = reader.execute("SELECT pg_backend_pid()").fetchone()[0]
reader_pid: Final = _backend_pid(reader)
with psycopg.connect(partitioned_database, autocommit=True) as inspector:
child_lock: Final = inspector.execute(
"SELECT 1 FROM pg_locks WHERE pid = %s AND relation = to_regclass(%s) "
@ -800,7 +811,7 @@ def test_an_attach_that_never_gets_its_lock_is_left_for_the_next_index_build(
reader.execute(
sql.SQL('SELECT count(*) FROM {} WHERE "litellm_call_id" IS NULL').format(sql.Identifier(partition))
).fetchone()
reader_pid: Final = reader.execute("SELECT pg_backend_pid()").fetchone()[0]
reader_pid: Final = _backend_pid(reader)
with psycopg.connect(partitioned_database, autocommit=True) as inspector:
child_lock: Final = inspector.execute(
"SELECT 1 FROM pg_locks WHERE pid = %s AND relation = to_regclass(%s) "
@ -929,3 +940,183 @@ def test_a_second_copy_of_a_managed_index_is_reported_with_its_drop_statement_an
assert 'remove it with: DROP INDEX CONCURRENTLY "public"."call_id_copy"' in caplog.text
assert _index_oids(scratch_database)["call_id_copy"] == copy
def _insert_spend_log_with_key(conn: "psycopg.Connection[tuple[object, ...]]", request_id: str, api_key: str) -> None:
conn.execute(
'INSERT INTO "LiteLLM_SpendLogs" ("request_id", "call_type", "startTime", "endTime", "api_key") '
"VALUES (%s, %s, %s, %s, %s)",
(request_id, "acompletion", "2025-06-03 14:02:11", "2025-06-03 14:02:13", api_key),
)
def _legacy_bearer_token() -> str:
"""A raw JWT-sized bearer token the way a pre-v1.99.0 proxy stored it: random, so
Postgres cannot compress it into the 2704-byte btree entry limit."""
return "Bearer " + secrets.token_hex(1600)
def _hash_sql_from(refusal: str) -> str:
return refusal[refusal.index('UPDATE "LiteLLM_SpendLogs"') : refusal.index("{64}$';") + len("{64}$';")]
@requires_db
@RESOLVERS
@pytest.mark.parametrize("release", (API_KEY_INDEX_MIGRATION,), ids=("v1.102.1",))
def test_the_migration_job_refuses_before_the_first_migration_when_spend_log_keys_are_too_long_for_the_index(
scratch_database: str, use_v2_resolver: bool
) -> None:
with psycopg.connect(scratch_database, autocommit=True) as conn:
_insert_spend_log_with_key(conn, "legacy-1", _legacy_bearer_token())
_insert_spend_log_with_key(conn, "legacy-2", _legacy_bearer_token())
_insert_spend_log_with_key(conn, "hashed-1", "a" * 64)
ledger_before: Final = _ledger(scratch_database)
with pytest.raises(RuntimeError) as refused:
ProxyExtrasDBManager.run_migration_job(use_migrate=True, use_v2_resolver=use_v2_resolver)
refusal: Final = str(refused.value)
assert (
'"LiteLLM_SpendLogs".api_key, startTime: 2 row(s) with an index entry over 2704 bytes, largest 3232 bytes '
"(api_key up to 3207 bytes)"
) in refusal, refusal
assert f'blocks: CREATE INDEX "{API_KEY_INDEX}"' in refusal, refusal
assert _ledger(scratch_database) == ledger_before, "a refused job must not apply a single migration"
assert API_KEY_INDEX not in _index_validity(scratch_database, "api_key_startTime_idx")
with psycopg.connect(scratch_database, autocommit=True) as conn:
conn.execute(_hash_sql_from(refusal)) # pyright: ignore[reportArgumentType] # the refusal's own SQL
assert ProxyExtrasDBManager.run_migration_job(use_migrate=True, use_v2_resolver=use_v2_resolver) is True
assert _index_validity(scratch_database, "api_key_startTime_idx") == {API_KEY_INDEX: True}
assert len(_ledger(scratch_database)) > len(ledger_before)
@requires_db
@pytest.mark.parametrize("release", (API_KEY_INDEX_MIGRATION,), ids=("v1.102.1",))
def test_a_serving_proxy_index_build_that_meets_too_long_keys_reports_the_rows_instead_of_the_postgres_error(
scratch_database: str, caplog: pytest.LogCaptureFixture
) -> None:
assert ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) is True
with psycopg.connect(scratch_database, autocommit=True) as conn:
_insert_spend_log_with_key(conn, "legacy-1", _legacy_bearer_token())
with caplog.at_level("WARNING", logger="litellm_proxy_extras"):
built: Final = ensure_request_log_indexes(scratch_database, "public")
assert built is False
assert f"Not building index {API_KEY_INDEX}" in caplog.text, caplog.text
assert '"LiteLLM_SpendLogs".api_key, startTime: 1 row(s) with an index entry over 2704 bytes' in caplog.text, (
caplog.text
)
assert "exceeds btree version 4 maximum" not in caplog.text, caplog.text
assert API_KEY_INDEX not in _index_validity(scratch_database, "api_key_startTime_idx")
_PROBE_STATEMENTS: Final = (
'CREATE UNIQUE INDEX "probe_b_key" ON "preflight_probe" ("b")',
'ALTER TABLE "preflight_probe" ALTER COLUMN "b" SET NOT NULL',
'ALTER TABLE "preflight_probe" ADD COLUMN "c" INTEGER NOT NULL',
'ALTER TABLE "preflight_probe" ADD CONSTRAINT "probe_r_fkey" FOREIGN KEY ("r") REFERENCES "preflight_parent"("id")',
'ALTER TABLE "preflight_probe" ADD CONSTRAINT "probe_a_check" CHECK ("a" <> \'bad\')',
'CREATE INDEX "probe_a_idx" ON "preflight_probe" ("a")',
)
@requires_db
def test_each_check_kind_counts_exactly_the_rows_postgres_would_reject(scratch_database: str) -> None:
with psycopg.connect(scratch_database, autocommit=True) as conn:
conn.execute('CREATE TABLE "preflight_parent" ("id" INTEGER PRIMARY KEY)')
conn.execute('CREATE TABLE "preflight_probe" ("a" TEXT, "b" INTEGER, "r" INTEGER)')
conn.execute('INSERT INTO "preflight_parent" VALUES (1)')
conn.execute(
'INSERT INTO "preflight_probe" VALUES (%s, 1, 1), (%s, 1, 1), (%s, NULL, 2), (%s, 2, NULL), (%s, 3, 1)',
("ok", "ok", "bad", "bad", secrets.token_hex(1400)),
)
checks: Final = tuple(
check
for statement in _PROBE_STATEMENTS
for check in migration_data_checks.checks_for_statement(statement, "probe")
)
findings: Final = migration_data_checks.find_blockers(conn, "public", checks)
for statement in _PROBE_STATEMENTS:
with conn.transaction(force_rollback=True), pytest.raises(psycopg.Error):
conn.execute(statement) # pyright: ignore[reportArgumentType] # test SQL
counted: Final = {
(f.check.kind, f.check.columns): f.rows for f in findings if isinstance(f, migration_data_checks.BlockedChange)
}
assert counted == {
("duplicates", ("b",)): 1,
("nulls", ("b",)): 1,
("any_row", ("c",)): 5,
("orphans", ("r",)): 1,
("check_violations", ()): 2,
("too_long", ("a",)): 1,
}
@requires_db
@pytest.mark.parametrize(
("index_columns", "s_value", "t_value", "longest_fitting_key", "described_as"),
(
(("k", "t"), None, "2025-06-03 14:02:11", 2684, "k up to 2685 bytes"),
(("s", "k"), "q" * 20, None, 2668, "s up to 20 bytes, k up to 2669 bytes"),
(("s", "k"), None, None, 2684, "k up to 2685 bytes"),
),
ids=("key then timestamp", "20-byte text then key", "NULL then key"),
)
def test_the_entry_size_check_draws_the_line_exactly_where_postgres_does(
scratch_database: str,
index_columns: tuple[str, ...],
s_value: str | None,
t_value: str | None,
longest_fitting_key: int,
described_as: str,
) -> None:
"""One row's key is the longest that still fits the index entry, the other's is one byte
longer. Where that line falls depends on the other indexed column, its alignment and the
null bitmap, so the same two rows go through the check and through Postgres."""
create_index: Final = sql.SQL('CREATE INDEX "entry_probe_idx" ON "entry_probe" ({})').format(
sql.SQL(", ").join(sql.Identifier(column) for column in index_columns)
)
with psycopg.connect(scratch_database, autocommit=True) as conn:
conn.execute('CREATE TABLE "entry_probe" ("s" TEXT, "k" TEXT, "t" TIMESTAMP(3))')
conn.execute(
'INSERT INTO "entry_probe" ("s", "k", "t") VALUES (%s, %s, %s), (%s, %s, %s)',
(
s_value,
secrets.token_hex(longest_fitting_key)[:longest_fitting_key],
t_value,
s_value,
secrets.token_hex(longest_fitting_key)[: longest_fitting_key + 1],
t_value,
),
)
findings: Final = migration_data_checks.find_blockers(
conn, "public", migration_data_checks.checks_for_statement(create_index.as_string(conn), "probe")
)
with pytest.raises(psycopg.errors.ProgramLimitExceeded, match="exceeds btree version 4 maximum 2704"):
conn.execute(create_index)
conn.execute('DELETE FROM "entry_probe" WHERE octet_length("k") > %s', (longest_fitting_key,))
conn.execute(create_index)
(finding,) = findings
assert isinstance(finding, migration_data_checks.BlockedChange)
assert finding.rows == 1, "the check must count the row one byte over the line and not the row on it"
assert finding.detail == f"with an index entry over 2704 bytes, largest 2712 bytes ({described_as})"
@requires_db
def test_a_scan_that_cannot_finish_in_time_is_reported_as_unverified_rather_than_passed(scratch_database: str) -> None:
with psycopg.connect(scratch_database, autocommit=True) as conn:
conn.execute('CREATE TABLE "preflight_slow" ("a" INTEGER)')
conn.execute('INSERT INTO "preflight_slow" SELECT generate_series(1, 50)')
(check,) = migration_data_checks.checks_for_statement(
'ALTER TABLE "preflight_slow" ADD CONSTRAINT "slow" CHECK (pg_sleep(0.1) IS NULL)', "probe"
)
findings: Final = migration_data_checks.find_blockers(conn, "public", (check,), timeout_seconds=0.5)
timeout_afterwards: Final = conn.execute("SHOW statement_timeout").fetchone()
assert findings == (migration_data_checks.CouldNotVerify(check, 0.5),)
assert timeout_afterwards == ("0",), "the preflight must hand the session back with the timeout it found"
assert "could not be checked within 0.5 s" in migration_data_checks.report(findings)

View file

@ -0,0 +1,423 @@
"""A Bedrock deployment whose ``guardrailConfig`` has ``trace: enabled`` bills the policy units Bedrock reports in the
trace as guardrail cost, on every surface the deployment serves: ``/v1/chat/completions`` and ``/v1/responses`` over
Converse and Invoke, streamed and not, and the four ``/bedrock/model/{model}`` pass-through actions. The cost rides
``x-litellm-response-cost`` on non-streaming responses and the spend row's ``spend`` and ``guardrail_information``
everywhere. ``trace: disabled`` leaves no usage, so nothing is billed beyond the tokens (LIT-10101)"""
import base64
import json
import uuid
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import Final
import boto3
import pytest
import yaml
from botocore.config import Config
from integration._support.budget import case_timeout_seconds
from integration._support.client import Gateway, Scenario, eventually
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.upstream import _aws_event_frame
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
import litellm
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 240)
_MODEL_ID: Final = "anthropic.claude-sonnet-4-5-20250929-v1:0"
_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
_GUARDRAIL_ID: Final = "native-guardrail"
_INPUT_COST_PER_TOKEN: Final = 0.001
_OUTPUT_COST_PER_TOKEN: Final = 0.002
_PROMPT_TOKENS: Final = 5
_COMPLETION_TOKENS: Final = 1
_INPUT_USAGE: Final[Mapping[str, int]] = {"topicPolicyUnits": 1, "contentPolicyUnits": 1, "wordPolicyUnits": 1}
_OUTPUT_USAGE: Final[Mapping[str, int]] = {"topicPolicyUnits": 2, "sensitiveInformationPolicyUnits": 2}
_SUMMED_USAGE: Final[Mapping[str, int]] = {
"topicPolicyUnits": 3,
"contentPolicyUnits": 1,
"wordPolicyUnits": 1,
"sensitiveInformationPolicyUnits": 2,
}
_ACTIONS: Final = ("converse", "converse-stream", "invoke", "invoke-with-response-stream")
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_SPEND_SQL: Final = 'SELECT spend, metadata FROM "LiteLLM_SpendLogs" WHERE api_key=%s ORDER BY "startTime"'
def _llm_cost() -> float:
return _PROMPT_TOKENS * _INPUT_COST_PER_TOKEN + _COMPLETION_TOKENS * _OUTPUT_COST_PER_TOKEN
def _guardrail_cost() -> float:
"""What the proxy's own cost map prices the traced units at, so the test owns the relation, not AWS's rates."""
rates: Final = litellm.model_cost["bedrock/guardrails"]["guardrail_cost_per_unit"]
return sum(units * rates[counter] for counter, units in _SUMMED_USAGE.items())
def _action(request: Request) -> str:
return request.target.partition("?")[0].rsplit("/", 1)[-1]
def _traced(request: Request) -> bool:
if _action(request).startswith("converse"):
config: Final = json.loads(request.body).get("guardrailConfig") or {}
return config.get("trace") == "enabled"
return request.headers.get("x-amzn-bedrock-trace", "").upper() == "ENABLED"
def _assessment(usage: Mapping[str, int]) -> dict[str, JsonValue]:
return {"invocationMetrics": {"usage": dict(usage), "guardrailProcessingLatency": 7}}
def _converse_trace() -> dict[str, JsonValue]:
return {
"guardrail": {
"inputAssessment": {_GUARDRAIL_ID: _assessment(_INPUT_USAGE)},
"outputAssessments": {_GUARDRAIL_ID: [_assessment(_OUTPUT_USAGE)]},
}
}
def _invoke_trace() -> dict[str, JsonValue]:
return {
"guardrail": {
"input": {_GUARDRAIL_ID: _assessment(_INPUT_USAGE)},
"outputs": [{_GUARDRAIL_ID: _assessment(_OUTPUT_USAGE)}],
}
}
def _bedrock(request: Request) -> Reply:
"""Bedrock's runtime as the caller sees it: a one-token answer, and the guardrail trace with its policy units only
when the request enabled tracing, in the shape of the action that served it."""
traced: Final = _traced(request)
action: Final = _action(request)
usage: Final = {"inputTokens": _PROMPT_TOKENS, "outputTokens": _COMPLETION_TOKENS, "totalTokens": 6}
converse_trace: Final = {"trace": _converse_trace()} if traced else {}
if action == "converse":
body: Final = {
"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}},
"stopReason": "end_turn",
"usage": usage,
"metrics": {"latencyMs": 3},
**converse_trace,
}
return Reply(body=json.dumps(body).encode())
if action == "converse-stream" and "toolConfig" in json.loads(request.body):
return Reply(
chunks=(
_aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"),
_aws_event_frame(
"contentBlockStart",
{"start": {"toolUse": {"toolUseId": "tool-1", "name": "lookup"}}, "contentBlockIndex": 0},
"sc",
"u",
),
_aws_event_frame(
"contentBlockDelta",
{"delta": {"toolUse": {"input": '{"q": "x"}'}}, "contentBlockIndex": 0},
"sc",
"u",
),
_aws_event_frame("contentBlockStop", {"contentBlockIndex": 0}, "sc", "u"),
_aws_event_frame("messageStop", {"stopReason": "tool_use"}, "sc", "u"),
_aws_event_frame(
"metadata", {"usage": usage, "metrics": {"latencyMs": 3}, **converse_trace}, "sc", "u"
),
),
content_type=_EVENT_STREAM,
)
if action == "converse-stream":
return Reply(
chunks=(
_aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"),
_aws_event_frame("contentBlockDelta", {"delta": {"text": "ok"}, "contentBlockIndex": 0}, "sc", "u"),
_aws_event_frame("contentBlockStop", {"contentBlockIndex": 0}, "sc", "u"),
_aws_event_frame("messageStop", {"stopReason": "end_turn"}, "sc", "u"),
_aws_event_frame(
"metadata", {"usage": usage, "metrics": {"latencyMs": 3}, **converse_trace}, "sc", "u"
),
),
content_type=_EVENT_STREAM,
)
invoke_trace: Final = {"amazon-bedrock-trace": _invoke_trace()} if traced else {}
if action == "invoke":
message: Final = {
"id": f"msg_{uuid.uuid4().hex[:12]}",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": _COMPLETION_TOKENS},
**invoke_trace,
}
return Reply(body=json.dumps(message).encode())
events: Final = (
{
"type": "message_start",
"message": {
"id": f"msg_{uuid.uuid4().hex[:12]}",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": 0},
},
},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ok"}},
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": _COMPLETION_TOKENS},
},
{
"type": "message_stop",
"amazon-bedrock-invocationMetrics": {
"inputTokenCount": _PROMPT_TOKENS,
"outputTokenCount": _COMPLETION_TOKENS,
"invocationLatency": 3,
"firstByteLatency": 1,
},
**invoke_trace,
},
)
return Reply(
chunks=tuple(
_aws_event_frame("chunk", {"bytes": base64.b64encode(json.dumps(event).encode()).decode()}, "sc", "u")
for event in events
),
content_type=_EVENT_STREAM,
)
def _deployment(wire: Wire, model: str, trace: str) -> Mapping[str, JsonValue]:
return {
"model": model,
"api_base": wire.url,
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
"aws_secret_access_key": "scripted-secret",
"aws_region_name": "us-east-1",
"use_in_pass_through": True,
"input_cost_per_token": _INPUT_COST_PER_TOKEN,
"output_cost_per_token": _OUTPUT_COST_PER_TOKEN,
"guardrailConfig": {"guardrailIdentifier": _GUARDRAIL_ID, "guardrailVersion": "1", "trace": trace},
}
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
scenario: Scenario
converse_model: str
invoke_model: str
untraced_model: str
def key(self) -> str:
return self.scenario.key(key_alias=f"native-trace-{uuid.uuid4().hex[:8]}")
def runtime(self, monkeypatch: pytest.MonkeyPatch, key: str): # noqa: ANN201 # boto3 clients are untyped
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", key)
return boto3.client(
"bedrock-runtime",
endpoint_url=f"{str(self.proxy.client.base_url).rstrip('/')}/bedrock",
region_name="us-east-1",
config=Config(read_timeout=case_timeout_seconds(), retries={"total_max_attempts": 1}),
)
@pytest.fixture(scope="module")
def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
suffix: Final = uuid.uuid4().hex[:12]
converse_model: Final = f"bedrock-traced-converse-{suffix}"
invoke_model: Final = f"bedrock-traced-invoke-{suffix}"
untraced_model: Final = f"bedrock-untraced-{suffix}"
directory: Final = tmp_path_factory.mktemp("bedrock-native-guardrail-trace")
base_config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
with wire_server(_bedrock) as wire:
config: Final = {
**base_config,
"model_list": [
*base_config.get("model_list", []),
{"model_name": converse_model, "litellm_params": _deployment(wire, f"bedrock/{_MODEL_ID}", "enabled")},
{
"model_name": invoke_model,
"litellm_params": _deployment(wire, f"bedrock/invoke/{_MODEL_ID}", "enabled"),
},
{"model_name": untraced_model, "litellm_params": _deployment(wire, f"bedrock/{_MODEL_ID}", "disabled")},
],
}
config_path: Final = directory / "bedrock-native-guardrail-trace.yaml"
config_path.write_text(yaml.safe_dump(config))
with owned_proxy(session_gateway, directory, {}, config=config_path) as proxy, proxy.scenario() as scenario:
yield Rig(
proxy=proxy,
scenario=scenario,
converse_model=converse_model,
invoke_model=invoke_model,
untraced_model=untraced_model,
)
@dataclass(frozen=True, slots=True)
class SpendRow:
spend: float
guardrail_information: tuple[dict[str, JsonValue], ...]
def _spend_row(proxy: Gateway, key: str) -> SpendRow:
digest: Final = sha256(key.encode()).hexdigest()
(row,) = eventually(lambda: proxy.rows(_SPEND_SQL, (digest,)), lambda rows: len(rows) == 1, seconds=70)
metadata: Final = _JSON_OBJECT.validate_python(
json.loads(row["metadata"]) if isinstance(row["metadata"], str) else row["metadata"]
)
raw_information: Final = metadata.get("guardrail_information")
entries: Final = raw_information if isinstance(raw_information, list) else [raw_information]
return SpendRow(
spend=float(str(row["spend"])),
guardrail_information=tuple(_JSON_OBJECT.validate_python(entry) for entry in entries if entry is not None),
)
def _assert_billed_as_guardrail_cost(row: SpendRow) -> None:
assert row.spend == pytest.approx(_llm_cost() + _guardrail_cost())
(entry,) = row.guardrail_information
assert entry["guardrail_provider"] == "bedrock"
assert entry["guardrail_name"] == _GUARDRAIL_ID
assert entry["guardrail_usage"] == _SUMMED_USAGE
assert isinstance(entry["guardrail_cost"], float) and entry["guardrail_cost"] == pytest.approx(_guardrail_cost())
def _chat_body(model: str, stream: bool) -> dict[str, JsonValue]:
return {"model": model, "messages": [{"role": "user", "content": "hello"}], "max_tokens": 16, "stream": stream}
def _responses_body(model: str, stream: bool) -> dict[str, JsonValue]:
return {"model": model, "input": "hello", "stream": stream}
@pytest.mark.parametrize("api", ["converse", "invoke"])
@pytest.mark.parametrize("endpoint", ["/v1/chat/completions", "/v1/responses"])
def test_non_streaming_response_prices_the_trace_into_the_cost_header_and_the_spend_row(
rig: Rig, endpoint: str, api: str
) -> None:
key: Final = rig.key()
model: Final = rig.converse_model if api == "converse" else rig.invoke_model
body: Final = _chat_body(model, False) if endpoint == "/v1/chat/completions" else _responses_body(model, False)
response: Final = rig.proxy.request("POST", endpoint, body, key=key)
assert response.is_success, response.text[:400]
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(_llm_cost() + _guardrail_cost())
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
@pytest.mark.parametrize("api", ["converse", "invoke"])
@pytest.mark.parametrize("endpoint", ["/v1/chat/completions", "/v1/responses"])
def test_streamed_response_prices_the_trace_of_the_final_event_into_the_spend_row(
rig: Rig, endpoint: str, api: str
) -> None:
key: Final = rig.key()
model: Final = rig.converse_model if api == "converse" else rig.invoke_model
body: Final = _chat_body(model, True) if endpoint == "/v1/chat/completions" else _responses_body(model, True)
with rig.proxy.client.stream(
"POST", endpoint, json=body, headers={"Authorization": f"Bearer {key}"}, timeout=case_timeout_seconds()
) as response:
frames: Final = tuple(line for line in response.iter_lines() if line.startswith("data:"))
assert response.is_success and frames
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
@pytest.mark.parametrize("action", _ACTIONS)
def test_passthrough_action_prices_the_trace_into_the_spend_row(
rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str
) -> None:
key: Final = rig.key()
runtime: Final = rig.runtime(monkeypatch, key)
messages: Final = [{"role": "user", "content": [{"text": "hello"}]}]
invoke_body: Final = json.dumps(
{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hello"}],
}
)
if action == "converse":
getattr(runtime, "converse")(modelId=rig.converse_model, messages=messages)
elif action == "converse-stream":
list(getattr(runtime, "converse_stream")(modelId=rig.converse_model, messages=messages)["stream"])
elif action == "invoke":
getattr(runtime, "invoke_model")(modelId=rig.converse_model, body=invoke_body)["body"].read()
else:
list(
getattr(runtime, "invoke_model_with_response_stream")(modelId=rig.converse_model, body=invoke_body)["body"]
)
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
def test_disabled_trace_bills_only_the_tokens(rig: Rig) -> None:
key: Final = rig.key()
response: Final = rig.proxy.request("POST", "/v1/chat/completions", _chat_body(rig.untraced_model, False), key=key)
assert response.is_success, response.text[:400]
assert "trace" not in response.json()
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(_llm_cost())
row: Final = _spend_row(rig.proxy, key)
assert row.spend == pytest.approx(_llm_cost())
assert row.guardrail_information == ()
def _messages_body(model: str, stream: bool) -> dict[str, JsonValue]:
return {
"model": model,
"messages": [{"role": "user", "content": f"hello from the messages api, stream={stream}"}],
"max_tokens": 16,
"stream": stream,
}
def test_messages_non_streaming_response_applies_the_guardrail_and_prices_its_trace(rig: Rig) -> None:
key: Final = rig.key()
response: Final = rig.proxy.request("POST", "/v1/messages", _messages_body(rig.invoke_model, False), key=key)
assert response.is_success, response.text[:400]
assert "amazon-bedrock-trace" in response.json()
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
def test_messages_streamed_response_prices_the_trace_of_the_message_stop_event(rig: Rig) -> None:
key: Final = rig.key()
with rig.proxy.client.stream(
"POST",
"/v1/messages",
json=_messages_body(rig.invoke_model, True),
headers={"Authorization": f"Bearer {key}"},
timeout=case_timeout_seconds(),
) as response:
body: Final = b"".join(response.iter_bytes())
assert response.is_success and b"message_stop" in body, body[:400]
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
def test_passthrough_streamed_tool_call_prices_the_trace_of_the_final_event(
rig: Rig, monkeypatch: pytest.MonkeyPatch
) -> None:
key: Final = rig.key()
runtime: Final = rig.runtime(monkeypatch, key)
tool_config: Final = {
"tools": [{"toolSpec": {"name": "lookup", "inputSchema": {"json": {"type": "object", "properties": {}}}}}]
}
events: Final = list(
getattr(runtime, "converse_stream")(
modelId=rig.converse_model,
messages=[{"role": "user", "content": [{"text": "hello"}]}],
toolConfig=tool_config,
)["stream"]
)
assert any("contentBlockStart" in event for event in events)
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))

View file

@ -1,7 +1,11 @@
import json
from typing import Final
import pytest
from pydantic import JsonValue
import litellm
from litellm.litellm_core_utils.llm_cost_calc import guardrail_cost
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
bedrock_guardrail_cost,
bedrock_guardrail_cost_by_unit,
@ -230,3 +234,118 @@ def test_guardrail_information_cost_skips_malformed_entry_keeps_siblings():
]
assert guardrail_information_cost(entries) == pytest.approx(0.0003)
assert guardrail_information_cost({"guardrail_cost": 0.5, "guardrail_cost_in_spend": "maybe"}) == 0.0
_TRACE_USAGE_IN: Final = {
"topicPolicyUnits": 1,
"contentPolicyUnits": 1,
"wordPolicyUnits": 1,
"sensitiveInformationPolicyUnits": 0,
"contextualGroundingPolicyUnits": 0,
"automatedReasoningPolicies": 0,
}
_TRACE_USAGE_OUT: Final = {
**_TRACE_USAGE_IN,
"topicPolicyUnits": 2,
"contentPolicyUnits": 2,
"sensitiveInformationPolicyUnits": 2,
}
_TRACE_PRICING: Final = {
"bedrock/guardrails": {
"litellm_provider": "bedrock",
"mode": "guardrails",
"guardrail_cost_per_unit": {
"topicPolicyUnits": 0.00015,
"contentPolicyUnits": 0.00015,
"wordPolicyUnits": 0.0,
"sensitiveInformationPolicyUnits": 0.0001,
},
}
}
def _converse_trace(action: str = "NONE") -> dict[str, JsonValue]:
return {
"guardrail": {
"inputAssessment": {"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_IN}}},
"outputAssessments": {
"gid": [
{
"topicPolicy": {"topics": [{"name": "t", "type": "DENY", "action": action}]},
"invocationMetrics": {"usage": _TRACE_USAGE_OUT},
}
]
},
}
}
def _invoke_trace() -> dict[str, JsonValue]:
return {
"guardrail": {
"input": {"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_IN}}},
"outputs": [{"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_OUT}}}],
}
}
@pytest.mark.parametrize("trace", [_converse_trace(), _invoke_trace()], ids=["converse", "invoke"])
def test_native_trace_sums_every_assessment_and_prices_it_like_the_proxy_guardrail(monkeypatch, trace) -> None:
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
trace, aws_region_name=None, guardrail_identifier=None
)
assert entry is not None
assert entry["guardrail_usage"] == {
"topicPolicyUnits": 3,
"contentPolicyUnits": 3,
"wordPolicyUnits": 2,
"sensitiveInformationPolicyUnits": 2,
"contextualGroundingPolicyUnits": 0,
}
assert entry["guardrail_cost"] == pytest.approx(3 * 0.00015 + 3 * 0.00015 + 2 * 0.0001)
assert entry["guardrail_cost_by_unit"]["contextualGroundingPolicyUnits"] is None
assert entry["guardrail_name"] == "gid"
assert entry["guardrail_provider"] == "bedrock"
assert entry["guardrail_status"] == "success"
assert guardrail_cost.guardrail_information_cost([entry]) == pytest.approx(entry["guardrail_cost"])
def test_native_trace_marks_a_blocked_assessment_intervened_and_prefers_the_configured_identifier(monkeypatch) -> None:
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
_converse_trace("BLOCKED"), aws_region_name="us-east-1", guardrail_identifier="configured-id"
)
assert entry is not None
assert entry["guardrail_status"] == "guardrail_intervened"
assert entry["guardrail_name"] == "configured-id"
def test_native_trace_without_usage_is_not_billed(monkeypatch) -> None:
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
unpriced: Final = {"guardrail": {"inputAssessment": {"gid": {"topicPolicy": {"topics": []}}}}}
assert guardrail_cost.native_bedrock_guardrail_information(unpriced, None, None) is None
assert guardrail_cost.bedrock_guardrail_trace({"usage": {}}) is None
def test_with_guardrail_entry_records_the_same_trace_once() -> None:
entry: Final = {"guardrail_name": "gid", "guardrail_cost": 0.001}
metadata: dict = {"standard_logging_guardrail_information": {"guardrail_name": "hook"}}
guardrail_cost.with_guardrail_entry(metadata, entry) # pyright: ignore[reportArgumentType] # partial entry
guardrail_cost.with_guardrail_entry(metadata, entry) # pyright: ignore[reportArgumentType] # partial entry
assert metadata["standard_logging_guardrail_information"] == [{"guardrail_name": "hook"}, entry]
def test_native_trace_entry_redacts_the_pii_matches_before_they_reach_spend_logs(monkeypatch) -> None:
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
trace: Final = _converse_trace()
trace["guardrail"]["inputAssessment"]["gid"]["sensitiveInformationPolicy"] = {
"piiEntities": [{"type": "US_SSN", "match": "123-45-6789", "action": "ANONYMIZED"}]
}
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
trace, aws_region_name=None, guardrail_identifier=None
)
assert entry is not None
assert entry["guardrail_status"] == "guardrail_intervened"
assert "123-45-6789" not in json.dumps(entry["guardrail_response"])
assert entry["guardrail_usage"]["sensitiveInformationPolicyUnits"] == 2

View file

@ -3672,6 +3672,73 @@ def test_get_extra_header_tags():
delattr(litellm, "extra_spend_tag_headers")
def test_get_extra_header_tags_matches_title_case_config_with_lowercase_request_header(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["X-Org-Id"], raising=False)
assert StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": {"x-org-id": "org_1"}}
) == ["X-Org-Id: org_1"]
def test_get_extra_header_tags_matches_mixed_case_request_header_with_lowercase_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["x-customer-id"], raising=False)
assert StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": {"X-Customer-Id": "v"}}
) == ["x-customer-id: v"]
def test_get_extra_header_tags_keeps_lowercase_label(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["x-customer-id"], raising=False)
assert StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": {"x-customer-id": "v"}}
) == ["x-customer-id: v"]
def test_get_extra_header_tags_returns_none_for_absent_or_empty_header(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["x-org-id"], raising=False)
assert StandardLoggingPayloadSetup._get_extra_header_tags(proxy_server_request={"headers": {}}) is None
assert StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": {"x-org-id": ""}}
) is None
def test_get_extra_header_tags_preserves_configured_header_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["x-second", "x-first"], raising=False)
assert StandardLoggingPayloadSetup._get_extra_header_tags(
proxy_server_request={"headers": {"x-first": "1", "x-second": "2"}}
) == ["x-second: 2", "x-first: 1"]
def test_get_request_tags_includes_extra_header_tag_with_user_agent_tags(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "extra_spend_tag_headers", ["X-Org-Id"], raising=False)
assert StandardLoggingPayloadSetup.get_request_tags(
litellm_params={},
proxy_server_request={
"headers": {
"user-agent": "litellm/1.0",
"x-org-id": "org_1",
}
},
) == ["User-Agent: litellm", "User-Agent: litellm/1.0", "X-Org-Id: org_1"]
def test_response_cost_calculator_with_response_cost_in_hidden_params(logging_obj):
from litellm import Router
@ -13642,3 +13709,54 @@ def test_native_messages_body_carries_the_reported_list_cost_into_the_priced_res
assert isinstance(priced, ModelResponse)
headers = priced._hidden_params.get("additional_headers") or {}
assert headers.get("llm_provider-x-litellm-response-cost") == expected_header
def test_record_native_bedrock_guardrail_bills_when_the_metadata_is_none() -> None:
import datetime
from litellm.litellm_core_utils.litellm_logging import Logging
logging_obj = Logging(
model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "hello"}],
stream=False,
call_type="completion",
start_time=datetime.datetime.now(),
litellm_call_id="call-none-metadata",
function_id="fn",
)
logging_obj.model_call_details["litellm_params"] = {"metadata": None}
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"contentPolicyUnits": 2}}}}}}
logging_obj.record_native_bedrock_guardrail({"trace": trace})
(entry,) = logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
assert entry["guardrail_usage"] == {"contentPolicyUnits": 2}
assert entry["guardrail_cost"] > 0
def test_record_native_bedrock_guardrail_bills_nothing_for_a_response_served_from_cache() -> None:
import datetime
from litellm.litellm_core_utils.litellm_logging import Logging
logging_obj = Logging(
model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "hello"}],
stream=False,
call_type="completion",
start_time=datetime.datetime.now(),
litellm_call_id="call-cache-hit",
function_id="fn",
)
logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"contentPolicyUnits": 2}}}}}}
cached = litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "hi"}}], model="x")
setattr(cached, "trace", trace)
cached._hidden_params = {"cache_hit": True}
logging_obj.record_native_bedrock_guardrail(cached)
logging_obj.model_call_details["cache_hit"] = True
logging_obj.record_native_bedrock_guardrail({"trace": trace})
assert "standard_logging_guardrail_information" not in logging_obj.model_call_details["litellm_params"]["metadata"]

View file

@ -26,15 +26,16 @@ import litellm
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.litellm_core_utils.redact_messages import (
_redact_responses_api_output,
_redacted_responses_api_response,
perform_redaction,
redacted_model_call_details_for_custom_logger,
redacted_response_obj_for_custom_logger,
redacted_standard_logging_payload,
should_redact_message_logging,
)
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.responses.main import mock_responses_api_response
from litellm.types.router import BaselineRouteStamp
from litellm.types.utils import (
@ -176,7 +177,11 @@ def test_redaction_hides_a_compaction_summary_in_responses_api_output(shape: str
object="response",
status="completed",
output=[
{"type": "compaction", "id": "cmp_1", "encrypted_content": json.dumps({"type": "compaction", "content": SECRET})}
{
"type": "compaction",
"id": "cmp_1",
"encrypted_content": json.dumps({"type": "compaction", "content": SECRET}),
}
],
)
result: Final = response if shape == "object" else response.model_dump()
@ -2041,3 +2046,32 @@ async def test_redaction_with_metadata_completion_api():
response = standard_logging_payload["response"]
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
def test_perform_redaction_strips_the_matched_text_from_the_bedrock_guardrail_traces() -> None:
finding = {
"guardrail": {
"inputAssessment": {
"gid": {
"sensitiveInformationPolicy": {
"piiEntities": [{"type": "US_SSN", "match": "123-45-6789", "action": "ANONYMIZED"}]
}
}
}
}
}
response = litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "hi"}}], model="x")
setattr(response, "trace", copy.deepcopy(finding))
setattr(response, "amazon-bedrock-trace", copy.deepcopy(finding))
opted_out: Final = CustomLogger(message_logging=False)
redacted: Final = redacted_response_obj_for_custom_logger(response, opted_out)
redacted_dict: Final = redacted_response_obj_for_custom_logger(
{"choices": [], "trace": copy.deepcopy(finding)}, opted_out
)
assert isinstance(redacted, litellm.ModelResponse)
assert "123-45-6789" not in redacted.model_dump_json()
assert "US_SSN" in redacted.model_dump_json(), "the finding stays, only its matched text goes"
assert "123-45-6789" not in str(redacted_dict)
assert "123-45-6789" in response.model_dump_json(), "the caller's own response is not redacted"

View file

@ -8,17 +8,19 @@ import httpx
import pytest
import respx
import litellm
from litellm import ChatCompletionUsageBlock, stream_chunk_builder
from litellm.types.utils import GenericStreamingChunk
from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor, stream_chunk_builder_text_completion
from litellm.litellm_core_utils.streaming_chunk_builder_utils import (
ChunkProcessor,
stream_chunk_builder_text_completion,
)
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
ChatCompletionMessageToolCall,
Delta,
Function,
GenericStreamingChunk,
ModelResponseStream,
PromptTokensDetails,
ServerToolUse,
@ -196,11 +198,7 @@ def test_get_combined_thinking_content_preserves_interleaved_blocks():
}
]
),
make_chunk(
thinking_blocks=[
{"type": "thinking", "thinking": None, "signature": "sig_block1"}
]
),
make_chunk(thinking_blocks=[{"type": "thinking", "thinking": None, "signature": "sig_block1"}]),
make_chunk(
thinking_blocks=[
{
@ -218,16 +216,10 @@ def test_get_combined_thinking_content_preserves_interleaved_blocks():
}
]
),
make_chunk(
thinking_blocks=[
{"type": "thinking", "thinking": None, "signature": "sig_block2"}
]
),
make_chunk(thinking_blocks=[{"type": "thinking", "thinking": None, "signature": "sig_block2"}]),
]
thinking_chunks = [
chunk for chunk in chunks if chunk["choices"][0]["delta"].get("thinking_blocks")
]
thinking_chunks = [chunk for chunk in chunks if chunk["choices"][0]["delta"].get("thinking_blocks")]
processor = ChunkProcessor(chunks=chunks)
result = processor.get_combined_thinking_content(thinking_chunks)
@ -297,9 +289,7 @@ def test_cache_read_input_tokens_retained():
prompt_tokens=11779,
total_tokens=11784,
completion_tokens_details=None,
prompt_tokens_details=PromptTokensDetails(
audio_tokens=None, cached_tokens=11775
),
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=11775),
cache_creation_input_tokens=4,
cache_read_input_tokens=11775,
),
@ -333,9 +323,7 @@ def test_cache_read_input_tokens_retained():
prompt_tokens=0,
total_tokens=214,
completion_tokens_details=None,
prompt_tokens_details=PromptTokensDetails(
audio_tokens=None, cached_tokens=0
),
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=0),
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
),
@ -395,10 +383,7 @@ def test_streaming_preserves_anthropic_1hr_cache_creation_breakdown():
)
# Sanity: the delta event genuinely lacks the breakdown - this is the input
# condition that used to defeat cost calc.
assert (
getattr(message_delta_usage.prompt_tokens_details, "cache_creation_token_details", None)
is None
)
assert getattr(message_delta_usage.prompt_tokens_details, "cache_creation_token_details", None) is None
def _usage_chunk(usage, finish_reason):
return ModelResponseStream(
@ -517,9 +502,7 @@ def test_cache_read_input_tokens_retained_genericstreamingchunk():
prompt_tokens=1234,
total_tokens=1239,
completion_tokens_details=None,
prompt_tokens_details=PromptTokensDetails(
audio_tokens=None, cached_tokens=543
).model_dump(),
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=543).model_dump(),
),
index=2,
)
@ -536,6 +519,7 @@ def test_cache_read_input_tokens_retained_genericstreamingchunk():
assert usage.prompt_tokens_details.cached_tokens == 543
def test_stream_chunk_builder_litellm_usage_chunks():
"""
Validate ChunkProcessor.calculate_usage uses provided usage fields from streaming chunks
@ -609,9 +593,7 @@ def test_stream_chunk_builder_litellm_usage_chunks():
chunks = [chunk1, chunk2]
processor = ChunkProcessor(chunks=chunks)
usage = processor.calculate_usage(
chunks=chunks, model="gemini/gemini-2.5-flash-lite", completion_output=""
)
usage = processor.calculate_usage(chunks=chunks, model="gemini/gemini-2.5-flash-lite", completion_output="")
assert usage.prompt_tokens == 50
assert usage.completion_tokens == 27
@ -655,15 +637,11 @@ def test_calculate_usage_honors_openai_sdk_completion_usage_chunks():
provider_specific_fields=None,
stream_options={"include_usage": True},
)
usage_chunk.usage = CompletionUsage(
prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704
)
usage_chunk.usage = CompletionUsage(prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704)
assert type(usage_chunk.usage) is CompletionUsage
chunks = [content_chunk, usage_chunk]
usage = ChunkProcessor(chunks=chunks).calculate_usage(
chunks=chunks, model="mantle-claude", completion_output=""
)
usage = ChunkProcessor(chunks=chunks).calculate_usage(chunks=chunks, model="mantle-claude", completion_output="")
assert usage.prompt_tokens == 20
assert usage.completion_tokens == 60
@ -686,9 +664,7 @@ def test_get_model_from_chunks_azure_model_router():
{"model": "gpt-4.1-nano-2025-04-14", "id": "chatcmpl-123", "choices": []},
]
result = ChunkProcessor._get_model_from_chunks(
chunks=chunks, first_chunk_model="azure-model-router"
)
result = ChunkProcessor._get_model_from_chunks(chunks=chunks, first_chunk_model="azure-model-router")
# Should return the actual model, not the request model
assert result == "gpt-4.1-nano-2025-04-14"
@ -699,9 +675,7 @@ def test_get_model_from_chunks_azure_model_router():
{"model": "gpt-4", "id": "chatcmpl-456", "choices": []},
]
result_same = ChunkProcessor._get_model_from_chunks(
chunks=chunks_same_model, first_chunk_model="gpt-4"
)
result_same = ChunkProcessor._get_model_from_chunks(chunks=chunks_same_model, first_chunk_model="gpt-4")
# Should return the first chunk's model when all are the same
assert result_same == "gpt-4"
@ -777,9 +751,7 @@ def test_stream_chunk_builder_anthropic_web_search():
chunks = [chunk1, chunk2]
processor = ChunkProcessor(chunks=chunks)
usage = processor.calculate_usage(
chunks=chunks, model="claude-sonnet-4-5-20250929", completion_output=""
)
usage = processor.calculate_usage(chunks=chunks, model="claude-sonnet-4-5-20250929", completion_output="")
assert usage.prompt_tokens == 50
assert usage.completion_tokens == 27
@ -931,15 +903,11 @@ def test_stream_chunk_builder_dict_snapshot_preserves_hidden_provider_fields():
],
)
chunk_dict = chunk.model_dump()
chunk_dict["_hidden_params"] = {
"provider_specific_fields": {"traffic_type": "default"}
}
chunk_dict["_hidden_params"] = {"provider_specific_fields": {"traffic_type": "default"}}
response = stream_chunk_builder(chunks=[chunk_dict])
assert response is not None
assert (
response._hidden_params["provider_specific_fields"]["traffic_type"] == "default"
)
assert response._hidden_params["provider_specific_fields"]["traffic_type"] == "default"
def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_chunks():
@ -984,10 +952,7 @@ def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_chunks():
assert getattr(response, "vertex_ai_grounding_metadata") == grounding_metadata
assert getattr(response, "vertex_ai_url_context_metadata") == url_context_metadata
assert response._hidden_params["vertex_ai_grounding_metadata"] == grounding_metadata
assert (
response._hidden_params["vertex_ai_url_context_metadata"]
== url_context_metadata
)
assert response._hidden_params["vertex_ai_url_context_metadata"] == url_context_metadata
dumped = response.model_dump()
assert dumped["vertex_ai_grounding_metadata"] == grounding_metadata
@ -1034,9 +999,7 @@ def test_stream_chunk_builder_uses_assembled_model_for_provider_metadata():
def test_stream_chunk_builder_propagates_vertex_ai_safety_results():
"""Assembled response must expose safety data under the non-streaming field name."""
safety_ratings = [
[{"category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE"}]
]
safety_ratings = [[{"category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE"}]]
chunk = ModelResponseStream(
id="chatcmpl-vertex-safety",
@ -1078,18 +1041,12 @@ def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_dict_chunks():
)
],
).model_dump()
chunk_dict["_hidden_params"] = {
"vertex_ai_grounding_metadata": [{"webSearchQueries": ["test query"]}]
}
chunk_dict["_hidden_params"] = {"vertex_ai_grounding_metadata": [{"webSearchQueries": ["test query"]}]}
response = stream_chunk_builder(chunks=[chunk_dict])
assert response is not None
assert getattr(response, "vertex_ai_grounding_metadata") == [
{"webSearchQueries": ["test query"]}
]
assert response.model_dump()["vertex_ai_grounding_metadata"] == [
{"webSearchQueries": ["test query"]}
]
assert getattr(response, "vertex_ai_grounding_metadata") == [{"webSearchQueries": ["test query"]}]
assert response.model_dump()["vertex_ai_grounding_metadata"] == [{"webSearchQueries": ["test query"]}]
def test_cost_field_in_usage_chunks():
@ -1098,29 +1055,21 @@ def test_cost_field_in_usage_chunks():
id="chatcmpl-1",
created=1745513206,
model="openrouter/claude",
choices=[
StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))
],
choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))],
usage=chunk1_usage,
)
chunk2_usage = Usage(
completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025
)
chunk2_usage = Usage(completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025)
chunk2 = ModelResponseStream(
id="chatcmpl-1",
created=1745513207,
model="openrouter/claude",
choices=[
StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))
],
choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))],
usage=chunk2_usage,
)
processor = ChunkProcessor(chunks=[chunk1, chunk2])
usage = processor.calculate_usage(
chunks=[chunk1, chunk2], model="openrouter/claude", completion_output="Hi"
)
usage = processor.calculate_usage(chunks=[chunk1, chunk2], model="openrouter/claude", completion_output="Hi")
assert hasattr(usage, "cost")
assert usage.cost == 0.00025
@ -1203,25 +1152,19 @@ def test_prompt_tokens_details_survive_later_usage_chunk_without_details():
id="chatcmpl-1",
created=1745513206,
model="openai/gpt-5.6-sol",
choices=[
StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))
],
choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))],
usage=Usage(
prompt_tokens=6017,
completion_tokens=4,
total_tokens=6021,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=6004, cache_write_tokens=10
),
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=6004, cache_write_tokens=10),
),
)
chunk_without_details = ModelResponseStream(
id="chatcmpl-1",
created=1745513207,
model="openai/gpt-5.6-sol",
choices=[
StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))
],
choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))],
usage=Usage(prompt_tokens=6017, completion_tokens=4, total_tokens=6021),
)
@ -1559,9 +1502,7 @@ def test_calculate_usage_fills_unknown_split_from_reasoning_estimate(
assert usage.completion_tokens_details.text_tokens == expected_text_tokens
def _openai_chunk(
choices: Sequence[Mapping[str, object]], usage: Mapping[str, int] | None = None
) -> dict[str, object]:
def _openai_chunk(choices: Sequence[Mapping[str, object]], usage: Mapping[str, int] | None = None) -> dict[str, object]:
base: Final = {
"id": "chatcmpl-lit6552",
"object": "chat.completion.chunk",
@ -1996,7 +1937,11 @@ def test_stream_chunk_builder_litellm_tool_call(respx_mock: respx.MockRouter) ->
)
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
{"function": {"arguments": '{"city":"Boston"}', "name": "get_weather"}, "id": "call_weather", "type": "function"}
{
"function": {"arguments": '{"city":"Boston"}', "name": "get_weather"},
"id": "call_weather",
"type": "function",
}
]
assert (response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens) == (5, 3, 8)
@ -2018,7 +1963,9 @@ def test_stream_chunk_builder_litellm_tool_call_regular_message(respx_mock: resp
def test_stream_chunk_builder_multiple_tool_calls() -> None:
def tool_call_chunk(*tool_calls: ChatCompletionDeltaToolCall, finish_reason: str | None = None) -> ModelResponseStream:
def tool_call_chunk(
*tool_calls: ChatCompletionDeltaToolCall, finish_reason: str | None = None
) -> ModelResponseStream:
return _legacy_stream_chunk("openai/gpt-4o", tool_calls=tool_calls, finish_reason=finish_reason)
chunks: Final = (
@ -2029,7 +1976,9 @@ def test_stream_chunk_builder_multiple_tool_calls() -> None:
),
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='{"base": 3, '))),
tool_call_chunk(
ChatCompletionDeltaToolCall(id="call_add", index=1, type="function", function=Function(name="add", arguments=""))
ChatCompletionDeltaToolCall(
id="call_add", index=1, type="function", function=Function(name="add", arguments="")
)
),
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='"exponent": 5}'))),
tool_call_chunk(
@ -2043,7 +1992,11 @@ def test_stream_chunk_builder_multiple_tool_calls() -> None:
assert response is not None
assert response.choices[0].finish_reason == "tool_calls"
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
{"function": {"arguments": '{"base": 3, "exponent": 5}', "name": "exponentiate"}, "id": "call_exp", "type": "function"},
{
"function": {"arguments": '{"base": 3, "exponent": 5}', "name": "exponentiate"},
"id": "call_exp",
"type": "function",
},
{"function": {"arguments": '{"x": 1, "y": 2}', "name": "add"}, "id": "call_add", "type": "function"},
]
@ -2077,9 +2030,7 @@ def test_grok_bug() -> None:
response: Final = stream_chunk_builder(chunks=chunks)
assert response is not None
assert json.loads(response.choices[0].message.tool_calls[0].function.arguments) == {
"location": "Boston"
}
assert json.loads(response.choices[0].message.tool_calls[0].function.arguments) == {"location": "Boston"}
def test_stream_chunk_builder_openai_audio_output_usage() -> None:
@ -2196,7 +2147,9 @@ def test_stream_chunk_builder_keeps_usage_dict_set_on_a_typed_text_completion_ch
@pytest.mark.parametrize("build", [litellm.stream_chunk_builder, litellm.stream_chunk_builder_text_completion])
def test_stream_chunk_builder_rebuilds_dict_text_completion_chunks(build: Callable[..., TextCompletionResponse]) -> None:
def test_stream_chunk_builder_rebuilds_dict_text_completion_chunks(
build: Callable[..., TextCompletionResponse],
) -> None:
def chunk(choices: list[dict[str, object]], **extra: object) -> dict[str, object]:
return {
"id": "cmpl-1",
@ -2450,7 +2403,9 @@ def test_stream_chunk_builder_rebuilds_text_completion_choice_indexes_that_are_n
]
)
assert tuple(choice.model_dump(include={"index", "text", "finish_reason"}) for choice in response.choices) == expected
assert (
tuple(choice.model_dump(include={"index", "text", "finish_reason"}) for choice in response.choices) == expected
)
def test_stream_chunk_builder_text_completion_survives_uncountable_prompt() -> None:
@ -2517,3 +2472,50 @@ def test_stream_chunk_builder_keeps_reasoning_items_on_text_only_replies():
assert response is not None
assert response.choices[0].message.content == "Hello world"
assert response.choices[0].message.reasoning_items == reasoning_items
def test_stream_chunk_builder_carries_the_bedrock_guardrail_trace_and_records_it_on_the_logging_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Bedrock stream reports its guardrail trace on the last chunk only; the built response carries it like the
non-streaming response and the call bills it as guardrail cost."""
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
monkeypatch.setattr(
litellm,
"model_cost",
{
"bedrock/guardrails": {
"litellm_provider": "bedrock",
"mode": "guardrails",
"guardrail_cost_per_unit": {"topicPolicyUnits": 0.00015},
}
},
)
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 2}}}}}}
chunks = [
ModelResponseStream(id="s", model="m", choices=[StreamingChoices(index=0, delta=Delta(content="hi"))]),
ModelResponseStream(
id="s",
model="m",
choices=[StreamingChoices(index=0, delta=Delta(), finish_reason="stop")],
provider_specific_fields={"trace": trace},
),
]
logging_obj = Logging(
model="bedrock/m",
messages=[{"role": "user", "content": "q"}],
stream=True,
call_type="acompletion",
start_time=None,
litellm_call_id="call",
function_id="fn",
)
logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
built = stream_chunk_builder(chunks=chunks, messages=[{"role": "user", "content": "q"}], logging_obj=logging_obj)
assert built is not None
assert getattr(built, "trace") == trace
(entry,) = logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
assert entry["guardrail_usage"] == {"topicPolicyUnits": 2}
assert entry["guardrail_cost"] == pytest.approx(2 * 0.00015)

View file

@ -0,0 +1,156 @@
from pathlib import Path
from typing import Final
import pytest
from litellm_proxy_extras.migration_data_checks import (
DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS,
MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR,
BlockedChange,
Check,
CouldNotVerify,
btree_max_entry_bytes,
checks_for_index,
checks_for_migration,
checks_for_statement,
migration_data_check_timeout_seconds,
report,
statements,
)
MIGRATIONS: Final = Path(__file__).resolve().parents[3] / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
API_KEY_INDEX_AS_SHIPPED_IN_V1_103_0: Final = 'CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" ON "LiteLLM_SpendLogs"("api_key", "startTime")'
def _shape(checks: tuple[Check, ...]) -> tuple[tuple[str, str, tuple[str, ...]], ...]:
return tuple((check.kind, check.table, check.columns) for check in checks)
@pytest.mark.parametrize(
("statement", "expected"),
(
(
API_KEY_INDEX_AS_SHIPPED_IN_V1_103_0,
(("too_long", "LiteLLM_SpendLogs", ("api_key", "startTime")),),
),
(
'CREATE UNIQUE INDEX CONCURRENTLY "T_a_b_key" ON "public"."T" USING btree ("a", "b" DESC)',
(("too_long", "T", ("a", "b")), ("duplicates", "T", ("a", "b"))),
),
('CREATE INDEX "T_a_idx" ON "T" USING gin ("a")', ()),
('CREATE INDEX "T_a_idx" ON "T" (lower("a"))', ()),
('ALTER TABLE "T" ALTER COLUMN "a" SET NOT NULL', (("nulls", "T", ("a",)),)),
('ALTER TABLE "T" ADD COLUMN "a" TEXT NOT NULL', (("any_row", "T", ("a",)),)),
('ALTER TABLE "T" ADD COLUMN "a" TEXT NOT NULL DEFAULT \'x\'', ()),
('ALTER TABLE "T" ADD COLUMN "a" TEXT', ()),
(
'ALTER TABLE "T" ADD CONSTRAINT "T_r_fkey" FOREIGN KEY ("r") REFERENCES "R"("id") ON DELETE CASCADE',
(("orphans", "T", ("r",)),),
),
('ALTER TABLE "T" ADD CONSTRAINT "T_r_fkey" FOREIGN KEY ("r") REFERENCES "R"("id") NOT VALID', ()),
('ALTER TABLE "T" ADD CONSTRAINT "T_a_key" UNIQUE ("a", "b")', (("duplicates", "T", ("a", "b")),)),
(
'ALTER TABLE "T" ADD CONSTRAINT "T_a_check" CHECK ("a" > 0 AND "b" IS NOT NULL)',
(("check_violations", "T", ()),),
),
('ALTER TABLE "T" ADD CONSTRAINT "T_a_check" CHECK ("a" > 0) NOT VALID', ()),
(
'ALTER TABLE "T" ADD COLUMN "c" TEXT, ALTER COLUMN "b" SET NOT NULL, ADD CONSTRAINT "u" UNIQUE ("c")',
(("nulls", "T", ("b",)), ("duplicates", "T", ("c",))),
),
('CREATE TABLE "T" ("id" TEXT NOT NULL, CONSTRAINT "T_pkey" PRIMARY KEY ("id"))', ()),
('DROP INDEX IF EXISTS "T_a_idx"', ()),
("SELECT 1", ()),
),
)
def test_a_statement_gets_one_check_per_way_existing_rows_can_make_it_fail(
statement: str, expected: tuple[tuple[str, str, tuple[str, ...]], ...]
) -> None:
assert _shape(checks_for_statement(statement, "migration x")) == expected
def test_a_foreign_key_check_keeps_the_referenced_table_and_columns() -> None:
(check,) = checks_for_statement(
'ALTER TABLE "T" ADD CONSTRAINT "fk" FOREIGN KEY ("r", "s") REFERENCES "R"("id", "sub")', "migration x"
)
assert (check.referenced_table, check.referenced_columns) == ("R", ("id", "sub"))
def test_a_check_constraint_keeps_its_expression_for_the_count_query() -> None:
(check,) = checks_for_statement('ALTER TABLE "T" ADD CONSTRAINT "c" CHECK ("a" > 0)', "migration x")
assert check.expression == '"a" > 0'
def test_comments_and_blank_statements_are_dropped_before_matching() -> None:
migration_sql: Final = "-- CreateIndex\n" + API_KEY_INDEX_AS_SHIPPED_IN_V1_103_0 + ";\n\n-- done; really\n"
assert statements(migration_sql) == (API_KEY_INDEX_AS_SHIPPED_IN_V1_103_0,)
assert _shape(checks_for_migration("x", migration_sql)) == _shape(
checks_for_statement(API_KEY_INDEX_AS_SHIPPED_IN_V1_103_0, "x")
)
def test_every_shipped_migration_is_readable_and_the_later_indexes_on_text_columns_get_a_check() -> None:
derived: Final = {
folder.name: checks_for_migration(folder.name, (folder / "migration.sql").read_text())
for folder in MIGRATIONS.iterdir()
if folder.is_dir()
}
assert len(derived) > 200
everywhere: Final = tuple(check for checks in derived.values() for check in checks) # comprehension-ok: flattening
assert any(
check.kind == "too_long" and check.table == "LiteLLM_SpendLogs" and "session_id" in check.columns
for check in everywhere
)
def test_a_request_log_index_gets_one_entry_size_check_over_all_its_columns() -> None:
assert _shape(
checks_for_index("LiteLLM_SpendLogs", "LiteLLM_SpendLogs_api_key_startTime_idx", ("api_key", "startTime"))
) == (("too_long", "LiteLLM_SpendLogs", ("api_key", "startTime")),)
def test_the_btree_entry_limit_on_the_default_page_is_the_number_in_postgres_error() -> None:
# "index row size 2976 exceeds btree version 4 maximum 2704", Postgres 14, 8 KB page (BTMaxItemSize, nbtree.h)
assert btree_max_entry_bytes(8192) == 2704
def test_the_report_names_the_rows_the_statement_and_the_fix_and_says_nothing_was_applied() -> None:
(check,) = checks_for_index(
"LiteLLM_SpendLogs", "LiteLLM_SpendLogs_api_key_startTime_idx", ("api_key", "startTime")
)
text: Final = report(
(BlockedChange(check, 2, "with an index entry over 2704 bytes, largest 3232 bytes (api_key up to 3207 bytes)"),)
)
assert (
'"LiteLLM_SpendLogs".api_key, startTime: 2 row(s) with an index entry over 2704 bytes, largest 3232 bytes '
"(api_key up to 3207 bytes)"
) in text
assert 'blocks: CREATE INDEX "LiteLLM_SpendLogs_api_key_startTime_idx" (request-log index)' in text
assert 'UPDATE "LiteLLM_SpendLogs" SET api_key = encode(sha256(' in text
assert text.startswith("Existing rows would make a pending schema change fail:")
assert "No schema change was made" in text
def test_a_scan_that_timed_out_is_reported_as_unverified_with_the_knob_to_turn() -> None:
(check,) = checks_for_statement('ALTER TABLE "T" ALTER COLUMN "a" SET NOT NULL', "migration 20270101000000_x")
text: Final = report((CouldNotVerify(check, 600.0),))
assert '"T".a: could not be checked within 600 s, refusing rather than guessing' in text
assert 'blocks: ALTER TABLE "T" ALTER COLUMN "a" SET NOT NULL (migration 20270101000000_x)' in text
assert MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR in text
@pytest.mark.parametrize(
("raw", "expected"),
(
(None, DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS),
("30", 30.0),
("nope", DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS),
),
)
def test_the_scan_timeout_comes_from_the_environment_and_falls_back_on_garbage(
monkeypatch: pytest.MonkeyPatch, raw: str | None, expected: float
) -> None:
if raw is None:
monkeypatch.delenv(MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR, raising=False)
else:
monkeypatch.setenv(MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR, raw)
assert migration_data_check_timeout_seconds() == expected

View file

@ -7,9 +7,12 @@ to be keyed on that same value rather than on the provider's own completion id.
import datetime
import json
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import Final
import pytest
import respx
from pydantic import TypeAdapter
import litellm
from litellm.llms.anthropic.messages.adapters.handler import (
@ -18,6 +21,153 @@ from litellm.llms.anthropic.messages.adapters.handler import (
from litellm.llms.anthropic.messages.adapters.streaming_iterator import (
AnthropicStreamWrapper,
)
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from litellm.types.utils import ModelResponseStream
_EVENT: Final = TypeAdapter(Mapping[str, object])
_MESSAGES: Final = TypeAdapter(list[AllMessageValues])
_TOOLS: Final = TypeAdapter(list[ChatCompletionToolParam])
_BLOCK_PROMPTS: Final[tuple[list[AllMessageValues], ...]] = (
[{"role": "user", "content": [{"type": "text", "text": "Count every word. " * 100}]}],
[{"role": "tool", "content": [{"type": "text", "text": "Count every word. " * 100}], "tool_call_id": "call_1"}],
[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://image.example/test.png", "detail": "high"}}
],
}
],
)
def _assert_estimated_start_and_provider_final_usage(raw_events: Sequence[bytes], request_body: bytes) -> None:
request: Final = _EVENT.validate_json(request_body)
events: Final = tuple(_EVENT.validate_json(chunk.decode().split("data: ", 1)[1]) for chunk in raw_events)
start: Final = _EVENT.validate_python(
next(event for event in events if event["type"] == "message_start")["message"]
)
final: Final = next(event for event in events if event["type"] == "message_delta")
expected: Final = litellm.token_counter(
model="kimi-k2",
messages=_MESSAGES.validate_python(request["messages"]),
tools=_TOOLS.validate_python(request["tools"]),
)
assert expected > 0
assert start["usage"] == {
"input_tokens": expected,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
assert final["usage"] == {"input_tokens": 3, "output_tokens": 4}
def test_sync_message_start_estimates_prompt_without_replacing_provider_usage(respx_mock: respx.MockRouter) -> None:
stream: Final = LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
max_tokens=16,
messages=MESSAGES,
system="Reply with a short greeting.",
tools=[{"name": "greet", "description": "Greet a person", "input_schema": {"type": "object"}}],
model="groq/kimi-k2",
stream=True,
custom_llm_provider="groq",
)
_assert_estimated_start_and_provider_final_usage(list(stream), respx_mock.calls.last.request.content)
@pytest.mark.asyncio
async def test_async_message_start_estimates_prompt_without_replacing_provider_usage(
respx_mock: respx.MockRouter,
) -> None:
stream: Final = await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler(
max_tokens=16,
messages=MESSAGES,
system="Reply with a short greeting.",
tools=[{"name": "greet", "description": "Greet a person", "input_schema": {"type": "object"}}],
model="groq/kimi-k2",
stream=True,
custom_llm_provider="groq",
)
raw: Final = [chunk async for chunk in stream]
_assert_estimated_start_and_provider_final_usage(raw, respx_mock.calls.last.request.content)
class _FailingUpstream:
def __iter__(self) -> Iterator[ModelResponseStream]:
raise RuntimeError("upstream failed before its first chunk")
def __aiter__(self) -> AsyncIterator[ModelResponseStream]:
raise RuntimeError("upstream failed before its first chunk")
@pytest.mark.parametrize("messages", _BLOCK_PROMPTS)
def test_sync_initial_estimate_counts_content_blocks(
messages: list[AllMessageValues], respx_mock: respx.MockRouter
) -> None:
image: Final = respx_mock.get("https://image.example/test.png").respond(503)
wrapper: Final = AnthropicStreamWrapper(iter([]), model="gpt-4o", completion_request={"messages": messages})
expected: Final = litellm.token_counter(model="gpt-4o", messages=messages, use_default_image_token_count=True)
first: Final = _EVENT.validate_python(next(wrapper))
message: Final = _EVENT.validate_python(first["message"])
assert message["usage"] == {
"input_tokens": expected,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
assert not image.called
@pytest.mark.asyncio
@pytest.mark.parametrize("messages", _BLOCK_PROMPTS)
async def test_async_initial_estimate_counts_content_blocks(
messages: list[AllMessageValues], respx_mock: respx.MockRouter
) -> None:
image: Final = respx_mock.get("https://image.example/test.png").respond(503)
wrapper: Final = AnthropicStreamWrapper(iter([]), model="gpt-4o", completion_request={"messages": messages})
expected: Final = litellm.token_counter(model="gpt-4o", messages=messages, use_default_image_token_count=True)
first: Final = _EVENT.validate_python(await anext(wrapper))
message: Final = _EVENT.validate_python(first["message"])
assert message["usage"] == {
"input_tokens": expected,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
assert not image.called
@pytest.mark.parametrize("prompt", [{}, {"messages": []}, {"messages": "invalid"}])
def test_sync_initial_estimate_fallback_does_not_consume_upstream(prompt: Mapping[str, object]) -> None:
wrapper: Final = AnthropicStreamWrapper(_FailingUpstream(), model="kimi-k2", completion_request=prompt)
first: Final = _EVENT.validate_python(next(wrapper))
message: Final = _EVENT.validate_python(first["message"])
assert message["usage"] == {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
assert list(wrapper) == []
@pytest.mark.asyncio
@pytest.mark.parametrize("prompt", [{}, {"messages": []}, {"messages": "invalid"}])
async def test_async_initial_estimate_fallback_does_not_consume_upstream(prompt: Mapping[str, object]) -> None:
wrapper: Final = AnthropicStreamWrapper(_FailingUpstream(), model="kimi-k2", completion_request=prompt)
first: Final = _EVENT.validate_python(await anext(wrapper))
message: Final = _EVENT.validate_python(first["message"])
assert message["usage"] == {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
}
with pytest.raises(RuntimeError, match="upstream failed before its first chunk"):
[chunk async for chunk in wrapper]
MESSAGES = [{"role": "user", "content": "hello"}]

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.litellm_logging import _get_provider_request_id
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.llms.bedrock.chat.invoke_handler import (
AmazonAnthropicClaudeStreamDecoder,
AmazonOpenAICompatibleStreamDecoder,
AWSEventStreamDecoder,
_stream_decoder,
@ -1120,6 +1121,7 @@ async def test_converse_streaming_completion_yields_text_and_finish_reason(
),
)
)
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/converse-stream")
return httpx.Response(200, content=event_stream_body, headers=_event_stream_headers())
@ -1179,9 +1181,7 @@ def test_mistral_invoke_stream_yields_text_and_finish_reason(
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == (
"A court case moves through the courts."
)
assert [chunk.choices[0].finish_reason for chunk in chunks if chunk.choices[0].finish_reason] == [
"stop"
]
assert [chunk.choices[0].finish_reason for chunk in chunks if chunk.choices[0].finish_reason] == ["stop"]
def test_converse_streaming_tool_call_arguments_are_valid_json(
@ -1721,6 +1721,7 @@ def test_filter_headers_for_aws_signature():
@pytest.mark.parametrize("missing", ["botocore", "unrelated_dependency"])
def test_invoke_decoder_reports_only_missing_aws_dependency(missing):
from unittest.mock import patch
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
failure = ModuleNotFoundError("missing dependency", name=missing)
@ -1835,9 +1836,7 @@ async def test_converse_stream_thinking_merges_into_content_with_think_tags() ->
"contentBlockDelta", {"contentBlockIndex": 0, "delta": {"reasoningContent": {"signature": "c2lnbmF0dXJl"}}}
),
_converse_event_frame("contentBlockStop", {"contentBlockIndex": 0}),
_converse_event_frame(
"contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "Hi I am Anthropic, "}}
),
_converse_event_frame("contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "Hi I am Anthropic, "}}),
_converse_event_frame(
"contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "I am a helpful assistant"}}
),
@ -1919,7 +1918,11 @@ _CUT_JSON_TOOL_STREAM: Final = b"".join(
"content_block": {"type": "tool_use", "id": "toolu_cut_1", "name": "json_tool_call", "input": {}},
},
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": '{"city"'}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ': "Paris", '}},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": ': "Paris", '},
},
{
"type": "content_block_delta",
"index": 0,
@ -1961,3 +1964,21 @@ async def test_async_anthropic_invoke_stream_cut_before_the_json_tool_block_ends
chunks: Final = [chunk async for chunk in _cut_json_tool_stream_decoder().aiter_bytes(_chunks())]
assert json.loads(_streamed_content(chunks)) == {"city": "Paris", "country": "France"}
def test_invoke_stream_decoder_carries_the_guardrail_trace_of_the_message_stop_chunk() -> None:
"""Bedrock Invoke reports the native guardrail trace once, on the final ``message_stop`` chunk, under
``amazon-bedrock-trace``. The chunk keeps it so the stream builder and the spend log can price it."""
decoder = AmazonAnthropicClaudeStreamDecoder(model="anthropic.claude-sonnet-5-v1:0", sync_stream=True)
trace = {"guardrail": {"input": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 1}}}}}}
plain = decoder._chunk_parser({"type": "message_stop", "amazon-bedrock-invocationMetrics": {"inputTokenCount": 3}})
traced = decoder._chunk_parser(
{
"type": "message_stop",
"amazon-bedrock-invocationMetrics": {"inputTokenCount": 3},
"amazon-bedrock-trace": trace,
}
)
assert (plain.provider_specific_fields or {}).get("amazon-bedrock-trace") is None
assert traced.provider_specific_fields is not None
assert traced.provider_specific_fields["amazon-bedrock-trace"] == trace

View file

@ -4169,3 +4169,32 @@ async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_nati
assert isinstance(raised, litellm.BadRequestError)
assert len(bedrock.bodies) == 1
@pytest.mark.parametrize("trace", ["enabled", "disabled"])
def test_bedrock_messages_applies_the_deployment_guardrail_config_as_invoke_headers(trace: str) -> None:
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
headers={},
model="anthropic.claude-sonnet-5-v1:0",
messages=[],
optional_params={},
litellm_params={"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "2", "trace": trace}},
)
assert headers["X-Amzn-Bedrock-GuardrailIdentifier"] == "gid"
assert headers["X-Amzn-Bedrock-GuardrailVersion"] == "2"
assert headers["X-Amzn-Bedrock-Trace"] == trace.upper()
def test_bedrock_messages_without_guardrail_config_sends_no_guardrail_headers() -> None:
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
headers={}, model="anthropic.claude-sonnet-5-v1:0", messages=[], optional_params={}, litellm_params={}
)
assert not any(name.startswith("X-Amzn-Bedrock-Guardrail") for name in headers)

View file

@ -440,14 +440,3 @@ class TestDashboardRegistration:
entry = json.loads(matrix_path.read_text())["providers"]["edenai"]
assert entry["url"] == "https://docs.litellm.ai/docs/providers/edenai"
served = {name for name, flag in entry["endpoints"].items() if flag}
assert served == {
"chat_completions",
"messages",
"responses",
"embeddings",
"image_generations",
"audio_transcriptions",
"audio_speech",
"video_generations",
}

View file

@ -58,26 +58,6 @@ def test_cortecs_is_available_in_add_model_form():
}
def test_cortecs_supported_endpoints():
matrix_path = Path(litellm.__file__).parent / "provider_endpoints_support_backup.json"
providers = json.loads(matrix_path.read_text())["providers"]
assert providers["cortecs"]["endpoints"] == {
"chat_completions": True,
"messages": True,
"responses": True,
"embeddings": False,
"image_generations": False,
"audio_transcriptions": False,
"audio_speech": False,
"moderations": False,
"batches": False,
"rerank": False,
"a2a": False,
"interactions": False,
}
def test_cortecs_chat_completion_request():
with respx.mock() as upstream:
route: Final = upstream.post("https://api.cortecs.ai/v1/chat/completions").respond(

View file

@ -91,10 +91,3 @@ class TestLibertAIProviderConfig:
matrix = json.loads(backup_path.read_text())
assert "libertai" in matrix["providers"]
endpoints = matrix["providers"]["libertai"]["endpoints"]
assert endpoints["chat_completions"] is True
# embeddings is advertised false: the JSON-configured-provider path only
# wires chat routing (the OpenAILike embedding handler is reached solely
# for the literal openai_like/llamafile/lm_studio providers), matching
# the llamagate precedent. bge-m3 stays in the cost map for metadata.
assert endpoints["embeddings"] is False

View file

@ -99,25 +99,6 @@ def test_prism_is_available_in_add_model_form():
}
def test_prism_supported_endpoints():
matrix_path = Path(litellm.__file__).parent / "provider_endpoints_support_backup.json"
providers = json.loads(matrix_path.read_text())["providers"]
assert providers["prism"]["endpoints"] == {
"chat_completions": True,
"messages": True,
"responses": True,
"embeddings": False,
"image_generations": False,
"audio_transcriptions": False,
"audio_speech": False,
"moderations": False,
"batches": False,
"rerank": False,
"a2a": False,
}
def test_prism_responses_request():
with respx.mock() as upstream:
route: Final = upstream.post("https://api.prisminference.com/v1/responses").respond(

View file

@ -115,28 +115,6 @@ def test_reka_is_available_in_add_model_form():
}
def test_reka_supported_endpoints():
expected: Final = {
"chat_completions": True,
"messages": True,
"responses": True,
"embeddings": False,
"image_generations": False,
"audio_transcriptions": False,
"audio_speech": False,
"moderations": False,
"batches": False,
"rerank": False,
"a2a": False,
"interactions": False,
}
backup_path = Path(litellm.__file__).parent / "provider_endpoints_support_backup.json"
root_path = Path(litellm.__file__).parent.parent / "provider_endpoints_support.json"
assert json.loads(backup_path.read_text())["providers"]["reka"]["endpoints"] == expected
assert json.loads(root_path.read_text())["providers"]["reka"]["endpoints"] == expected
def test_reka_chat_completion_request():
with respx.mock() as upstream:
route: Final = upstream.post("https://api.reka.ai/v1/chat/completions").respond(200, json=_CHAT_COMPLETION)

View file

@ -1,7 +1,7 @@
import asyncio
import json
from datetime import datetime
from typing import Any, Dict, Final, List
from typing import Final
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
@ -284,9 +284,10 @@ class TestAzureAnthropicCostCalculation:
@patch("litellm.completion_cost")
def test_cost_calculation_with_azure_ai_custom_llm_provider(self, mock_completion_cost):
"""Test that custom_llm_provider is passed to completion_cost for Azure AI Anthropic"""
from litellm.types.utils import ModelResponse
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.001
logging_obj = self._create_mock_logging_obj(
@ -319,9 +320,10 @@ class TestAzureAnthropicCostCalculation:
@patch("litellm.completion_cost")
def test_cost_calculation_without_custom_llm_provider(self, mock_completion_cost):
"""Test that cost calculation works without custom_llm_provider (standard Anthropic)"""
from litellm.types.utils import ModelResponse
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.001
# No custom_llm_provider in model_call_details
@ -353,9 +355,10 @@ class TestAzureAnthropicCostCalculation:
@patch("litellm.completion_cost")
def test_cost_calculation_does_not_duplicate_provider_prefix(self, mock_completion_cost):
"""Test that provider prefix is not duplicated if already present in model name"""
from litellm.types.utils import ModelResponse
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.001
logging_obj = self._create_mock_logging_obj(
@ -826,9 +829,10 @@ class TestAnthropicBatchPassthroughCostTracking:
mock_request_body,
):
"""Test that model gets 'anthropic/' prefix when not found in router"""
import base64
from litellm.llms.anthropic.batches.transformation import AnthropicBatchesConfig
from litellm.types.utils import LiteLLMBatch
import base64
# Model not in router - returns same model name
mock_get_model_id.return_value = "claude-sonnet-4-5-20250929"
@ -1089,7 +1093,7 @@ class TestAnthropicBatchPassthroughCostTracking:
class TestBuildCompleteStreamingResponseRobustness:
"""_build_complete_streaming_response must tolerate non-standard SSE frames."""
def _build(self, chunks: List[str]):
def _build(self, chunks: list[str]):
return AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=chunks,
litellm_logging_obj=MagicMock(),
@ -1840,9 +1844,6 @@ class TestStreamFalseDeduplication:
This is the _is_assembled_stream_success gate: with stream=False it
always returned False and the guard was permanently disabled.
"""
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
EndpointType,
)
from litellm.types.utils import ModelResponse
# Simulate what pass_through_endpoints.py now does after stream detection
@ -2642,8 +2643,6 @@ def test_create_anthropic_response_logging_payload(mock_logging_obj, metadata_pa
def test_handle_logging_anthropic_collected_chunks(all_chunks):
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
AnthropicPassthroughLoggingHandler,
PassthroughStandardLoggingPayload,
EndpointType,
)
from litellm.types.utils import ModelResponse
@ -2943,3 +2942,65 @@ def test_streamed_messages_from_other_providers_ignore_a_zero_usage_cost_field()
headers = result["result"]._hidden_params.get("additional_headers") or {}
assert "llm_provider-x-litellm-response-cost" not in headers
assert result["kwargs"].get("response_cost") != 0.0
_BEDROCK_TRACE_PRICING: Final = {
"bedrock/guardrails": {
"litellm_provider": "bedrock",
"mode": "guardrail",
"guardrail_cost_per_unit": {"topicPolicyUnits": 0.00015, "contentPolicyUnits": 0.00015},
}
}
_BEDROCK_INVOKE_TRACE: Final = {
"guardrail": {
"input": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 1}}}},
"outputs": [{"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 2, "contentPolicyUnits": 2}}}}],
}
}
def _recorded_guardrail_entries(logging_obj: LiteLLMLoggingObj) -> list:
return logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
def test_anthropic_passthrough_handler_bills_the_bedrock_guardrail_trace_of_a_messages_response(
monkeypatch, mock_httpx_response, mock_response, mock_logging_obj
) -> None:
monkeypatch.setattr(litellm, "model_cost", _BEDROCK_TRACE_PRICING)
mock_logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
AnthropicPassthroughLoggingHandler.anthropic_passthrough_handler(
httpx_response=mock_httpx_response,
response_body={**mock_response, "amazon-bedrock-trace": _BEDROCK_INVOKE_TRACE},
logging_obj=mock_logging_obj,
url_route="/v1/messages",
result="success",
start_time=datetime(2025, 1, 1),
end_time=datetime(2025, 1, 1),
cache_hit=False,
)
(entry,) = _recorded_guardrail_entries(mock_logging_obj)
assert entry["guardrail_provider"] == "bedrock"
assert entry["guardrail_usage"] == {"topicPolicyUnits": 3, "contentPolicyUnits": 3}
assert entry["guardrail_cost"] == pytest.approx(6 * 0.00015)
def test_anthropic_passthrough_stream_bills_the_bedrock_guardrail_trace_of_the_message_stop_event(
monkeypatch, mock_logging_obj, all_chunks
) -> None:
monkeypatch.setattr(litellm, "model_cost", _BEDROCK_TRACE_PRICING)
mock_logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
stop_event: Final = json.dumps({"type": "message_stop", "amazon-bedrock-trace": _BEDROCK_INVOKE_TRACE})
chunks: Final = [*all_chunks, f"event: message_stop\ndata: {stop_event}\n\n"]
AnthropicPassthroughLoggingHandler.handle_logging_anthropic_collected_chunks(
litellm_logging_obj=mock_logging_obj,
passthrough_success_handler_obj=MagicMock(),
url_route="/v1/messages",
request_body={"model": "claude", "messages": []},
endpoint_type=MagicMock(),
start_time=datetime(2025, 1, 1),
all_chunks=chunks,
end_time=datetime(2025, 1, 1),
)
(entry,) = _recorded_guardrail_entries(mock_logging_obj)
assert entry["guardrail_usage"] == {"topicPolicyUnits": 3, "contentPolicyUnits": 3}
assert entry["guardrail_cost"] == pytest.approx(6 * 0.00015)

View file

@ -14,7 +14,7 @@ from openai.types.responses.response_function_web_search import (
ActionSearchSource,
ResponseFunctionWebSearch,
)
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY
@ -5062,6 +5062,50 @@ class TestHostedWebSearchReplay:
assert replayed == search
assert input_item == original
def test_replays_search_without_action(self) -> None:
input_item: Final = {"type": "web_search_call", "id": "ws_no_action", "status": "completed"}
messages: Final = (
LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=input_item
)
)
assert len(messages) == 1
assert messages[0]["role"] == "assistant"
content: Final = messages[0].get("content")
assert isinstance(content, str)
assert json.loads(content[content.index("{") :]) == input_item
@pytest.mark.parametrize(
"fields",
(
{"status": "completed"},
{"id": "ws_invalid"},
{"id": 42, "status": "completed"},
{"id": "ws_invalid", "status": "invalid"},
{"id": "ws_invalid", "status": "completed", "action": None},
{"id": "ws_invalid", "status": "completed", "action": 42},
{"id": "ws_invalid", "status": "completed", "action": {"type": "invalid"}},
{"id": "ws_invalid", "status": "completed", "action": {"type": "find_in_page"}},
),
ids=(
"missing-id",
"missing-status",
"invalid-id",
"invalid-status",
"null-action",
"invalid-action-type",
"unknown-action",
"incomplete-action",
),
)
def test_rejects_search_with_other_invalid_fields(self, fields: Mapping[str, object]) -> None:
with pytest.raises(ValidationError):
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[{"type": "web_search_call", **fields}], responses_api_request={}
)
@pytest.mark.parametrize("order", ((0, 1, 2, 3), (1, 0, 3, 2), (1, 3, 0, 2)))
@pytest.mark.parametrize("modify_params", (False, True))
@pytest.mark.parametrize("structured_content", (False, True))