mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
* feat(dashscope): add Wan 3.0, Wan 2.7 and HappyHorse video generation via /v1/videos
DashScopeVideoConfig maps litellm's OpenAI Videos API onto Alibaba Model
Studio's async task API: create hits
/api/v1/services/aigc/video-generation/video-synthesis and status and content
poll /api/v1/tasks/{task_id}. It is registered for dashscope and for the
qwencloud and qwen_ai_platform brand aliases, following the same
get_dashscope_family_*_config pattern image generation uses. Unlike the
Sora-shaped providers the request body is nested, so prompt and media go under
input and everything else goes under parameters. X-DashScope-Async is always
sent, because DashScope rejects the call outright without it
OpenAI params translate to DashScope's contract. seconds becomes duration, and
size yields both a ratio and a named resolution tier (1280x720 gives 16:9 plus
720P), since sending only the ratio leaves the request on the default 1080P and
silently bills at that rate. input_reference becomes a single media entry,
base64 encoded into a data URI for file inputs. Which media type a model takes
(first_frame or, on the reference-to-video models, reference_image) is read
from provider_specific_entry.dashscope_video_reference_input via
get_model_info rather than hardcoded; models without it predate the media
array and get the flat img_url field. A caller-supplied media array always
wins, as that is the only way to express multi-role combinations
The create call is the only billed call, so it bills what DashScope will
deliver: the requested duration and tier, or the documented 5 second and 1080P
defaults when they are omitted. Smart duration (duration -1) lets the service
pick the length after that call, so it is rejected rather than recorded at
zero, with a 400. Task creation and task lookup both report failures as a 200 carrying
top-level code and message with no output, so that shape is raised instead of
surfacing as a queued video with an empty id. Status responses re-encode the
deployment model from the polled id, so follow-up polls and downloads route to
the same deployment. Timestamps parse as UTC+8. DashScope publishes no list,
delete or remix endpoint, so those raise rather than guess at a URL
The end-to-end tests drive the real avideo_generation() entrypoint through an
httpx MockTransport and assert on the JSON body httpx actually encoded. That is
what catches a mapper that drops input_reference before the request is built,
or a body fragment json cannot serialize, neither of which a test calling the
transform directly would see
Both model_prices maps gain wan3.0-video, wan3.0-video-prime, wan2.7-t2v,
wan2.7-i2v, wan2.7-r2v and the HappyHorse 1.0 and 1.1 t2v, i2v and r2v models,
for dashscope and the qwencloud and qwen_ai_platform aliases, priced per output
second by resolution tier from Model Studio's USD rate cards. dashscope and
qwen_ai_platform default to the Beijing host and use its rate card; qwencloud
defaults to the international host and uses Singapore's. The three
*_API_BASE_VIDEO overrides are documented in BerriAI/litellm-docs#1920
Co-authored-by: AaronHowell <237895480@qq.com>
* fix(dashscope): fail loudly on video responses that cannot be read
Addresses the review on the video generation PR. A body that is not the
documented task shape, an output block missing task_id or task_status, an
undocumented task_status and a missing output all raise DashScopeVideoError
now, instead of defaulting into a queued video with an empty id that callers
poll forever. A JSONDecodeError from a gateway response no longer escapes as
a generic connection error.
task_id and task_status are required on _TaskOutput, and a missing output
raises even when the body carries no code or message.
Download failures move into the 4xx range, since a 5xx has the proxy retry
them: 409 while the task is still running, 400 for a task that failed, 404
for an id that expired. The status a provider reported is kept when it
reported one, so an in-body failure that arrives on a 200 is billed as the
400 it is rather than as a retryable connection error.
characters and image are only dropped when drop_params is set; the params
that are rebuilt into the body or consumed by the caller (model, prompt,
user, extra_*) stay silently skipped, since the proxy stamps user from the
caller's end-user header.
Co-authored-by: AaronHowell <237895480@qq.com>
* fix(dashscope): clear the lint gates after merging main
* fix(dashscope): accept file tuples and paths as video input_reference
video_generation() types input_reference as FileTypes, which includes
(filename, content[, content_type[, headers]]) tuples and pathlib paths,
but the DashScope encoder only read bytes and file objects, so those
image-to-video calls raised before reaching DashScope. Bare strings stay
URLs and are never opened as paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashscope): snap size to a supported ratio and read file tuples and paths
* test(dashscope): assert whole mapped values and bill against hand-written rates
The tier pricing test read its expected rate from the same map the cost
calculator uses, so a wrong rate passed. Billing is now checked against
fixed rates driven through the status response usage, while the map test
only asserts every supported tier has its own rate. Size mapping tests
compare the full mapped dict, and the single-test wiring class is a
plain function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(videos): download content through the caller's client and stop retrying dead tasks
Two Cursor Bugbot findings on the DashScope video PR.
The DashScope artifact lives on OSS, so transform_video_content_response makes a
second request. It built its own cached client, discarding an injected client and
its ssl_verify. Thread the client through the content response the way
transform_video_status_retrieve_response already does, and use it in the three
providers that fetch from a second URL (DashScope, RunwayML, fal.ai). The
remaining providers accept it and read the response body, matching how they
already accept an unused client on status retrieve.
A FAILED or CANCELED task reported without a code or message fell past the
in-flight check into the 502 branch, so the proxy retried a task that can never
produce a video. Raise 400 for a terminal status instead.
* fix(dashscope): answer 404, not 409, when a Wan video is downloaded before it finishes
A download that lands while the task is still PENDING or RUNNING answered 409. That
reads as a client error, which was the intent, but litellm's retry predicate counts 409
as retryable, so the proxy polled DashScope twice more before giving up and then counted
the caller's own early download against the model's error rate
404 is what the OpenAI videos API documents for content that is not ready, and it is the
one status in that family the retry predicate leaves alone. The rest of the mapping is
unchanged: 400 for a task the provider reported as failed, 404 for an expired task id,
502 when a succeeded task carries no artifact
The download tests now drive avideo_content and assert the caller sees NotFoundError
rather than an internal server error
* fix(dashscope): route a video poll back to the model group that created the task
The create response encoded the upstream model name into the returned id. Both the
dashscope and qwen_ai_platform prefixes serve the same model names from their own hosts,
so a proxy with a deployment per region had deployments that only the model group told
apart. Encoding the bare model name resolved a poll to whichever deployment the router
lists first, which sent it to the other region's host with that region's key and a task
id that region never issued. That is the reroute the earlier proof records as the
dashscope and qwen_ai_platform poll ids coming back tagged qwencloud
The id now carries the model group the create was routed to, which is what the proxy
resolves a poll back to, and falls back to the model name for a direct SDK call that has
no router
The regression test drives a real router with one deployment per region, under a
different prefix and under the same one, and asserts the poll carries the key of the
region that created the task
* fix(dashscope): read the routed model group from litellm_metadata
The router stamps the model group it picked into litellm_metadata. metadata only
carries a copy when the caller sent no metadata of its own, and every proxy request
sends some, so a proxied create still encoded the bare model name and the poll
resolved to whichever region's deployment the router lists first
The regression test now also creates the video with caller metadata, which fails
without this change
* fix(dashscope): reject unbillable video requests and keep fal and runway on their own clients
* fix(dashscope): show the caller why a video task is gone or not ready instead of the bare upstream body
* fix(dashscope): check video request values before the billed create and honor per-call drop_params
Type check documented DashScope parameters so a bad seed answers 400 instead of a billed failed task,
pass the call's and deployment's drop_params into the video mapping, answer 400 for an expired task id so
the deployment is not cooled down, read DASHSCOPE_API_BASE for video, keep polling SUSPENDED tasks, and
refuse to map size onto models litellm has no price for.
* fix(dashscope): carry the requested model name in video ids so alias and wildcard keys can poll
A key scoped to an alias or to a name a wildcard serves could create a
video and then got a 403 on the poll and the download, because the id
carried the group that served the call (wan3, qwencloud/*) and the key
was never given that name. The id now carries the name the router was
asked for on that hop, and the router resolves an alias, routing group
or wildcard name in an id back to itself when no deployment name or
provider model matches it first
* fix(dashscope): keep a routing group's video polls on the member that created the task
A routing group spans several model groups, so an id carrying the group's name
load-balanced its polls, and some left for another region's host and key with a
task id that region never issued. The id now carries the member that served the
task when the request named a routing group, and keeps the requested name for a
model group, an alias, or a wildcard match.
* fix(dashscope): carry the requested and served model in video ids so group-scoped keys poll in the creating region
* fix(proxy): pin a video's served group only after key and team aliases resolve
* test(integration): audit DashScope video create, poll, download, routing and chaos paths
* fix(proxy): keep catch-all video polls on their provider and count served polls against the requested group
* test(routing): expect a forged served group to be dropped from the polled video id
* test(integration): cover rate-limited routing group polls and catch-all polls of DashScope videos
---------
Co-authored-by: AaronHowell <237895480@qq.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
497 lines
18 KiB
Python
497 lines
18 KiB
Python
"""Behavior pins for ``litellm/router_utils/pattern_match_deployments.py``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Final
|
|
from unittest.mock import Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import TypeAdapter
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
from litellm.litellm_core_utils import get_llm_provider_logic
|
|
from litellm.router import Deployment, LiteLLM_Params
|
|
from litellm.router_utils.pattern_match_deployments import PatternMatchRouter, PatternUtils
|
|
from litellm.types.router import ModelInfo
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
def _wildcard_deployment(model_name: str) -> dict:
|
|
return {"model_name": model_name, "litellm_params": {"model": model_name}}
|
|
|
|
|
|
def _matched_models(matches: list[dict] | None) -> list[str]:
|
|
return [deployment["litellm_params"]["model"] for deployment in matches or []]
|
|
|
|
|
|
def test_get_pattern_never_resolves_declared_authenticating_providers(monkeypatch):
|
|
"""Regression: resolving a github_copilot/chatgpt name through ``get_llm_provider`` runs the
|
|
provider's OAuth device flow; the auth layer walks every wildcard router on every request, so
|
|
a single metadata lookup for an unserved name would block the proxy's event loop."""
|
|
resolution_attempts: list[str] = []
|
|
|
|
def _oauth_tripwire(model, *args, **kwargs):
|
|
resolution_attempts.append(model)
|
|
raise AssertionError("get_llm_provider would run the OAuth device flow")
|
|
|
|
monkeypatch.setattr(get_llm_provider_logic, "get_llm_provider", _oauth_tripwire)
|
|
|
|
unmatched_router = PatternMatchRouter()
|
|
unmatched_router.add_pattern("anthropic/*", _wildcard_deployment("anthropic/*"))
|
|
assert unmatched_router.get_pattern("github_copilot/gpt-4o") is None
|
|
|
|
matched_router = PatternMatchRouter()
|
|
matched_router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*"))
|
|
assert _matched_models(matched_router.get_pattern("github_copilot/gpt-4o")) == ["github_copilot/gpt-4o"]
|
|
assert _matched_models(matched_router.get_pattern("gpt-4o", custom_llm_provider="github_copilot")) == [
|
|
"github_copilot/gpt-4o"
|
|
]
|
|
|
|
assert resolution_attempts == []
|
|
|
|
|
|
def test_get_pattern_bare_provider_name_never_matches_that_providers_wildcard(monkeypatch):
|
|
"""Regression: a bare ``github_copilot`` adopted itself as its provider and retried as
|
|
``github_copilot/github_copilot``, false-matching the wildcard for a name no deployment serves."""
|
|
|
|
def _unknown_provider(model, *args, **kwargs):
|
|
raise ValueError(f"unknown provider for {model}")
|
|
|
|
monkeypatch.setattr(get_llm_provider_logic, "get_llm_provider", _unknown_provider)
|
|
router = PatternMatchRouter()
|
|
router.add_pattern("github_copilot/*", _wildcard_deployment("github_copilot/*"))
|
|
assert router.get_pattern("github_copilot") is None
|
|
|
|
|
|
def test_get_pattern_missing_model_returns_none(monkeypatch):
|
|
"""Regression: a request without a model reaches the auth layer's pattern walk as ``None``; the
|
|
declared-provider guard raised ``TypeError`` where the old inline resolve swallowed every
|
|
resolver error, so the proxy's missing-model 400 became a crash."""
|
|
|
|
def _unknown_provider(model, *args, **kwargs):
|
|
raise ValueError(f"unknown provider for {model}")
|
|
|
|
monkeypatch.setattr(get_llm_provider_logic, "get_llm_provider", _unknown_provider)
|
|
router = PatternMatchRouter()
|
|
router.add_pattern("openai/*", _wildcard_deployment("openai/*"))
|
|
assert router.get_pattern(None) is None
|
|
|
|
|
|
def test_get_pattern_still_resolves_unqualified_names(monkeypatch):
|
|
monkeypatch.setattr(
|
|
get_llm_provider_logic,
|
|
"get_llm_provider",
|
|
lambda model, **kwargs: (model, "openai", None, None),
|
|
)
|
|
router = PatternMatchRouter()
|
|
router.add_pattern("openai/*", _wildcard_deployment("openai/*"))
|
|
assert _matched_models(router.get_pattern("gpt-4o")) == ["openai/gpt-4o"]
|
|
|
|
|
|
class _CountingPatternUtils(PatternUtils):
|
|
sorted_patterns = staticmethod(Mock(wraps=PatternUtils.sorted_patterns))
|
|
|
|
|
|
def test_route_never_sorts_and_the_most_specific_pattern_still_wins_after_registry_changes():
|
|
"""Regression for LIT-6886: the auth layer walks the wildcard registry for every request, so an
|
|
unmatched model name (an invalid-model 403) re-sorted every pattern by specificity per request and
|
|
a burst of rejections saturated the worker CPU. Lookups must not sort; adding a pattern or removing
|
|
a deployment must still leave the most specific pattern winning."""
|
|
router = PatternMatchRouter(pattern_utils=_CountingPatternUtils)
|
|
router.add_pattern("openai/*", _wildcard_deployment("openai/*"))
|
|
router.add_pattern("anthropic/*", _wildcard_deployment("anthropic/*"))
|
|
router.add_pattern("openai/gpt-*", {"model_name": "openai/gpt-*", "litellm_params": {"model": "azure/gpt-*"}})
|
|
sorts_after_setup = _CountingPatternUtils.sorted_patterns.call_count
|
|
|
|
for _ in range(3):
|
|
assert router.route("does-not-exist") is None
|
|
assert _matched_models(router.route("openai/gpt-4o")) == ["azure/gpt-4o"]
|
|
assert _matched_models(router.route("openai/o3")) == ["openai/o3"]
|
|
assert _CountingPatternUtils.sorted_patterns.call_count == sorts_after_setup
|
|
|
|
router.add_pattern("openai/*", {**_wildcard_deployment("openai/*"), "model_info": {"id": "id-1"}})
|
|
assert len(_matched_models(router.route("openai/o3"))) == 2
|
|
router.remove_deployment("id-1")
|
|
assert _matched_models(router.route("openai/gpt-4o")) == ["azure/gpt-4o"]
|
|
assert _matched_models(router.route("openai/o3")) == ["openai/o3"]
|
|
|
|
|
|
def test_pattern_match_router_initialization():
|
|
router = PatternMatchRouter()
|
|
assert router.patterns == {}
|
|
|
|
|
|
def test_add_pattern():
|
|
"""
|
|
Tests that openai/* is added to the patterns
|
|
|
|
when we try to get the pattern, it should return the deployment
|
|
"""
|
|
router = PatternMatchRouter()
|
|
deployment = Deployment(
|
|
model_name="openai-1",
|
|
litellm_params=LiteLLM_Params(model="gpt-3.5-turbo"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
router.add_pattern("openai/*", deployment.to_json(exclude_none=True))
|
|
assert len(router.patterns) == 1
|
|
assert list(router.patterns.keys())[0] == "openai/(.*)"
|
|
|
|
# try getting the pattern
|
|
assert router.route(request="openai/gpt-15") == [
|
|
deployment.to_json(exclude_none=True)
|
|
]
|
|
|
|
|
|
def test_add_pattern_vertex_ai():
|
|
"""
|
|
Tests that vertex_ai/* is added to the patterns
|
|
|
|
when we try to get the pattern, it should return the deployment
|
|
"""
|
|
router = PatternMatchRouter()
|
|
deployment = Deployment(
|
|
model_name="this-can-be-anything",
|
|
litellm_params=LiteLLM_Params(model="vertex_ai/gemini-1.5-flash-latest"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
router.add_pattern("vertex_ai/*", deployment.to_json(exclude_none=True))
|
|
assert len(router.patterns) == 1
|
|
assert list(router.patterns.keys())[0] == "vertex_ai/(.*)"
|
|
|
|
# try getting the pattern
|
|
assert router.route(request="vertex_ai/gemini-1.5-flash-latest") == [
|
|
deployment.to_json(exclude_none=True)
|
|
]
|
|
|
|
|
|
def test_add_multiple_deployments():
|
|
"""
|
|
Tests adding multiple deployments for the same pattern
|
|
|
|
when we try to get the pattern, it should return the deployment
|
|
"""
|
|
router = PatternMatchRouter()
|
|
deployment1 = Deployment(
|
|
model_name="openai-1",
|
|
litellm_params=LiteLLM_Params(model="gpt-3.5-turbo"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
deployment2 = Deployment(
|
|
model_name="openai-2",
|
|
litellm_params=LiteLLM_Params(model="gpt-4"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
router.add_pattern("openai/*", deployment1.to_json(exclude_none=True))
|
|
router.add_pattern("openai/*", deployment2.to_json(exclude_none=True))
|
|
assert len(router.route("openai/gpt-4o")) == 2
|
|
|
|
|
|
def test_pattern_to_regex():
|
|
"""
|
|
Tests that the pattern is converted to a regex
|
|
"""
|
|
router = PatternMatchRouter()
|
|
assert router.pattern_to_regex("openai/*") == "openai/(.*)"
|
|
assert (
|
|
router.pattern_to_regex("openai/fo::*::static::*")
|
|
== "openai/fo::(.*)::static::(.*)"
|
|
)
|
|
|
|
|
|
def test_route_with_none():
|
|
"""
|
|
Tests that the router returns None when the request is None
|
|
"""
|
|
router = PatternMatchRouter()
|
|
assert router.route(None) is None
|
|
|
|
|
|
def test_route_with_multiple_matching_patterns():
|
|
"""
|
|
Tests that the router returns the first matching pattern when there are multiple matching patterns
|
|
"""
|
|
router = PatternMatchRouter()
|
|
deployment1 = Deployment(
|
|
model_name="openai-1",
|
|
litellm_params=LiteLLM_Params(model="gpt-3.5-turbo"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
deployment2 = Deployment(
|
|
model_name="openai-2",
|
|
litellm_params=LiteLLM_Params(model="gpt-4"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
router.add_pattern("openai/*", deployment1.to_json(exclude_none=True))
|
|
router.add_pattern("openai/gpt-*", deployment2.to_json(exclude_none=True))
|
|
assert router.route("openai/gpt-3.5-turbo") == [
|
|
deployment2.to_json(exclude_none=True)
|
|
]
|
|
|
|
|
|
def test_route_with_exception():
|
|
"""
|
|
Tests that the router returns None when there is an exception calling router.route()
|
|
"""
|
|
router = PatternMatchRouter()
|
|
deployment = Deployment(
|
|
model_name="openai-1",
|
|
litellm_params=LiteLLM_Params(model="gpt-3.5-turbo"),
|
|
model_info=ModelInfo(),
|
|
)
|
|
router.add_pattern("openai/*", deployment.to_json(exclude_none=True))
|
|
|
|
router.patterns = (
|
|
[]
|
|
) # this will cause router.route to raise an exception, since router.patterns should be a dict
|
|
|
|
result = router.route("openai/gpt-3.5-turbo")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_route_with_no_matching_pattern():
|
|
"""
|
|
Tests that the router returns None when there is no matching pattern
|
|
"""
|
|
from litellm.types.router import RouterErrors
|
|
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "*meta.llama3*",
|
|
"litellm_params": {"model": "bedrock/meta.llama3*"},
|
|
}
|
|
]
|
|
)
|
|
|
|
## WORKS
|
|
result = await router.acompletion(
|
|
model="bedrock/meta.llama3-70b",
|
|
messages=[{"role": "user", "content": "Hello, world!"}],
|
|
mock_response="Works",
|
|
)
|
|
assert result.choices[0].message.content == "Works"
|
|
|
|
## WORKS
|
|
result = await router.acompletion(
|
|
model="meta.llama3-70b-instruct-v1:0",
|
|
messages=[{"role": "user", "content": "Hello, world!"}],
|
|
mock_response="Works",
|
|
)
|
|
assert result.choices[0].message.content == "Works"
|
|
|
|
## FAILS
|
|
with pytest.raises(litellm.BadRequestError) as e:
|
|
await router.acompletion(
|
|
model="my-fake-model",
|
|
messages=[{"role": "user", "content": "Hello, world!"}],
|
|
mock_response="Works",
|
|
)
|
|
|
|
assert RouterErrors.no_deployments_available.value not in str(e.value)
|
|
|
|
with pytest.raises(litellm.BadRequestError):
|
|
await router.aembedding(
|
|
model="my-fake-model",
|
|
input="Hello, world!",
|
|
)
|
|
|
|
|
|
def test_router_pattern_match_e2e():
|
|
"""
|
|
Tests the end to end flow of the router
|
|
"""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
|
|
client = HTTPHandler()
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "llmengine/*",
|
|
"litellm_params": {"model": "anthropic/*", "api_key": "test"},
|
|
}
|
|
]
|
|
)
|
|
|
|
with patch.object(client, "post", new=MagicMock()) as mock_post:
|
|
|
|
router.completion(
|
|
model="llmengine/my-custom-model",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
client=client,
|
|
api_key="test",
|
|
)
|
|
mock_post.assert_called_once()
|
|
request_body = json.loads(mock_post.call_args.kwargs["data"])
|
|
assert request_body["model"] == "my-custom-model"
|
|
assert request_body["messages"] == [
|
|
{"role": "user", "content": [{"type": "text", "text": "Hello, how are you?"}]}
|
|
]
|
|
|
|
|
|
def test_pattern_matching_router_with_default_wildcard_and_model_wildcard():
|
|
"""
|
|
Match to more specific pattern first.
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "*",
|
|
"litellm_params": {"model": "*"},
|
|
"model_info": {"access_groups": ["default"]},
|
|
},
|
|
{
|
|
"model_name": "llmengine/*",
|
|
"litellm_params": {"model": "openai/*"},
|
|
},
|
|
]
|
|
)
|
|
|
|
assert len(router.pattern_router.patterns) > 0
|
|
|
|
pattern_router = router.pattern_router
|
|
deployments = pattern_router.route("llmengine/gpt-3.5-turbo")
|
|
assert len(deployments) == 1
|
|
assert deployments[0]["model_name"] == "llmengine/*"
|
|
|
|
|
|
def test_pattern_matching_router_uses_default_wildcard_for_unmatched_model() -> None:
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "*",
|
|
"litellm_params": {"model": "*"},
|
|
},
|
|
{
|
|
"model_name": "anthropic-*",
|
|
"litellm_params": {"model": "anthropic/claude"},
|
|
},
|
|
]
|
|
)
|
|
|
|
deployments: Final = router.pattern_router.route("gpt-4o-mini")
|
|
|
|
assert tuple(deployment["model_name"] for deployment in deployments) == ("*",)
|
|
|
|
|
|
def test_sorted_patterns():
|
|
"""
|
|
Tests that the pattern specificity is calculated correctly
|
|
"""
|
|
from litellm.router_utils.pattern_match_deployments import PatternUtils
|
|
|
|
sorted_patterns = PatternUtils.sorted_patterns(
|
|
{
|
|
"llmengine/*": [{"model_name": "anthropic/claude-3-5-sonnet"}],
|
|
"*": [{"model_name": "openai/*"}],
|
|
},
|
|
)
|
|
assert sorted_patterns[0][0] == "llmengine/*"
|
|
|
|
|
|
def test_calculate_pattern_specificity():
|
|
from litellm.router_utils.pattern_match_deployments import PatternUtils
|
|
|
|
assert PatternUtils.calculate_pattern_specificity("llmengine/*") == (11, 1)
|
|
assert PatternUtils.calculate_pattern_specificity("*") == (1, 1)
|
|
|
|
|
|
def test_wildcard_priority_over_deployment_names():
|
|
"""
|
|
Test that wildcard routes take priority over deployment_names (litellm_params.model) matching.
|
|
|
|
Scenario:
|
|
- deployment 1: model_name="zapier-multi-provider-text-embedding-3-small", model="openai/text-embedding-3-small"
|
|
- deployment 2: model_name="*", model="openai/*"
|
|
- deployment 3: model_name="openai/*", model="openai/*"
|
|
|
|
When calling "openai/text-embedding-3-small", it should match deployment 3 (wildcard),
|
|
NOT deployment 1 (even though deployment 1's litellm_params.model matches).
|
|
|
|
Priority order should be:
|
|
1. Exact model_name match
|
|
2. Wildcard model_name match
|
|
3. deployment_names (litellm_params.model) match
|
|
"""
|
|
router = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "zapier-multi-provider-text-embedding-3-small",
|
|
"litellm_params": {
|
|
"model": "openai/text-embedding-3-small",
|
|
"api_base": "http://localhost:8080/openai",
|
|
"api_key": "test-key-1",
|
|
},
|
|
"model_info": {
|
|
"id": "zapier-multi-provider-text-embedding-3-small-openai"
|
|
},
|
|
},
|
|
{
|
|
"model_name": "*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
"api_base": "http://localhost:8081/openai",
|
|
"api_key": "test-key-2",
|
|
},
|
|
},
|
|
{
|
|
"model_name": "openai/*",
|
|
"litellm_params": {
|
|
"model": "openai/*",
|
|
"api_base": "http://localhost:8082/openai",
|
|
"api_key": "test-key-3",
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
# Test 1: Request "openai/text-embedding-3-small" should match wildcard "openai/*", not deployment_names
|
|
deployments = router.get_model_list(model_name="openai/text-embedding-3-small")
|
|
|
|
assert deployments is not None, "No deployments found"
|
|
assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}"
|
|
|
|
# Should match the "openai/*" wildcard deployment (api_base ending in 8082)
|
|
assert (
|
|
deployments[0]["litellm_params"]["api_base"] == "http://localhost:8082/openai"
|
|
), f"Expected wildcard deployment (8082), got {deployments[0]['litellm_params']['api_base']}"
|
|
|
|
# Test 2: Request exact model_name should still work
|
|
deployments = router.get_model_list(
|
|
model_name="zapier-multi-provider-text-embedding-3-small"
|
|
)
|
|
|
|
assert deployments is not None, "No deployments found"
|
|
assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}"
|
|
assert (
|
|
deployments[0]["litellm_params"]["api_base"] == "http://localhost:8080/openai"
|
|
), f"Expected exact match deployment (8080), got {deployments[0]['litellm_params']['api_base']}"
|
|
|
|
# Test 3: Request with "*" wildcard should match the "*" deployment
|
|
deployments = router.get_model_list(model_name="some-random-model")
|
|
|
|
assert deployments is not None, "No deployments found"
|
|
assert len(deployments) == 1, f"Expected 1 deployment, got {len(deployments)}"
|
|
assert (
|
|
deployments[0]["litellm_params"]["api_base"] == "http://localhost:8081/openai"
|
|
), f"Expected '*' wildcard deployment (8081), got {deployments[0]['litellm_params']['api_base']}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("litellm_params", "custom_llm_provider", "expected"),
|
|
[
|
|
({"model": "openai/*"}, "openai", True),
|
|
({"model": "openai/*"}, "dashscope", False),
|
|
({"model": "*", "custom_llm_provider": "dashscope"}, "dashscope", True),
|
|
],
|
|
)
|
|
def test_routes_to_provider_matches_the_provider_the_wildcard_deployment_sends_to(
|
|
litellm_params, custom_llm_provider, expected
|
|
):
|
|
router = PatternMatchRouter()
|
|
router.add_pattern("*", {"model_name": "*", "litellm_params": litellm_params})
|
|
assert router.routes_to_provider("wan3.0-video", custom_llm_provider) is expected
|