litellm/tests/unit/router_utils/test_pattern_match_deployments.py
Rick 4f3cb48dc1
feat(dashscope): add Wan 3.0, Wan 2.7 and HappyHorse video generation via /v1/videos (#43706)
* 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>
2026-10-14 10:10:07 -07:00

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