litellm/tests/local_testing/test_get_llm_provider.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

573 lines
18 KiB
Python

import os
import sys
import traceback
from dotenv import load_dotenv
load_dotenv()
import io
from unittest.mock import patch
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import pytest
import litellm
from litellm.types.router import LiteLLM_Params
def test_get_llm_provider():
_, response, _, _ = litellm.get_llm_provider(model="anthropic.claude-v2:1")
assert response == "bedrock"
# test_get_llm_provider()
def test_get_llm_provider_fireworks(): # tests finetuned fireworks models - https://github.com/BerriAI/litellm/issues/4923
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model="fireworks_ai/accounts/my-test-1234"
)
assert custom_llm_provider == "fireworks_ai"
assert model == "accounts/my-test-1234"
def test_get_llm_provider_catch_all():
_, response, _, _ = litellm.get_llm_provider(model="*")
assert response == "openai"
def test_get_llm_provider_gpt_instruct():
_, response, _, _ = litellm.get_llm_provider(model="gpt-3.5-turbo-instruct-0914")
assert response == "text-completion-openai"
def test_get_llm_provider_mistral_custom_api_base():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="mistral/mistral-large-fr",
api_base="https://mistral-large-fr-ishaan.francecentral.inference.ai.azure.com/v1",
)
assert custom_llm_provider == "mistral"
assert model == "mistral-large-fr"
assert (
api_base
== "https://mistral-large-fr-ishaan.francecentral.inference.ai.azure.com/v1"
)
def test_get_llm_provider_deepseek_custom_api_base():
os.environ["DEEPSEEK_API_BASE"] = "MY-FAKE-BASE"
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="deepseek/deep-chat",
)
assert custom_llm_provider == "deepseek"
assert model == "deep-chat"
assert api_base == "MY-FAKE-BASE"
os.environ.pop("DEEPSEEK_API_BASE")
def test_get_llm_provider_vertex_ai_image_models():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="imagegeneration@006", custom_llm_provider=None
)
assert custom_llm_provider == "vertex_ai"
def test_get_llm_provider_ai21_chat():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="jamba-1.5-large",
)
assert custom_llm_provider == "ai21_chat"
assert model == "jamba-1.5-large"
assert api_base == "https://api.ai21.com/studio/v1"
def test_get_llm_provider_ai21_chat_test2():
"""
if user prefix with ai21/ but calls jamba-1.5-large then it should be ai21_chat provider
"""
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="ai21/jamba-1.5-large",
)
print("model=", model)
print("custom_llm_provider=", custom_llm_provider)
print("api_base=", api_base)
assert custom_llm_provider == "ai21_chat"
assert model == "jamba-1.5-large"
assert api_base == "https://api.ai21.com/studio/v1"
def test_get_llm_provider_cohere_chat_test2():
"""
if user prefix with cohere/ but calls command-r-plus then it should be cohere_chat provider
"""
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="cohere/command-r-plus",
)
print("model=", model)
print("custom_llm_provider=", custom_llm_provider)
print("api_base=", api_base)
assert custom_llm_provider == "cohere_chat"
assert model == "command-r-plus"
def test_get_llm_provider_azure_o1():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="azure/o1-mini",
)
assert custom_llm_provider == "azure"
assert model == "o1-mini"
def test_default_api_base():
from litellm.litellm_core_utils.get_llm_provider_logic import (
_get_openai_compatible_provider_info,
)
from litellm.types.utils import LlmProviders
# Patch environment variable to remove API base if it's set
with patch.dict(os.environ, {}, clear=True):
for provider in litellm.openai_compatible_providers:
# Get the API base for the given provider
if provider == "github_copilot":
continue
# Skip chatgpt as it requires OAuth authentication
if provider == "chatgpt":
continue
# Skip ragflow as it requires specific model format: ragflow/chat/{id}/{model} or ragflow/agent/{id}/{model}
if provider == "ragflow":
continue
_, _, _, api_base = _get_openai_compatible_provider_info(
model=f"{provider}/*", api_base=None, api_key=None, dynamic_api_key=None
)
if api_base is None:
continue
for other_provider in LlmProviders:
if other_provider.value != provider and provider != "{}_chat".format(
other_provider.value
):
if provider == "codestral" and other_provider.value == "mistral":
continue
elif provider == "github" and other_provider.value == "azure":
continue
assert other_provider.value not in api_base.replace("/openai", "")
def test_hosted_vllm_default_api_key():
from litellm.litellm_core_utils.get_llm_provider_logic import (
_get_openai_compatible_provider_info,
)
_, _, dynamic_api_key, _ = _get_openai_compatible_provider_info(
model="hosted_vllm/llama-3.1-70b-instruct",
api_base=None,
api_key=None,
dynamic_api_key=None,
)
assert dynamic_api_key == "fake-api-key"
def test_get_llm_provider_jina_ai():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="jina_ai/jina-embeddings-v3",
)
assert custom_llm_provider == "jina_ai"
assert api_base == "https://api.jina.ai/v1"
assert model == "jina-embeddings-v3"
def test_get_llm_provider_hosted_vllm():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="hosted_vllm/llama-3.1-70b-instruct",
)
assert custom_llm_provider == "hosted_vllm"
assert model == "llama-3.1-70b-instruct"
assert dynamic_api_key == "fake-api-key"
def test_get_llm_provider_llamafile():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="llamafile/mistralai/mistral-7b-instruct-v0.2",
)
assert custom_llm_provider == "llamafile"
assert model == "mistralai/mistral-7b-instruct-v0.2"
assert dynamic_api_key == "fake-api-key"
assert api_base == "http://127.0.0.1:8080/v1"
def test_get_llm_provider_watson_text():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="watsonx_text/watson-text-to-speech",
)
assert custom_llm_provider == "watsonx_text"
assert model == "watson-text-to-speech"
def test_azure_global_standard_get_llm_provider():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="azure_ai/gpt-4o-global-standard",
api_base="https://my-deployment-francecentral.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview",
api_key="fake-api-key",
)
assert custom_llm_provider == "azure_ai"
def test_nova_bedrock_converse():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="amazon.nova-micro-v1:0",
)
assert custom_llm_provider == "bedrock"
assert model == "amazon.nova-micro-v1:0"
def test_bedrock_invoke_anthropic():
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
model="bedrock/invoke/anthropic.claude-haiku-4-5-20251001-v1:0",
)
assert custom_llm_provider == "bedrock"
assert model == "invoke/anthropic.claude-haiku-4-5-20251001-v1:0"
@pytest.mark.parametrize("model", ["xai/grok-2-vision-latest", "grok-2-vision-latest"])
def test_xai_api_base(model):
args = {
"model": model,
"custom_llm_provider": "xai",
"api_base": None,
"api_key": "xai-my-specialkey",
"litellm_params": None,
}
model, custom_llm_provider, dynamic_api_key, api_base = litellm.get_llm_provider(
**args
)
assert custom_llm_provider == "xai"
assert model == "grok-2-vision-latest"
assert api_base == "https://api.x.ai/v1"
assert dynamic_api_key == "xai-my-specialkey"
# -------- Tests for force_use_litellm_proxy ---------
def test_get_litellm_proxy_custom_llm_provider():
"""
Tests force_use_litellm_proxy uses LITELLM_PROXY_API_BASE and LITELLM_PROXY_API_KEY from env.
"""
test_model = "gpt-3.5-turbo"
expected_api_base = "http://localhost:8000"
expected_api_key = "test_proxy_key"
with patch.dict(
os.environ,
{
"LITELLM_PROXY_API_BASE": expected_api_base,
"LITELLM_PROXY_API_KEY": expected_api_key,
},
clear=True,
):
(
model,
provider,
key,
base,
) = litellm.LiteLLMProxyChatConfig().litellm_proxy_get_custom_llm_provider_info(
model=test_model
)
assert model == test_model
assert provider == "litellm_proxy"
assert key == expected_api_key
assert base == expected_api_base
def test_get_litellm_proxy_with_args_override_env_vars():
"""
Tests force_use_litellm_proxy uses api_base and api_key args over environment variables.
"""
test_model = "gpt-4"
arg_api_base = "http://custom-proxy.com"
arg_api_key = "custom_key_from_arg"
env_api_base = "http://env-proxy.com"
env_api_key = "env_key"
with patch.dict(
os.environ,
{"LITELLM_PROXY_API_BASE": env_api_base, "LITELLM_PROXY_API_KEY": env_api_key},
clear=True,
):
(
model,
provider,
key,
base,
) = litellm.LiteLLMProxyChatConfig().litellm_proxy_get_custom_llm_provider_info(
model=test_model, api_base=arg_api_base, api_key=arg_api_key
)
assert model == test_model
assert provider == "litellm_proxy"
assert key == arg_api_key
assert base == arg_api_base
def test_get_litellm_proxy_model_prefix_stripping():
"""
Tests force_use_litellm_proxy strips 'litellm_proxy/' prefix from model name.
"""
original_model = "litellm_proxy/claude-2"
expected_model = "claude-2"
expected_api_base = "http://localhost:4000"
expected_api_key = "proxy_secret_key"
with patch.dict(
os.environ,
{
"LITELLM_PROXY_API_BASE": expected_api_base,
"LITELLM_PROXY_API_KEY": expected_api_key,
},
clear=True,
):
(
model,
provider,
key,
base,
) = litellm.LiteLLMProxyChatConfig().litellm_proxy_get_custom_llm_provider_info(
model=original_model
)
assert model == expected_model
assert provider == "litellm_proxy"
assert key == expected_api_key
assert base == expected_api_base
# -------- Tests for get_llm_provider triggering use_litellm_proxy ---------
def test_get_llm_provider_LITELLM_PROXY_ALWAYS_true():
"""
Tests get_llm_provider uses litellm_proxy when USE_LITELLM_PROXY is "True".
"""
test_model_input = "openai/gpt-4"
expected_model_output = "openai/gpt-4"
proxy_api_base = "http://my-global-proxy.com"
proxy_api_key = "global_proxy_key"
with patch.dict(
os.environ,
{
"USE_LITELLM_PROXY": "True",
"LITELLM_PROXY_API_BASE": proxy_api_base,
"LITELLM_PROXY_API_KEY": proxy_api_key,
},
clear=True,
):
model, provider, key, base = litellm.get_llm_provider(model=test_model_input)
print("get_llm_provider", model, provider, key, base)
assert model == expected_model_output
assert provider == "litellm_proxy"
assert key == proxy_api_key
assert base == proxy_api_base
def test_get_llm_provider_LITELLM_PROXY_ALWAYS_true_model_prefix():
"""
Tests get_llm_provider with USE_LITELLM_PROXY="True" and model prefix "litellm_proxy/".
"""
test_model_input = "litellm_proxy/gpt-4-turbo"
expected_model_output = "gpt-4-turbo"
proxy_api_base = "http://another-proxy.net"
proxy_api_key = "another_key"
with patch.dict(
os.environ,
{
"USE_LITELLM_PROXY": "True",
"LITELLM_PROXY_API_BASE": proxy_api_base,
"LITELLM_PROXY_API_KEY": proxy_api_key,
},
clear=True,
):
model, provider, key, base = litellm.get_llm_provider(model=test_model_input)
assert model == expected_model_output
assert provider == "litellm_proxy"
assert key == proxy_api_key
assert base == proxy_api_base
def test_get_llm_provider_use_proxy_arg_true():
"""
Tests get_llm_provider uses litellm_proxy when use_proxy=True argument is passed.
"""
test_model_input = "mistral/mistral-large"
expected_model_output = (
"mistral/mistral-large" # force_use_litellm_proxy keep the model name
)
proxy_api_base = "http://my-arg-proxy.com"
proxy_api_key = "arg_proxy_key"
# Ensure LITELLM_PROXY_ALWAYS is not set or False
with patch.dict(
os.environ,
{
"LITELLM_PROXY_API_BASE": proxy_api_base,
"LITELLM_PROXY_API_KEY": proxy_api_key,
},
clear=True,
): # clear=True removes LITELLM_PROXY_ALWAYS if it was set by other tests
model, provider, key, base = litellm.get_llm_provider(
model=test_model_input,
litellm_params=LiteLLM_Params(
use_litellm_proxy=True, model=test_model_input
),
)
assert model == expected_model_output
assert provider == "litellm_proxy"
assert key == proxy_api_key
assert base == proxy_api_base
def test_get_llm_provider_use_proxy_arg_true_with_direct_args():
"""
Tests get_llm_provider with use_proxy=True and explicit api_base/api_key args.
These args should be passed to force_use_litellm_proxy and override env vars.
"""
test_model_input = "anthropic/claude-3-opus"
expected_model_output = "anthropic/claude-3-opus"
arg_api_base = "http://specific-proxy-endpoint.org"
arg_api_key = "specific_key_for_call"
# Set some env vars to ensure they are overridden
env_proxy_api_base = "http://env-default-proxy.com"
env_proxy_api_key = "env_default_key"
with patch.dict(
os.environ,
{
"LITELLM_PROXY_API_BASE": env_proxy_api_base,
"LITELLM_PROXY_API_KEY": env_proxy_api_key,
},
clear=True,
):
model, provider, key, base = litellm.get_llm_provider(
model=test_model_input,
api_base=arg_api_base,
api_key=arg_api_key,
litellm_params=LiteLLM_Params(
use_litellm_proxy=True, model=test_model_input
),
)
assert model == expected_model_output
assert provider == "litellm_proxy"
assert key == arg_api_key # Should use the argument key
assert base == arg_api_base # Should use the argument base
# -------- Tests for the anthropic-claude fallback generalization rule ---------
@pytest.fixture
def shipped_generalizations():
"""Install the rules shipped in the bundled backup, then restore.
The remote-fetched cost map pinned to ``main`` may not yet carry the rule
added on this branch, so these tests install the rule the branch actually
ships rather than depending on whatever the live URL returns.
"""
from litellm.litellm_core_utils.fallback_generalizations import (
get_fallback_generalization_rules,
set_fallback_generalizations,
)
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
previous = list(get_fallback_generalization_rules())
backup = GetModelCostMap.load_local_model_cost_map()
rules = backup.get("fallback_generalizations", {}).get("rules", [])
set_fallback_generalizations(rules)
try:
yield rules
finally:
set_fallback_generalizations(previous)
class TestClaudeModelPatternMatching:
"""
The ``anthropic-claude`` fallback generalization rule routes future Claude
models to the Anthropic provider without requiring a
model_prices_and_context_window.json entry. These tests exercise the rule
end-to-end through ``get_llm_provider`` and ``match_fallback_generalization``.
"""
@pytest.mark.parametrize(
"model",
[
"claude-opus-4-9",
"claude-opus-5-1",
"claude-sonnet-4-6",
"claude-sonnet-5-0",
"claude-haiku-4-5",
"claude-haiku-5-0",
"claude-opus-5-1-20270101",
"claude-sonnet-4-7-20260601",
"claude-haiku-4-6-20251201",
# A tier segment we don't know about today still routes: the regex
# accepts any [a-z]+ tier rather than a hard-coded opus|sonnet|haiku
# list, so a future tier is covered without a code change.
"claude-mini-4-5",
"claude-neptune-6-0",
],
)
def test_unknown_claude_routes_to_anthropic(self, model, shipped_generalizations):
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model)
assert custom_llm_provider == "anthropic"
@pytest.mark.parametrize(
"model",
[
"gpt-4",
"mistral-large",
"llama-3",
# Wrong order (variant before name)
"claude-4-opus",
# Missing version numbers
"claude-opus",
# Old format (claude-3-opus instead of claude-opus-3)
"claude-3-opus-20240229",
],
)
def test_non_matching_models_do_not_match_rule(
self, model, shipped_generalizations
):
from litellm.litellm_core_utils.fallback_generalizations import (
match_fallback_generalization,
)
assert match_fallback_generalization(model) is None
def test_routing_comes_from_the_rule_not_python(self, shipped_generalizations):
"""With the rule cleared, an unknown claude must no longer route to
anthropic; this guards against re-introducing a hard-coded Python regex."""
from litellm.litellm_core_utils.fallback_generalizations import (
set_fallback_generalizations,
)
set_fallback_generalizations([])
with pytest.raises(Exception):
litellm.get_llm_provider(model="claude-opus-4-9")