Merge branch 'main' into litellm_lite_cli_reasoning_efforts

# Conflicts:
#	litellm/router.py
This commit is contained in:
mateo-berri 2026-10-29 10:29:19 -07:00
commit d671708b98
349 changed files with 28255 additions and 2911 deletions

View file

@ -7,6 +7,8 @@ import zipfile
from pathlib import Path
from typing import Final
COST_MAP: Final = Path(__file__).resolve().parents[2] / "model_prices_and_context_window.json"
CHILD_SCRIPT: Final = """
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
@ -34,6 +36,9 @@ else:
after = module.gil_stats()
if not isinstance(after.get("releases"), int):
raise AssertionError(f"native module unusable after panic: {after!r}")
if module.bundled_model_cost_map() != Path(sys.argv[2]).read_bytes():
raise AssertionError("the wheel embeds a different model_prices_and_context_window.json than this checkout")
"""
@ -55,7 +60,9 @@ def main() -> int:
native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name
native_path.write_bytes(archive.read(native_members[0]))
result: Final = subprocess.run((sys.executable, "-c", CHILD_SCRIPT, str(native_path)), check=False)
result: Final = subprocess.run(
(sys.executable, "-c", CHILD_SCRIPT, str(native_path), str(COST_MAP)), check=False
)
if result.returncode != 0:
sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n")

View file

@ -46,3 +46,24 @@ jobs:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
uv run --no-project python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
- name: Check whether the pull request changes the cost map
id: changed
env:
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet "$MERGE_BASE" "$HEAD_SHA" -- model_prices_and_context_window.json; then
echo "cost_map=false" >> "$GITHUB_OUTPUT"
else
echo "cost_map=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.changed.outputs.cost_map == 'true'
run: rustup toolchain install --no-self-update
- name: Load the cost map through model-catalog
if: steps.changed.outputs.cost_map == 'true'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git show "${HEAD_SHA}:model_prices_and_context_window.json" > "$RUNNER_TEMP/model_prices_and_context_window.json"
cargo run --locked --quiet --manifest-path litellm-rust/Cargo.toml -p litellm-model-catalog --bin check-registry \
-- "$RUNNER_TEMP/model_prices_and_context_window.json"

View file

@ -376,10 +376,10 @@ jobs:
exit 0
fi
files="$(git diff --name-only "$base" HEAD)"
if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/test-linting\.yml$)' <<< "$files"; then
if grep -Eq '^(litellm/|enterprise/|(pyproject\.toml|uv\.lock)$|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/test-linting\.yml$)' <<< "$files"; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "No proxy, types or generator changes in this pull request, nothing to verify."
echo "No litellm, enterprise, dependency or generator changes in this pull request, nothing to verify."
echo "relevant=false" >> "$GITHUB_OUTPUT"
fi

View file

@ -12,6 +12,7 @@ on:
paths:
- "terraform/provider/**"
- "litellm/proxy/**"
- "enterprise/**"
- ".github/workflows/test-terraform-provider.yml"
permissions:

View file

@ -322,6 +322,7 @@ jobs:
tests/unit/llms/openai_like
tests/unit/llms/opencode
tests/unit/llms/opensandbox
tests/unit/llms/opensearch
tests/unit/llms/openrouter
tests/unit/llms/ovhcloud
tests/unit/llms/parallel_ai

View file

@ -24,6 +24,7 @@ ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \

View file

@ -15,9 +15,10 @@ ignore:
flag_management:
default_rules:
carryforward: true
# Dead flags no CI job uploads anymore: their carried-forward sessions were
# measured against old revisions, and the stale line maps mark comment lines
# of since-edited files as missed, sinking patch coverage on unrelated PRs.
# Dead flags no CI job uploads anymore, and flags whose job is path-gated on
# PRs: their carried-forward sessions were measured against old revisions, and
# the stale line maps mark comment lines of since-edited files as missed,
# sinking patch coverage on unrelated PRs.
individual_flags:
- name: proxy-mgmt-behavior
carryforward: false
@ -99,6 +100,10 @@ flag_management:
carryforward: false
- name: proxy-db-endpoints-and-responses
carryforward: false
- name: roi-postgres
carryforward: false
- name: redis-compat
carryforward: false
component_management:
individual_components:

View file

@ -40,6 +40,7 @@ ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \

View file

@ -259,3 +259,9 @@ itself.
`lensWorker.mode` selects `disabled`, `bundled` or `external`. Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for complete values, verification, GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
## Shutdown accounting
`GRACEFUL_SHUTDOWN_TIMEOUT` defaults to 30 seconds. Pending success callbacks share the time remaining since SIGTERM or the first `/health/drain` call. Callback flushing reserves the existing 10-second teardown headroom for cancellation, tracked accounting tasks and persistence. Exhausting this budget can discard callbacks, which is logged
Set `terminationGracePeriodSeconds` to at least `GRACEFUL_SHUTDOWN_TIMEOUT + 10`, allowing additional time for preStop hooks and database writes. This is not a hard shutdown bound or a guarantee of persistence. Database retries retain their own limits, and cancellation-resistant callbacks can remain alive. The existing preStop hook can also be followed by Uvicorn's separate connection drain, as described in `values.yaml`

View file

@ -14,11 +14,9 @@ either, so the migration keeps today's behavior for it.
"""
import os
import re
from collections.abc import Callable, Iterator
from collections.abc import Callable
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
@ -43,7 +41,7 @@ MIGRATION_DATA_CHECK_TIMEOUT_ENV_VAR: Final = "LITELLM_MIGRATION_DATA_CHECK_TIME
DEFAULT_MIGRATION_DATA_CHECK_TIMEOUT_SECONDS: Final = 600.0
REFUSAL: Final = "Existing rows would make a pending schema change fail:"
Kind = Literal["too_long", "duplicates", "nulls", "any_row", "orphans", "check_violations"]
Kind = Literal["too_long"]
@dataclass(frozen=True, slots=True)
@ -54,9 +52,6 @@ class Check:
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)
@ -100,33 +95,6 @@ class ColumnType:
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."""
@ -145,117 +113,6 @@ def migration_data_check_timeout_seconds() -> float:
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),)
@ -424,42 +281,7 @@ def _entry_size_query(check: Check, schema: str, types: tuple[ColumnType, ...],
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
)
return _entry_size_query(check, schema, types, limit_bytes)
def _applies(
@ -474,15 +296,7 @@ def _applies(
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
return types is not None and any(column.can_overflow_btree for column in types)
def _too_long_detail(check: Check, types: tuple[ColumnType, ...], values: tuple[object, ...], limit_bytes: int) -> str:
@ -515,21 +329,7 @@ def _run_check(
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})")
return BlockedChange(check, rows, _too_long_detail(check, types, values, limit_bytes))
def find_blockers(
@ -571,19 +371,7 @@ def find_blockers(
def _remedy(check: Check) -> str:
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.'
return f'Shorten or hash the long "{check.table}" ({joined}) values so every row\'s index entry fits.'
def _describe(finding: Finding) -> str:
@ -607,30 +395,4 @@ def _describe(finding: Finding) -> str:
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
return f"{REFUSAL}\n{lines}\nNo index was built. Fix the rows and the next index build picks it up."

View file

@ -0,0 +1,184 @@
"""`litellm --check_migrations_against_existing_data`: run the pending migrations for real, each
inside its own transaction that is always rolled back, so Postgres itself says whether this
database's rows and schema take them. Each migration holds the locks the real upgrade will,
and releases them before the next one runs."""
from __future__ import annotations
import re
import time
from collections.abc import Callable
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from typing import TYPE_CHECKING, Final
from litellm_proxy_extras.migration_data_checks import pending_migrations
if TYPE_CHECKING:
import psycopg
_CONCURRENTLY: Final = re.compile(
r"\b((?:CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+INDEX|REINDEX\s+\w+)\s+)CONCURRENTLY\s+", re.IGNORECASE
)
UNDEFINED_SQLSTATES: Final = frozenset({"42703", "42P01", "42704", "42883"})
Migration = tuple[str, str]
@dataclass(frozen=True, slots=True)
class Applied:
name: str
seconds: float
replayed: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class Rejected:
name: str
error: str
seconds: float
@dataclass(frozen=True, slots=True)
class Baseline:
"""A database with tables but no `_prisma_migrations`: the boot baselines it instead of replaying
history, so the dry run asks the same question the boot does. `refusal` is None when the boot
will baseline, otherwise its reason (a custom migration folder, or the schema diff)."""
refusal: str | None
@property
def matches(self) -> bool:
return self.refusal is None
@dataclass(frozen=True, slots=True)
class DryRun:
applied: tuple[Applied, ...]
rejected: Rejected | None
baseline: Baseline | None = None
@property
def ok(self) -> bool:
return self.rejected is None and (self.baseline is None or self.baseline.matches)
def transactional(migration_sql: str) -> str:
"""The migration as it can run inside a transaction: `CONCURRENTLY` index builds become plain ones."""
return _CONCURRENTLY.sub(r"\1", migration_sql)
def _attempt(
connection: psycopg.Connection[tuple[object, ...]],
schema: str,
chain: tuple[Migration, ...],
clock: Callable[[], float],
) -> tuple[float, psycopg.Error | None]:
import psycopg
from psycopg import sql
started: Final = clock()
try:
with connection.transaction(force_rollback=True):
connection.execute(sql.SQL("SET LOCAL search_path TO {}").format(sql.Identifier(schema)))
for _, migration_sql in chain:
connection.execute(transactional(migration_sql))
except psycopg.Error as exc:
return clock() - started, exc
return clock() - started, None
def _run(
connection: psycopg.Connection[tuple[object, ...]],
schema: str,
pending: tuple[Migration, ...],
position: int,
done: DryRun,
clock: Callable[[], float],
) -> DryRun:
"""One migration in its own rolled-back transaction. A migration that fails because something
an earlier pending migration creates does not exist yet is rerun with those replayed in front."""
if done.rejected is not None:
return done
name: Final = pending[position][0]
seconds, error = _attempt(connection, schema, (pending[position],), clock)
if error is None:
return DryRun(done.applied + (Applied(name, seconds),), None, done.baseline)
if error.sqlstate not in UNDEFINED_SQLSTATES or position == 0:
return DryRun(done.applied, Rejected(name, str(error).strip(), seconds), done.baseline)
earlier: Final = pending[:position]
chained_seconds, chained_error = _attempt(connection, schema, earlier + (pending[position],), clock)
if chained_error is None:
replayed: Final = tuple(earlier_name for earlier_name, _ in earlier)
return DryRun(done.applied + (Applied(name, chained_seconds, replayed),), None, done.baseline)
return DryRun(done.applied, Rejected(name, str(chained_error).strip(), chained_seconds), done.baseline)
def _has_ledger(connection: psycopg.Connection[tuple[object, ...]], schema: str) -> bool:
from psycopg import sql
row: Final = connection.execute(
"SELECT to_regclass(%s)", (sql.Identifier(schema, "_prisma_migrations").as_string(connection),)
).fetchone()
return row is not None and row[0] is not None
def _has_litellm_tables(connection: psycopg.Connection[tuple[object, ...]], schema: str) -> bool:
row: Final = connection.execute(
"SELECT 1 FROM information_schema.tables WHERE table_schema = %s AND table_name LIKE 'LiteLLM\\_%%' LIMIT 1",
(schema,),
).fetchone()
return row is not None
def dry_run(
connection: psycopg.Connection[tuple[object, ...]],
schema: str,
migrations_dir: Path,
baseline_check: Callable[[], str | None] | None = None,
clock: Callable[[], float] = time.monotonic,
) -> DryRun:
"""Apply every pending migration in order, each in its own transaction that is rolled back
before the next one starts. The first migration Postgres rejects ends the run with its error.
`baseline_check` (None when the boot would baseline, otherwise why it refuses) stands in for
the replay on a database that has tables but no migration history, which the boot baselines."""
if baseline_check is not None and not _has_ledger(connection, schema) and _has_litellm_tables(connection, schema):
return DryRun((), None, Baseline(baseline_check()))
pending: Final = pending_migrations(connection, schema, migrations_dir)
return reduce(
lambda done, position: _run(connection, schema, pending, position, done, clock),
range(len(pending)),
DryRun((), None),
)
def _timing(applied: Applied) -> str:
replay: Final = f" (depends on {', '.join(applied.replayed)}, replayed in front of it)" if applied.replayed else ""
return f"\n {applied.name}: {applied.seconds:.1f} s{replay}"
def report(result: DryRun) -> str:
"""What the operator reads: the per-migration time the real upgrade will hold its locks,
or the migration Postgres rejected with Postgres's own words. Nothing was applied either way."""
if result.baseline is not None:
if result.baseline.matches:
return (
"No migration history and the schema matches this build, "
"the proxy baselines it at boot, nothing to dry-run."
)
return f"No migration history and the proxy cannot baseline it at boot: {result.baseline.refusal}"
timings: Final = "".join(_timing(applied) for applied in result.applied)
if result.rejected is None:
count: Final = len(result.applied)
if count == 0:
return "No pending migrations, this database is on this version's schema."
return (
f"{count} pending migration(s) applied one at a time in a dry run, each rolled back before the next "
f"so its locks are released, nothing changed:{timings}"
)
return (
f"Migration {result.rejected.name} fails on this database after {result.rejected.seconds:.1f} s, "
f"nothing was applied.\n{result.rejected.error}"
+ (f"\nApplied before it in the dry run:{timings}" if timings else "")
)

View file

@ -86,6 +86,18 @@ def migration_files(directory: Path) -> tuple[tuple[str, str], ...]:
)
def has_packaged_history(migrations_dir: Path) -> bool:
"""Whether `migrations_dir` carries exactly the migration files and schema.prisma this package ships,
the only history the boot baselines an existing database onto."""
packaged_dir: Final = Path(__file__).parent
migrations: Final = migration_files(migrations_dir)
return (
bool(migrations)
and migrations == migration_files(packaged_dir)
and (migrations_dir / "schema.prisma").read_bytes() == (packaged_dir / "schema.prisma").read_bytes()
)
def baseline_current_schema(
coordinator: MigrationCoordinator,
schema: str,
@ -95,14 +107,9 @@ def baseline_current_schema(
) -> None:
from psycopg import sql
packaged_dir: Final = Path(__file__).parent
migrations: Final = migration_files(migrations_dir)
if (
not migrations
or migrations != migration_files(packaged_dir)
or (migrations_dir / "schema.prisma").read_bytes() != (packaged_dir / "schema.prisma").read_bytes()
):
if not has_packaged_history(migrations_dir):
raise RuntimeError("Cannot automatically baseline an existing database with custom migration history.")
migrations: Final = migration_files(migrations_dir)
coordinator.acquire_prisma_lock()
existing: Final = coordinator.connection.execute(

View file

@ -11,18 +11,13 @@ from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Final, Optional, Protocol, Union
from typing import TYPE_CHECKING, Final, Protocol
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_dry_run import dry_run
from litellm_proxy_extras.migration_dry_run import report as dry_run_report
from litellm_proxy_extras.migration_lock import held_migration_lock
from litellm_proxy_extras.prisma_toolchain import (
PRISMA_COMMAND_TIMEOUT_ENV_VAR,
@ -36,7 +31,6 @@ from litellm_proxy_extras.replica_identity import (
apply_replica_identity_full,
)
from litellm_proxy_extras.request_log_indexes import (
REQUEST_LOG_INDEXES,
ensure_request_log_indexes,
filter_request_log_index_diff,
)
@ -52,7 +46,7 @@ class LensCheckConnect(Protocol):
) -> "psycopg.Connection[tuple[object, ...]]": ...
def str_to_bool(value: Optional[str]) -> bool:
def str_to_bool(value: str | None) -> bool:
if value is None:
return False
return value.lower() in ("true", "1", "t", "y", "yes")
@ -169,7 +163,7 @@ def _without_sql_comments(statement: str) -> str:
).strip()
def _without_spend_logs_pk_clauses(statement: str) -> Optional[str]:
def _without_spend_logs_pk_clauses(statement: str) -> str | None:
prefix_match = _SPEND_LOGS_ALTER_RE.match(statement)
if not prefix_match:
return statement
@ -217,7 +211,7 @@ def _max_migration_timestamp(names) -> int:
return max(_migration_timestamp(n) for n in names)
def _redacted_command(command: object) -> Union[str, tuple[str, ...], list[str]]:
def _redacted_command(command: object) -> str | tuple[str, ...] | list[str]:
if isinstance(command, tuple):
return tuple(_redact_credentials(str(argument)) for argument in command)
if isinstance(command, list):
@ -404,7 +398,7 @@ class ProxyExtrasDBManager:
return tuple(row) if row is not None else ()
@staticmethod
def _failed_migration_logs(migration_name: str, started_at: str) -> Optional[str]:
def _failed_migration_logs(migration_name: str, started_at: str) -> str | None:
row: Final = ProxyExtrasDBManager._read_migration_ledger(
"SELECT logs FROM {} WHERE migration_name = %s AND started_at = %s::timestamptz "
"AND finished_at IS NULL AND rolled_back_at IS NULL",
@ -721,10 +715,10 @@ class ProxyExtrasDBManager:
return row is not None
@staticmethod
def _prisma_schema_param(url: str) -> Optional[str]:
def _prisma_schema_param(url: str) -> str | None:
"""The `schema` query param Prisma uses to pick its target schema,
or None when the URL does not set one."""
from urllib.parse import urlparse, parse_qsl
from urllib.parse import parse_qsl, urlparse
return next(
(v for k, v in parse_qsl(urlparse(url).query) if k == "schema"),
@ -1424,7 +1418,6 @@ class ProxyExtrasDBManager:
bool: True if setup was successful, False otherwise
"""
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
@ -1433,46 +1426,69 @@ class ProxyExtrasDBManager:
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."""
def _baseline_refusal(prisma_dir: Path) -> str | None:
"""Why `baseline_current_schema` would refuse this database at boot, None when it would baseline:
the migrations folder must be the packaged one, then `prisma migrate diff` must find the live
database and this build's schema.prisma identical."""
from litellm_proxy_extras.migration_lock import migration_environment
from litellm_proxy_extras.migration_recovery import has_packaged_history
if not has_packaged_history(prisma_dir):
return (
f"{prisma_dir} is a custom migration history (LITELLM_MIGRATION_DIR), it differs from the "
"migrations this build ships, and the proxy only baselines onto the packaged history."
)
try:
prisma_toolchain.run_prisma(
(
_get_prisma_command(),
"migrate",
"diff",
"--from-schema-datasource",
str(prisma_dir / "schema.prisma"),
"--to-schema-datamodel",
str(prisma_dir / "schema.prisma"),
"--exit-code",
),
timeout=prisma_toolchain.prisma_command_timeout(),
env=migration_environment(_get_prisma_env()),
)
except subprocess.CalledProcessError as exc:
diff: Final = str(exc.stdout or exc.stderr or exc).strip()
return f"the schema differs from this build. prisma migrate diff:\n{diff}"
except subprocess.TimeoutExpired as exc:
return f"prisma migrate diff did not finish within {exc.timeout} s"
return None
@staticmethod
def migration_dry_run_summary(use_migrate: bool = True) -> tuple[bool, str]:
"""`--check_migrations_against_existing_data`: the pending migrations applied for real on
DIRECT_URL or DATABASE_URL inside one transaction that is rolled back, so Postgres reports
what the upgrade would hit. (True, the per-migration timings) when they all apply,
(False, Postgres's own error) when one does not."""
import psycopg
if not use_migrate:
return (True, "The schema is pushed rather than migrated, there is nothing to dry-run.")
database_url: Final = os.environ.get("DATABASE_URL")
if not database_url:
return ()
return (False, "DATABASE_URL is not set.")
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)
prisma_dir: Final = Path(ProxyExtrasDBManager._get_prisma_dir())
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())
result: Final = dry_run(
connection,
schema,
prisma_dir / "migrations",
baseline_check=lambda: ProxyExtrasDBManager._baseline_refusal(prisma_dir),
)
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)
return (False, f"Could not reach the database: {_redact_credentials(str(exc))}")
return (result.ok, dry_run_report(result))
@staticmethod
def build_request_log_indexes(build: Callable[[str, str], bool] = ensure_request_log_indexes) -> bool:
@ -1724,5 +1740,4 @@ class ProxyExtrasDBManager:
time.sleep(random.randrange(5, 15))
finally:
os.chdir(original_dir)
pass
return False

View file

@ -4345,6 +4345,7 @@ dependencies = [
"litellm-inference-transcription",
"litellm-llms",
"litellm-llms-types",
"litellm-model-catalog",
"litellm-router-types",
"litellm-secrets",
"litellm-secrets-aws",

View file

@ -53,6 +53,7 @@ pub(super) const RULES: &[Rule] = &[
|mapping| {
mapping.error_str.contains("invalid_request_error")
&& !mapping.error_str.contains("Incorrect API key provided")
&& !matches!(mapping.status, Some(401 | 404))
},
PublicError::BadRequest,
),
@ -132,6 +133,23 @@ mod tests {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::unauthorized(Some(401), None)]
#[case::not_found(Some(404), None)]
#[case::forbidden(Some(403), Some(PublicError::BadRequest))]
#[case::rate_limited(Some(429), Some(PublicError::BadRequest))]
#[case::server_error(Some(500), Some(PublicError::BadRequest))]
#[case::no_status(None, Some(PublicError::BadRequest))]
fn invalid_request_errors_leave_401_and_404_to_the_status_mapping(
#[case] status: Option<u16>,
#[case] expected: Option<PublicError>,
) {
assert_eq!(
classified(status, "invalid_request_error bad field"),
expected
);
}
#[rstest::rstest]
#[case::rate_limit_before_context_window(
"rate limit and This model's maximum context length is 10",

View file

@ -25,3 +25,5 @@ A new `N`, image size or resolution in an existing key shape parses with no Rust
## Tests
`BUNDLED_REGISTRY` embeds the root `model_prices_and_context_window.json`, so the tests over it fail a JSON change with a bad value or a key no type reads
`Catalog::problems` is the one definition of a mergeable cost map. The bundled test asserts it is empty, and the cost map guard workflow runs it on a pull request's JSON through `cargo run --bin check-registry <path>`

View file

@ -10,6 +10,7 @@ schema = ["dep:schemars", "litellm-cost/schema"]
[dependencies]
litellm-cost = { workspace = true, features = ["serde"] }
litellm-error.workspace = true
indexmap = { version = "2.14.0", features = ["serde"] }
jiff = { version = "0.2.37", default-features = false, features = ["std", "tzdb-bundle-always"] }
@ -24,5 +25,4 @@ time.workspace = true
[dev-dependencies]
jsonschema = { version = "0.55.1", default-features = false }
litellm-error.workspace = true
rstest.workspace = true

View file

@ -0,0 +1,26 @@
use std::{env, fs, path::PathBuf, process::ExitCode};
use litellm_model_catalog::Catalog;
fn main() -> ExitCode {
let Some(path) = env::args_os().nth(1).map(PathBuf::from) else {
eprintln!("usage: check-registry <model_prices_and_context_window.json>");
return ExitCode::from(2);
};
let body = match fs::read(&path) {
Ok(body) => body,
Err(error) => {
eprintln!("{}: {error}", path.display());
return ExitCode::FAILURE;
}
};
let problems = Catalog::problems(&body);
for problem in &problems {
eprintln!("{}", litellm_error::Report::new(problem));
}
if problems.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}

View file

@ -5,7 +5,7 @@ use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::Value;
use crate::error::{Error, Loaded, Rejects, Repr};
use crate::error::{Error, Loaded, Rejects, Repr, pointer_child, pointer_for_key};
use crate::fallback::{FallbackGeneralizations, FallbackRule};
use crate::model::Model;
use crate::store::{Rows, Store};
@ -211,6 +211,36 @@ impl Catalog {
})
}
pub fn problems(body: &[u8]) -> Vec<Error> {
let Loaded {
value: catalog,
errors,
} = match Self::parse(body) {
Ok(loaded) => loaded,
Err(fatal) => return vec![fatal],
};
let unread = catalog.rows().entries.iter().flat_map(|(model, entry)| {
EntryWire::read(entry.fields())
.unknown
.into_iter()
.map(move |(key, _)| {
Error::new(
pointer_child(&pointer_for_key(model), &key),
Repr::UnreadField,
)
})
});
let no_fallback_rules = catalog
.fallback_rules()
.is_none_or(<[_]>::is_empty)
.then(|| Error::from_key(FALLBACK_GENERALIZATIONS, Repr::NoFallbackRules));
errors
.into_iter()
.chain(unread)
.chain(no_fallback_rules)
.collect()
}
pub fn row_errors(model: &str, fields: &Fields) -> Vec<Error> {
let (_, _, errors) = read_row(fields);
errors
@ -404,3 +434,85 @@ fn unknown_timezone(wire: &EntryWire) -> Option<Error> {
)
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
const RULES: &str = r#""fallback_generalizations":{"rules":[{"name":"family","pattern":"^new-","model_info":{"mode":"chat"}}]}"#;
#[rstest]
#[case::clean(format!(r#"{{{RULES},"m":{{"litellm_provider":"test"}}}}"#), vec![])]
#[case::not_json("{".to_owned(), vec![("", "json")])]
#[case::row_error(format!(r#"{{{RULES},"m":{{}}}}"#), vec![("/m/litellm_provider", "missing_provider")])]
#[case::unread_key(
format!(r#"{{{RULES},"m":{{"litellm_provider":"test","typo_cost":1}}}}"#),
vec![("/m/typo_cost", "unread_field")]
)]
#[case::empty_model_key(
format!(r#"{{{RULES},"":{{"litellm_provider":"test","future_field":true}}}}"#),
vec![("//future_field", "unread_field")]
)]
#[case::unread_key_on_invalid_row(
format!(r#"{{{RULES},"m":{{"typo_cost":1}}}}"#),
vec![("/m/litellm_provider", "missing_provider"), ("/m/typo_cost", "unread_field")]
)]
#[case::escaped_unread_key_on_invalid_row(
format!(r#"{{{RULES},"open/router~v1":{{"typo/cost~":1}}}}"#),
vec![("/open~1router~0v1/litellm_provider", "missing_provider"), ("/open~1router~0v1/typo~1cost~0", "unread_field")]
)]
#[case::no_rules(
r#"{"m":{"litellm_provider":"test"}}"#.to_owned(),
vec![("/fallback_generalizations", "no_fallback_rules")]
)]
#[case::empty_rules(
r#"{"fallback_generalizations":{"rules":[]},"m":{"litellm_provider":"test"}}"#.to_owned(),
vec![("/fallback_generalizations", "no_fallback_rules")]
)]
fn problems_list_what_a_cost_map_change_must_fix(
#[case] body: String,
#[case] expected: Vec<(&str, &str)>,
) {
let problems: Vec<_> = Catalog::problems(body.as_bytes())
.iter()
.map(|problem| (problem.path().to_owned(), <&str>::from(problem.kind())))
.collect();
let expected: Vec<_> = expected
.into_iter()
.map(|(path, kind)| (path.to_owned(), kind))
.collect();
assert_eq!(problems, expected);
}
#[rstest]
fn runtime_parsing_preserves_custom_fields_and_recovers_valid_rows() {
let body = br#"{
"good":{"litellm_provider":"test","custom_metadata":true},
"bad":{"litellm_provider":"test","input_cost_per_token":"wrong"}
}"#;
let loaded = Catalog::parse(body).unwrap();
assert_eq!(loaded.errors.len(), 1);
assert_eq!(loaded.errors[0].path(), "/bad/input_cost_per_token");
let (catalog, rejected) = loaded.value.without_rejected(&loaded.errors);
assert_eq!(rejected.len(), 1);
assert!(catalog.lookup("bad").is_none());
let good = catalog.lookup("good").unwrap();
assert!(good.entry.model().is_some());
assert_eq!(
good.entry.field("custom_metadata"),
Some(&Value::Bool(true))
);
assert!(
Catalog::problems(body)
.iter()
.any(|problem| problem.kind() == crate::ErrorKind::UnreadField)
);
assert!(
Catalog::problems(body)
.iter()
.any(|problem| problem.kind() == crate::ErrorKind::NoFallbackRules)
);
}
}

View file

@ -109,6 +109,12 @@ pub(crate) enum Repr {
#[source]
cause: Option<DateCause>,
},
#[error("not a field litellm reads")]
#[strum_discriminants(strum(serialize = "unread_field"))]
UnreadField,
#[error("has no rules")]
#[strum_discriminants(strum(serialize = "no_fallback_rules"))]
NoFallbackRules,
}
/// What a problem takes out of the served map
@ -148,7 +154,7 @@ impl Error {
| Repr::NoOffPeakHours
| Repr::EmptyList
| Repr::InvalidDate { .. } => Rejects::Row,
Repr::MissingProvider => Rejects::Nothing,
Repr::MissingProvider | Repr::UnreadField | Repr::NoFallbackRules => Rejects::Nothing,
}
}
}
@ -334,6 +340,8 @@ mod tests {
#[case::no_off_peak_hours(Repr::NoOffPeakHours, Rejects::Row)]
#[case::empty_list(Repr::EmptyList, Rejects::Row)]
#[case::invalid_date(Repr::InvalidDate { cause: None }, Rejects::Row)]
#[case::unread_field(Repr::UnreadField, Rejects::Nothing)]
#[case::no_fallback_rules(Repr::NoFallbackRules, Rejects::Nothing)]
fn policy_classifies_each_error(#[case] error: Repr, #[case] expected: Rejects) {
assert_eq!(Error::from(error).rejects(), expected);
}

View file

@ -366,18 +366,14 @@ fn written_is_the_document_in_order_including_sections_and_bad_entries() {
}
#[rstest]
fn the_bundled_registry_parses_with_no_problems_and_every_key_understood() {
let loaded = Catalog::parse(BUNDLED_REGISTRY).unwrap();
let catalog = loaded.value;
assert!(loaded.errors.is_empty(), "{:#?}", loaded.errors);
let unrecognized: Vec<_> = catalog.unrecognized().collect();
assert!(unrecognized.is_empty(), "{unrecognized:#?}");
assert!(
catalog
.fallback_rules()
.is_some_and(|rules| !rules.is_empty())
);
fn the_bundled_registry_has_no_problems() {
let problems: Vec<_> = Catalog::problems(BUNDLED_REGISTRY)
.iter()
.map(report)
.collect();
assert!(problems.is_empty(), "{problems:#?}");
const REFERENCE_MODEL_COUNT: usize = 4303;
let catalog = Catalog::parse(BUNDLED_REGISTRY).unwrap().value;
assert!(catalog.model_count() as f64 >= REFERENCE_MODEL_COUNT as f64 * 0.9);
}

View file

@ -106,6 +106,18 @@ GIL handling to `litellm-host-python`.
## Data Handling
- Reuse large immutable bundled data and parsed catalogs across calls, including in
replacement APIs. Share Rust storage and cache repeated Python projections instead
of reparsing, serializing or copying the full payload on every access
- Cache owned Python objects with `PyOnceLock<Py<T>>`, then bind and clone references
while attached. Do not use blocking `OnceLock::get_or_init` while attached
([PyO3 initialization](https://pyo3.rs/v0.29.2/faq.html),
[reference ownership](https://pyo3.rs/v0.29.2/types.html))
- Never reuse Python objects across interpreters or interpreter reinitialization.
Once-only caches are for immutable data. Reloadable/custom data needs versioned
entries or invalidation, and caller-mutable Python containers must not be shared
- Test unchanged contents and allocation reuse, not just equal values. Keep this
coverage when replacing a bundled-data accessor with a catalog API
- OCR payloads can contain personal data and large base64 images. Do not log
payloads or provider responses.
- Avoid copying large payloads more than needed. The current JSON round-trip is

View file

@ -49,6 +49,7 @@ litellm-secrets-types.workspace = true
litellm-llms-types.workspace = true
litellm-router-types.workspace = true
litellm-host-python.workspace = true
litellm-model-catalog.workspace = true
litellm-token-counter = { path = "../token-counter", default-features = false }
pyo3.workspace = true
prost.workspace = true

View file

@ -8,6 +8,7 @@ mod execution;
mod http;
mod logger;
mod marshal;
mod model_registry;
mod preflight;
mod python_settings;
mod routes;
@ -30,6 +31,8 @@ mod _native {
#[pymodule_export]
use crate::logger::NativeDiagnosticProcessor;
#[pymodule_export]
use crate::model_registry::bundled_model_cost_map;
#[pymodule_export]
use crate::routes::audio_transcription::{atranscription, transcription};
#[pymodule_export]
use crate::routes::chat_completions::{acompletion, completion};
@ -110,6 +113,7 @@ mod tests {
"NativeClickHouseSpendConfig",
"NativeClickHouseSpendStorage",
"trace_encode_error",
"bundled_model_cost_map",
"TokenCounter",
"Tokenizer",
"gil_stats",

View file

@ -0,0 +1,30 @@
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::PyBytes;
#[pyfunction]
pub fn bundled_model_cost_map(py: Python<'_>) -> Bound<'_, PyBytes> {
static BYTES: PyOnceLock<Py<PyBytes>> = PyOnceLock::new();
BYTES
.get_or_init(py, || {
PyBytes::new(py, litellm_model_catalog::BUNDLED_REGISTRY).unbind()
})
.bind(py)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
fn repeated_calls_reuse_the_bundled_registry_bytes() {
Python::initialize();
let first = Python::attach(|py| bundled_model_cost_map(py).unbind());
Python::attach(|py| {
let second = bundled_model_cost_map(py);
assert!(first.bind(py).is(&second));
assert_eq!(second.as_bytes(), litellm_model_catalog::BUNDLED_REGISTRY);
});
}
}

View file

@ -685,6 +685,7 @@ moonshot_models: Set = set()
publicai_models: Set = set()
darkbloom_models: Set = set()
openinfer_models: Final[Set[str]] = set()
kyma_models: Final[set[str]] = set() # mutable-ok: populated when the shared model catalog loads
streamlake_models: Final[Set[str]] = set()
zerogpu_models: Final[Set[str]] = set()
v0_models: Set = set()
@ -981,6 +982,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
darkbloom_models.add(key)
elif value.get("litellm_provider") == "openinfer":
openinfer_models.add(key)
elif value.get("litellm_provider") == "kyma":
kyma_models.add(key)
elif value.get("litellm_provider") == "streamlake":
streamlake_models.add(key)
elif value.get("litellm_provider") == "zerogpu":
@ -1159,6 +1162,7 @@ model_list = list(
| publicai_models
| darkbloom_models
| openinfer_models
| kyma_models
| streamlake_models
| zerogpu_models
| v0_models
@ -1290,6 +1294,7 @@ def _build_models_by_provider() -> dict:
"publicai": publicai_models,
"darkbloom": darkbloom_models,
"openinfer": openinfer_models,
"kyma": kyma_models,
"streamlake": streamlake_models,
"zerogpu": zerogpu_models,
"v0": v0_models,

View file

@ -36,6 +36,8 @@ REDIS_FAMILIES_METADATA_KEY: Final = "families"
_service_caller: Final[ContextVar[str | None]] = ContextVar("service_caller", default=None)
_mirrored_call: Final[ContextVar[bool]] = ContextVar("mirrored_call", default=False)
@contextmanager
def internal_sub_call() -> Generator[None]:
@ -123,6 +125,30 @@ def current_service_caller() -> str | None:
return _service_caller.get()
@contextmanager
def mirrored_call() -> Generator[None]:
"""A silent_model mirror of a request: every litellm call inside, its retries and fallbacks included."""
token: Final = _mirrored_call.set(True)
try:
yield
finally:
_restore(_mirrored_call, token)
def in_mirrored_call() -> bool:
return _mirrored_call.get()
@contextmanager
def outside_mirrored_call() -> Generator[None]:
"""Logging a mirrored call: the calls its callbacks start are not mirrors themselves."""
token: Final = _mirrored_call.set(False)
try:
yield
finally:
_restore(_mirrored_call, token)
@contextmanager
def pinned_billing_time(moment: datetime) -> Generator[None]:
"""Price every rate lookup inside this block at ``moment`` rather than at each one's own clock read."""

View file

@ -0,0 +1,325 @@
"""Body fields that an ``anthropic-beta`` value gates.
``anthropic_beta_headers_config.json`` says which beta each provider leg
carries. When a leg drops a beta, the request fields that beta unlocks
would make the provider 400. Under ``drop_params`` the same request degrades on
that leg by dropping those fields too; without it the request goes out as sent
and the provider answers its own 400, like any unsupported param. Every helper
here is copy-on-write: the caller's containers are returned untouched when
nothing is stripped, and a shallow copy is returned otherwise, so a fallback
leg still sends the full request.
"""
from collections.abc import Mapping, Sequence, Set
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
)
ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER: Final = "mid-conversation-system-clear-at-2026-08-21"
ANTHROPIC_INLINE_TOOLS_BETA_HEADER: Final = "inline-tools-2026-09-15"
THINKING_BLOCK_BINDING: Final = "thinking.block_binding"
THINKING_DISPLAY_UPDATES: Final = "thinking.display=updates"
SYSTEM_CLEAR_AT: Final = "messages[].system.clear_at"
SYSTEM_OUTPUT_CONFIG: Final = "messages[].system.output_config"
SYSTEM_TOOL_CHANGES: Final = "messages[].system.content[].tool_addition|tool_removal"
SYSTEM_MESSAGE_EMPTIED: Final = "messages[].system (emptied by the strip)"
_TOOL_CHANGE_BLOCK_TYPES: Final = frozenset({"tool_addition", "tool_removal"})
BETA_GATED_FIELDS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
{
ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER: (THINKING_BLOCK_BINDING,),
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: (THINKING_DISPLAY_UPDATES,),
ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER: (SYSTEM_CLEAR_AT,),
ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: (SYSTEM_OUTPUT_CONFIG,),
ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value: (SYSTEM_OUTPUT_CONFIG,),
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: (SYSTEM_TOOL_CHANGES,),
ANTHROPIC_INLINE_TOOLS_BETA_HEADER: (SYSTEM_TOOL_CHANGES,),
}
)
def _betas_unlocking(field: str) -> frozenset[str]:
return frozenset(beta for beta, fields in BETA_GATED_FIELDS.items() if field in fields)
_FIELD_BETAS: Final[Mapping[str, frozenset[str]]] = MappingProxyType(
{
field: _betas_unlocking(field) for fields in BETA_GATED_FIELDS.values() for field in fields
} # comprehension-ok: flattening the beta-to-fields map into a field-to-betas index
)
def field_is_gated(field: str, active_betas: Set[str]) -> bool:
"""True when no active beta unlocks ``field``, so the field must be stripped."""
return _FIELD_BETAS[field].isdisjoint(active_betas)
_MAPPING_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_SEQUENCE_ADAPTER: Final = TypeAdapter(tuple[object, ...])
_HEADERS_ADAPTER: Final = TypeAdapter(Mapping[str, str])
def _as_mapping(value: object) -> Mapping[str, object] | None:
try:
return _MAPPING_ADAPTER.validate_python(value, strict=True)
except ValidationError:
return None
def _as_sequence(value: object) -> tuple[object, ...] | None:
try:
return _SEQUENCE_ADAPTER.validate_python(value)
except ValidationError:
return None
def _is_tool_change_block(block: object) -> bool:
mapping: Final = _as_mapping(block)
block_type: Final = mapping.get("type") if mapping is not None else None
return isinstance(block_type, str) and block_type in _TOOL_CHANGE_BLOCK_TYPES
def _header_beta_values(headers: Mapping[str, str]) -> frozenset[str]:
joined: Final = ",".join(value for key, value in headers.items() if key.lower() == "anthropic-beta")
return frozenset(piece.strip() for piece in joined.split(",") if piece.strip())
def _validated_headers(headers: object) -> Mapping[str, str]:
try:
return _HEADERS_ADAPTER.validate_python(headers)
except ValidationError:
return MappingProxyType({})
def active_beta_values(headers: object, body_betas: object = ()) -> frozenset[str]:
"""The ``anthropic-beta`` values a request will send: the comma-joined header (key matched
case-insensitively) plus the body's ``anthropic_beta`` list on legs that carry betas in the body."""
body_values: Final = _as_sequence(body_betas) or ()
body_betas_as_strings: Final = frozenset(beta for beta in body_values if isinstance(beta, str))
return _header_beta_values(_validated_headers(headers)) | body_betas_as_strings
@dataclass(frozen=True, slots=True)
class StrippedThinking:
thinking: object
stripped: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class StrippedMessages:
messages: Sequence[object]
stripped: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class StrippedRequest:
request: Mapping[str, object]
stripped: tuple[str, ...]
def strip_beta_gated_thinking(thinking: object, active_betas: Set[str], *, drop_params: object) -> StrippedThinking:
"""Drop ``thinking`` sub-fields whose beta is absent. Returns the same object when nothing is gated."""
mapping: Final = _as_mapping(thinking)
if mapping is None or not drops_unsupported_params(drop_params):
return StrippedThinking(thinking=thinking, stripped=())
binding_gated: Final = "block_binding" in mapping and field_is_gated(THINKING_BLOCK_BINDING, active_betas)
display_gated: Final = mapping.get("display") == "updates" and field_is_gated(
THINKING_DISPLAY_UPDATES, active_betas
)
if not binding_gated and not display_gated:
return StrippedThinking(thinking=thinking, stripped=())
gated_keys: Final = frozenset(
{*(("block_binding",) if binding_gated else ()), *(("display",) if display_gated else ())}
)
stripped: Final = (
*((THINKING_BLOCK_BINDING,) if binding_gated else ()),
*((THINKING_DISPLAY_UPDATES,) if display_gated else ()),
)
return StrippedThinking(
thinking={key: value for key, value in mapping.items() if key not in gated_keys},
stripped=stripped,
)
def _strip_beta_gated_system_message(message: Mapping[str, object], active_betas: Set[str]) -> StrippedMessages:
"""One mid-conversation ``role: system`` message. ``messages`` holds the rewritten message, or nothing
when the strip left it with neither content nor ``output_config``, since such a system turn is itself a 400."""
clear_at_gated: Final = "clear_at" in message and field_is_gated(SYSTEM_CLEAR_AT, active_betas)
output_config_gated: Final = "output_config" in message and field_is_gated(SYSTEM_OUTPUT_CONFIG, active_betas)
content: Final = message.get("content")
blocks: Final = _as_sequence(content) if not isinstance(content, str) else None
tool_changes_gated: Final = (
blocks is not None
and any(_is_tool_change_block(block) for block in blocks)
and field_is_gated(SYSTEM_TOOL_CHANGES, active_betas)
)
if not clear_at_gated and not output_config_gated and not tool_changes_gated:
return StrippedMessages(messages=(message,), stripped=())
kept_content: Final[object] = (
[block for block in blocks if not _is_tool_change_block(block)]
if tool_changes_gated and blocks is not None
else content
)
gated_keys: Final = frozenset(
{*(("clear_at",) if clear_at_gated else ()), *(("output_config",) if output_config_gated else ())}
)
rewritten: Final = {
key: (kept_content if key == "content" else value) for key, value in message.items() if key not in gated_keys
}
stripped: Final = (
*((SYSTEM_CLEAR_AT,) if clear_at_gated else ()),
*((SYSTEM_OUTPUT_CONFIG,) if output_config_gated else ()),
*((SYSTEM_TOOL_CHANGES,) if tool_changes_gated else ()),
)
if kept_content in (None, "", []) and "output_config" not in rewritten:
return StrippedMessages(messages=(), stripped=(*stripped, SYSTEM_MESSAGE_EMPTIED))
return StrippedMessages(messages=(rewritten,), stripped=stripped)
def _strip_beta_gated_message(message: object, active_betas: Set[str]) -> StrippedMessages:
mapping: Final = _as_mapping(message)
if mapping is None or mapping.get("role") != "system":
return StrippedMessages(messages=(message,), stripped=())
return _strip_beta_gated_system_message(mapping, active_betas)
def _ends_on_assistant(messages: Sequence[object]) -> bool:
last: Final = _as_mapping(messages[-1]) if messages else None
return last is not None and last.get("role") == "assistant"
def strip_beta_gated_system_messages(
messages: Sequence[object], active_betas: Set[str], *, drop_params: object
) -> StrippedMessages:
"""Drop the gated fields of every mid-conversation ``role: system`` message. Returns the same
sequence when nothing is gated, or when dropping an emptied system turn would leave the conversation
ending on an assistant turn, which the model would read as a prefill to continue."""
if not drops_unsupported_params(drop_params):
return StrippedMessages(messages=messages, stripped=())
per_message: Final = tuple(_strip_beta_gated_message(message, active_betas) for message in messages)
stripped: Final = tuple(
name for result in per_message for name in result.stripped
) # comprehension-ok: flattening per-message strip reports
if not stripped:
return StrippedMessages(messages=messages, stripped=())
kept: Final = [
message for result in per_message for message in result.messages
] # comprehension-ok: flattening per-message results
if _ends_on_assistant(kept) and not _ends_on_assistant(messages):
return StrippedMessages(messages=messages, stripped=())
return StrippedMessages(messages=kept, stripped=stripped)
def strip_beta_gated_request_fields(
request: Mapping[str, object], active_betas: Set[str], *, drop_params: object
) -> StrippedRequest:
"""Apply both strips to an Anthropic ``/v1/messages`` shaped body. Returns the same mapping when
nothing is gated, otherwise a shallow copy with ``thinking`` and ``messages`` replaced."""
thinking: Final = strip_beta_gated_thinking(request.get("thinking"), active_betas, drop_params=drop_params)
messages: Final = request.get("messages")
message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None
system_messages: Final = (
strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params)
if message_sequence is not None
else StrippedMessages(messages=(), stripped=())
)
stripped: Final = (*thinking.stripped, *system_messages.stripped)
if not stripped:
return StrippedRequest(request=request, stripped=())
rewritten: Final = {
**request,
**({"thinking": thinking.thinking} if thinking.stripped else {}),
**({"messages": system_messages.messages} if system_messages.stripped else {}),
}
return StrippedRequest(request=rewritten, stripped=stripped)
@dataclass(frozen=True, slots=True)
class StrippedNativeRequest:
thinking: object
thinking_stripped: bool
messages: Sequence[object]
messages_stripped: bool
stripped: tuple[str, ...]
def unstripped_native_request(optional_params: object, messages: object) -> StrippedNativeRequest:
"""The identity result for legs that strip later, once their final ``anthropic_beta`` list exists."""
params: Final = _as_mapping(optional_params) or MappingProxyType({})
message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None
return StrippedNativeRequest(
thinking=params.get("thinking"),
thinking_stripped=False,
messages=message_sequence if message_sequence is not None else (),
messages_stripped=False,
stripped=(),
)
def strip_beta_gated_native_request(
headers: object, optional_params: object, messages: object, *, body_betas: object = (), drop_params: object
) -> StrippedNativeRequest:
"""The ``/v1/messages`` seam, fed the final (already filtered) headers, the final body ``anthropic_beta``
list on legs that carry betas in the body, plus the body parts it may rewrite."""
if not drops_unsupported_params(drop_params):
return unstripped_native_request(optional_params, messages)
active_betas: Final = active_beta_values(headers, body_betas)
params: Final = _as_mapping(optional_params) or MappingProxyType({})
thinking: Final = strip_beta_gated_thinking(params.get("thinking"), active_betas, drop_params=drop_params)
message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None
system_messages: Final = (
strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params)
if message_sequence is not None
else StrippedMessages(messages=(), stripped=())
)
return StrippedNativeRequest(
thinking=thinking.thinking,
thinking_stripped=bool(thinking.stripped),
messages=system_messages.messages,
messages_stripped=bool(system_messages.stripped),
stripped=(*thinking.stripped, *system_messages.stripped),
)
def log_beta_gated_strips(stripped: Sequence[str], model: str | None, provider: str) -> None:
if not stripped:
return
described: Final = ", ".join(
f"{field} (unlocked by anthropic-beta {' or '.join(sorted(_FIELD_BETAS[field]))})"
if field in _FIELD_BETAS
else field
for field in sorted(set(stripped))
)
verbose_logger.warning(
"Dropped %s for model=%s provider=%s because drop_params is on and the provider would reject them on "
"this request, either since this provider leg does not send that anthropic-beta (see "
"anthropic_beta_headers_config.json) or since the model turns mid-conversation system messages into user "
"messages.",
described,
model,
provider,
)
def strip_beta_gated_request_body(
request: Mapping[str, object], headers: object, *, drop_params: object, model: str | None, provider: str
) -> Mapping[str, object]:
gated: Final = strip_beta_gated_request_fields(
request, active_beta_values(headers, request.get("anthropic_beta") or ()), drop_params=drop_params
)
log_beta_gated_strips(gated.stripped, model=model, provider=provider)
return gated.request

View file

@ -248,6 +248,7 @@
"text_editor_20241022": null,
"text_editor_20250124": null,
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
"thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18",
"token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
"web-fetch-2025-09-10": null,
"web-search-2025-03-05": "web-search-2025-03-05"

View file

@ -241,6 +241,13 @@ def get_provider_name(provider: str) -> str:
return aliases.get(provider, provider)
def has_beta_headers_config(provider: str) -> bool:
config: Final = _bundled_beta_headers_config()
aliases: Final = config.get("provider_aliases", {})
canonical_provider: Final = (aliases.get(provider) if isinstance(aliases, Mapping) else None) or provider
return canonical_provider in config
def filter_and_transform_beta_headers(
beta_headers: list[str],
provider: str,
@ -397,7 +404,7 @@ def update_request_with_filtered_beta(
headers: dict,
request_data: dict,
provider: str,
) -> tuple[dict, dict]:
) -> tuple[dict[str, str], dict[str, object]]:
"""
Update both headers and request body beta fields based on provider support.
Modifies both dicts in place and returns them.

View file

@ -455,17 +455,33 @@ class Cache:
3. Else use the `model` passed in kwargs, plus `model_id` when set (e.g. a SageMaker inference component)
"""
litellm_params: Final[dict] = kwargs.get("litellm_params", {}) or {}
metadata_sources: Final[tuple[dict, ...]] = (
kwargs.get("metadata") or {},
kwargs.get("litellm_metadata") or {},
litellm_params.get("metadata") or {},
litellm_params.get("litellm_metadata") or {},
metadata_sources: Final = cast( # cast-ok: each source is the untyped request's metadata dict or empty
"tuple[dict[str, object], dict[str, object], dict[str, object], dict[str, object]]",
(
kwargs.get("metadata") or {},
kwargs.get("litellm_metadata") or {},
litellm_params.get("metadata") or {},
litellm_params.get("litellm_metadata") or {},
),
)
model_group: Final[str | None] = next(
(source["model_group"] for source in metadata_sources if source.get("model_group")), None
metadata, litellm_metadata, params_metadata, params_litellm_metadata = metadata_sources
call_model_info: Final[object] = kwargs.get("model_info")
routed_source: Final = next(
(
source
for source in (litellm_metadata, metadata, params_litellm_metadata, params_metadata)
if call_model_info and source.get("model_group") and source.get("model_info") == call_model_info
),
None,
)
model_group: Final = (
str(routed_source["model_group"])
if routed_source is not None
else next((str(source["model_group"]) for source in metadata_sources if source.get("model_group")), None)
)
caching_group_sources: Final = (routed_source,) if routed_source is not None else metadata_sources
caching_group: Final = next(
(group for source in metadata_sources if (group := self._get_caching_group(source, model_group))), None
(group for source in caching_group_sources if (group := self._get_caching_group(source, model_group))), None
)
model_id_suffix: Final = (
f" model_id: {kwargs['model_id']}" if "model_id" in kwargs and kwargs["model_id"] is not None else ""

View file

@ -13,7 +13,7 @@ import asyncio
import json
import os
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Protocol, cast
from typing import TYPE_CHECKING, Any, Final, Protocol
from pydantic import TypeAdapter
@ -292,10 +292,7 @@ class QdrantSemanticCache(BaseCache):
prompt: Final = get_semantic_cache_prompt_from_messages(messages)
# create an embedding for prompt
embedding_response: Final = cast(
EmbeddingResponse,
self._get_embedding(prompt, metadata=kwargs.get("metadata")),
)
embedding_response: Final = self._get_embedding(prompt, metadata=kwargs.get("metadata"))
# get the embedding
embedding: Final = _EMBEDDING_VECTOR.validate_python(embedding_response["data"][0]["embedding"])
@ -331,10 +328,7 @@ class QdrantSemanticCache(BaseCache):
prompt: Final = get_semantic_cache_prompt_from_messages(messages)
# convert to embedding
embedding_response: Final = cast(
EmbeddingResponse,
self._get_embedding(prompt, metadata=kwargs.get("metadata")),
)
embedding_response: Final = self._get_embedding(prompt, metadata=kwargs.get("metadata"))
# get the embedding
embedding: Final = _EMBEDDING_VECTOR.validate_python(embedding_response["data"][0]["embedding"])

View file

@ -14,7 +14,9 @@ import asyncio
import json
import os
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, Final, cast
from typing import TYPE_CHECKING, Final, cast
from pydantic import ConfigDict, TypeAdapter
import litellm
from litellm._logging import print_verbose, verbose_logger
@ -38,6 +40,11 @@ if TYPE_CHECKING:
from litellm.router import Router
_CACHE_RESPONSE_ADAPTER: Final[TypeAdapter[str | bytes | None]] = TypeAdapter(
str | bytes | None, config=ConfigDict(strict=True, hide_input_in_errors=True)
)
class RedisSemanticCache(BaseCache):
"""
Redis-backed semantic cache for LLM responses.
@ -380,7 +387,7 @@ class RedisSemanticCache(BaseCache):
)
return embedding_response["data"][0]["embedding"]
def _get_cache_logic(self, cached_response: Any) -> object:
def _get_cache_logic(self, cached_response: str | bytes | None) -> object:
"""
Process the cached response to prepare it for use.
@ -391,24 +398,23 @@ class RedisSemanticCache(BaseCache):
The processed cache response, or None if input was None
"""
if cached_response is None:
return cached_response
return None
# Convert bytes to string if needed
if isinstance(cached_response, bytes):
cached_response = cached_response.decode("utf-8")
response_text: Final = (
cached_response.decode("utf-8") if isinstance(cached_response, bytes) else cached_response
)
# Convert string representation to Python object
try:
cached_response = json.loads(cached_response)
return json.loads(response_text)
except json.JSONDecodeError:
try:
cached_response = ast.literal_eval(cached_response)
return ast.literal_eval(response_text)
except (ValueError, SyntaxError) as e:
print_verbose(f"Error parsing cached response: {e}")
return None
return cached_response
def set_cache(self, key: str, value: object, **kwargs) -> None:
"""
Store a value in the semantic cache.
@ -494,7 +500,7 @@ class RedisSemanticCache(BaseCache):
similarity: Final = 1 - vector_distance
cached_prompt: Final = cache_hit["prompt"]
cached_response: Final = cache_hit["response"]
cached_response: Final = _CACHE_RESPONSE_ADAPTER.validate_python(cache_hit["response"])
# update kwargs["metadata"] with similarity, don't rewrite the original metadata
kwargs.setdefault("metadata", {})["semantic-similarity"] = similarity
@ -645,7 +651,7 @@ class RedisSemanticCache(BaseCache):
similarity: Final = 1 - vector_distance
cached_prompt: Final = cache_hit["prompt"]
cached_response: Final = cache_hit["response"]
cached_response: Final = _CACHE_RESPONSE_ADAPTER.validate_python(cache_hit["response"])
# update kwargs["metadata"] with similarity, don't rewrite the original metadata
kwargs.setdefault("metadata", {})["semantic-similarity"] = similarity

View file

@ -22,7 +22,7 @@ from litellm.constants import (
OPENAI_LIVE_TERMINAL_RESPONSE_EVENT_TYPES,
REALTIME_METERED_USAGE_KEY,
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.core_helpers import as_str_mapping, billed_usage_cost
from litellm.litellm_core_utils.cost_discount import resolve_cost_discount
from litellm.litellm_core_utils.decisions_pricing import decisions_pricing_fields, is_decisions_call_type
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
@ -50,6 +50,7 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import (
parse_prompt_tokens_details,
select_cost_metric_for_model,
)
from litellm.litellm_core_utils.llm_response_utils.response_metadata import response_ms_since_logging_start
from litellm.llms.anthropic.cost_calculation import (
cost_per_token as anthropic_cost_per_token,
)
@ -1396,9 +1397,9 @@ def _apply_cost_margin(
def _store_cost_breakdown_in_logging_obj(
litellm_logging_obj: LitellmLoggingObject | None,
prompt_tokens_cost_usd_dollar: float,
completion_tokens_cost_usd_dollar: float,
cost_for_built_in_tools_cost_usd_dollar: float,
prompt_tokens_cost_usd_dollar: float | None,
completion_tokens_cost_usd_dollar: float | None,
cost_for_built_in_tools_cost_usd_dollar: float | None,
total_cost_usd_dollar: float,
additional_costs: dict | None = None,
original_cost: float | None = None,
@ -1500,9 +1501,45 @@ def _response_time_ms_for_cost(
return float(stamped)
if total_time:
return total_time
if litellm_logging_obj is not None:
return litellm_logging_obj.get_response_ms()
return 0.0
if litellm_logging_obj is None:
return 0.0
reported_ms: Final = response_ms_since_logging_start(litellm_logging_obj)
if reported_ms is not None and litellm_logging_obj.stream is not True:
return reported_ms
return litellm_logging_obj.get_response_ms()
def _adjusted_provider_billed_cost(
reported_cost: float,
custom_llm_provider: str | None,
model: str | None,
region_name: str | None,
litellm_logging_obj: LitellmLoggingObject | None,
service_tier: str | None,
data_residency: str | None,
) -> float:
discounted_cost, discount_percent, discount_amount = _apply_cost_discount(
base_cost=reported_cost, custom_llm_provider=custom_llm_provider, model=model, region_name=region_name
)
adjusted_cost, margin_percent, margin_fixed_amount, margin_total_amount = _apply_cost_margin(
base_cost=discounted_cost, custom_llm_provider=custom_llm_provider
)
_store_cost_breakdown_in_logging_obj(
litellm_logging_obj=litellm_logging_obj,
prompt_tokens_cost_usd_dollar=None,
completion_tokens_cost_usd_dollar=None,
cost_for_built_in_tools_cost_usd_dollar=None,
total_cost_usd_dollar=adjusted_cost,
original_cost=reported_cost,
discount_percent=discount_percent,
discount_amount=discount_amount,
margin_percent=margin_percent,
margin_fixed_amount=margin_fixed_amount,
margin_total_amount=margin_total_amount,
service_tier=service_tier,
data_residency=data_residency,
)
return adjusted_cost
def completion_cost(
@ -1616,6 +1653,9 @@ def completion_cost(
)
explicit_pricing: Final = custom_pricing is True or base_model is not None
caller_set_prices: Final = (
explicit_pricing or custom_cost_per_token is not None or custom_cost_per_second is not None
)
custom_priced_request_model: Final = model if custom_pricing is True else None
configured_cost_per_query: Final = (
_configured_search_cost_per_query(litellm_logging_obj) if custom_pricing else None
@ -1666,6 +1706,7 @@ def completion_cost(
raw_candidates if custom_pricing is True else _priced_candidates_before_unpriced_rules(raw_candidates)
)
priced_response: Final[object] = completion_response
for idx, (candidate_model, candidate_pricing_provider) in enumerate(potential_model_names):
model = candidate_model
try:
@ -1984,6 +2025,19 @@ def completion_cost(
total_time=total_time,
)
model_for_discount = custom_priced_request_model or model
if (
not caller_set_prices
and (reported_cost := billed_usage_cost(priced_response, custom_llm_provider)) is not None
):
return _adjusted_provider_billed_cost(
reported_cost=reported_cost,
custom_llm_provider=custom_llm_provider,
model=model_for_discount,
region_name=region_name if isinstance(region_name, str) else None,
litellm_logging_obj=litellm_logging_obj,
service_tier=service_tier,
data_residency=data_residency,
)
# Calculate cost based on prompt_tokens, completion_tokens
if (
"togethercomputer" in model or "together_ai" in model or custom_llm_provider == "together_ai"

View file

@ -139,6 +139,7 @@ class AuthenticationError(openai.AuthenticationError):
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
body: Mapping[str, object] | None = None,
):
self.status_code = 401
self.message = f"litellm.AuthenticationError: {message}"
@ -152,7 +153,7 @@ class AuthenticationError(openai.AuthenticationError):
request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object
)
super().__init__(
self.message, response=self.response, body=None
self.message, response=self.response, body=body
) # Call the base class constructor with the parameters it needs
def __str__(self):
@ -189,6 +190,7 @@ class NotFoundError(openai.NotFoundError):
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
body: Mapping[str, object] | None = None,
):
self.status_code = 404
self.message = f"litellm.NotFoundError: {message}"
@ -202,7 +204,7 @@ class NotFoundError(openai.NotFoundError):
request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object
)
super().__init__(
self.message, response=self.response, body=None
self.message, response=self.response, body=body
) # Call the base class constructor with the parameters it needs
def __str__(self):
@ -321,6 +323,17 @@ class UnsupportedEndpointError(BadRequestError):
pass
class EndpointNotSupportedByModelError(BadRequestError):
def __init__(self, message: str, model: str, llm_provider: str = "") -> None:
super().__init__( # pyright: ignore[reportUnknownMemberType] # inherited public exception constructor is untyped
message=message, model=model, llm_provider=llm_provider
)
self.message = message
self.type = "invalid_request_error"
self.param = "model"
self.code = "400"
class UnprocessableEntityError(openai.UnprocessableEntityError):
def __init__(
self,

View file

@ -68,6 +68,11 @@ from litellm.utils import (
)
_PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints)
_PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE: Final = (
LlmProviders.DASHSCOPE,
LlmProviders.QWENCLOUD,
LlmProviders.QWEN_AI_PLATFORM,
)
# Cache for ImageEditRequestUtils to avoid repeated __getattr__ calls
_ImageEditRequestUtils_cache: type["ImageEditRequestUtils"] | None = None
@ -271,6 +276,7 @@ def image_generation(
"""
try:
args: Final = locals()
requested_api_base: Final = api_base
aimg_generation: Final = kwargs.get("aimg_generation", False)
litellm_call_id: Final = kwargs.get("litellm_call_id", None)
logger_fn: Final = kwargs.get("logger_fn", None)
@ -468,7 +474,11 @@ def image_generation(
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")
# Resolve api_base from litellm.api_base if not explicitly provided
_api_base: Final = api_base or litellm.api_base
_api_base: Final = (
requested_api_base
if custom_llm_provider in _PROVIDERS_RESOLVING_THEIR_OWN_IMAGE_BASE
else api_base or litellm.api_base
)
litellm_params_dict["api_base"] = _api_base
return llm_http_handler.image_generation_handler(
@ -641,6 +651,8 @@ def image_generation(
timeout=timeout,
client=custom_client,
)
elif custom_llm_provider == LlmProviders.KYMA.value:
raise LiteLLMUnknownProvider(model=model, custom_llm_provider=custom_llm_provider)
return model_response
except Exception as e:

View file

@ -513,7 +513,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
data: dict,
user_api_key_dict: UserAPIKeyAuth,
call_type: CallTypesLiteral,
) -> Any:
) -> object:
pass
async def async_post_call_streaming_hook(

View file

@ -205,11 +205,12 @@ class DatadogCostManagementLogger(CustomBatchLogger):
# see _RESERVED_TAG_KEYS for the rationale.
if self.cost_tag_keys:
allow: Final = set(self.cost_tag_keys)
client_allow: Final = allow - {"is_silent_experiment"}
for rt in log.get("request_tags") or []:
if not isinstance(rt, str) or ":" not in rt:
continue
k, _, v = rt.partition(":")
if k in allow and v:
if k in client_allow and v:
self._set_custom_tag(tags, k, v)
for k, v in metadata.items():
if k in allow and v is not None and not isinstance(v, (dict, list)):
@ -218,7 +219,7 @@ class DatadogCostManagementLogger(CustomBatchLogger):
nested = metadata.get(nested_key)
if isinstance(nested, dict):
for k, v in nested.items():
if k in allow and v is not None and not isinstance(v, (dict, list)):
if k in client_allow and v is not None and not isinstance(v, (dict, list)):
self._set_custom_tag(tags, k, str(v))
return tags

View file

@ -833,6 +833,12 @@ class LangFuseLogger:
("vertex_location", vertex_location, bool(vertex_location)),
("aws_region_name", aws_region_name, bool(aws_region_name)),
("cache_hit", kwargs.get("cache_hit") or False, "cache_hit" in kwargs),
(
"is_silent_experiment",
"is_silent_experiment" in allowlisted_metadata
and allowlisted_metadata["is_silent_experiment"] is True,
"is_silent_experiment" in allowlisted_metadata,
),
)
enrichments: Final[Mapping[str, object]] = {
key: value for key, value, include in candidate_enrichments if include

View file

@ -15,7 +15,7 @@ class BaseLLMObsOTELAttributes(ABC):
pass
def cast_as_primitive_value_type(value) -> str | bool | int | float:
def cast_as_primitive_value_type(value: object) -> str | bool | int | float:
"""
Converts a value to an OTEL-supported primitive for Arize/Phoenix observability.
"""

View file

@ -5148,6 +5148,12 @@ def _get_combined_custom_metadata_from_standard_logging_payload(
**(requester_metadata if isinstance(requester_metadata, dict) else {}),
**(user_api_key_auth_metadata if isinstance(user_api_key_auth_metadata, dict) else {}),
**(spend_logs_metadata if isinstance(spend_logs_metadata, dict) else {}),
"is_silent_experiment": (
"true"
if "is_silent_experiment" in standard_logging_metadata
and standard_logging_metadata["is_silent_experiment"] is True
else None
),
}

View file

@ -37,6 +37,7 @@ from litellm.integrations.websearch_interception.transformation import (
from litellm.litellm_core_utils.agentic_loop_settings import (
validated_max_agentic_loops,
)
from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model
from litellm.llms.base_llm.search.transformation import SearchResponse
from litellm.types.integrations.custom_logger import (
CHAT_COMPLETION_AGENTIC_SURFACE,
@ -114,6 +115,18 @@ class _WebSearchSettingsView(TypedDict):
websearch_interception_params: WebSearchInterceptionConfig
def _caller_model(model: str, logging_obj: "LiteLLMLoggingObj | None", kwargs: Mapping[str, object]) -> str:
agentic_view: Final[_AgenticLoopParamsView] = {
"agentic_loop_params": logging_obj.model_call_details.get("agentic_loop_params", {}) if logging_obj else {}
}
recorded_model: Final = agentic_view["agentic_loop_params"].get("model")
custom_llm_provider: Final = kwargs.get("custom_llm_provider")
return qualify_provider_stripped_model(
recorded_model if isinstance(recorded_model, str) and recorded_model else model,
custom_llm_provider if isinstance(custom_llm_provider, str) else "",
)
class _SearchToolLitellmParams(TypedDict, total=False):
search_provider: ReadOnly[str | None]
@ -1155,6 +1168,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=optional_params,
kwargs=kwargs,
logging_obj=logging_obj,
response_format=response_format,
)
return AgenticLoopPlan(
@ -1181,6 +1195,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=optional_params,
kwargs=kwargs,
logging_obj=logging_obj,
)
return AgenticLoopPlan(
run_agentic_loop=True,
@ -1195,6 +1210,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls: list[dict],
optional_params: dict,
kwargs: dict,
logging_obj: "LiteLLMLoggingObj | None",
) -> AgenticLoopRequestPatch:
"""Execute litellm.asearch() and build a Responses API rerun patch."""
search_tasks: Final = [
@ -1254,9 +1270,7 @@ class WebSearchInterceptionLogger(CustomLogger):
}
}
full_model_name = model
if "/" not in model and isinstance(kwargs.get("custom_llm_provider"), str):
full_model_name = f"{kwargs['custom_llm_provider']}/{model}"
full_model_name: Final = _caller_model(model, logging_obj, kwargs)
verbose_logger.debug(
"WebSearchInterception: Built responses request patch model=%s input_items=%d searches=%d",
@ -1814,6 +1828,7 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_calls=tool_calls,
optional_params=dict[str, object](optional_params),
kwargs=dict[str, object](kwargs),
logging_obj=logging_obj,
response_format=response_format,
)
if request_patch.messages is None:
@ -1836,7 +1851,8 @@ class WebSearchInterceptionLogger(CustomLogger):
messages: list[dict],
tool_calls: list[dict],
optional_params: dict,
kwargs: dict,
kwargs: dict[str, object],
logging_obj: "LiteLLMLoggingObj | None",
response_format: str = "openai",
) -> AgenticLoopRequestPatch:
"""Execute litellm.search() and build chat-completion rerun patch."""
@ -1922,11 +1938,7 @@ class WebSearchInterceptionLogger(CustomLogger):
k: v for k, v in kwargs.items() if not k.startswith("_websearch_interception") and k not in internal_params
}
full_model_name = model
if "custom_llm_provider" in kwargs:
custom_llm_provider: Final = kwargs["custom_llm_provider"]
if not model.startswith(custom_llm_provider) and "/" not in model:
full_model_name = f"{custom_llm_provider}/{model}"
full_model_name: Final = _caller_model(model, logging_obj, kwargs)
verbose_logger.debug(
"WebSearchInterception: Built chat completion request patch model=%s messages=%d",

View file

@ -103,6 +103,7 @@ class LiteLLMResponsesInteractionsHandler:
return LiteLLMResponsesInteractionsConfig.transform_responses_response_to_interactions_response(
responses_response=responses_api_response,
model=model,
custom_llm_provider=custom_llm_provider,
)
async def async_interactions_api_handler(
@ -138,4 +139,5 @@ class LiteLLMResponsesInteractionsHandler:
return LiteLLMResponsesInteractionsConfig.transform_responses_response_to_interactions_response(
responses_response=responses_api_response,
model=model,
custom_llm_provider=responses_request.get("custom_llm_provider"),
)

View file

@ -12,6 +12,7 @@ from typing import Final, cast
from pydantic import BaseModel
from litellm.litellm_core_utils.core_helpers import billed_usage_cost
from litellm.types.interactions import (
InteractionInput,
InteractionsAPIOptionalRequestParams,
@ -190,6 +191,7 @@ class LiteLLMResponsesInteractionsConfig:
def transform_responses_response_to_interactions_response(
responses_response: ResponsesAPIResponse,
model: str | None = None,
custom_llm_provider: str | None = None,
) -> InteractionsAPIResponse:
"""
Transform a Responses API response to an Interactions API response.
@ -266,9 +268,11 @@ class LiteLLMResponsesInteractionsConfig:
# (total_input_tokens, total_output_tokens)
usage: Final[object] = getattr(responses_response, "usage", None)
if usage:
reported_cost: Final = billed_usage_cost(responses_response, custom_llm_provider)
interactions_response_dict["usage"] = {
"total_input_tokens": getattr(usage, "input_tokens", 0),
"total_output_tokens": getattr(usage, "output_tokens", 0),
**({} if reported_cost is None else {"cost": reported_cost}),
}
# Add updated (same as created for now)

View file

@ -15,6 +15,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import (
DEFAULT_MAX_AGENTIC_LOOPS,
validated_max_agentic_loops,
)
from litellm.litellm_core_utils.core_helpers import qualify_agentic_followup_model
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
from litellm.types.integrations.custom_logger import (
@ -182,9 +183,7 @@ async def _execute_chat_completion_agentic_plan(
if patch.messages is None:
raise ValueError("Agentic loop plan missing patched messages")
full_model_name = patch.model or model
if "/" not in full_model_name:
full_model_name = f"{custom_llm_provider}/{full_model_name}"
full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider)
optional_params_for_followup: Final = {**optional_params, **patch.optional_params}
if patch.tools is not None:

View file

@ -2,6 +2,7 @@
## Helper utilities
import copy
import logging
import math
import re
from collections.abc import Collection, Iterable, Iterator, Mapping
from types import MappingProxyType
@ -34,6 +35,49 @@ def is_codex_user_agent(user_agent: str) -> bool:
return bool(_CODEX_CLIENT_PREFIX_RE.match(user_agent))
def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str:
"""Put the provider prefix back on a provider-stripped model."""
if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"):
return model
if _already_routes_to(model, custom_llm_provider):
return model
return f"{custom_llm_provider}/{model}"
def _already_routes_to(model: str, custom_llm_provider: str) -> bool:
from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider
return _leads_with_provider(model) and inferred_provider(model) == custom_llm_provider
def _leads_with_provider(model: str) -> bool:
leading_segment, separator, _ = model.partition("/")
return bool(separator) and names_a_provider(leading_segment)
def names_a_provider(candidate: str) -> bool:
"""Whether a model name's leading segment is a provider rather than part of the model."""
import litellm
from litellm.litellm_core_utils.get_llm_provider_logic import is_registered_custom_provider
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
provider_names: Final = {getattr(provider, "value", provider) for provider in litellm.provider_list}
return (
candidate in provider_names
or JSONProviderRegistry.exists(candidate)
or is_registered_custom_provider(candidate)
)
def qualify_agentic_followup_model(patch_model: str | None, model: str, custom_llm_provider: str) -> str:
"""Resolve the model an agentic follow-up re-dispatches, keeping a hook's own provider."""
if not patch_model or patch_model == model:
return qualify_provider_stripped_model(model, custom_llm_provider)
if _leads_with_provider(patch_model):
return patch_model
return qualify_provider_stripped_model(patch_model, custom_llm_provider)
def safe_divide_seconds(seconds: float, denominator: float, default: float | None = None) -> float | None:
"""
Safely divide seconds by denominator, handling zero division.
@ -973,6 +1017,16 @@ def chunk_usage(chunk: object) -> object:
return getattr(chunk, "usage", None)
USAGE_COST_BILLED_PROVIDERS: Final = frozenset({"kyma"})
def billed_usage_cost(response: object, custom_llm_provider: str | None) -> float | None:
if custom_llm_provider not in USAGE_COST_BILLED_PROVIDERS:
return None
cost: Final = provider_reported_usage_cost(_usage_field(response, "usage"), custom_llm_provider)
return cost if cost is not None and math.isfinite(cost) and cost >= 0 else None
_MESSAGES_REPORTED_COST_PROVIDERS: Final = frozenset({"opencode_go"})

View file

@ -413,6 +413,25 @@ def _map_openai_exception(
body=getattr(original_exception, "body", None),
)
elif "invalid_request_error" in error_str and "Incorrect API key provided" not in error_str:
invalid_request_status: Final = getattr(original_exception, "status_code", None)
if invalid_request_status == 401:
raise AuthenticationError(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
if invalid_request_status == 404:
raise NotFoundError(
message=f"{exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
raise BadRequestError(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,

View file

@ -31,7 +31,13 @@ EXTRACTION_HEALTH_CHECK_FIELDS: Final[Mapping[str, Mapping[str, str]]] = Mapping
)
def native_health_check_mode(model: str, custom_llm_provider: str | None) -> Literal["anthropic_messages"] | None:
def native_health_check_mode(
model: str, custom_llm_provider: str | None
) -> Literal["anthropic_messages", "realtime"] | None:
if custom_llm_provider == "soniox":
from litellm.llms.soniox.common_utils import soniox_health_check_mode
return soniox_health_check_mode(model)
if custom_llm_provider != "bedrock_mantle":
return None
from litellm.llms.bedrock_mantle.common_utils import mantle_health_check_mode

View file

@ -23,7 +23,7 @@ from httpx import Response, Timeout
from pydantic import BaseModel, JsonValue, TypeAdapter
import litellm
from litellm._internal_context import post_response_phase
from litellm._internal_context import in_mirrored_call, outside_mirrored_call, post_response_phase
from litellm._logging import (
is_debugging_on,
redact_string,
@ -162,9 +162,10 @@ from litellm.types.llms.openai import (
from litellm.types.mcp import MCPPostCallResponseObject
from litellm.types.prompts.init_prompts import PromptSpec
from litellm.types.rerank import RerankResponse
from litellm.types.router import RouterRateLimitError
from litellm.types.router import RouterRateLimitError, SilentExperimentFlag
from litellm.types.utils import (
DEPLOYMENT_SCOPED_PRICING_FIELDS,
AgenticLoopParams,
CachingDetails,
CallTypes,
CostBreakdown,
@ -314,7 +315,7 @@ else:
_in_memory_loggers: Final[list[CustomLogger]] = []
_STANDARD_LOGGING_METADATA_RESOLVED_KEYS: Final[frozenset[str]] = frozenset(
("used_client_oauth_token", "usage_object", KUBERNETES_POD_ROUTING_KEY)
("used_client_oauth_token", "usage_object", KUBERNETES_POD_ROUTING_KEY, "is_silent_experiment")
)
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = (
frozenset(StandardLoggingMetadata.__annotations__.keys()) - _STANDARD_LOGGING_METADATA_RESOLVED_KEYS
@ -808,6 +809,7 @@ class Logging(LiteLLMLoggingBaseClass):
litellm_params = scrub_sensitive_keys_in_metadata(litellm_params)
self.litellm_params = litellm_params
self.is_silent_experiment: Final = in_mirrored_call()
# Initialize cost breakdown field
self.cost_breakdown: CostBreakdown | None = None
@ -1158,6 +1160,9 @@ class Logging(LiteLLMLoggingBaseClass):
if "custom_llm_provider" in self.model_call_details:
self.custom_llm_provider = self.model_call_details["custom_llm_provider"]
def record_agentic_loop_params(self, params: AgenticLoopParams) -> None:
self.model_call_details["agentic_loop_params"] = params
def record_applied_timeout(self, timeout: float | Timeout) -> None:
self.applied_timeout = timeout
self.litellm_params["timeout"] = timeout
@ -1937,10 +1942,10 @@ class Logging(LiteLLMLoggingBaseClass):
def set_cost_breakdown(
self,
input_cost: float,
output_cost: float,
input_cost: float | None,
output_cost: float | None,
total_cost: float,
cost_for_built_in_tools_cost_usd_dollar: float,
cost_for_built_in_tools_cost_usd_dollar: float | None,
additional_costs: dict | None = None,
original_cost: float | None = None,
discount_percent: float | None = None,
@ -1979,14 +1984,17 @@ class Logging(LiteLLMLoggingBaseClass):
self.billed_token_rates = billed_token_rates
self.cost_breakdown = CostBreakdown(
input_cost=input_cost,
output_cost=output_cost,
total_cost=total_cost,
tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar,
service_tier=service_tier,
data_residency=data_residency,
vertex_location=vertex_location,
)
if input_cost is not None:
self.cost_breakdown["input_cost"] = input_cost
if output_cost is not None:
self.cost_breakdown["output_cost"] = output_cost
if cost_for_built_in_tools_cost_usd_dollar is not None:
self.cost_breakdown["tool_usage_cost"] = cost_for_built_in_tools_cost_usd_dollar
if cache_read_cost is not None and cache_read_cost > 0:
self.cost_breakdown["cache_read_cost"] = cache_read_cost
if cache_creation_cost is not None and cache_creation_cost > 0:
@ -3249,7 +3257,7 @@ class Logging(LiteLLMLoggingBaseClass):
"""Restores trace_id/session_id contextvars once this attempt's own success
logging (including any nested calls its callbacks trigger) is fully done."""
try:
with post_response_phase():
with post_response_phase(), outside_mirrored_call():
return self._success_handler_body(
result=result,
start_time=start_time,
@ -3749,7 +3757,7 @@ class Logging(LiteLLMLoggingBaseClass):
"""Restores trace_id/session_id contextvars once this attempt's own success
logging (including any nested calls its callbacks trigger) is fully done."""
try:
with post_response_phase():
with post_response_phase(), outside_mirrored_call():
return await self._async_success_handler_body(
result=result, start_time=start_time, end_time=end_time, cache_hit=cache_hit, **kwargs
)
@ -4266,13 +4274,14 @@ class Logging(LiteLLMLoggingBaseClass):
``for_async_request`` marks the sync run of a failure whose async run also logs it, so a
CustomLogger that gets ``async_log_failure_event`` from that async run skips its sync hook."""
try:
return self._failure_handler_body(
exception=exception,
traceback_exception=traceback_exception,
start_time=start_time,
end_time=end_time,
for_async_request=for_async_request,
)
with outside_mirrored_call():
return self._failure_handler_body(
exception=exception,
traceback_exception=traceback_exception,
start_time=start_time,
end_time=end_time,
for_async_request=for_async_request,
)
finally:
self._restore_correlation_context()
@ -4465,12 +4474,13 @@ class Logging(LiteLLMLoggingBaseClass):
"""Restores trace_id/session_id contextvars once this attempt's own failure
logging (including any nested calls its callbacks trigger) is fully done."""
try:
return await self._async_failure_handler_body(
exception=exception,
traceback_exception=traceback_exception,
start_time=start_time,
end_time=end_time,
)
with outside_mirrored_call():
return await self._async_failure_handler_body(
exception=exception,
traceback_exception=traceback_exception,
start_time=start_time,
end_time=end_time,
)
finally:
self._restore_correlation_context()
@ -6559,6 +6569,7 @@ class StandardLoggingPayloadSetup:
start_time: dt_object | None = None,
response_id: str | None = None,
custom_llm_provider: str | None = None,
is_silent_experiment: bool = False,
) -> StandardLoggingMetadata:
"""
Clean and filter the metadata dictionary to include only the specified keys in StandardLoggingMetadata.
@ -6632,6 +6643,7 @@ class StandardLoggingPayloadSetup:
proxy_stamped_used_client_oauth_token(metadata, litellm_params),
custom_llm_provider,
),
**(SilentExperimentFlag(is_silent_experiment=True) if is_silent_experiment else SilentExperimentFlag()),
)
if isinstance(metadata, dict):
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
@ -7432,6 +7444,7 @@ def get_standard_logging_object_payload(
start_time=start_time,
response_id=id,
custom_llm_provider=selected_provider if isinstance(selected_provider, str) else None,
is_silent_experiment=logging_obj.is_silent_experiment is True,
)
_request_body: Final = proxy_server_request.get("body", {})
end_user_id: Final = clean_metadata["user_api_key_end_user_id"] or _request_body.get(
@ -7716,9 +7729,8 @@ def get_standard_logging_metadata(
)
if isinstance(metadata, dict):
# Update the clean_metadata with values from input metadata that match StandardLoggingMetadata fields
for key in StandardLoggingMetadata.__annotations__:
if key in metadata:
clean_metadata[key] = metadata[key]
for key in (metadata.keys() & StandardLoggingMetadata.__annotations__.keys()) - {"is_silent_experiment"}:
clean_metadata[key] = metadata[key]
if metadata.get("user_api_key") is not None:
if is_valid_sha256_hash(str(metadata.get("user_api_key"))):

View file

@ -3,7 +3,7 @@ import json
import re
import time
import traceback
from collections.abc import Mapping, Sequence
from collections.abc import Generator, Iterator, Mapping, Sequence
from typing import Final, Literal, cast
from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config
@ -19,7 +19,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.llms.ollama.common_utils import OllamaResponseIdNormalizer
from litellm.types.llms.databricks import DatabricksTool
from litellm.types.llms.openai import (
ChatCompletionReasoningItem,
ChatCompletionAnnotation,
ChatCompletionThinkingBlock,
ImageURLListItem,
OpenAIModerationResponse,
@ -59,6 +59,31 @@ _CHOICES_FIELDS: Final[frozenset] = frozenset(Choices.model_fields.keys())
_MODEL_RESPONSE_FIELDS: Final[frozenset] = frozenset(ModelResponse.model_fields.keys()) | {"usage"}
_ANNOTATION_ENTRIES: Final = TypeAdapter(tuple[object, ...])
def _is_provider_specific_annotation(annotation: object) -> bool:
match annotation:
case {"type": "url_citation"}:
return False
case {"type": _}:
return True
case _:
return False
def _annotation_entries(annotations: object) -> tuple[object, ...]:
return _ANNOTATION_ENTRIES.validate_python(annotations) if isinstance(annotations, list) else ()
def partition_annotations(annotations: object) -> tuple[object, tuple[object, ...]]:
entries: Final = _annotation_entries(annotations)
others: Final = tuple(annotation for annotation in entries if _is_provider_specific_annotation(annotation))
if not others:
return annotations, ()
return [annotation for annotation in entries if not _is_provider_specific_annotation(annotation)] or None, others
def _normalize_images_for_message(
images: list[dict] | None,
) -> list[ImageURLListItem] | None:
@ -145,31 +170,89 @@ def _split_assembled_content_for_replay(content: str | None) -> list[str]:
return _REPLAY_CONTENT_SLICE_RE.findall(content)
def _clear_later_replay_slice_metadata(choice: StreamingChoices) -> None:
# Rebuild the delta as content-only so every accumulate-able field (role,
# tool_calls, reasoning_content, thinking_blocks, audio, images,
# annotations, ...) is dropped on later slices instead of an enumerated
# subset; repeating any of them makes downstream handlers that accumulate
# streamed deltas collect it once per slice, and a field added to Delta
# later can't silently re-introduce the duplication.
choice.delta = Delta(content=choice.delta.content)
choice.logprobs = None
if hasattr(choice, "enhancements"):
del choice.enhancements
_REPLAY_REASONING_FIELDS: Final = frozenset({"reasoning_content", "thinking_blocks", "reasoning_items"})
_REPLAY_LEAD_FIELDS: Final = _REPLAY_REASONING_FIELDS | {"role", "provider_specific_fields"}
_REPLAY_TOOL_FIELDS: Final = frozenset({"tool_calls", "function_call"})
_REPLAY_TEXT_FIELDS: Final = frozenset({"content", "annotations"})
def _first_replay_delta(
message: Mapping[str, object], reasoning_items: Sequence[ChatCompletionReasoningItem] | None
) -> Delta:
return Delta(
content=message.get("content"),
role=message["role"],
function_call=message.get("function_call"),
tool_calls=message.get("tool_calls"),
reasoning_items=None if reasoning_items is None else list(reasoning_items),
provider_specific_fields=_cached_provider_specific_fields(
_CACHED_MESSAGE_PROVIDER_FIELDS.validate_python(message)
),
def _fields_in(fields: Mapping[str, object], keys: frozenset[str]) -> dict[str, object]:
return {key: value for key, value in fields.items() if key in keys}
def _replay_deltas(delta: Delta) -> tuple[Delta, ...]:
fields: Final[dict[str, object]] = delta.model_dump(exclude_none=True)
lead: Final = _fields_in(fields, _REPLAY_LEAD_FIELDS) if _REPLAY_REASONING_FIELDS.intersection(fields) else {}
pieces: Final = _split_assembled_content_for_replay(delta.content) or ([delta.content] if delta.content else [])
if not pieces and not lead:
return (delta,)
if not pieces:
held_back: Final = lead.keys() | {"content"}
rest: Final = {key: value for key, value in fields.items() if key not in held_back}
return (Delta.model_validate(lead),) + ((Delta.model_validate(rest),) if rest else ())
tools: Final = _fields_in(fields, _REPLAY_TOOL_FIELDS)
annotations: Final = _fields_in(fields, frozenset({"annotations"}))
moved: Final = lead.keys() | _REPLAY_TOOL_FIELDS | _REPLAY_TEXT_FIELDS
first_slice: Final = {key: value for key, value in fields.items() if key not in moved}
last_index: Final = len(pieces) - 1
return (
((Delta.model_validate(lead),) if lead else ())
+ tuple(
Delta.model_validate(
{
**(first_slice if index == 0 else {}),
"content": piece,
**(annotations if index == last_index else {}),
}
)
for index, piece in enumerate(pieces)
)
+ ((Delta.model_validate(tools),) if tools else ())
)
def _replay_frame(
response: ModelResponseStream, delta: Delta, finish_reason: str | None, keeps_choice_metadata: bool
) -> ModelResponseStream:
frame: Final = response.model_copy(deep=True)
choice: Final = frame.choices[0]
choice.delta = delta
choice.finish_reason = finish_reason
if not keeps_choice_metadata:
choice.logprobs = None
if hasattr(choice, "enhancements"):
del choice.enhancements
return frame
def _replay_frames(response: ModelResponseStream) -> Iterator[ModelResponseStream]:
if len(response.choices) != 1:
yield response
return
usage: Final = getattr(response, "usage", None)
if usage is not None:
delattr(response, "usage")
choice: Final = response.choices[0]
deltas: Final = _replay_deltas(choice.delta)
first_text: Final = next((index for index, delta in enumerate(deltas) if delta.content), 0)
for index, delta in enumerate(deltas):
yield _replay_frame(response, delta, finish_reason=None, keeps_choice_metadata=index == first_text)
finish_frame: Final = _replay_frame(
response, Delta(), finish_reason=choice.finish_reason, keeps_choice_metadata=False
)
if usage is not None:
setattr(finish_frame, "usage", usage)
yield finish_frame
def _first_replay_delta(message: Mapping[str, object]) -> Delta:
return Delta.model_validate(
{
**message,
"provider_specific_fields": _cached_provider_specific_fields(
_CACHED_MESSAGE_PROVIDER_FIELDS.validate_python(message)
),
}
)
@ -234,7 +317,7 @@ async def convert_to_streaming_response_async(
t["index"] = index
pydantic_tool_calls.append(ChatCompletionDeltaToolCall(**t))
choice["message"]["tool_calls"] = pydantic_tool_calls
delta = _first_replay_delta(choice["message"], choice["message"].get("reasoning_items"))
delta = _first_replay_delta(choice["message"])
finish_reason = choice.get("finish_reason", None)
if finish_reason is None:
@ -266,41 +349,14 @@ async def convert_to_streaming_response_async(
if "model" in response_object:
model_response_object.model = response_object["model"]
# Replay cached content with per-word cadence so stream=true cache hits
# don't arrive as a single SSE frame. Multi-choice (n>1) responses and
# unsplittable content (None/empty/whitespace-free) keep the original
# single-yield behavior.
slices: list[str] = []
if len(model_response_object.choices) == 1:
slices = _split_assembled_content_for_replay(model_response_object.choices[0].delta.content)
if len(slices) <= 1:
yield model_response_object
await asyncio.sleep(0)
return
# Detach usage from the base object so we can re-attach it only to the
# final slice chunk. A non-None usage always lives in __pydantic_extra__
# here (set via setattr above), so delattr cannot fail.
original_usage: Final = getattr(model_response_object, "usage", None)
if original_usage is not None:
delattr(model_response_object, "usage")
original_finish_reason: Final = model_response_object.choices[0].finish_reason
last_idx: Final = len(slices) - 1
for i, piece in enumerate(slices):
slice_chunk = model_response_object.model_copy(deep=True)
slice_chunk.choices[0].delta.content = piece
if i > 0:
_clear_later_replay_slice_metadata(slice_chunk.choices[0])
slice_chunk.choices[0].finish_reason = original_finish_reason if i == last_idx else None
if i == last_idx and original_usage is not None:
setattr(slice_chunk, "usage", original_usage)
yield slice_chunk
for frame in _replay_frames(model_response_object):
yield frame
await asyncio.sleep(0)
def convert_to_streaming_response(
response_object: dict | None = None,
):
) -> Generator[ModelResponseStream, None, None]:
# used for yielding Cache hits when stream == True
if response_object is None:
raise Exception("Error in response object format")
@ -356,30 +412,7 @@ def convert_to_streaming_response(
if "model" in response_object:
model_response_object.model = response_object["model"]
# Replay cached content with per-word cadence on sync cache-hit paths
# (S3Cache, sync completion()). See convert_to_streaming_response_async
# for the full rationale — this mirrors its tail.
slices: list[str] = []
if len(model_response_object.choices) == 1:
slices = _split_assembled_content_for_replay(model_response_object.choices[0].delta.content)
if len(slices) <= 1:
yield model_response_object
return
original_usage: Final = getattr(model_response_object, "usage", None)
if original_usage is not None:
delattr(model_response_object, "usage")
original_finish_reason: Final = model_response_object.choices[0].finish_reason
last_idx: Final = len(slices) - 1
for i, piece in enumerate(slices):
slice_chunk = model_response_object.model_copy(deep=True)
slice_chunk.choices[0].delta.content = piece
if i > 0:
_clear_later_replay_slice_metadata(slice_chunk.choices[0])
slice_chunk.choices[0].finish_reason = original_finish_reason if i == last_idx else None
if i == last_idx and original_usage is not None:
setattr(slice_chunk, "usage", original_usage)
yield slice_chunk
yield from _replay_frames(model_response_object)
from collections import defaultdict
@ -787,6 +820,9 @@ def convert_to_model_response_object(
provider_specific_fields = dict(choice["message"].get("provider_specific_fields", None) or {})
for f in choice["message"].keys() - _MESSAGE_FIELDS:
provider_specific_fields[f] = choice["message"][f]
annotations, other_annotations = partition_annotations(choice["message"].get("annotations", None))
if other_annotations:
provider_specific_fields["annotations"] = list(other_annotations)
# Handle reasoning models that display `reasoning_content` within `content`
reasoning_content, content = extract_reasoning_content(choice["message"])
@ -809,7 +845,9 @@ def convert_to_model_response_object(
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
reasoning_items=choice["message"].get("reasoning_items", None),
annotations=choice["message"].get("annotations", None),
annotations=cast( # cast-ok: provider annotation dicts pass through whole, keeping keys such as url_citation.content
list[ChatCompletionAnnotation] | None, annotations
),
images=_normalize_images_for_message(choice["message"].get("images", None)),
)
finish_reason = choice.get("finish_reason", None)

View file

@ -27,6 +27,15 @@ def _timing_window_start(
return start_time, False
def response_ms_since_logging_start(logging_obj: LiteLLMLoggingObject) -> float | None:
reported_ms: Final = logging_obj.response_timing_metrics.get("_response_ms")
if reported_ms is None:
return None
logging_start: Final = logging_obj.start_time
window_start: Final = _timing_window_start(logging_start, logging_obj)[0]
return reported_ms - max(0.0, logging_start.timestamp() - window_start.timestamp()) * 1000
def _union_duration_ms(windows: object, lower: float, upper: float) -> float | None:
if not isinstance(windows, (list, tuple)):
return None

View file

@ -6,7 +6,7 @@ import atexit
import contextvars
import inspect
import logging
from collections.abc import Coroutine, Iterator
from collections.abc import Awaitable, Callable, Coroutine, Iterator
from typing import Final
from typing_extensions import TypedDict
@ -66,14 +66,19 @@ class LoggingWorker:
max_queue_size: int = LOGGING_WORKER_MAX_QUEUE_SIZE,
concurrency: int = LOGGING_WORKER_CONCURRENCY,
timeout_summary_window: float = LOGGING_WORKER_TIMEOUT_SUMMARY_WINDOW_SECONDS,
*,
retry_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
):
self.timeout = timeout
self.max_queue_size = max_queue_size
self.concurrency = concurrency
self.timeout_summary_window = timeout_summary_window
self._queue: asyncio.Queue[LoggingTask] | None = None
self._worker_task: asyncio.Task | None = None
self._running_tasks: set[asyncio.Task] = set()
self._worker_task: asyncio.Task[None] | None = None
self._running_tasks: set[asyncio.Task[None]] = set()
self._overflow_tasks: set[asyncio.Task[None]] = set() # mutable-ok: done callbacks discard refs
self._pending_enqueues: dict[int, LoggingTask] = {} # mutable-ok: retries and shutdown share callback ownership
self._retry_sleep = retry_sleep
self._dequeued_tasks: dict[int, LoggingTask] = {} # mutable-ok: refs so flush can rescue never-started tasks
self._sem: asyncio.Semaphore | None = None
self._bound_loop: asyncio.AbstractEventLoop | None = None
@ -168,6 +173,7 @@ class LoggingWorker:
self._sem = None
self._worker_task = None
self._running_tasks.clear()
self._overflow_tasks.clear()
self._timeout_summary_task = None
self._timeout_burst_count = 0
self._queue = new_queue
@ -185,6 +191,10 @@ class LoggingWorker:
self._sem = asyncio.Semaphore(self.concurrency)
if self._worker_task is None or self._worker_task.done():
self._worker_task = asyncio.create_task(self._worker_loop())
if not self._overflow_tasks:
self._aggressive_clear_in_progress = False
for pending in tuple(self._pending_enqueues.values()):
self._schedule_delayed_enqueue_retry(pending)
async def _process_log_task(
self, task: LoggingTask, sem: asyncio.Semaphore, queue: "asyncio.Queue[LoggingTask]"
@ -278,12 +288,24 @@ class LoggingWorker:
# Capture the current context when enqueueing
task: Final = LoggingTask(coroutine=coroutine, context=contextvars.copy_context())
self._enqueue_task(task)
def _enqueue_task(self, task: LoggingTask) -> None:
if self._queue is None:
return
try:
self._queue.put_nowait(task)
except asyncio.QueueFull:
# Queue is full - handle it appropriately
verbose_logger.exception("LoggingWorker queue is full")
self._pending_enqueues[id(task)] = task
self._handle_queue_full(task)
else:
self._pending_enqueues.pop(id(task), None)
def _start_overflow_task(self, coroutine: Coroutine[object, object, None]) -> None:
task: Final = asyncio.create_task(coroutine)
self._overflow_tasks.add(task)
task.add_done_callback(self._overflow_tasks.discard)
def _should_start_aggressive_clear(self) -> bool:
"""
@ -326,7 +348,7 @@ class LoggingWorker:
if self._should_start_aggressive_clear():
self._mark_aggressive_clear_started()
# Schedule clearing as async task so enqueue returns immediately (non-blocking)
asyncio.create_task(self._aggressively_clear_queue_async(task))
self._start_overflow_task(self._aggressively_clear_queue_async(task))
else:
# Cooldown active or clear in progress, schedule a delayed retry
self._schedule_delayed_enqueue_retry(task)
@ -363,7 +385,7 @@ class LoggingWorker:
delay: Final = self._calculate_retry_delay()
# Schedule the retry as a background task
asyncio.create_task(self._retry_enqueue_task(task, delay))
self._start_overflow_task(self._retry_enqueue_task(task, delay))
except RuntimeError:
# No event loop, drop the task as we can't schedule a retry
pass
@ -373,17 +395,8 @@ class LoggingWorker:
Retry enqueueing the task after delay, preserving original context.
This is called as a background task from _schedule_delayed_enqueue_retry.
"""
await asyncio.sleep(delay)
# Try to enqueue the task directly, preserving its original context
if self._queue is None:
return
try:
self._queue.put_nowait(task)
except asyncio.QueueFull:
# Still full - handle it appropriately (clear or retry again)
self._handle_queue_full(task)
await self._retry_sleep(delay)
self._enqueue_task(task)
def _extract_tasks_from_queue(self) -> list[LoggingTask]:
"""
@ -423,15 +436,10 @@ class LoggingWorker:
return
extracted_tasks: Final = self._extract_tasks_from_queue()
# Add new task to extracted tasks to process directly
if new_task is not None:
self._track_dequeued(new_task)
extracted_tasks.append(new_task)
# Process extracted tasks directly
self._enqueue_task(new_task)
if extracted_tasks:
await self._process_extracted_tasks(extracted_tasks)
await asyncio.gather(*(self._process_single_task(task) for task in extracted_tasks))
except Exception as e:
verbose_logger.exception("LoggingWorker error during aggressive clear: %s", e)
finally:
@ -439,7 +447,7 @@ class LoggingWorker:
self._aggressive_clear_in_progress = False
async def _process_single_task(self, task: LoggingTask) -> None:
"""Process a single task and mark it done."""
"""Process a task extracted from the queue and mark it done."""
if self._queue is None:
return
@ -455,17 +463,6 @@ class LoggingWorker:
self._untrack_dequeued(task)
self._queue.task_done()
async def _process_extracted_tasks(self, tasks: list[LoggingTask]) -> None:
"""
Process tasks that were extracted from the queue to make room.
Processes them concurrently without semaphore limits for maximum speed.
"""
if not tasks or self._queue is None:
return
# Process all tasks concurrently for maximum speed
await asyncio.gather(*[self._process_single_task(task) for task in tasks])
def ensure_initialized_and_enqueue(self, async_coroutine: Coroutine[object, None, None]) -> None:
"""
Ensure the logging worker is initialized and enqueue the coroutine.
@ -473,32 +470,63 @@ class LoggingWorker:
self.start()
self.enqueue(async_coroutine)
async def stop(self) -> None:
@property
def pending_count(self) -> int:
return (
(self._queue.qsize() if self._queue is not None else 0)
+ len(self._dequeued_tasks)
+ len(self._pending_enqueues)
)
async def stop(self, *, discard_pending: bool = False, timeout: float | None = None) -> None:
"""Stop the logging worker and clean up resources."""
if discard_pending and self._queue is not None:
discarded: Final = self._drain_pending(self._queue)
for task in discarded:
task["coroutine"].close()
self._queue.task_done()
if discarded:
verbose_logger.warning("LoggingWorker shutdown: %d queued callbacks discarded", len(discarded))
if self._timeout_summary_task is not None:
self._timeout_summary_task.cancel()
self._timeout_summary_task = None
self._emit_timeout_summary()
if self._worker_task is None and not self._running_tasks:
# No worker launched and no in-flight tasks to drain.
return
if discard_pending:
rejected: Final = tuple(self._pending_enqueues.values())
self._pending_enqueues.clear()
for task in rejected:
task["coroutine"].close()
if rejected:
verbose_logger.warning("LoggingWorker shutdown: %d overflow callbacks discarded", len(rejected))
tasks_to_cancel: Final[list[asyncio.Task]] = list(self._running_tasks)
if self._worker_task:
# Include the main worker loop so it stops fetching work.
tasks_to_cancel.append(self._worker_task)
tasks_to_cancel: Final = (
*self._running_tasks,
*self._overflow_tasks,
*((self._worker_task,) if self._worker_task is not None else ()),
)
if not tasks_to_cancel:
return
for task in tasks_to_cancel:
# Propagate cancellation to every pending task.
task.cancel()
# Wait for cancellation to settle; ignore errors raised during shutdown.
await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
done, pending = await asyncio.wait(tasks_to_cancel, timeout=timeout)
await asyncio.gather(*done, return_exceptions=True)
if pending:
verbose_logger.warning("LoggingWorker shutdown: %d tasks still alive after cancellation", len(pending))
self._worker_task = None
if discard_pending and self._queue is not None:
for task in self._unstarted_dequeued_tasks():
task["coroutine"].close()
self._untrack_dequeued(task)
self._queue.task_done()
if self._worker_task is not None and self._worker_task.done():
self._worker_task = None
# Drop references to completed tasks so we can restart cleanly.
self._running_tasks.clear()
self._running_tasks.difference_update(done)
async def flush(self) -> None:
"""Flush the logging queue.
@ -516,6 +544,9 @@ class LoggingWorker:
return
self.start()
await self._queue.join()
while self._overflow_tasks:
await asyncio.wait(tuple(self._overflow_tasks))
await self._queue.join()
async def clear_queue(self):
"""

View file

@ -424,6 +424,109 @@ def anthropic_image_source_to_openai_url(image_source: Mapping[str, object]) ->
return None
@dataclass(frozen=True, slots=True)
class InlineDocument:
filename: str
file_data: str
def _document_media_type(source: object) -> str:
match source:
case {"media_type": str() as media_type} if media_type:
return media_type
case _:
return "application/pdf"
def anthropic_inline_document(document: Mapping[str, object]) -> InlineDocument | None:
source: Final = document.get("source")
match source:
case {"type": "base64", "data": str() as data} if data:
title: Final = document.get("title")
return InlineDocument(
filename=title if isinstance(title, str) and title else "document.pdf",
file_data=f"data:{_document_media_type(source)};base64,{data}",
)
case _:
return None
def is_pdf_file_part(part: object) -> bool:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data.startswith("data:"):
return file_data.startswith("data:application/pdf")
case {"type": "file", "file": {"format": str() as file_format}}:
return file_format == "application/pdf"
case {"type": "file", "file": {"filename": str() as filename}}:
return filename.lower().endswith(".pdf")
case _:
return False
_CONTENT_PARTS: Final = TypeAdapter(tuple[object, ...])
def content_parts(message: Mapping[str, object]) -> Sequence[object]:
content: Final = message.get("content")
return _CONTENT_PARTS.validate_python(content) if isinstance(content, list) else ()
def _has_pdf_file_part(message: Mapping[str, object]) -> bool:
return any(is_pdf_file_part(part) for part in content_parts(message))
def messages_have_pdf_file_part(messages: Sequence[Mapping[str, object]]) -> bool:
return any(_has_pdf_file_part(message) for message in messages)
def _message_without_pdf_file_parts(message: AllMessageValues) -> AllMessageValues:
if not _has_pdf_file_part(message):
return message
kept: Final = [part for part in content_parts(message) if not is_pdf_file_part(part)]
return cast(AllMessageValues, {**message, "content": kept}) # cast-ok: same message with fewer content parts
def _is_pdf_only_user_turn(message: AllMessageValues) -> bool:
parts: Final = content_parts(message)
return message["role"] == "user" and bool(parts) and all(is_pdf_file_part(part) for part in parts)
def messages_without_pdf_file_parts(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(
_message_without_pdf_file_parts(message) for message in messages if not _is_pdf_only_user_turn(message)
)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_as_image_url(part: object) -> object:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
fields: Final = _CONTENT_PART.validate_python(part)
return {key: value for key, value in fields.items() if key != "file"} | {
"type": "image_url",
"image_url": {"url": file_data},
}
case _:
return part
def message_with_file_parts_as_image_urls(message: Mapping[str, object]) -> Mapping[str, object]:
parts: Final = content_parts(message)
if not parts:
return message
return {**message, "content": [_file_part_as_image_url(part) for part in parts]}
def _typed_message_with_file_parts_as_image_urls(message: AllMessageValues) -> AllMessageValues:
return cast(AllMessageValues, message_with_file_parts_as_image_urls(message)) # cast-ok: same message, image parts
def messages_with_file_parts_as_image_urls(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(_typed_message_with_file_parts_as_image_urls(message) for message in messages)
def _image_part_url(part: Mapping[str, object]) -> str | None:
"""The image URL carried by one content part, whichever of the three dialects wrote it."""
part_type: Final = part.get("type")

View file

@ -20,6 +20,7 @@ from litellm.constants import (
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
)
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.base_llm.realtime.transcription_protocol import RealtimeTranscriptionProtocolError
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig, RealtimeBackend
from litellm.types.llms.openai import (
OpenAIRealtimeEvents,
@ -1281,24 +1282,29 @@ class RealTimeStreaming:
verbose_logger.exception("Connection closed in backend to client send messages - %s", e)
close: Final = backend_close_from(e)
self._flush_unbilled_transcription_usage()
if self._backend_refused_session(close):
await self.log_backend_refusal(e)
else:
await self.log_messages()
await self._log_session_end(e if self._backend_refused_session(close) else None)
return close
except asyncio.CancelledError:
self._flush_unbilled_transcription_usage()
await self.log_messages()
await self._log_session_end(None)
raise
except Exception as e:
verbose_logger.exception("Error in backend to client send messages: %s", e)
self._flush_unbilled_transcription_usage()
await self.log_messages()
await self._log_session_end(None)
return BackendClose(code=1011, reason="proxy failed while relaying the upstream websocket")
def _backend_refused_session(self, close: BackendClose) -> bool:
return close.code != 1000 and not self._backend_sent_frames
async def _log_session_end(self, transport_refusal: Exception | None) -> None:
provider_refusal: Final = None if self.provider_config is None else self.provider_config.session_refusal()
refusal: Final = provider_refusal or transport_refusal
if refusal is None:
await self.log_messages()
return
await self.log_backend_refusal(refusal)
async def log_backend_refusal(self, error: Exception) -> None:
if not self.logging_obj:
return
@ -1691,7 +1697,13 @@ class RealTimeStreaming:
# ``_maybe_send_guardrail_turn_detection_update`` would retry.
if msg_type == "session.update" and self.provider_config is None:
await self._send_session_gate_once()
sent = await self._send_to_backend(message)
try:
sent = await self._send_to_backend(message)
except RealtimeTranscriptionProtocolError as e:
await self.websocket.send_text(realtime_error_event(str(e), error_type="invalid_request_error"))
if self._uses_deferred_backend_setup():
raise
continue
if guardrail_turn_detection_injected and sent:
self._guardrail_turn_detection_update_sent = True

View file

@ -4,12 +4,13 @@ post-call guardrail rewrote rather than the provider response the proxy assemble
from __future__ import annotations
from collections.abc import Sequence
from itertools import chain
from typing import Final, Literal
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import ModelResponse, ModelResponseStream
from litellm.types.utils import ModelResponse, ModelResponseStream, TextCompletionResponse
SERVED_OUTPUT_TEXTS_KEY: Final = "served_output_texts"
@ -44,6 +45,19 @@ class _ChatChoices(LiteLLMBaseModel):
choices: list[object]
class _TextCompletionChoice(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
index: int | None = None
text: str | None = None
class _TextCompletionChunk(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
choices: tuple[_TextCompletionChoice, ...]
def _as_json_object(response: object) -> dict[str, object] | None:
candidate: Final = response.model_dump() if isinstance(response, BaseModel) else response
try:
@ -108,8 +122,13 @@ def served_output_texts(response: object) -> ServedTexts | None:
def served_stream_output_texts(chunks: Sequence[object]) -> ServedTexts | None:
if chunks and all(isinstance(chunk, ModelResponseStream) for chunk in chunks):
return _chat_stream_texts(tuple(chunk for chunk in chunks if isinstance(chunk, ModelResponseStream)))
chat_chunks: Final = tuple(chunk for chunk in chunks if isinstance(chunk, ModelResponseStream))
if chunks and len(chat_chunks) == len(chunks):
return _indexed_texts_by_choice(tuple(chain.from_iterable(_chat_chunk_texts(chunk) for chunk in chat_chunks)))
if chunks and all(isinstance(chunk, TextCompletionResponse) for chunk in chunks):
return _indexed_texts_by_choice(
tuple(chain.from_iterable(_text_completion_chunk_texts(chunk) for chunk in chunks))
)
from litellm.llms.anthropic.messages.stream_assembly import (
assemble_anthropic_sse_stream,
is_anthropic_sse_stream,
@ -121,19 +140,26 @@ def served_stream_output_texts(chunks: Sequence[object]) -> ServedTexts | None:
return _chat_texts(assembled) if assembled is not None else None
def _chat_stream_choice_text(chunks: Sequence[ModelResponseStream], index: int) -> str | None:
contents: Final = tuple(
content
for chunk in chunks
def _chat_chunk_texts(chunk: ModelResponseStream) -> tuple[tuple[int, str | None], ...]:
return tuple(
(choice.index or 0, content if isinstance(content := choice.delta.content, str) else None)
for choice in chunk.choices
if (choice.index or 0) == index and isinstance(content := choice.delta.content, str)
)
return "".join(contents) if contents else None
def _chat_stream_texts(chunks: Sequence[ModelResponseStream]) -> ServedTexts | None:
choice_count: Final = max(((choice.index or 0) + 1 for chunk in chunks for choice in chunk.choices), default=0)
texts: Final = tuple(_chat_stream_choice_text(chunks, index) for index in range(choice_count))
def _text_completion_chunk_texts(chunk: object) -> tuple[tuple[int, str | None], ...]:
parsed: Final = _TextCompletionChunk.model_validate(_as_json_object(chunk))
return tuple((choice.index or 0, choice.text) for choice in parsed.choices)
def _joined_choice_text(indexed_texts: Sequence[tuple[int, str | None]], index: int) -> str | None:
texts: Final = tuple(text for text_index, text in indexed_texts if text_index == index and text is not None)
return "".join(texts) if texts else None
def _indexed_texts_by_choice(indexed_texts: Sequence[tuple[int, str | None]]) -> ServedTexts | None:
choice_count: Final = max((index + 1 for index, _ in indexed_texts), default=0)
texts: Final = tuple(_joined_choice_text(indexed_texts, index) for index in range(choice_count))
return texts if any(text is not None for text in texts) else None
@ -170,6 +196,8 @@ def _choice_with_text(choice: object, text: str | None) -> object:
if choice_obj is None or text is None:
return choice
message: Final = _as_json_object(choice_obj.get("message"))
if message is None or message.get("content") == text:
return choice
return {**choice_obj, "message": {**message, "content": text}}
if message is not None:
return choice if message.get("content") == text else {**choice_obj, "message": {**message, "content": text}}
if isinstance(choice_obj.get("text"), str):
return {**choice_obj, "text": text}
return choice

View file

@ -12,9 +12,14 @@ from typing_extensions import ReadOnly, Required
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.core_helpers import (
USAGE_COST_BILLED_PROVIDERS,
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.llm_response_utils.convert_dict_to_response import partition_annotations
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
)
@ -1352,7 +1357,7 @@ def stream_chunk_builder_text_completion(
return TextCompletionResponse(**response)
_CALCULATOR_PRICED_REPORTED_COST_PROVIDERS: Final = frozenset({LlmProviders.XAI.value})
_CALCULATOR_PRICED_REPORTED_COST_PROVIDERS: Final = frozenset({LlmProviders.XAI.value}) | USAGE_COST_BILLED_PROVIDERS
def _logged_provider(logging_obj: Optional["Logging"]) -> object:
@ -1696,10 +1701,17 @@ def stream_chunk_builder(
# Merge annotations from ALL chunks — providers may spread
# them across multiple streaming chunks or send them only in
# the final chunk.
all_annotations: Final[list] = []
all_annotations: Final[list[object]] = []
for ac in annotation_chunks:
all_annotations.extend(ac["choices"][0]["delta"]["annotations"])
response["choices"][0]["message"]["annotations"] = all_annotations
message_annotations, provider_annotations = partition_annotations(all_annotations)
if message_annotations is not None:
response["choices"][0]["message"]["annotations"] = message_annotations
if provider_annotations:
response.choices[0].message.provider_specific_fields = {
**(response.choices[0].message.provider_specific_fields or {}),
"annotations": list(provider_annotations),
}
audio_chunks: Final = [
chunk
@ -1762,7 +1774,10 @@ def stream_chunk_builder(
if combined_provider_fields:
_choice = response.choices[0]
_choice.message.provider_specific_fields = combined_provider_fields
_choice.message.provider_specific_fields = {
**(_choice.message.provider_specific_fields or {}),
**combined_provider_fields,
}
completion_output = get_content_from_model_response(response)

View file

@ -432,27 +432,30 @@ class AnthropicMessagesHandler(BaseTranslation):
message, _ = serialize_http_exception_detail(exc.detail)
return tuple(anthropic_sse_error_frames(message))
def _standalone_block_chunks(self, exc: "ModifyResponseException") -> list[bytes]:
def build_block_response(self, exc: "ModifyResponseException") -> "AnthropicMessagesResponse":
import uuid
from litellm.llms.anthropic.messages.native.fake_stream_iterator import (
FakeAnthropicMessagesStreamIterator,
)
from litellm.llms.base_llm.guardrail_translation.utils import (
blocked_response_usage,
)
from litellm.types.utils import AnthropicMessagesResponse
block_response: Final = AnthropicMessagesResponse(
return AnthropicMessagesResponse(
id=f"msg_{uuid.uuid4()}",
type="message",
role="assistant",
content=[{"type": "text", "text": exc.message}],
model=exc.model,
stop_reason="end_turn",
usage=blocked_response_usage(getattr(exc, "original_response", None)),
usage=blocked_response_usage(exc.original_response),
)
return list(FakeAnthropicMessagesStreamIterator(response=block_response))
def _standalone_block_chunks(self, exc: "ModifyResponseException") -> list[bytes]:
from litellm.llms.anthropic.messages.native.fake_stream_iterator import (
FakeAnthropicMessagesStreamIterator,
)
return list(FakeAnthropicMessagesStreamIterator(response=self.build_block_response(exc)))
def _block_continuation_chunks(
self, exc: "ModifyResponseException", responses_so_far: Sequence[object]

View file

@ -17,6 +17,7 @@ import litellm
import litellm.litellm_core_utils
import litellm.types
import litellm.types.utils
from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body
from litellm.anthropic_beta_headers_manager import (
update_request_with_filtered_beta,
)
@ -506,11 +507,20 @@ class AnthropicChatCompletion(BaseLLM):
The pair stays mutable because the streaming path rewrites it in
place (`data["stream"] = True`) before sending.
"""
request_headers, data = update_request_with_filtered_beta(
request_headers, filtered_data = update_request_with_filtered_beta(
headers=headers,
request_data=request_data,
provider=custom_llm_provider,
)
data: Final = dict(
strip_beta_gated_request_body(
filtered_data,
request_headers,
drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the handler's bare dict
model=model,
provider=custom_llm_provider,
)
)
logging_obj.pre_call(
input=messages,
api_key=api_key,

View file

@ -51,6 +51,7 @@ from litellm.types.llms.anthropic import (
ANTHROPIC_OAUTH_BETA_HEADER,
ANTHROPIC_OAUTH_TOKEN_PREFIX,
ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX,
ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_TOOL_TYPES,
AllAnthropicToolsValues,
@ -600,6 +601,20 @@ class AnthropicModelInfo(BaseLLMModelInfo):
return False
return thinking.get("type") in ("adaptive", "enabled") and thinking.get("display") == "updates"
def thinking_betas(self, thinking: AnthropicThinkingParam | None) -> tuple[str, ...]:
return (
*(
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,)
if self.is_thinking_display_updates_used(thinking)
else ()
),
*(
(ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,)
if isinstance(thinking, dict) and "block_binding" in thinking
else ()
),
)
def is_mid_conversation_tool_change_used(self, messages: Sequence[object]) -> bool:
for message in messages:
if message_field(message, "role") != "system":
@ -1180,7 +1195,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
*,
custom_llm_provider: str,
is_mid_conversation_output_config_used: bool = False,
is_thinking_display_updates_used: bool = False,
thinking: AnthropicThinkingParam | None = None,
is_mid_conversation_tool_change_used: bool = False,
) -> list[str]:
"""
@ -1217,13 +1232,10 @@ class AnthropicModelInfo(BaseLLMModelInfo):
if is_mid_conversation_output_config_used:
betas.append(ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER)
thinking_display_betas: Final = (
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else ()
)
tool_change_betas: Final = (
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else ()
)
return list(set(betas).union(thinking_display_betas, tool_change_betas))
return list(set(betas).union(self.thinking_betas(thinking), tool_change_betas))
@staticmethod
def _make_api_key_auth_header(
@ -1285,7 +1297,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
api_base: str | None = None,
use_bearer_for_custom_base: bool = False,
is_mid_conversation_output_config_used: bool = False,
is_thinking_display_updates_used: bool = False,
thinking: AnthropicThinkingParam | None = None,
is_mid_conversation_tool_change_used: bool = False,
wif_minted: bool = False,
) -> dict:
@ -1346,7 +1358,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
betas.update(user_anthropic_beta_headers)
all_betas: Final = betas.union(
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else (),
self.thinking_betas(thinking),
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else (),
)
@ -1433,7 +1445,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
auth_token=auth_token,
file_id_used=file_id_used,
is_mid_conversation_output_config_used=is_mid_conversation_output_config_used,
is_thinking_display_updates_used=self.is_thinking_display_updates_used(optional_params.get("thinking")),
thinking=optional_params.get("thinking"),
is_mid_conversation_tool_change_used=self.is_mid_conversation_tool_change_used(messages),
web_search_tool_used=web_search_tool_used,
is_vertex_request=optional_params.get("is_vertex_request", False),

View file

@ -123,6 +123,7 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
from litellm.litellm_core_utils.prompt_templates.common_utils import (
anthropic_image_source_to_openai_url,
anthropic_inline_document,
parse_tool_call_arguments,
reasoning_content_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -185,6 +186,8 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionFileObject,
ChatCompletionFileObjectFile,
ChatCompletionImageObject,
ChatCompletionImageUrlObject,
ChatCompletionRedactedThinkingBlock,
@ -203,7 +206,7 @@ from litellm.types.llms.openai import (
ChatCompletionUserMessage,
ToolMessageContentPart,
)
from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage
from litellm.types.utils import Choices, LlmProviders, ModelResponse, StreamingChoices, Usage
from litellm.utils import supports_mid_conversation_system
from .streaming_iterator import AnthropicSSEStream, AnthropicStreamWrapper
@ -362,6 +365,9 @@ class AnthropicAdapter:
_BlockT: Final = TypeVar("_BlockT", bound=Mapping[str, object])
_CLAUDE_OVER_REASONING_EFFORT_PROVIDERS: Final = frozenset({LlmProviders.KYMA.value})
class LiteLLMAnthropicMessagesAdapter:
def __init__(self):
pass
@ -495,7 +501,9 @@ class LiteLLMAnthropicMessagesAdapter:
for m in ordered_messages:
user_message: ChatCompletionUserMessage | None = None
tool_message_list: list[ChatCompletionToolMessage] = []
new_user_content_list: list[ChatCompletionTextObject | ChatCompletionImageObject] = []
new_user_content_list: list[
ChatCompletionTextObject | ChatCompletionImageObject | ChatCompletionFileObject
] = []
if m["role"] == "system":
system_message = self._translate_midturn_system_message_to_openai(m, model)
if system_message is not None:
@ -528,13 +536,8 @@ class LiteLLMAnthropicMessagesAdapter:
self._add_prompt_cache_breakpoint_if_present(content, image_obj)
)
elif content.get("type") == "document":
# Convert Anthropic document format (PDF, etc.) to OpenAI format
source = content.get("source", {})
openai_image_url = self._translate_anthropic_image_to_openai(cast(dict, source))
if openai_image_url:
image_url_obj = ChatCompletionImageUrlObject(url=openai_image_url)
doc_obj = ChatCompletionImageObject(type="image_url", image_url=image_url_obj)
doc_obj = self._translate_anthropic_document_to_openai(content)
if doc_obj is not None:
self._add_cache_control_if_applicable(content, doc_obj, model)
new_user_content_list.append(doc_obj)
elif content.get("type") == "tool_result":
@ -1128,7 +1131,8 @@ class LiteLLMAnthropicMessagesAdapter:
"""Translate Anthropic thinking to either thinking or reasoning_effort.
A Claude-family target keeps ``thinking`` verbatim, since every bridged provider serving one
speaks that param. Carrying its adaptive effort tier alongside takes two different params,
speaks that param, except Kyma, which serves Claude over the OpenAI dialect and takes only
``reasoning_effort``, so its Claude models get the non-Claude mapping. Carrying its adaptive effort tier alongside takes two different params,
because the two are not interchangeable at the provider mapping below.
Bedrock takes ``output_config`` directly, which attaches the tier and leaves ``thinking``
@ -1159,7 +1163,9 @@ class LiteLLMAnthropicMessagesAdapter:
is_bedrock_target: Final = model.startswith(("bedrock/", "converse/", "invoke/")) or self.is_bedrock_arn_model(
model
)
is_claude_target: Final = self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model)
is_claude_target: Final = (
self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model)
) and custom_llm_provider not in _CLAUDE_OVER_REASONING_EFFORT_PROVIDERS
output_config: Final = anthropic_message_request.get("output_config")
if is_claude_target:
@ -1351,7 +1357,7 @@ class LiteLLMAnthropicMessagesAdapter:
text: Final = tool_result_text_part(block)
return None if text is None else ChatCompletionTextObject(type="text", text=text)
case "image" | "document":
return self._tool_result_image_part(block.get("source"))
return self._source_image_url_part(block.get("source"))
case "tool_reference":
return ChatCompletionToolReferenceObject(
type="tool_reference", tool_name=str(block.get("tool_name") or "")
@ -1359,7 +1365,18 @@ class LiteLLMAnthropicMessagesAdapter:
case _:
return None
def _tool_result_image_part(self, image_source: object) -> ChatCompletionImageObject | None:
def _translate_anthropic_document_to_openai(
self, document: Mapping[str, object]
) -> ChatCompletionFileObject | ChatCompletionImageObject | None:
inline_document: Final = anthropic_inline_document(document)
if inline_document is None:
return self._source_image_url_part(document.get("source"))
return ChatCompletionFileObject(
type="file",
file=ChatCompletionFileObjectFile(filename=inline_document.filename, file_data=inline_document.file_data),
)
def _source_image_url_part(self, image_source: object) -> ChatCompletionImageObject | None:
if not isinstance(image_source, dict):
return None
openai_image_url = self._translate_anthropic_image_to_openai(image_source)

View file

@ -2,10 +2,21 @@ from collections.abc import AsyncIterator, Mapping, Sequence
from typing import ClassVar, Final
import httpx
from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config
from litellm.anthropic_beta_gated_fields import (
log_beta_gated_strips,
strip_beta_gated_native_request,
strip_beta_gated_system_messages,
unstripped_native_request,
)
from litellm.anthropic_beta_headers_manager import (
get_unsupported_headers,
has_beta_headers_config,
is_beta_header_supported_by_bundled_config,
)
from litellm.constants import ANTHROPIC_BILLING_METADATA_PREFIX
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
@ -20,7 +31,6 @@ from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
AnthropicMessagesRequest,
)
from litellm.types.llms.anthropic_messages.anthropic_response import (
@ -51,6 +61,8 @@ DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01"
_CALLER_CREDENTIAL_HEADERS: Final = frozenset({"x-api-key", "authorization"})
_MESSAGE_MAPPINGS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
def _carries_caller_credential(headers: Mapping[str, str]) -> bool:
"""Whether the caller sent their own Anthropic credential, in which case this passthrough
@ -85,6 +97,9 @@ def _ensure_stop_sequence(response: AnthropicMessagesResponse) -> AnthropicMessa
return response
_MESSAGES_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
_workload_identity_eligible: ClassVar[bool] = True
@ -229,7 +244,18 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
else:
return system_param
def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None:
def _strip_beta_gated_system_turns(
self, messages: Sequence[Mapping[str, object]], model: str, drop_params: object
) -> Sequence[Mapping[str, object]]:
if not has_beta_headers_config(self._resolved_provider):
return messages
gated: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=drop_params)
log_beta_gated_strips(gated.stripped, model=model, provider=self._resolved_provider)
return _MESSAGE_MAPPINGS.validate_python(gated.messages) if gated.stripped else messages
def _normalize_system_role_messages(
self, anthropic_messages_request: dict, model: str, drop_params: object
) -> None:
"""Normalize ``role: "system"`` entries in ``messages`` per the Anthropic
``/v1/messages`` contract, which the first-party API, Bedrock Invoke,
Vertex, and Azure Foundry all enforce identically.
@ -277,7 +303,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
custom_llm_provider=self.custom_llm_provider,
key="supports_mid_conversation_system",
)
else list(convert_mid_conversation_system_turns(messages[leading_count:]))
else list(
convert_mid_conversation_system_turns(
self._strip_beta_gated_system_turns(
messages[leading_count:], # pyright: ignore[reportUnknownArgumentType] # messages comes from the bare request dict
model=model,
drop_params=drop_params,
)
)
)
)
if hoisted or remaining != messages:
anthropic_messages_request["messages"] = remaining
@ -336,7 +370,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
model=model,
),
)
return self._finalize_messages_headers(headers, optional_params, messages), api_base
return self.finalize_messages_headers(headers, optional_params, messages), api_base
async def avalidate_anthropic_messages_environment(
self,
@ -376,7 +410,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
model=model,
),
)
return self._finalize_messages_headers(oauth_headers, optional_params, messages), api_base
return self.finalize_messages_headers(oauth_headers, optional_params, messages), api_base
def _require_auth_header(self, auth_header: Mapping[str, str] | None, model: str) -> Mapping[str, str]:
if auth_header is None:
@ -410,7 +444,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
return config_allows_workload_identity(self)
def _finalize_messages_headers(
def finalize_messages_headers(
self,
headers: dict, # mutable-ok: out-param
optional_params: dict, # mutable-ok: out-param
@ -619,6 +653,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if thinking_enabled or effort_enabled:
optional_params.pop("temperature", None)
def strips_beta_gated_fields_from_final_headers(self) -> bool:
"""False on legs whose final ``anthropic_beta`` list is only known after this transform (Bedrock Invoke),
which strip beta-gated fields themselves once that list exists, and on providers with no row in
``anthropic_beta_headers_config.json``, whose filter drops every beta and so cannot say which fields fail."""
return has_beta_headers_config(self._resolved_provider)
def transform_anthropic_messages_request(
self,
model: str,
@ -720,18 +760,37 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if not _has_advisor:
messages = strip_advisor_blocks_from_messages(messages)
gated: Final = (
strip_beta_gated_native_request(
headers, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare dict
anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare dict
messages, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare list
drop_params=getattr(litellm_params, "drop_params", None),
)
if self.strips_beta_gated_fields_from_final_headers()
else unstripped_native_request(anthropic_messages_optional_request_params, messages) # pyright: ignore[reportUnknownArgumentType] # the base signature types these as bare dicts
)
log_beta_gated_strips(gated.stripped, model=model, provider=self._resolved_provider)
anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest(
messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(
drop_non_string_tool_result_texts(messages, getattr(litellm_params, "drop_params", None))
drop_non_string_tool_result_texts(
_MESSAGES_ADAPTER.validate_python(gated.messages) if gated.messages_stripped else messages,
getattr(litellm_params, "drop_params", None),
)
),
max_tokens=max_tokens,
model=model,
**anthropic_messages_optional_request_params,
)
request: Final = (
{**dict(anthropic_messages_request), "thinking": gated.thinking}
if gated.thinking_stripped
else dict(anthropic_messages_request) # pyright: ignore[reportUnknownVariableType] # built from the base signature's bare dict
)
if not isinstance(tools, list):
return dict(anthropic_messages_request)
return request
return {
**anthropic_messages_request,
**request,
"tools": self.tools_for_upstream(
OBJECT_LIST_ADAPTER.validate_python(tools), model, litellm_params.drop_params
),
@ -866,17 +925,13 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if AnthropicModelInfo().is_tool_search_used(tools):
beta_values.add(get_tool_search_beta_header(custom_llm_provider))
thinking_display_betas: Final = (
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,)
if AnthropicModelInfo().is_thinking_display_updates_used(optional_params.get("thinking"))
else ()
)
thinking_betas: Final = AnthropicModelInfo().thinking_betas(optional_params.get("thinking"))
tool_change_betas: Final = (
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,)
if AnthropicModelInfo().is_mid_conversation_tool_change_used(messages)
else ()
)
all_beta_values: Final = beta_values.union(thinking_display_betas, tool_change_betas)
all_beta_values: Final = beta_values.union(thinking_betas, tool_change_betas)
dangerous_tool_use: Final = ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value
forwarded_beta_values: Final = ( # pyright: ignore[reportUnknownVariableType] # derived from the bare beta set
all_beta_values - {dangerous_tool_use}

View file

@ -13,6 +13,7 @@ from typing import Any, Final, cast
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_BOUNDARY,
TOOL_RESULT_IMAGE_PLACEHOLDER,
anthropic_inline_document,
encrypted_reasoning_signature,
responses_reasoning_items_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -106,27 +107,18 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
block: Mapping[str, object],
) -> dict[str, str] | None: # mutable-ok: API message payload
"""Convert an Anthropic document block to a Responses input_file part."""
inline_document: Final = anthropic_inline_document(block)
if inline_document is not None:
return {
"type": "input_file",
"filename": inline_document.filename,
"file_data": inline_document.file_data,
}
raw_source: Final = block.get("source")
if not isinstance(raw_source, Mapping):
return None
source: Final = cast(Mapping[str, object], raw_source) # cast-ok: untrusted client payload
source_type: Final = source.get("type")
if source_type == "base64":
data: Final = source.get("data")
if not isinstance(data, str) or not data:
return None
raw_media_type: Final = source.get("media_type")
media_type: Final = (
raw_media_type if isinstance(raw_media_type, str) and raw_media_type else "application/pdf"
)
raw_title: Final = block.get("title")
filename: Final = raw_title if isinstance(raw_title, str) and raw_title else "document.pdf"
return {
"type": "input_file",
"filename": filename,
"file_data": f"data:{media_type};base64,{data}",
}
if source_type == "url":
if source.get("type") == "url":
url: Final = source.get("url")
if not isinstance(url, str) or not url:
return None

View file

@ -661,12 +661,13 @@ def prepare_native_baseline_body(request: Mapping[str, object], model: str) -> M
TypeAdapter(tuple[str, ...]).validate_python(owned.get("additional_drop_params") or ()),
dict(options),
)
body: Final = AnthropicMessagesConfig().transform_anthropic_messages_request(
config: Final = AnthropicMessagesConfig()
body: Final = config.transform_anthropic_messages_request(
model=resolved_model,
messages=strip_provider_specific_fields_from_anthropic_messages(messages),
anthropic_messages_optional_request_params=filtered,
litellm_params=GenericLiteLLMParams(),
headers={},
headers=config.finalize_messages_headers({}, dict(filtered), list(messages)), # pyright: ignore[reportUnknownMemberType] # the shared helper takes and returns bare dicts
)
return MappingProxyType(_JSON_OBJECT.validate_python(body))
except Exception: # noqa: BLE001 # an unsupported hypothetical request is unavailable, never an inference failure

View file

@ -24,6 +24,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
GPT5_SERIES_ROUTE = "gpt5_series/"
OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh", "none"))
LOOKS_UP_BASE_MODEL: ClassVar[bool] = True
@classmethod
def _model_map_lookup_name(cls, model: str) -> str:
@ -88,7 +89,7 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config):
drop_params: bool,
api_version: str = "",
*,
declared_reasoning_efforts: Collection[str] = (),
declared_reasoning_efforts: Collection[str] | None = None,
) -> dict:
return OpenAIGPT5Config.map_openai_params(
self,

View file

@ -4,11 +4,12 @@ Azure Anthropic handler - reuses AnthropicChatCompletion logic with Azure authen
import copy
import json
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Final
import httpx
from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion
from litellm.llms.anthropic.chat.transformation import JsonModeRequest
@ -75,13 +76,22 @@ class AzureAnthropicChatCompletion(AnthropicChatCompletion):
litellm_params=litellm_params,
)
data: Final = config.transform_request(
request_body: Final[Mapping[str, object]] = config.transform_request(
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers=headers,
)
data: Final = dict(
strip_beta_gated_request_body(
request_body,
headers, # pyright: ignore[reportUnknownArgumentType] # validate_environment returns a bare dict
drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the handler's bare dict
model=model,
provider=custom_llm_provider,
)
)
## LOGGING
logging_obj.pre_call(

View file

@ -145,6 +145,8 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig):
litellm_params=litellm_params,
headers=headers,
)
self._normalize_system_role_messages(anthropic_messages_request, model=model)
self._normalize_system_role_messages(
anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None)
)
self._remove_scope_from_cache_control(anthropic_messages_request)
return anthropic_messages_request

View file

@ -107,9 +107,11 @@ class AzureAnthropicConfig(AnthropicConfig):
is_vertex_request=optional_params.get("is_vertex_request", False),
user_anthropic_beta_headers=user_anthropic_beta_headers,
mcp_server_used=mcp_server_used,
thinking=optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict
)
# Merge headers - Azure auth (api-key or Authorization) takes precedence
headers = {**anthropic_headers, **headers}
merged_beta_header: Final = {k: v for k, v in anthropic_headers.items() if k == "anthropic-beta"}
headers = {**anthropic_headers, **headers, **merged_beta_header}
# Ensure anthropic-version header is set
if "anthropic-version" not in headers:

View file

@ -1,7 +1,7 @@
import copy
import enum
import re
from collections.abc import AsyncIterator, Collection, Iterator
from collections.abc import AsyncIterator, Collection, Iterator, Mapping
from typing import TYPE_CHECKING, Final, cast
import httpx
@ -10,10 +10,11 @@ from httpx import Response
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
audio_or_image_in_message_content,
content_parts,
convert_content_list_to_str,
drop_tool_reference_parts_from_tool_messages,
filter_value_from_dict,
is_pdf_file_part,
)
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure_ai.common_utils import (
@ -46,6 +47,20 @@ NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = (
"cache_control",
)
LIST_ONLY_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_audio"})
def _is_list_only_content_part(part: object) -> bool:
match part:
case {"type": str() as part_type} if part_type in LIST_ONLY_CONTENT_PART_TYPES:
return True
case _:
return is_pdf_file_part(part)
def _has_list_only_content_part(message: Mapping[str, object]) -> bool:
return any(_is_list_only_content_part(part) for part in content_parts(message))
class AzureAIChatCompletionResponseIterator(OpenAIChatCompletionResponseIterator):
def chunk_parser(self, chunk: dict[str, object]) -> ModelResponseStream: # mutable-ok: mirrors override contract
@ -106,7 +121,7 @@ class AzureAIStudioConfig(OpenAIConfig):
model: str,
drop_params: bool,
*,
declared_reasoning_efforts: Collection[str] = (),
declared_reasoning_efforts: Collection[str] | None = None,
) -> dict[str, object]: # mutable-ok: OpenAIConfig.map_openai_params signature
if not azureAIGPT5Config.is_model_gpt_5_model(model):
return super().map_openai_params(
@ -246,7 +261,7 @@ class AzureAIStudioConfig(OpenAIConfig):
these with "Extra inputs are not permitted", which breaks multi-turn
Anthropic-format clients that echo thinking blocks back as history.
2. Transforms list content to a string.
3. If message contains an image or audio, send as is (user-intended)
3. If message contains an image, audio, or PDF file part, send as is (user-intended)
Operates on a deep copy so the caller's messages keep their thinking blocks
and provider metadata, which a fallback to another provider still needs.
@ -257,8 +272,7 @@ class AzureAIStudioConfig(OpenAIConfig):
for field in NON_OPENAI_SPEC_MESSAGE_FIELDS:
filter_value_from_dict(message_dict, field)
# Do nothing if the message contains an image or audio
if audio_or_image_in_message_content(message):
if _has_list_only_content_part(message):
continue
texts = convert_content_list_to_str(message=message)

View file

@ -5,8 +5,9 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Final
import httpx
from pydantic import TypeAdapter, ValidationError
from pydantic import ConfigDict, TypeAdapter, ValidationError
from litellm._lazy_imports import get_default_encoding
from litellm._logging import verbose_logger
from litellm.llms.azure_ai.common_utils import (
AzureFoundryModelInfo,
@ -22,10 +23,15 @@ from litellm.llms.base_llm.passthrough.transformation import (
relayed_body,
strip_leading_model_segment,
)
from litellm.llms.cohere.embed.transformation import (
CohereEmbeddingRowShape,
CohereImageTokenBilling,
cohere_embedding_response,
)
from litellm.rust_bridge.ocr.entrypoints import NATIVE_OCR_PASSTHROUGH_RESPONSE, NativeOcrPassthroughResponse
from litellm.types.llms.openai import AllMessageValues
from litellm.types.rerank import RerankResponse
from litellm.types.utils import CallTypes, ImageResponse, StandardPassThroughResponseObject
from litellm.types.utils import CallTypes, EmbeddingResponse, ImageResponse, StandardPassThroughResponseObject
if TYPE_CHECKING:
from httpx import URL, Response
@ -35,6 +41,7 @@ if TYPE_CHECKING:
EMPTY_QUERY: Final[Mapping[str, object]] = MappingProxyType({})
ECHOED_TEXTS: Final = TypeAdapter(tuple[str, ...], config=ConfigDict(hide_input_in_errors=True))
def api_version_from(litellm_params: Mapping[str, object]) -> str | None:
@ -85,9 +92,29 @@ def relay_query_params(
return MappingProxyType({**(request_query_params or EMPTY_QUERY), "api-version": api_version})
def echoed_texts(body: Mapping[str, object]) -> tuple[str, ...]:
try:
return ECHOED_TEXTS.validate_python(body.get("texts"))
except ValidationError:
return ()
def cohere_embed_relay_response(body: Mapping[str, object]) -> EmbeddingResponse:
return cohere_embedding_response(
body,
None,
get_default_encoding(),
echoed_texts(body),
CohereImageTokenBilling.FOLDED_INTO_INPUT_TOKENS,
CohereEmbeddingRowShape.TYPED,
)
FOUNDRY_RELAY_SHAPES: Final = (
RelayShape("/rerank", CallTypes.arerank, RerankResponse.model_validate),
RelayShape("/providers/blackforestlabs/v1/flux-2-pro", CallTypes.aimage_generation, ImageResponse.model_validate),
RelayShape("/providers/cohere/v1/embed", CallTypes.aembedding, cohere_embed_relay_response),
RelayShape("/providers/cohere/v2/embed", CallTypes.aembedding, cohere_embed_relay_response),
)

View file

@ -232,7 +232,7 @@ class AzureAIAgentResponsesAPIConfig(AzureAIResponsesAPIConfig):
model: str,
drop_params: bool,
*,
declared_reasoning_efforts: Collection[str],
declared_reasoning_efforts: Collection[str] | None,
) -> Mapping[str, object]:
mapped: Final = super().map_openai_params_for_deployment(
response_api_optional_params, model, drop_params, declared_reasoning_efforts=declared_reasoning_efforts

View file

@ -4,7 +4,6 @@ from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol
import httpx
from typing_extensions import Self
from litellm.types.llms.openai import OpenAIRealtimeStreamSessionEvents
from litellm.types.realtime import (
@ -24,7 +23,7 @@ else:
class RealtimeBackend(Protocol):
async def __aenter__(self) -> Self: ...
async def __aenter__(self) -> object: ...
async def __aexit__(
self,
@ -97,9 +96,15 @@ class BaseRealtimeConfig(ABC):
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return None
def session_refusal(self) -> Exception | None:
return None
async def open_backend(self, url: str, headers: Mapping[str, str]) -> RealtimeBackend | None:
return None
def wrap_backend(self, backend: RealtimeBackend) -> RealtimeBackend:
return backend
def transform_session_created_event(
self,
model: str,

View file

@ -226,6 +226,9 @@ class BaseVectorStoreConfig:
``get_complete_url`` adds a query string, like Azure, override this."""
return f"{api_base}/{encoded_vector_store_id}"
def get_vector_store_list_url(self, api_base: str) -> str:
return api_base
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
from ..chat.transformation import BaseLLMException

View file

@ -17,6 +17,8 @@ from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_beta_gated_fields import active_beta_values, log_beta_gated_strips, strip_beta_gated_thinking
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.constants import (
BEDROCK_MIN_THINKING_BUDGET_TOKENS,
RESPONSE_FORMAT_TOOL_NAME,
@ -117,6 +119,7 @@ from litellm.utils import (
)
from ..common_utils import (
DROP_DISABLED_REASONING_EFFORT_WARNING,
BedrockError,
BedrockModelInfo,
bedrock_cache_point_ttl_supported,
@ -1192,11 +1195,7 @@ class AmazonConverseConfig(BaseConfig):
),
status_code=400,
)
verbose_logger.debug(
"Dropping unsupported `reasoning_effort=%s` for Bedrock model=%s.",
value,
model,
)
verbose_logger.warning(DROP_DISABLED_REASONING_EFFORT_WARNING, "reasoning_effort", value, model)
elif param == "reasoning_effort" and isinstance(value, str):
self._handle_reasoning_effort_parameter(
model=model, reasoning_effort=value, optional_params=optional_params
@ -1828,7 +1827,16 @@ class AmazonConverseConfig(BaseConfig):
filtered_tools.append(tool)
base_model: Final = BedrockModelInfo.get_base_model(model)
client_beta_list: Final = get_anthropic_beta_from_headers(headers or {})
header_beta_list: Final = get_anthropic_beta_from_headers(headers or {})
client_beta_list: Final = header_beta_list or filter_and_transform_beta_headers(
sorted(
active_beta_values(
{},
additional_request_params.get("anthropic_beta"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # additional_request_params is a bare dict
)
),
provider="bedrock_converse",
)
eager_beta: Final = (
(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,)
if base_model.startswith("anthropic")
@ -1836,7 +1844,15 @@ class AmazonConverseConfig(BaseConfig):
and ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER not in client_beta_list
else ()
)
anthropic_beta_list: Final = [*client_beta_list, *eager_beta]
thinking_betas: Final = AnthropicModelInfo().thinking_betas(
additional_request_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # additional_request_params is a bare dict
)
missing_thinking_betas: Final = (
tuple(beta for beta in thinking_betas if beta not in client_beta_list)
if base_model.startswith("anthropic")
else ()
)
anthropic_beta_list: Final = [*client_beta_list, *eager_beta, *missing_thinking_betas]
# Only separate tools if computer use tools are actually present
if filtered_tools and self.is_computer_use_tool_used(filtered_tools, model):
@ -1906,7 +1922,8 @@ class AmazonConverseConfig(BaseConfig):
else:
computer_use_header = "computer-use-2024-10-22"
anthropic_beta_list.append(computer_use_header)
if computer_use_header not in anthropic_beta_list:
anthropic_beta_list.append(computer_use_header)
# Transform computer use tools to proper Bedrock format
transformed_computer_tools: Final = self._transform_computer_use_tools(computer_use_tools)
additional_request_params["tools"] = transformed_computer_tools
@ -2031,6 +2048,16 @@ class AmazonConverseConfig(BaseConfig):
bedrock_tools, anthropic_beta_list = self._process_tools_and_beta(
original_tools, model, headers, additional_request_params, litellm_params, deployment_model
)
requested_thinking: Final = additional_request_params.get("thinking") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # _prepare_request_params hands back a bare dict
body_betas: Final = additional_request_params.get("anthropic_beta") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # same bare dict
gated_thinking: Final = strip_beta_gated_thinking(
requested_thinking, # pyright: ignore[reportUnknownArgumentType] # same bare dict
active_beta_values({}, body_betas) | frozenset(anthropic_beta_list), # pyright: ignore[reportUnknownArgumentType] # _process_tools_and_beta hands back a bare list
drop_params=drop_params,
)
if gated_thinking.stripped:
log_beta_gated_strips(gated_thinking.stripped, model=model, provider="bedrock_converse")
additional_request_params["thinking"] = gated_thinking.thinking
# Append cachePoint to tools if cache_control_injection_points has tool_config
cache_injection_points: Final = additional_request_params.pop("cache_control_injection_points", None)

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Final
import httpx
from pydantic import TypeAdapter
from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body
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 (
@ -210,7 +211,15 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
if beta_list:
_anthropic_request["anthropic_beta"] = beta_list
return _anthropic_request
return dict(
strip_beta_gated_request_body(
_anthropic_request, # pyright: ignore[reportUnknownArgumentType] # built by the base signature's bare dict transform
{},
drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the base signature's bare dict
model=model,
provider="bedrock_invoke",
)
)
@property
def uses_async_transform_request(self) -> bool:
@ -294,7 +303,6 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
programmatic_tool_calling_used: Final = self.is_programmatic_tool_calling_used(tools)
input_examples_used: Final = self.is_input_examples_used(tools)
is_mid_conversation_output_config_used: Final = self.is_mid_conversation_output_config_used(messages)
is_thinking_display_updates_used: Final = self.is_thinking_display_updates_used(thinking)
client_beta_set: Final = set(get_anthropic_beta_from_headers(headers))
user_beta_set: Final = (
@ -312,7 +320,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
mcp_server_used=self.is_mcp_server_used(optional_params.get("mcp_servers")),
custom_llm_provider="bedrock",
is_mid_conversation_output_config_used=is_mid_conversation_output_config_used,
is_thinking_display_updates_used=is_thinking_display_updates_used,
thinking=thinking,
)
beta_set.update(auto_betas)

View file

@ -72,6 +72,7 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig):
),
code_execution_tool_used=self.is_code_execution_tool_used(tools=optional_params.get("tools")),
container_with_skills_used=self.is_container_with_skills_used(optional_params=optional_params),
thinking=optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict
)
anthropic_headers["anthropic-workspace-id"] = workspace_id
return {**headers, **anthropic_headers}

View file

@ -1331,6 +1331,12 @@ def _mantle_api_base_from_env() -> str | None:
return next((base[: -len(suffix)] for suffix in _MANTLE_OPENAI_BASE_SUFFIXES if base.endswith(suffix)), base)
DROP_DISABLED_REASONING_EFFORT_WARNING: Final = (
"Dropping %s=%r for Bedrock model=%r (drop_params=True) because the model map marks that level unsupported. "
"Send a level the model supports."
)
def bedrock_reasoning_effort_disabled(model: str, effort: str) -> bool:
from litellm.utils import is_explicitly_disabled_factory

View file

@ -9,9 +9,11 @@ from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Final, get_args, overload
import httpx
from pydantic import TypeAdapter
import litellm
from litellm.constants import BEDROCK_EMBEDDING_PROVIDERS_LITERAL
from litellm.litellm_core_utils.hidden_params import set_hidden_param
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.cohere.embed.handler import embedding as cohere_embedding
from litellm.llms.custom_httpx.http_handler import (
@ -36,7 +38,7 @@ from ..base_aws_llm import (
pop_aws_auth_params,
run_aws_signing,
)
from ..common_utils import BedrockError
from ..common_utils import BedrockError, split_bedrock_region_path
from .amazon_nova_transformation import AmazonNovaEmbeddingConfig
from .amazon_titan_g1_transformation import AmazonTitanG1Config
from .amazon_titan_multimodal_transformation import (
@ -64,12 +66,21 @@ def _sign_get_request(
return request.prepare()
_OPTIONAL_STR: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
def _without_region_path(model: str) -> str:
region, model_id = split_bedrock_region_path(model)
return model if region is None else model_id
class BedrockEmbedding(BaseAWSLLM):
@overload
def _load_credentials(
self,
optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place
bearer_token: None = None,
region_from_model: str | None = None,
) -> tuple[Credentials, str]: ...
@overload
@ -77,15 +88,18 @@ class BedrockEmbedding(BaseAWSLLM):
self,
optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place
bearer_token: str,
region_from_model: str | None = None,
) -> tuple[None, str]: ...
def _load_credentials(
self,
optional_params: dict,
bearer_token: str | None = None,
region_from_model: str | None = None,
) -> tuple[Credentials | None, str]:
auth_params: Final = pop_aws_auth_params(optional_params)
aws_region_name = optional_params.pop("aws_region_name", None)
explicit_region_name: Final = optional_params.pop("aws_region_name", None)
aws_region_name = region_from_model if explicit_region_name is None else explicit_region_name
### SET REGION NAME ###
if aws_region_name is None:
@ -184,14 +198,17 @@ class BedrockEmbedding(BaseAWSLLM):
provider: BEDROCK_EMBEDDING_PROVIDERS_LITERAL,
is_async_invoke: bool | None = False,
batch_data: list[dict] | None = None,
aws_region_name: str | None = None,
) -> EmbeddingResponse | None:
"""
Transforms the response from the Bedrock embedding provider to the OpenAI format.
"""
returned_response: EmbeddingResponse | None = None
bedrock_model: Final = _without_region_path(model)
is_async_job: Final = bool(is_async_invoke) and len(response_list) == 1 and "invocationArn" in response_list[0]
# Handle async invoke responses (single response with invocationArn)
if is_async_invoke and len(response_list) == 1 and "invocationArn" in response_list[0]:
if is_async_job:
if provider == "twelvelabs":
returned_response = TwelveLabsMarengoEmbeddingConfig().transform_async_invoke_response(
response=response_list[0], model=model
@ -227,15 +244,15 @@ class BedrockEmbedding(BaseAWSLLM):
)
else:
# Handle regular invoke responses
if model == "amazon.titan-embed-image-v1":
if bedrock_model == "amazon.titan-embed-image-v1":
returned_response = AmazonTitanMultimodalEmbeddingG1Config().transform_response(
response_list=response_list, model=model, batch_data=batch_data
)
elif model == "amazon.titan-embed-text-v1":
elif bedrock_model == "amazon.titan-embed-text-v1":
returned_response = AmazonTitanG1Config().transform_response(response_list=response_list, model=model)
elif model == "amazon.titan-embed-text-v2:0":
elif bedrock_model == "amazon.titan-embed-text-v2:0":
returned_response = AmazonTitanV2Config().transform_response(response_list=response_list, model=model)
elif model == "amazon.titan-embed-g1-text-02":
elif bedrock_model == "amazon.titan-embed-g1-text-02":
returned_response = AmazonTitanG1Config().transform_response(response_list=response_list, model=model)
elif provider == "twelvelabs":
returned_response = TwelveLabsMarengoEmbeddingConfig().transform_response(
@ -251,6 +268,8 @@ class BedrockEmbedding(BaseAWSLLM):
##########################################################
if returned_response is None:
raise Exception(f"Unable to map model response to known provider format. model={model}")
if aws_region_name is not None and not is_async_job:
set_hidden_param(returned_response, "region_name", aws_region_name)
return returned_response
def _single_func_embeddings(
@ -319,6 +338,7 @@ class BedrockEmbedding(BaseAWSLLM):
provider=provider,
is_async_invoke=is_async_invoke,
batch_data=batch_data,
aws_region_name=aws_region_name,
)
async def _async_single_func_embeddings(
@ -390,6 +410,7 @@ class BedrockEmbedding(BaseAWSLLM):
provider=provider,
is_async_invoke=is_async_invoke,
batch_data=batch_data,
aws_region_name=aws_region_name,
)
def embeddings(
@ -409,26 +430,29 @@ class BedrockEmbedding(BaseAWSLLM):
litellm_params: dict,
api_key: str | None = None,
) -> EmbeddingResponse:
model_id_param: Final = _OPTIONAL_STR.validate_python(optional_params.pop("model_id", None)) or None
has_async_invoke: Final = "async_invoke/" in model
model_without_async_route: Final = model.replace("async_invoke/", "", 1)
path_region: Final = split_bedrock_region_path(model_without_async_route)[0] if model_id_param is None else None
bedrock_model: Final = _without_region_path(model_without_async_route)
credentials, aws_region_name = self._load_credentials(
optional_params, bearer_token=bedrock_bearer_token(api_key)
optional_params,
bearer_token=bedrock_bearer_token(api_key),
region_from_model=self._get_aws_region_from_model_arn(model_id_param) or path_region,
)
### TRANSFORMATION ###
unencoded_model_id: Final = optional_params.pop("model_id", None) or model # default to model if not passed
unencoded_model_id: Final = model_id_param or bedrock_model
modelId: Final = urllib.parse.quote(unencoded_model_id, safe="")
aws_region_name = self._get_aws_region_name(
optional_params={"aws_region_name": aws_region_name},
model=model,
model=bedrock_model,
model_id=unencoded_model_id,
)
# Check async invoke needs to be used
has_async_invoke: Final = "async_invoke/" in model
if has_async_invoke:
model = model.replace("async_invoke/", "", 1)
provider: Final = self.get_bedrock_embedding_provider(model)
provider: Final = self.get_bedrock_embedding_provider(bedrock_model)
if provider is None:
raise Exception(
f"Unable to determine bedrock embedding provider for model: {model}. "
f"Unable to determine bedrock embedding provider for model: {bedrock_model}. "
f"Supported providers: {list(get_args(BEDROCK_EMBEDDING_PROVIDERS_LITERAL))}"
)
inference_params = copy.deepcopy(optional_params)
@ -441,9 +465,9 @@ class BedrockEmbedding(BaseAWSLLM):
batch_data: list | None = None
if provider == "cohere":
data = BedrockCohereEmbeddingConfig().transform_request(
model=model, input=input, inference_params=inference_params
model=bedrock_model, input=input, inference_params=inference_params
)
elif provider == "amazon" and model in [
elif provider == "amazon" and bedrock_model in [
"amazon.titan-embed-image-v1",
"amazon.titan-embed-text-v1",
"amazon.titan-embed-text-v2:0",
@ -451,28 +475,28 @@ class BedrockEmbedding(BaseAWSLLM):
]:
batch_data = []
for i in input:
if model == "amazon.titan-embed-image-v1":
if bedrock_model == "amazon.titan-embed-image-v1":
transformed_request: AmazonEmbeddingRequest = (
AmazonTitanMultimodalEmbeddingG1Config().transform_request(
input=i, inference_params=inference_params
)
)
elif model == "amazon.titan-embed-text-v1":
elif bedrock_model == "amazon.titan-embed-text-v1":
transformed_request = AmazonTitanG1Config().transform_request(
input=i, inference_params=inference_params
)
elif model == "amazon.titan-embed-text-v2:0":
elif bedrock_model == "amazon.titan-embed-text-v2:0":
transformed_request = AmazonTitanV2Config().transform_request(
input=i, inference_params=inference_params
)
elif model == "amazon.titan-embed-g1-text-02":
elif bedrock_model == "amazon.titan-embed-g1-text-02":
transformed_request = AmazonTitanG1Config().transform_request(
input=i, inference_params=inference_params
)
else:
raise Exception(
"Unmapped model. Received={}. Expected={}".format(
model,
bedrock_model,
[
"amazon.titan-embed-image-v1",
"amazon.titan-embed-text-v1",
@ -485,7 +509,7 @@ class BedrockEmbedding(BaseAWSLLM):
elif provider == "twelvelabs":
batch_data = []
for i in input:
twelvelabs_request = TwelveLabsMarengoEmbeddingConfig(model=model).transform_request(
twelvelabs_request = TwelveLabsMarengoEmbeddingConfig(model=bedrock_model).transform_request(
input=i,
inference_params=inference_params,
async_invoke_route=has_async_invoke,
@ -527,7 +551,7 @@ class BedrockEmbedding(BaseAWSLLM):
extra_headers=extra_headers,
endpoint_url=endpoint_url,
aws_region_name=aws_region_name,
model=model,
model=model_without_async_route,
logging_obj=logging_obj,
api_key=api_key,
provider=provider,
@ -541,7 +565,7 @@ class BedrockEmbedding(BaseAWSLLM):
extra_headers=extra_headers,
endpoint_url=endpoint_url,
aws_region_name=aws_region_name,
model=model,
model=model_without_async_route,
logging_obj=logging_obj,
api_key=api_key,
provider=provider,
@ -570,8 +594,9 @@ class BedrockEmbedding(BaseAWSLLM):
## ROUTING ##
# Convert CaseInsensitiveDict to regular dict for httpx compatibility
headers_for_request: Final = dict(prepped.headers) if hasattr(prepped, "headers") else {}
set_hidden_param(model_response, "region_name", aws_region_name)
return cohere_embedding(
model=model,
model=model_without_async_route,
input=input,
model_response=model_response,
logging_obj=logging_obj,

View file

@ -7,6 +7,7 @@ from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_beta_gated_fields import log_beta_gated_strips, strip_beta_gated_native_request
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.constants import (
BEDROCK_MIN_THINKING_BUDGET_TOKENS,
@ -549,6 +550,9 @@ class AmazonAnthropicClaudeMessagesConfig(
"edits": retained_edits,
}
def strips_beta_gated_fields_from_final_headers(self) -> bool:
return False
def _get_bedrock_invoke_anthropic_beta_headers(
self,
model: str,
@ -584,9 +588,7 @@ class AmazonAnthropicClaudeMessagesConfig(
),
custom_llm_provider="bedrock",
is_mid_conversation_output_config_used=is_mid_conversation_output_config_used,
is_thinking_display_updates_used=anthropic_model_info.is_thinking_display_updates_used(
anthropic_messages_request.get("thinking")
),
thinking=anthropic_messages_request.get("thinking"),
is_mid_conversation_tool_change_used=anthropic_model_info.is_mid_conversation_tool_change_used(
outgoing_messages_typed
),
@ -757,7 +759,9 @@ class AmazonAnthropicClaudeMessagesConfig(
),
"display": "updates",
}
self._normalize_system_role_messages(anthropic_messages_request, model=model)
self._normalize_system_role_messages(
anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None)
)
#########################################################
############## BEDROCK Invoke SPECIFIC TRANSFORMATION ###
#########################################################
@ -825,6 +829,20 @@ class AmazonAnthropicClaudeMessagesConfig(
if filtered_betas:
anthropic_messages_request["anthropic_beta"] = filtered_betas
final_request: Final = OBJECT_MAPPING_ADAPTER.validate_python(anthropic_messages_request) # pyright: ignore[reportUnknownArgumentType] # built from the base signature's bare dict
gated: Final = strip_beta_gated_native_request(
{},
final_request,
final_request.get("messages"),
body_betas=filtered_betas,
drop_params=getattr(litellm_params, "drop_params", None),
)
log_beta_gated_strips(gated.stripped, model=model, provider="bedrock_invoke")
if gated.thinking_stripped:
anthropic_messages_request["thinking"] = gated.thinking
if gated.messages_stripped:
anthropic_messages_request["messages"] = list(gated.messages)
outbound_tools: Final = tools_without_eager_input_streaming(anthropic_messages_request)
if outbound_tools is not None:
anthropic_messages_request["tools"] = outbound_tools

View file

@ -52,6 +52,7 @@ from litellm.llms.base_llm.responses.codex_compat import drop_unsupported_tools,
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import (
BEDROCK_CHAT_COMPLETIONS_ROUTE_PREFIX,
DROP_DISABLED_REASONING_EFFORT_WARNING,
BedrockError,
bedrock_reasoning_effort_disabled,
bedrock_supports_openai_responses,
@ -173,7 +174,7 @@ def _without_disabled_reasoning_effort(
),
status_code=400,
)
verbose_logger.debug("Dropping unsupported `reasoning.effort=%s` for Bedrock model=%s.", effort, model)
verbose_logger.warning(DROP_DISABLED_REASONING_EFFORT_WARNING, "reasoning.effort", effort, model)
rest: Final = {key: value for key, value in reasoning.items() if key != "effort"}
without_reasoning: Final = {key: value for key, value in params.items() if key != "reasoning"}
return {**without_reasoning, "reasoning": rest} if rest else without_reasoning

View file

@ -10,11 +10,13 @@ Convers
Docs - https://docs.cohere.com/v2/reference/embed
"""
from collections.abc import Iterable, Mapping, Sized
from typing import Final, Protocol, cast
from collections.abc import Iterable, Iterator, Mapping, Sequence, Sized
from enum import Enum
from itertools import chain, starmap
from typing import Final, Protocol, TypeAlias, cast
import httpx
from pydantic import ConfigDict, TypeAdapter
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
import litellm
from litellm import COHERE_DEFAULT_EMBEDDING_INPUT_TYPE
@ -32,14 +34,117 @@ from litellm.utils import is_base64_encoded
from ..common_utils import CohereError
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
_EMBEDDINGS_BY_TYPE: Final = TypeAdapter(Mapping[str, Iterable[object]], config=ConfigDict(hide_input_in_errors=True))
_DECODED_JSON: Final = TypeAdapter(object)
class _SupportsEncode(Protocol):
"""Tokenizer handle: the embedding usage path only encodes text to measure its token length."""
def encode(self, text: str, /) -> Sized: ...
def encode_ordinary(self, text: str, /) -> Sized: ...
class CohereImageTokenBilling(Enum):
SEPARATE_FROM_INPUT_TOKENS = "separate_from_input_tokens"
FOLDED_INTO_INPUT_TOKENS = "folded_into_input_tokens"
class CohereEmbeddingRowShape(Enum):
OPENAI = "openai"
TYPED = "typed"
class _CohereBilledUnits(BaseModel):
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
input_tokens: int | None = None
image_tokens: int | None = None
images: int | None = None
class _CohereEmbedMeta(BaseModel):
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
billed_units: _CohereBilledUnits = Field(default_factory=_CohereBilledUnits)
_CohereEmbeddings: TypeAlias = Mapping[str, Sequence[object]] | Sequence[object]
class _CohereEmbedBody(BaseModel):
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
embeddings: _CohereEmbeddings
meta: _CohereEmbedMeta = Field(default_factory=_CohereEmbedMeta)
def _embedding_rows(vectors: Sequence[object]) -> Iterator[Mapping[str, object]]:
return ({"object": "embedding", "index": index, "embedding": vector} for index, vector in enumerate(vectors))
def _typed_embedding_rows(embedding_type: str, vectors: Sequence[object]) -> Iterator[Mapping[str, object]]:
return ({**row, "type": embedding_type} for row in _embedding_rows(vectors))
def _response_rows(embeddings: _CohereEmbeddings, row_shape: CohereEmbeddingRowShape) -> Iterable[Mapping[str, object]]:
if not isinstance(embeddings, Mapping):
return _embedding_rows(embeddings)
match row_shape:
case CohereEmbeddingRowShape.OPENAI:
return chain.from_iterable(map(_embedding_rows, embeddings.values()))
case CohereEmbeddingRowShape.TYPED:
return chain.from_iterable(starmap(_typed_embedding_rows, embeddings.items()))
def _prompt_usage(prompt_tokens: int, prompt_tokens_details: PromptTokensDetailsWrapper | None) -> Usage:
return Usage(
prompt_tokens=prompt_tokens,
completion_tokens=0,
total_tokens=prompt_tokens,
prompt_tokens_details=prompt_tokens_details,
)
def _billed_usage(billed_units: _CohereBilledUnits, image_billing: CohereImageTokenBilling) -> Usage:
match image_billing:
case CohereImageTokenBilling.SEPARATE_FROM_INPUT_TOKENS:
image_units: Final = (billed_units.image_tokens or 0) + (billed_units.images or 0)
return _prompt_usage(
(billed_units.input_tokens or 0) + image_units,
PromptTokensDetailsWrapper(
text_tokens=billed_units.input_tokens,
image_tokens=image_units or None,
image_count=billed_units.images,
),
)
case CohereImageTokenBilling.FOLDED_INTO_INPUT_TOKENS:
return _prompt_usage(billed_units.input_tokens or 0, None)
def _cohere_embed_usage(
billed_units: _CohereBilledUnits,
input_texts: Iterable[str],
encoding: _SupportsEncode,
image_billing: CohereImageTokenBilling,
) -> Usage:
if billed_units.input_tokens is None and billed_units.image_tokens is None and billed_units.images is None:
return _prompt_usage(sum(len(encoding.encode_ordinary(text)) for text in input_texts), None)
return _billed_usage(billed_units, image_billing)
def cohere_embedding_response(
body: Mapping[str, object],
model: str | None,
encoding: _SupportsEncode,
input_texts: Iterable[str],
image_billing: CohereImageTokenBilling,
row_shape: CohereEmbeddingRowShape,
) -> EmbeddingResponse:
cohere_body: Final = _CohereEmbedBody.model_validate(body)
return EmbeddingResponse(
model=model,
data=list(_response_rows(cohere_body.embeddings, row_shape)),
usage=_cohere_embed_usage(cohere_body.meta.billed_units, input_texts, encoding, image_billing),
)
class CohereEmbeddingConfig(BaseEmbeddingConfig):
@ -145,44 +250,16 @@ class CohereEmbeddingConfig(BaseEmbeddingConfig):
),
)
def _calculate_usage(self, input: list[str], encoding: _SupportsEncode, meta: dict) -> Usage:
input_tokens = 0
text_tokens: Final[int | None] = meta.get("billed_units", {}).get("input_tokens")
image_tokens: Final[int | None] = meta.get("billed_units", {}).get("images")
prompt_tokens_details: PromptTokensDetailsWrapper | None = None
if image_tokens is None and text_tokens is None:
for text in input:
input_tokens += len(encoding.encode(text))
else:
prompt_tokens_details = PromptTokensDetailsWrapper(
image_tokens=image_tokens,
text_tokens=text_tokens,
)
if image_tokens:
input_tokens += image_tokens
if text_tokens:
input_tokens += text_tokens
return Usage(
prompt_tokens=input_tokens,
completion_tokens=0,
total_tokens=input_tokens,
prompt_tokens_details=prompt_tokens_details,
)
def _transform_response(
def transform_native_embed_response(
self,
response: httpx.Response,
api_key: str | None,
logging_obj: LiteLLMLoggingObj,
data: dict | CohereEmbeddingRequest,
model_response: EmbeddingResponse,
data: Mapping[str, object] | CohereEmbeddingRequest,
model: str,
encoding: _SupportsEncode,
input: list,
input: Sequence[str],
row_shape: CohereEmbeddingRowShape,
) -> EmbeddingResponse:
response_json: Final = _DECODED_JSON.validate_python(response.json())
## LOGGING
@ -192,38 +269,15 @@ class CohereEmbeddingConfig(BaseEmbeddingConfig):
additional_args={"complete_input_dict": data},
original_response=response_json,
)
"""
response
{
'object': "list",
'data': [
]
'model',
'usage'
}
"""
payload: Final = _JSON_OBJECT.validate_python(response_json)
embeddings: Final = _EMBEDDINGS_BY_TYPE.validate_python(payload["embeddings"])
output_data: Final = []
for k, embedding_list in embeddings.items():
for idx, embedding in enumerate(embedding_list):
output_data.append({"object": "embedding", "index": idx, "embedding": embedding})
model_response.object = "list"
model_response.data = output_data
model_response.model = model
input_tokens = 0
for text in input:
input_tokens += len(encoding.encode(text))
setattr(
model_response,
"usage",
self._calculate_usage(input, encoding, dict(_JSON_OBJECT.validate_python(payload.get("meta", {})))),
return cohere_embedding_response(
_JSON_OBJECT.validate_python(response_json),
model,
encoding,
input,
CohereImageTokenBilling.SEPARATE_FROM_INPUT_TOKENS,
row_shape,
)
return model_response
def transform_embedding_response(
self,
model: str,
@ -235,15 +289,15 @@ class CohereEmbeddingConfig(BaseEmbeddingConfig):
optional_params: dict,
litellm_params: dict,
) -> EmbeddingResponse:
return self._transform_response(
return self.transform_native_embed_response(
response=raw_response,
api_key=api_key,
logging_obj=logging_obj,
data=request_data,
model_response=model_response,
model=model,
encoding=litellm.encoding,
input=logging_obj.model_call_details["input"],
row_shape=CohereEmbeddingRowShape.OPENAI,
)
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:

View file

@ -50,7 +50,11 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
SUBTITLE_RESPONSE_FORMATS,
synthesize_subtitle_document,
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, set_provider_response_headers_in_hidden_params
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
qualify_agentic_followup_model,
set_provider_response_headers_in_hidden_params,
)
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS
from litellm.litellm_core_utils.get_llm_provider_logic import get_provider_affinity_header
@ -2577,6 +2581,7 @@ class BaseLLMHTTPHandler:
"preset_cache_key": None,
"stream_response": {},
"model_info": kwargs.get("model_info"),
**litellm_params.per_second_pricing(),
**vertex_location_params,
**anthropic_messages_optional_request_params,
},
@ -6275,9 +6280,7 @@ class BaseLLMHTTPHandler:
if patch.messages is None:
raise ValueError("Agentic loop plan missing patched messages")
full_model_name = patch.model or model
if "/" not in full_model_name:
full_model_name = f"{custom_llm_provider}/{full_model_name}"
full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider)
optional_params_for_followup: Final = dict(optional_params)
optional_params_for_followup.update(patch.optional_params)
@ -6920,7 +6923,7 @@ class BaseLLMHTTPHandler:
try:
ssl_context: Final = realtime_ssl_for_url(url)
provider_backend: Final = await provider_config.open_backend(url, headers)
backend_ws: Final = (
backend_ws: Final = provider_config.wrap_backend(
provider_backend
if provider_backend is not None
else await self._open_realtime_backend_ws(websockets, url, headers, ssl_context)
@ -6958,9 +6961,7 @@ class BaseLLMHTTPHandler:
await backend_ws.send(_session_config)
realtime_streaming.session_configuration_request = _session_config
# For providers that defer setup until client session.update, optionally
# send synthetic session.created to unblock clients waiting on connect.
if not provider_config.requires_session_configuration():
if not _session_config:
synthetic_session: Final = provider_config.transform_session_created_event(
model=model,
logging_session_id=logging_obj.litellm_trace_id,
@ -11149,7 +11150,7 @@ class BaseLLMHTTPHandler:
litellm_params=dict(litellm_params),
)
url: Final = api_base
url: Final = vector_store_provider_config.get_vector_store_list_url(api_base)
params: Final[dict[str, object]] = {}
if after is not None:
@ -11228,7 +11229,7 @@ class BaseLLMHTTPHandler:
litellm_params=dict(litellm_params),
)
url: Final = api_base
url: Final = vector_store_provider_config.get_vector_store_list_url(api_base)
params: Final[dict[str, object]] = {}
if after is not None:

View file

@ -10,6 +10,7 @@ from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
from ..common_utils import resolve_dashscope_openai_api_base
class DashScopeChatConfig(OpenAIGPTConfig):
@ -55,11 +56,9 @@ class DashScopeChatConfig(OpenAIGPTConfig):
def _get_openai_compatible_provider_info(
self, api_base: str | None, api_key: str | None
) -> tuple[str | None, str | None]:
api_base = (
api_base or get_secret_str("DASHSCOPE_API_BASE") or "https://dashscope.aliyuncs.com/compatible-mode/v1"
)
resolved_api_base: Final = resolve_dashscope_openai_api_base(api_base)
dynamic_api_key: Final = api_key or get_secret_str("DASHSCOPE_API_KEY")
return api_base, dynamic_api_key
return resolved_api_base, dynamic_api_key
def get_openai_compatible_provider_info(
self,
@ -69,7 +68,7 @@ class DashScopeChatConfig(OpenAIGPTConfig):
return self._get_openai_compatible_provider_info(api_base, api_key)
def _resolve_chat_api_base(self, api_base: str | None) -> str:
return api_base or "https://dashscope.aliyuncs.com/compatible-mode/v1"
return resolve_dashscope_openai_api_base(api_base)
def get_complete_url(
self,

View file

@ -2,6 +2,7 @@
Common utilities for the DashScope LLM provider.
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from urllib.parse import urlparse
@ -19,10 +20,123 @@ if TYPE_CHECKING:
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
DASHSCOPE_CHAT_COMPATIBLE_PATH: Final = "/compatible-mode/v1"
DASHSCOPE_RERANK_PATH: Final = "/compatible-api/v1/reranks"
DASHSCOPE_ANTHROPIC_PATH: Final = "/apps/anthropic"
DASHSCOPE_NATIVE_V1_PATH: Final = "/api/v1"
DASHSCOPE_COMPATIBLE_API_PATH: Final = "/compatible-api/v1"
DASHSCOPE_RERANK_PATH: Final = f"{DASHSCOPE_COMPATIBLE_API_PATH}/reranks"
DASHSCOPE_IMAGE_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
DASHSCOPE_DEFAULT_ORIGIN: Final = "https://dashscope.aliyuncs.com"
_STANDARD_DASHSCOPE_HOSTS: Final = frozenset(
{
"dashscope.aliyuncs.com",
"dashscope-intl.aliyuncs.com",
"dashscope-us.aliyuncs.com",
}
)
_WORKSPACE_HOST_SUFFIX: Final = ".maas.aliyuncs.com"
_EXCLUDED_WORKSPACE_PREFIXES: Final = ("trial.", "token-plan.")
_PROTOCOL_PATH_PREFIXES: Final = (
DASHSCOPE_CHAT_COMPATIBLE_PATH,
DASHSCOPE_ANTHROPIC_PATH,
DASHSCOPE_NATIVE_V1_PATH,
DASHSCOPE_COMPATIBLE_API_PATH,
)
def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None:
@dataclass(frozen=True, slots=True)
class DashScopeEndpoints:
origin: str
openai: str
anthropic: str
native: str
def _is_dashscope_host(host: str) -> bool:
if host in _STANDARD_DASHSCOPE_HOSTS:
return True
return host.endswith(_WORKSPACE_HOST_SUFFIX) and not host.startswith(_EXCLUDED_WORKSPACE_PREFIXES)
def _dashscope_origin(api_base: str | None) -> str | None:
if not api_base:
return None
parsed: Final = urlparse(api_base)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
if parsed.query or parsed.fragment:
return None
host: Final = (parsed.hostname or "").lower()
if not _is_dashscope_host(host):
return None
path: Final = parsed.path.rstrip("/")
if path and not any(path == prefix or path.startswith(f"{prefix}/") for prefix in _PROTOCOL_PATH_PREFIXES):
return None
return f"{parsed.scheme}://{parsed.netloc}"
def resolve_dashscope_endpoints(api_base: str | None) -> DashScopeEndpoints | None:
"""
Derive DashScope's protocol-specific bases from any standard DashScope base.
Accepts the domain root, an OpenAI-compatible base, an Anthropic Messages
base, or a native `/api/v1` base for `dashscope.aliyuncs.com`,
`dashscope-intl.aliyuncs.com`, `dashscope-us.aliyuncs.com`, and
`{workspace}.{region}.maas.aliyuncs.com`. Returns None for any other host
or path so callers keep the caller-supplied base untouched.
"""
origin: Final = _dashscope_origin(api_base)
if origin is None:
return None
return DashScopeEndpoints(
origin=origin,
openai=f"{origin}{DASHSCOPE_CHAT_COMPATIBLE_PATH}",
anthropic=f"{origin}{DASHSCOPE_ANTHROPIC_PATH}",
native=f"{origin}{DASHSCOPE_NATIVE_V1_PATH}",
)
def is_dashscope_native_endpoint_url(api_base: str) -> bool:
return urlparse(api_base).path.rstrip("/").startswith(f"{DASHSCOPE_NATIVE_V1_PATH}/")
def resolve_dashscope_openai_api_base(
api_base: str | None,
env_var: str = "DASHSCOPE_API_BASE",
default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_CHAT_COMPATIBLE_PATH}",
) -> str:
candidate: Final = api_base or get_secret_str(env_var)
if candidate and is_dashscope_native_endpoint_url(candidate):
return candidate
endpoints: Final = resolve_dashscope_endpoints(candidate)
if endpoints is not None:
return endpoints.openai
return candidate or default
def resolve_dashscope_anthropic_api_base(
api_base: str | None,
env_var: str = "DASHSCOPE_API_BASE",
default: str = f"{DASHSCOPE_DEFAULT_ORIGIN}{DASHSCOPE_ANTHROPIC_PATH}",
) -> str:
candidate: Final = api_base or get_secret_str(env_var)
endpoints: Final = resolve_dashscope_endpoints(candidate)
if endpoints is not None:
return endpoints.anthropic
return candidate or default
def dashscope_base_selects_anthropic_messages(api_base: str | None) -> bool:
candidate: Final = api_base or get_secret_str("DASHSCOPE_API_BASE")
if not candidate:
return False
path: Final = urlparse(candidate).path.rstrip("/")
if path.endswith(DASHSCOPE_ANTHROPIC_PATH) or f"{DASHSCOPE_ANTHROPIC_PATH}/" in path:
return True
return not path and resolve_dashscope_endpoints(candidate) is not None
def _chat_shaped_aliyun_origin(api_base: str | None) -> str | None:
if api_base is None:
return None
parsed: Final = urlparse(api_base)
@ -30,14 +144,24 @@ def _rerank_base_for_chat_shaped_base(api_base: str | None) -> str | None:
on_aliyun_host: Final = host == "aliyuncs.com" or host.endswith(".aliyuncs.com")
if not on_aliyun_host or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH:
return None
return f"{parsed.scheme}://{parsed.netloc}{DASHSCOPE_RERANK_PATH}"
return f"{parsed.scheme}://{parsed.netloc}"
def _is_compatible_api_base(api_base: str) -> bool:
path: Final = urlparse(api_base).path.rstrip("/")
return path == DASHSCOPE_COMPATIBLE_API_PATH or path.startswith(f"{DASHSCOPE_COMPATIBLE_API_PATH}/")
def resolve_dashscope_family_rerank_api_base(api_base: str | None, env_var: str, default_rerank_base: str) -> str:
remapped: Final = _rerank_base_for_chat_shaped_base(api_base)
if api_base is not None and remapped is None:
if api_base is None:
return get_secret_str(env_var) or default_rerank_base
if _is_compatible_api_base(api_base):
return api_base
return get_secret_str(env_var) or remapped or default_rerank_base
chat_origin: Final = _chat_shaped_aliyun_origin(api_base)
if chat_origin is not None:
return get_secret_str(env_var) or f"{chat_origin}{DASHSCOPE_RERANK_PATH}"
endpoints: Final = resolve_dashscope_endpoints(api_base)
return f"{endpoints.origin}{DASHSCOPE_RERANK_PATH}" if endpoints is not None else api_base
def get_dashscope_family_embedding_config(custom_llm_provider: str) -> "BaseEmbeddingConfig":

View file

@ -25,7 +25,7 @@ from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
from litellm.types.utils import EmbeddingResponse, Usage
from ..common_utils import DashScopeError
from ..common_utils import DashScopeError, resolve_dashscope_openai_api_base
DEFAULT_API_BASE: Final = "https://dashscope.aliyuncs.com/compatible-mode/v1"
@ -91,7 +91,7 @@ class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
return resolved_api_key
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("DASHSCOPE_API_BASE") or DEFAULT_API_BASE
return resolve_dashscope_openai_api_base(api_base, default=DEFAULT_API_BASE)
def validate_environment(
self,

View file

@ -39,6 +39,13 @@ from litellm.types.llms.openai import (
)
from litellm.types.utils import ImageObject, ImageResponse
from ..common_utils import (
DASHSCOPE_CHAT_COMPATIBLE_PATH,
DASHSCOPE_IMAGE_PATH,
is_dashscope_native_endpoint_url,
resolve_dashscope_endpoints,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
@ -53,8 +60,6 @@ _DECODED_JSON: Final = TypeAdapter(object)
DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
CHAT_COMPATIBLE_MODE_PATH: Final = "/compatible-mode/v1"
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
"256x256": "256*256",
@ -102,8 +107,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
raise ValueError("DASHSCOPE_API_KEY is not set")
return resolved_api_key
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("DASHSCOPE_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return DEFAULT_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("DASHSCOPE_API_BASE")
def get_complete_url(
self,
@ -114,10 +125,18 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
litellm_params: dict,
stream: bool | None = None,
) -> str:
image_api_base: Final = (
api_base if api_base and not api_base.rstrip("/").endswith(CHAT_COMPATIBLE_MODE_PATH) else None
)
return self._resolve_image_api_base(image_api_base)
image_env_api_base: Final = self._image_api_base_env()
configured_api_base: Final = api_base or self._configured_chat_api_base()
if not configured_api_base or configured_api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH):
return image_env_api_base or self._default_image_api_base()
if is_dashscope_native_endpoint_url(configured_api_base):
return configured_api_base
endpoints: Final = resolve_dashscope_endpoints(configured_api_base)
if endpoints is None:
return configured_api_base
if not api_base and image_env_api_base:
return image_env_api_base
return f"{endpoints.origin}{DASHSCOPE_IMAGE_PATH}"
def validate_environment(
self,

View file

@ -0,0 +1,7 @@
"""
DashScope Anthropic-compatible Messages Module
"""
from .transformation import DashScopeAnthropicMessagesConfig
__all__ = ["DashScopeAnthropicMessagesConfig"]

View file

@ -0,0 +1,96 @@
"""
DashScope Anthropic-compatible Messages transformation config.
"""
from typing import ClassVar, Final
import litellm
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.anthropic.messages.native.transformation import (
AnthropicMessagesConfig,
)
from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str
from ..common_utils import (
dashscope_base_selects_anthropic_messages,
resolve_dashscope_anthropic_api_base,
)
class DashScopeAnthropicMessagesConfig(AnthropicMessagesConfig):
"""
DashScope exposes an Anthropic-compatible Messages API at
``{origin}/apps/anthropic`` for every DashScope domain, including
``dashscope.aliyuncs.com`` and ``{workspace}.{region}.maas.aliyuncs.com``.
"""
_workload_identity_eligible: ClassVar[bool] = False
@property
def custom_llm_provider(self) -> str | None:
return "dashscope"
def should_strip_billing_metadata(self) -> bool:
return True
def handles_web_search_natively(self) -> bool:
return False
def serves_api_base(self, model: str, api_base: str | None) -> bool:
return dashscope_base_selects_anthropic_messages(api_base)
@staticmethod
def get_api_key(api_key: str | None = None) -> str | None:
return (
normalize_nonempty_secret_str(api_key)
or normalize_nonempty_secret_str(get_secret_str("DASHSCOPE_API_KEY"))
or normalize_nonempty_secret_str(litellm.api_key)
)
@staticmethod
def get_api_base(api_base: str | None = None) -> str:
return resolve_dashscope_anthropic_api_base(api_base)
def validate_anthropic_messages_environment(
self,
headers: dict[str, str], # mutable-ok: mirrors the legacy base override signature
model: str,
messages: list[object], # mutable-ok: mirrors the legacy base override signature
optional_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature
litellm_params: dict[str, object], # mutable-ok: mirrors the legacy base override signature
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict[str, str], str | None]: # mutable-ok: mirrors the base signature
resolved_api_key: Final = self.get_api_key(api_key=api_key)
if resolved_api_key is None:
present_headers: Final = {name.lower() for name in headers}
if "x-api-key" not in present_headers and "authorization" not in present_headers:
raise AuthenticationError(
message=(
"Missing DashScope API key. Set DASHSCOPE_API_KEY or pass api_key explicitly. "
"Anthropic credentials are never sent to a DashScope endpoint."
),
llm_provider="dashscope",
model=model,
)
return super().validate_anthropic_messages_environment( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportReturnType] # base signature uses an unparameterized dict
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=resolved_api_key,
api_base=api_base,
)
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict[str, object], # mutable-ok: mirrors the base get_complete_url signature
litellm_params: dict[str, object], # mutable-ok: mirrors the base get_complete_url signature
stream: bool | None = None,
) -> str:
return ensure_api_route(self.get_api_base(api_base=api_base), "v1/messages")

View file

@ -3,7 +3,7 @@ from typing import Final
from litellm.secret_managers.main import get_secret_str
from .chat.transformation import DashScopeChatConfig
from .common_utils import resolve_dashscope_family_rerank_api_base
from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base
from .embed.transformation import DashScopeEmbeddingConfig
from .image_generation.transformation import DashScopeImageGenerationConfig
from .rerank.transformation import DashScopeRerankConfig
@ -38,7 +38,7 @@ class QwenAIPlatformChatConfig(DashScopeChatConfig):
return self._resolve_chat_api_base(api_base), _resolve_qwen_ai_platform_api_key(api_key)
def _resolve_chat_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE)
class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig):
@ -46,7 +46,7 @@ class QwenAIPlatformEmbeddingConfig(DashScopeEmbeddingConfig):
return _require_qwen_ai_platform_api_key(api_key)
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE") or QWEN_AI_PLATFORM_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWEN_AI_PLATFORM_API_BASE", QWEN_AI_PLATFORM_API_BASE)
class QwenAIPlatformRerankConfig(DashScopeRerankConfig):
@ -63,8 +63,14 @@ class QwenAIPlatformImageGenerationConfig(DashScopeImageGenerationConfig):
def _resolve_api_key(self, api_key: str | None) -> str:
return _require_qwen_ai_platform_api_key(api_key)
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE") or QWEN_AI_PLATFORM_IMAGE_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("QWEN_AI_PLATFORM_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return QWEN_AI_PLATFORM_IMAGE_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("QWEN_AI_PLATFORM_API_BASE")
class QwenAIPlatformVideoConfig(DashScopeVideoConfig):

View file

@ -3,7 +3,7 @@ from typing import Final
from litellm.secret_managers.main import get_secret_str
from .chat.transformation import DashScopeChatConfig
from .common_utils import resolve_dashscope_family_rerank_api_base
from .common_utils import resolve_dashscope_family_rerank_api_base, resolve_dashscope_openai_api_base
from .embed.transformation import DashScopeEmbeddingConfig
from .image_generation.transformation import DashScopeImageGenerationConfig
from .rerank.transformation import DashScopeRerankConfig
@ -38,7 +38,7 @@ class QwenCloudChatConfig(DashScopeChatConfig):
return self._resolve_chat_api_base(api_base), _resolve_qwencloud_api_key(api_key)
def _resolve_chat_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE)
class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig):
@ -46,7 +46,7 @@ class QwenCloudEmbeddingConfig(DashScopeEmbeddingConfig):
return _require_qwencloud_api_key(api_key)
def _resolve_embedding_api_base(self, api_base: str | None) -> str:
return api_base or get_secret_str("QWENCLOUD_API_BASE") or QWENCLOUD_API_BASE
return resolve_dashscope_openai_api_base(api_base, "QWENCLOUD_API_BASE", QWENCLOUD_API_BASE)
class QwenCloudRerankConfig(DashScopeRerankConfig):
@ -63,8 +63,14 @@ class QwenCloudImageGenerationConfig(DashScopeImageGenerationConfig):
def _resolve_api_key(self, api_key: str | None) -> str:
return _require_qwencloud_api_key(api_key)
def _resolve_image_api_base(self, image_api_base: str | None) -> str:
return image_api_base or get_secret_str("QWENCLOUD_API_BASE_IMAGE") or QWENCLOUD_IMAGE_API_BASE
def _image_api_base_env(self) -> str | None:
return get_secret_str("QWENCLOUD_API_BASE_IMAGE")
def _default_image_api_base(self) -> str:
return QWENCLOUD_IMAGE_API_BASE
def _configured_chat_api_base(self) -> str | None:
return get_secret_str("QWENCLOUD_API_BASE")
class QwenCloudVideoConfig(DashScopeVideoConfig):

View file

@ -38,6 +38,8 @@ from litellm.types.videos.utils import (
)
from litellm.utils import get_model_info
from ..common_utils import resolve_dashscope_endpoints
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
@ -206,6 +208,9 @@ def _parse_task_response(raw_response: httpx.Response) -> _TaskResponse:
def _normalized_api_base(api_base: str) -> str:
endpoints: Final = resolve_dashscope_endpoints(api_base)
if endpoints is not None:
return endpoints.origin
trimmed: Final = api_base.rstrip("/")
if trimmed.endswith(DASHSCOPE_COMPATIBLE_MODE_PATH):
return trimmed[: -len(DASHSCOPE_COMPATIBLE_MODE_PATH)]

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, cast, overload
import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm.anthropic_beta_gated_fields import log_beta_gated_strips, strip_beta_gated_thinking
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
handle_invalid_parallel_tool_calls,
@ -514,6 +515,14 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
self.update_optional_params_with_thinking_tokens(
non_default_params=non_default_params, optional_params=mapped_params
)
gated_thinking: Final = strip_beta_gated_thinking(
mapped_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # mapped_params is the base config's bare dict
frozenset(),
drop_params=drop_params,
)
if gated_thinking.stripped:
log_beta_gated_strips(gated_thinking.stripped, model=model, provider="databricks")
mapped_params["thinking"] = gated_thinking.thinking
return mapped_params

View file

@ -144,7 +144,7 @@ class DatabricksAnthropicMessagesConfig(DatabricksBase, AnthropicMessagesConfig)
("content-type" if key.lower() == "content-type" else key): value
for key, value in {**auth_headers, **client_anthropic_headers}.items()
}
finalized_headers: Final[dict[str, str]] = self._finalize_messages_headers( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the shared helper returns a bare dict # mutable-ok: becomes the outbound headers
finalized_headers: Final[dict[str, str]] = self.finalize_messages_headers( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the shared helper returns a bare dict # mutable-ok: becomes the outbound headers
headers=normalized_headers,
optional_params=optional_params,
messages=messages,
@ -191,6 +191,8 @@ class DatabricksAnthropicMessagesConfig(DatabricksBase, AnthropicMessagesConfig)
litellm_params=litellm_params,
headers=headers,
)
self._normalize_system_role_messages(anthropic_messages_request, model=model) # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict
self._normalize_system_role_messages( # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict
anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None)
)
self._remove_scope_from_cache_control(anthropic_messages_request) # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict
return anthropic_messages_request

View file

@ -38,6 +38,15 @@ else:
_EMPTY_FUNCTION: Final[Mapping[str, object]] = MappingProxyType({})
_JSON_VALUE: Final = TypeAdapter(object)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_data(part: Mapping[str, object]) -> str | None:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
return file_data
case _:
return None
def is_valid_json(value: str) -> bool:
@ -295,7 +304,7 @@ class GigaChatConfig(BaseConfig):
def _transform_list_content(self, content: Sequence) -> tuple[str, Sequence[object]]:
"""
Extract text and image attachments from a multimodal message content list.
Extract text, image and file attachments from a multimodal message content list.
Args:
content: List of content parts (OpenAI multimodal format)
@ -320,6 +329,10 @@ class GigaChatConfig(BaseConfig):
file_id = self._upload_image(upload_url)
if file_id:
attachments.append(file_id)
elif (file_data := _file_part_data(_CONTENT_PART.validate_python(part))) and (
uploaded_file_id := self._upload_image(file_data)
):
attachments.append(uploaded_file_id)
text: Final = "\n".join(texts) if texts else ""
return text, attachments

View file

@ -20,6 +20,7 @@ import litellm
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_OCI_CHAT_MAX_TOKENS
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
from litellm.litellm_core_utils.prompt_templates.common_utils import messages_with_file_parts_as_image_urls
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -681,7 +682,9 @@ class OCIChatConfig(BaseConfig):
servingMode=serving_mode,
chatRequest=OCIChatRequestPayload(
apiFormat=vendor.value,
messages=adapt_messages_to_generic_oci_standard(messages),
messages=adapt_messages_to_generic_oci_standard(
list(messages_with_file_parts_as_image_urls(messages))
),
**self._get_optional_params(vendor, optional_params, model),
),
)

View file

@ -5,8 +5,10 @@ from collections.abc import Collection
from typing import ClassVar, Final
import litellm
from litellm._logging import verbose_logger
from litellm.utils import (
declared_value_factory,
has_catalog_entry,
is_explicitly_disabled_factory,
supports_factory,
)
@ -16,6 +18,25 @@ from .gpt_transformation import OpenAIGPTConfig
_GPT_SERIES_VERSION: Final = re.compile(r"^gpt-(\d+)(?:\.(\d+))?(?=[.-]|$)")
def refused_reasoning_effort_warning(*, suggest_declared_levels: bool, suggest_base_model: bool) -> str:
return "".join(
(
"Dropping %s=%r for model=%r (drop_params=True) because the model map does not mark that level "
"supported for %r.",
(
" If the deployment supports it, list every level it supports in model_info.reasoning_effort_levels."
if suggest_declared_levels
else ""
),
(
" If the deployment name is not its underlying model, set model_info.base_model to that model."
if suggest_base_model
else ""
),
)
)
def _catalogue_declares_default_effort() -> bool:
"""Whether the loaded cost map carries default_reasoning_effort for ANY entry.
@ -93,6 +114,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
OPT_IN_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("xhigh",))
ROUTE_SPECIFIC_REASONING_EFFORTS: ClassVar[frozenset[str]] = frozenset(("max",))
LOOKS_UP_BASE_MODEL: ClassVar[bool] = False
@classmethod
def is_model_gpt_5_model(cls, model: str) -> bool:
@ -307,7 +329,7 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
model: str,
drop_params: bool,
*,
declared_reasoning_efforts: Collection[str] = (),
declared_reasoning_efforts: Collection[str] | None = None,
) -> dict:
if self.is_model_gpt_5_search_model(model):
if "max_tokens" in non_default_params:
@ -338,10 +360,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
if (
requested_effort is not None
and requested_effort not in declared_reasoning_efforts
and requested_effort not in (declared_reasoning_efforts or ())
and self.refuses_reasoning_effort_level(model, requested_effort)
):
if litellm.drop_params or drop_params:
verbose_logger.warning(
refused_reasoning_effort_warning(
suggest_declared_levels=declared_reasoning_efforts is not None,
suggest_base_model=self.LOOKS_UP_BASE_MODEL
and not has_catalog_entry(self._model_map_lookup_name(model), None),
),
"reasoning_effort",
requested_effort,
model,
self._model_map_lookup_name(model),
)
non_default_params.pop("reasoning_effort", None)
optional_params.pop("reasoning_effort", None)
else:

View file

@ -104,6 +104,19 @@ def is_openai_backed_api_base(api_base: str) -> bool:
return hostname is not None and (hostname == _OPENAI_API_HOST or hostname.endswith(f".{_OPENAI_API_HOST}"))
_OPENAI_AGENTS_API_RESOURCES: Final = frozenset({"agents", "vaults"})
def openai_api_resource(path: str) -> str | None:
segments: Final = tuple(segment for segment in path.split("/") if segment)
resource_segments: Final = segments[1:] if segments[:1] == ("v1",) else segments
return next(iter(resource_segments), None)
def is_openai_agents_api_path(path: str) -> bool:
return openai_api_resource(path) in _OPENAI_AGENTS_API_RESOURCES
class OpenAIError(BaseLLMException):
def __init__(
self,

View file

@ -5,18 +5,30 @@ This module provides guardrail translation support for OpenAI's text completion
The handler processes the 'prompt' parameter for guardrails.
"""
from collections.abc import Mapping
import json
from collections.abc import Mapping, Sequence
from itertools import chain
from typing import TYPE_CHECKING, Final
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.types.utils import GenericGuardrailAPIInputs
from litellm.llms.base_llm.guardrail_translation.base_translation import (
BaseTranslation,
StreamingScanKey,
StreamTransformSink,
)
from litellm.llms.base_llm.guardrail_translation.utils import (
blocked_chat_stream_usage,
stream_item_field,
stream_item_items,
)
from litellm.types.utils import GenericGuardrailAPIInputs, TextChoices, TextCompletionResponse, Usage
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from fastapi import HTTPException
from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.types.utils import TextCompletionResponse
class OpenAITextCompletionHandler(BaseTranslation):
@ -32,6 +44,9 @@ class OpenAITextCompletionHandler(BaseTranslation):
- A list of strings (for batch completions)
"""
delivers_ended_stream_rewrites = True
assembles_streamed_response = True
async def process_input_messages(
self,
data: dict,
@ -125,7 +140,7 @@ class OpenAITextCompletionHandler(BaseTranslation):
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
user_api_key_dict: "UserAPIKeyAuth | None" = None,
request_data: dict | None = None,
request_data: dict[str, object] | None = None,
) -> "TextCompletionResponse":
"""
Process output response by applying guardrails to completion text.
@ -162,11 +177,7 @@ class OpenAITextCompletionHandler(BaseTranslation):
if "response" not in request_data:
request_data["response"] = response
# Add user API key metadata with prefixed keys
if "litellm_metadata" not in request_data:
user_metadata: Final = self.transform_user_api_key_dict_to_metadata(user_api_key_dict)
if user_metadata:
request_data["litellm_metadata"] = user_metadata
self.merge_user_api_key_metadata_into_request(request_data, user_api_key_dict)
inputs: Final = GenericGuardrailAPIInputs(texts=texts_to_check)
# Include model information from the response if available
@ -195,3 +206,168 @@ class OpenAITextCompletionHandler(BaseTranslation):
)
return response
async def process_output_streaming_response(
self,
responses_so_far: list[object], # mutable-ok: rewrites the caller's buffered chunks in place
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
user_api_key_dict: "UserAPIKeyAuth | None" = None,
request_data: dict[str, object] | None = None, # mutable-ok: the hooks share and mutate the request payload
stream_transform_sink: StreamTransformSink | None = None,
deliver_ended_stream_rewrites: bool = False,
) -> Sequence[object]:
chunks: Final = _text_completion_chunks(responses_so_far)
if not self._check_streaming_has_ended(chunks):
return responses_so_far
assembled: Final = self._assemble_ended_stream(chunks)
pre_guardrail_texts: Final = _choice_texts(assembled)
await self.process_output_response(
response=assembled,
guardrail_to_apply=guardrail_to_apply,
litellm_logging_obj=litellm_logging_obj,
user_api_key_dict=user_api_key_dict,
request_data=request_data,
)
if not deliver_ended_stream_rewrites:
return responses_so_far
rewrites: Final = tuple(
after for before, after in zip(pre_guardrail_texts, _choice_texts(assembled)) if after != before
)
for choice_index, text in rewrites:
self._write_choice_text(chunks, choice_index, text)
return responses_so_far
def get_streaming_scan_key(self, responses_so_far: Sequence[object]) -> StreamingScanKey | None:
chunks: Final = _text_completion_chunks(responses_so_far)
if not self._check_streaming_has_ended(chunks):
return None
assembled: Final = self._assemble_ended_stream(chunks)
return StreamingScanKey(texts=tuple(text for _, text in _choice_texts(assembled)), stream_ended=True)
def released_stream_as_ended(self, responses_so_far: Sequence[object]) -> tuple[object, ...]:
chunks: Final = _text_completion_chunks(responses_so_far)
streamed_choices: Final = tuple(chain.from_iterable(chunk.choices for chunk in chunks))
finished_indices: Final = frozenset(
_choice_index(choice) for choice in streamed_choices if choice.finish_reason
)
unfinished_indices: Final = tuple(
dict.fromkeys(
_choice_index(choice) for choice in streamed_choices if _choice_index(choice) not in finished_indices
)
)
if not unfinished_indices:
return tuple(responses_so_far)
terminator: Final = TextCompletionResponse(
id=chunks[0].id,
model=chunks[0].model,
object="text_completion",
choices=[TextChoices(index=index, text="", finish_reason="length") for index in unfinished_indices],
)
return (*responses_so_far, terminator)
def build_block_sse_chunks(
self,
exc: "ModifyResponseException",
stream_started: bool = False,
responses_so_far: Sequence[object] | None = None,
) -> Sequence[bytes]:
streamed: Final = _text_completion_chunks(responses_so_far or ())
source: Final = streamed[0] if streamed else TextCompletionResponse(model=exc.model)
message_chunk: Final = TextCompletionResponse(
id=source.id,
created=source.created,
model=source.model or exc.model,
object="text_completion",
choices=[TextChoices(index=0, text=exc.message)],
).model_copy(update={"usage": None})
final_chunk: Final = TextCompletionResponse(
id=source.id,
created=source.created,
model=source.model or exc.model,
object="text_completion",
choices=[TextChoices(index=0, text="", finish_reason="content_filter")],
).model_copy(update={"usage": _blocked_stream_usage(exc.original_response)})
return tuple(
f"data: {chunk.model_dump_json(exclude_none=True)}\n\n".encode() for chunk in (message_chunk, final_chunk)
)
def build_stream_error_items(
self,
exc: "HTTPException",
responses_so_far: Sequence[object] | None = None,
) -> Sequence[bytes] | None:
from litellm.proxy.common_request_processing import sse_error_payload
_, error_obj = sse_error_payload(exc)
return (f'data: {{"error": {json.dumps(error_obj)}}}\n\n'.encode(),)
def _check_streaming_has_ended(self, responses_so_far: Sequence[object]) -> bool:
streamed_choices: Final = chain.from_iterable(stream_item_items(item, "choices") for item in responses_so_far)
return any(stream_item_field(choice, "finish_reason") is not None for choice in streamed_choices)
@staticmethod
def _assemble_ended_stream(responses_so_far: Sequence[TextCompletionResponse]) -> TextCompletionResponse:
streamed_choices: Final = tuple(chain.from_iterable(item.choices for item in responses_so_far))
choice_indices: Final = tuple(dict.fromkeys(_choice_index(choice) for choice in streamed_choices))
assembled_choices: Final = [
TextChoices(
index=index,
text="".join(_choice_text(choice) for choice in streamed_choices if _choice_index(choice) == index),
finish_reason=next(
(
choice.finish_reason
for choice in streamed_choices
if _choice_index(choice) == index and choice.finish_reason
),
None,
),
)
for index in choice_indices
]
first: Final = responses_so_far[0]
return TextCompletionResponse(
id=first.id,
created=first.created,
model=first.model,
object="text_completion",
choices=assembled_choices,
usage=next((item.usage for item in reversed(responses_so_far) if item.usage is not None), None),
)
@staticmethod
def _write_choice_text(responses_so_far: Sequence[TextCompletionResponse], choice_index: int, text: str) -> None:
streamed_choices: Final = chain.from_iterable(item.choices for item in responses_so_far)
head, *tail = (choice for choice in streamed_choices if _choice_index(choice) == choice_index)
head.text = text
for choice in tail:
if isinstance(stream_item_field(choice, "text"), str):
choice.text = ""
def _blocked_stream_usage(original_response: object) -> Usage | None:
prompt_tokens, completion_tokens = blocked_chat_stream_usage(original_response)
if isinstance(original_response, list) and prompt_tokens == completion_tokens == 0:
return None
return Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
def _text_completion_chunks(responses_so_far: Sequence[object]) -> tuple[TextCompletionResponse, ...]:
return tuple(item for item in responses_so_far if isinstance(item, TextCompletionResponse))
def _choice_text(choice: TextChoices) -> str:
text: Final = stream_item_field(choice, "text")
return text if isinstance(text, str) else ""
def _choice_index(choice: TextChoices) -> int:
return choice.index or 0
def _choice_texts(response: TextCompletionResponse) -> tuple[tuple[int, str], ...]:
return tuple((_choice_index(choice), _choice_text(choice)) for choice in response.choices)

View file

@ -32,6 +32,7 @@ from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_a
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.openai.chat.gpt_reasoning_transformation import (
is_gpt_reasoning_series_name,
refused_reasoning_effort_warning,
unsupported_reasoning_effort_message,
)
from litellm.responses.litellm_completion_transformation.custom_tools import TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE
@ -55,6 +56,9 @@ from ..common_utils import OpenAIError
from ..workload_identity import get_workload_identity_bearer_token, resolve_openai_workload_identity_config
OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS: Final = 16
DROP_UNSUPPORTED_REASONING_WARNING: Final = (
"Dropping reasoning=%r for model=%r (drop_params=True) because its model cost map entry lacks supports_reasoning."
)
_RAW_RESPONSE_JSON: Final = TypeAdapter(dict[str, object], config=ConfigDict(strict=True))
if TYPE_CHECKING:
@ -417,12 +421,19 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return declared
return info["key"] in _bundled_openai_reasoning_models()
@staticmethod
def _is_known_non_reasoning_model(model: str) -> bool:
from litellm.utils import is_known_non_reasoning_model
return is_known_non_reasoning_model(model.split("/")[-1], LlmProviders.OPENAI.value)
@staticmethod
def _requests_reasoning_effort(reasoning: object) -> bool:
effort: Final = (
reasoning.get("effort") if isinstance(reasoning, Mapping) else getattr(reasoning, "effort", None)
)
return effort is not None
return OpenAIResponsesAPIConfig._requested_effort(reasoning) is not None
@staticmethod
def _requested_effort(reasoning: object) -> object:
return reasoning.get("effort") if isinstance(reasoning, Mapping) else getattr(reasoning, "effort", None)
@staticmethod
def _enforce_min_max_output_tokens(max_output_tokens: "int | None") -> "int | None":
@ -464,17 +475,20 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
) -> dict:
return dict(
self.map_openai_params_for_deployment(
response_api_optional_params, model, drop_params, declared_reasoning_efforts=()
response_api_optional_params, model, drop_params, declared_reasoning_efforts=None
)
)
def honors_declared_reasoning_efforts(self) -> bool:
return type(self).map_openai_params is OpenAIResponsesAPIConfig.map_openai_params
def map_openai_params_for_deployment(
self,
response_api_optional_params: ResponsesAPIOptionalRequestParams,
model: str,
drop_params: bool,
*,
declared_reasoning_efforts: Collection[str],
declared_reasoning_efforts: Collection[str] | None,
) -> Mapping[str, object]:
"""No mapping applied since inputs are in OpenAI spec already.
@ -494,7 +508,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
and not self._supports_reasoning_param(model=model)
):
if drop_params or litellm.drop_params:
params.pop("reasoning", None)
dropped_reasoning: Final = params.pop("reasoning", None)
if self._requested_effort(dropped_reasoning) != "none" or not self._is_known_non_reasoning_model(model):
verbose_logger.warning(DROP_UNSUPPORTED_REASONING_WARNING, dropped_reasoning, model)
else:
raise litellm.UnsupportedParamsError(
message=(
@ -512,7 +528,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
if (
isinstance(reasoning, dict)
and isinstance(requested_effort, str)
and requested_effort not in declared_reasoning_efforts
and requested_effort not in (declared_reasoning_efforts or ())
and self._effort_level_is_disabled(lookup_name, requested_effort)
):
if not (drop_params or litellm.drop_params):
@ -520,6 +536,15 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
message=unsupported_reasoning_effort_message("reasoning.effort", requested_effort, model),
status_code=400,
)
verbose_logger.warning(
refused_reasoning_effort_warning(
suggest_declared_levels=declared_reasoning_efforts is not None, suggest_base_model=False
),
"reasoning.effort",
requested_effort,
model,
lookup_name,
)
remaining: Final = reasoning.copy()
remaining.pop("effort", None)
if remaining:

View file

@ -270,6 +270,13 @@
"/v1/responses"
]
},
"kyma": {
"base_url": "https://kymaapi.com/v1",
"api_key_env": "KYMA_API_KEY",
"provider_info": {
"supports_function_calling": true
}
},
"streamlake": {
"base_url": "https://vanchin.streamlake.ai/api/gateway/v1/endpoints",
"api_key_env": "STREAMLAKE_API_KEY",

View file

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