litellm/tests/local_testing/conftest.py
Mateo Wang b76a858826
feat: declarative fallback generalizations for unknown models (#29718)
* feat: declarative fallback generalizations for unknown models

Unknown or newly-released models previously degraded (missed cost lookups,
wrong supports_* flags, broken provider routing) and were patched with one-off
hardcoded regexes scattered across Python. This adds a single data-driven source
of truth: a fallback_generalizations block in model_prices_and_context_window.json
holding ordered, case-insensitive regex rules that map a model name to the
metadata to apply when it has no exact entry.

A new fallback_generalizations module owns the rules and a compiled-regex cache
that is built once and invalidated on reload, so the O(n) scan runs only on a
cache miss. get_llm_provider now routes an otherwise-unknown model via the first
matching rule's litellm_provider, replacing the hardcoded _CLAUDE_PATTERN and
_matches_claude_model_pattern. _get_model_info_helper falls back to a matching
rule's model_info after the exact lookups miss, so get_model_info and the
supports_* helpers resolve unknown models from the same rule. get_model_cost_map
extracts the block out of the returned map, and the integrity check now counts
real model entries (excluding reserved meta keys) so the new key cannot mask a
genuinely shrunk upstream file.

The top level of the file stays a flat map of models so existing litellm releases
that fetch the live file keep working and keep receiving updates; the block ships
in both the root file and the bundled backup. An anthropic-claude rule reproduces
the old future-claude routing and additionally supplies capability flags and a
context window

https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo

* refactor(anthropic): derive adaptive-thinking from a version threshold; harden generalizations

Replace the per-minor-version _is_claude_4_6_model / _is_claude_4_7_model substring
matchers with a single _claude_version_at_least predicate that parses the Claude
family version from the model name and compares against 4.6. This covers 4.8/4.9/5.x
without a code change (the old matchers missed 4.8 entirely) while keeping an explicit
supports_adaptive_thinking flag authoritative when present, so there is one source of
truth. The two direct call sites in the chat transformation now route through
_is_adaptive_thinking_model instead of the deleted matchers.

Also address review feedback on the generalizations module: return a copy of the
matched model_info so a future caller cannot mutate the compiled-rule cache, document
that patterns are matched with re.search and must anchor with ^ and $, and reindent
the fallback_generalizations block to the file's 2-space style in both JSON files.

https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo

* fix(anthropic): surface adaptive-thinking from the cost map; fix date misparse

supports_adaptive_thinking shipped in the model cost map but was never declared
on ModelInfo nor copied during construction, so get_model_info (and the supports_*
factory) silently dropped it for every provider-prefixed or generalized name; only
a bare base entry resolved. Wire it through ModelInfo like the other capability
flags and backfill the flag onto the genuine Claude 4.6/4.7/4.8 entries across
providers so the data, not code, declares the capability. The anthropic-claude
fallback rule also carries the flag (and now accepts a dotted minor, e.g. 4.6) so
an unmapped future Claude degrades to adaptive thinking without a code change.

Tighten the Claude version parser so an eight-digit date suffix
(claude-opus-4-20250514, the non-adaptive Opus 4.0) is no longer read as minor
4.20250514. The cost map stays authoritative; the version check is only a fallback
for provider-prefixed names (bedrock/invoke routes, -v1-less ids) that resolve to
no mapped entry and so cannot be reached by an exact lookup or the bare-name rule.

https://claude.ai/code/session_01G8Jro8dPLktwnaaSJwVDpo

* fix(anthropic): date-safe adaptive-thinking version fallback, conservative fallback pricing, ruff strict gate

Reconcile adaptive-thinking detection after merging litellm_internal_staging.
Keep the cost-map resolver (_supports_model_capability) as the source of truth and
add a date-safe opus/sonnet/haiku >= 4.6 name version as a fallback for
provider-prefixed ids the cost map cannot resolve (e.g.
bedrock/invoke/us.anthropic.claude-opus-4-6). A two-digit cap on the minor keeps an
eight-digit date suffix from being misread as a minor version, so the dated Claude
4.0 release stays non-adaptive

Price the shipped anthropic-claude fallback rule at the Opus tier so an unknown or
newly released Claude is over-costed rather than billed as free

Drop the module-level global state in fallback_generalizations (PLW0603) in favor of
a small registry object, and switch its annotations plus the new utils helper to
builtin generics (UP006), bringing the ruff strict-rule totals back under ceiling

* refactor(anthropic): drive adaptive-thinking version gate from a declarative rule

Replace the bespoke _claude_version_at_least heuristic with a version-gated fallback_generalizations rule. Unmapped Claude ids now resolve adaptive thinking purely from the cost map: an explicit entry, or the new self-contained anthropic-claude-adaptive-thinking rule that matches opus/sonnet/haiku >= 4.6 (covering 5.x, 6.x and beyond with no code change). New families ship via Price Data Reload instead of a code edit

The rule carries the same Opus-tier pricing as the broad anthropic-claude rule plus supports_adaptive_thinking, and is matched first; the broad rule stays version-neutral, so an unmapped >= 4.6 Claude resolves to full pricing and the adaptive flag from one rule, while a sub-4.6 alias such as claude-opus-4-0 is still priced yet stays non-adaptive. The regex caps the minor at two digits so a dated 4.0 id (...-4-20250514) is never read as a >= 4.6 minor

* refactor(anthropic): dedupe adaptive-thinking rule via declarative extends

The version-gated anthropic-claude-adaptive-thinking rule duplicated the
broad anthropic-claude rule's entire Opus-tier price block because rules do
not merge: first match wins and returns one rule's whole model_info, so the
adaptive rule had to be self-contained.

Add a declarative extends field to fallback_generalizations: a rule names a
parent and inherits its model_info, with its own keys overriding. Inheritance
is resolved once at install time against each rule's raw model_info, so the
adaptive rule now carries only its delta (supports_adaptive_thinking) and
inherits pricing from the broad rule. Runtime matching, provider routing and
gating are unchanged; the broad rule stays anchored and first-match-wins still
holds.

* docs(anthropic): add ignored description key documenting each generalization regex

* fix(anthropic): drop fabricated pricing from the anthropic-claude fallback rule

Per review feedback, the base rule no longer carries input/output/cache costs, and the
adaptive-thinking rule that extends it inherits that no-pricing model_info. Pricing an
unmapped model at a guessed tier reports a confidently-wrong cost without the caller
knowing; dropping it keeps the standard unpriced behavior (zero, not a fabricated
number) so a missing price stays visible. The rules still supply provider routing,
context window, and capability flags, so a brand-new Claude can still be called and its
capabilities (including adaptive thinking for >= 4.6) resolved. Description and tests
updated to match
2026-06-27 21:01:19 -07:00

281 lines
9.9 KiB
Python

# conftest.py
#
# xdist-compatible test isolation for local_testing tests.
# Pattern matches tests/test_litellm/conftest.py:
# - Function-scoped fixture saves/restores litellm globals (no reload)
# - Module-scoped fixture reloads only in single-process mode
#
# IMPORTANT: True defaults are captured at conftest import time (before any
# test module can pollute them via module-level assignments like
# `litellm.num_retries = 3`). The function-scoped fixture resets globals to
# these true defaults before every test, preventing cross-test contamination
# under xdist where module reload is skipped.
import importlib
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import litellm
# ``litellm.model_cost`` is loaded at import time from the URL pinned to ``main``
# (``LITELLM_MODEL_COST_MAP_URL``). The in-tree backup ships with this branch
# and can include pricing entries that ``main`` has not yet picked up (e.g.
# Mistral now returns ``ministral-8b-2512`` from ``mistral-tiny`` and the entry
# was added on this branch). Backfill any entries that are missing from the
# remote-fetched map so cost-calculator lookups in tests succeed against the
# cassette state the branch is being tested with.
from litellm.litellm_core_utils.get_model_cost_map import (
RESERVED_TOP_LEVEL_KEYS,
GetModelCostMap,
)
for _k, _v in GetModelCostMap.load_local_model_cost_map().items():
if _k in RESERVED_TOP_LEVEL_KEYS:
continue
litellm.model_cost.setdefault(_k, _v)
from tests._vcr_conftest_common import ( # noqa: E402,F401
VerboseReporterState,
_pin_multipart_boundary,
apply_vcr_auto_marker_to_items,
emit_cassette_cache_session_banner,
emit_vcr_classification_summary,
emit_vcr_diagnostic_log,
install_live_call_probe,
record_vcr_outcome,
register_persister_if_enabled,
reset_vcr_diag_dir,
vcr_config_dict,
)
from tests.fake_openai_endpoint import ensure_fake_openai_endpoint # noqa: E402
@pytest.fixture(scope="session", autouse=True)
def fake_openai_endpoint():
ensure_fake_openai_endpoint()
yield
# Per-item respx detection (``apply_vcr_auto_marker_to_items``) auto-skips
# tests whose ``@pytest.mark.respx`` marker or ``respx_mock`` fixture
# would conflict with vcrpy's transport patch. We no longer maintain a
# file-level ``_RESPX_CONFLICTING_FILES`` list here — the previous
# entries (``test_router.py``) had only a stale ``from respx import
# MockRouter`` import with no actual respx wiring, so file-level
# blacklisting was masking valid cache opportunities.
# Files where VCR replay breaks the test:
# - ``test_router_caching.py``: asserts upstream returns a *new* id per call,
# which a deterministic cassette replay violates.
_VCR_INCOMPATIBLE_FILES = frozenset(
{
"test_router_caching.py",
# Hits the local fake OpenAI endpoint on 127.0.0.1; nothing to record.
"test_fake_openai_endpoint.py",
}
)
# Individual tests (vs. whole files above) that VCR replay can't model:
# - ``test_router_text_completion_client``: a concurrency test that fires 300
# identical requests to verify the async OpenAI client is *reused* across
# calls (per its own comment, it "fails when we create a new Async OpenAI
# client per request"). vcrpy patches the HTTP transport, so replay never
# opens real connections and cannot exercise the client pool the test exists
# to validate. Recording instead stores ~300 near-identical episodes, which
# blows past MAX_EPISODES_PER_CASSETTE (50) so the cassette is refused on
# every run (MISS:OVERFLOW). The endpoint is a free mock, so the live calls
# carry no real provider cost.
_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = (
"test_router.py::test_router_text_completion_client",
)
_verbose_state = VerboseReporterState()
@pytest.fixture(scope="module")
def vcr_config():
return vcr_config_dict()
def pytest_recording_configure(config, vcr):
register_persister_if_enabled(vcr)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture(autouse=True)
def _vcr_outcome_gate(request, vcr):
install_live_call_probe(request, vcr)
yield
record_vcr_outcome(request, vcr)
def pytest_configure(config):
_verbose_state.remember_pluginmanager(config)
reset_vcr_diag_dir()
def pytest_runtest_logreport(report):
_verbose_state.maybe_emit_verdict(report)
def pytest_terminal_summary(terminalreporter, exitstatus, config):
emit_cassette_cache_session_banner(terminalreporter)
emit_vcr_classification_summary(terminalreporter)
emit_vcr_diagnostic_log(terminalreporter)
# ---------------------------------------------------------------------------
# Capture TRUE defaults at conftest import time. This runs before any test
# module's top-level code (e.g. `litellm.num_retries = 3`) executes, so
# the values here are guaranteed to be the real package defaults.
# ---------------------------------------------------------------------------
_SCALAR_DEFAULTS = {
"num_retries": getattr(litellm, "num_retries", None),
"num_retries_per_request": getattr(litellm, "num_retries_per_request", None),
"request_timeout": getattr(litellm, "request_timeout", None),
"set_verbose": getattr(litellm, "set_verbose", False),
"cache": getattr(litellm, "cache", None),
"allowed_fails": getattr(litellm, "allowed_fails", 3),
"default_fallbacks": getattr(litellm, "default_fallbacks", None),
"enable_azure_ad_token_refresh": getattr(
litellm, "enable_azure_ad_token_refresh", None
),
"tag_budget_config": getattr(litellm, "tag_budget_config", None),
"model_cost": getattr(litellm, "model_cost", None),
"token_counter": getattr(litellm, "token_counter", None),
"disable_aiohttp_transport": getattr(litellm, "disable_aiohttp_transport", False),
"force_ipv4": getattr(litellm, "force_ipv4", False),
"drop_params": getattr(litellm, "drop_params", None),
"modify_params": getattr(litellm, "modify_params", False),
"api_base": getattr(litellm, "api_base", None),
"api_key": getattr(litellm, "api_key", None),
}
@pytest.fixture(scope="function", autouse=True)
def isolate_litellm_state():
"""
Per-function isolation fixture.
Resets litellm globals to their true defaults before each test and
restores them afterward, so tests don't leak side effects.
Works safely under pytest-xdist parallel execution.
"""
# ---- Save current callback state (for teardown restore) ----
original_state = {}
for attr in (
"callbacks",
"success_callback",
"failure_callback",
"_async_success_callback",
"_async_failure_callback",
):
if hasattr(litellm, attr):
val = getattr(litellm, attr)
original_state[attr] = val.copy() if val else []
# Save list-type globals
for attr in ("pre_call_rules", "post_call_rules"):
if hasattr(litellm, attr):
val = getattr(litellm, attr)
original_state[attr] = val.copy() if val else []
# Save scalar globals
for attr in _SCALAR_DEFAULTS:
if hasattr(litellm, attr):
original_state[attr] = getattr(litellm, attr)
# ---- Reset to true defaults before the test ----
# Flush HTTP client cache
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
# Clear callbacks and rules
for attr in (
"callbacks",
"success_callback",
"failure_callback",
"_async_success_callback",
"_async_failure_callback",
"pre_call_rules",
"post_call_rules",
):
if hasattr(litellm, attr):
setattr(litellm, attr, [])
# Reset scalar globals to true defaults (prevents contamination from
# module-level code like `litellm.num_retries = 3` in test files)
for attr, default_val in _SCALAR_DEFAULTS.items():
if hasattr(litellm, attr):
setattr(litellm, attr, default_val)
yield
# ---- Teardown: restore saved state ----
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
for attr, original_value in original_state.items():
if hasattr(litellm, attr):
setattr(litellm, attr, original_value)
@pytest.fixture(scope="module", autouse=True)
def setup_and_teardown():
"""
Module-scoped setup. Reloads litellm only in single-process mode
(skipped under xdist to avoid cross-worker interference).
"""
sys.path.insert(0, os.path.abspath("../.."))
import litellm
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
if worker_id is None:
importlib.reload(litellm)
try:
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
import litellm.proxy.proxy_server
importlib.reload(litellm.proxy.proxy_server)
except Exception as e:
print(f"Error reloading litellm.proxy.proxy_server: {e}")
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
yield
def pytest_collection_modifyitems(config, items):
apply_vcr_auto_marker_to_items(
items,
skip_files=_VCR_INCOMPATIBLE_FILES,
skip_nodeid_suffixes=_VCR_INCOMPATIBLE_NODEID_SUFFIXES,
)
# Separate tests in 'test_amazing_proxy_custom_logger.py' and other tests
custom_logger_tests = [
item for item in items if "custom_logger" in item.parent.name
]
other_tests = [item for item in items if "custom_logger" not in item.parent.name]
# Sort tests based on their names
custom_logger_tests.sort(key=lambda x: x.name)
other_tests.sort(key=lambda x: x.name)
# Reorder the items list
items[:] = custom_logger_tests + other_tests