feat(dashscope): support qwen-image-3.0 and qwen-image-3.0-pro image generation

Register both models, route image requests to the multimodal generation endpoint instead of the chat compatible-mode base, and pass OpenAI n through as DashScope n so multi-image requests return every image.
This commit is contained in:
Devin AI 2026-08-27 01:59:48 +00:00
parent 3eba0b332a
commit f7c9c87280
4 changed files with 141 additions and 12 deletions

View file

@ -11,7 +11,7 @@ Request format:
"input": {
"messages": [{"role": "user", "content": [{"text": "<prompt>"}]}]
},
"parameters": {"size": "1024*1024", ...}
"parameters": {"size": "1024*1024", "n": 1, ...}
}
Response format:
@ -19,7 +19,7 @@ Response format:
"output": {
"choices": [{"message": {"content": [{"image": "<url>"}]}}]
},
"usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}
"usage": {"output_width": 1024, "output_height": 1024, "output_image_count": 1}
}
"""
@ -46,6 +46,9 @@ else:
DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
# get_llm_provider resolves every dashscope route to the chat/embed base, which cannot serve images
CHAT_COMPATIBLE_MODE_PATH: Final = "/compatible-mode/v1"
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
"256x256": "256*256",
@ -59,7 +62,8 @@ OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
"""
Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro).
Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro,
qwen-image-3.0, qwen-image-3.0-pro).
"""
def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]:
@ -82,8 +86,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
if k == "size":
# Convert "WxH" → "W*H"
mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*"))
elif k == "n":
mapped["image_count"] = v
else:
mapped[k] = v
return mapped
def get_complete_url(
@ -95,7 +99,10 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
litellm_params: dict,
stream: bool | None = None,
) -> str:
return api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
image_api_base: Final = (
api_base if api_base and not api_base.rstrip("/").endswith(CHAT_COMPATIBLE_MODE_PATH) else None
)
return image_api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE
def validate_environment(
self,

View file

@ -14647,6 +14647,22 @@
"/v1/images/generations"
]
},
"dashscope/qwen-image-3.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"dashscope/qwen-image-3.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"databricks/databricks-bge-large-en": {
"cache_creation_input_token_cost": 1.0003e-07,
"cache_read_input_token_cost": 1.0003e-07,

View file

@ -14647,6 +14647,22 @@
"/v1/images/generations"
]
},
"dashscope/qwen-image-3.0": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"dashscope/qwen-image-3.0-pro": {
"litellm_provider": "dashscope",
"mode": "image_generation",
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
"supported_endpoints": [
"/v1/images/generations"
]
},
"databricks/databricks-bge-large-en": {
"cache_creation_input_token_cost": 1.0003e-07,
"cache_read_input_token_cost": 1.0003e-07,

View file

@ -1,5 +1,6 @@
"""
Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro).
Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro,
qwen-image-3.0, qwen-image-3.0-pro).
Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v
"""
@ -30,6 +31,8 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException
[
"dashscope/qwen-image-2.0",
"dashscope/qwen-image-2.0-pro",
"dashscope/qwen-image-3.0",
"dashscope/qwen-image-3.0-pro",
],
)
def test_get_llm_provider_returns_dashscope(model_string: str):
@ -48,6 +51,8 @@ def test_get_llm_provider_returns_dashscope(model_string: str):
[
("dashscope/qwen-image-2.0", "dashscope"),
("dashscope/qwen-image-2.0-pro", "dashscope"),
("dashscope/qwen-image-3.0", "dashscope"),
("dashscope/qwen-image-3.0-pro", "dashscope"),
],
)
def test_get_model_info_mode_is_image_generation(
@ -93,6 +98,19 @@ class TestDashScopeImageGenerationConfig:
url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {})
assert url == custom
@pytest.mark.parametrize(
"chat_api_base",
[
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/",
],
)
def test_get_complete_url_ignores_chat_compatible_mode_base(
self, chat_api_base: str
):
url = self.cfg.get_complete_url(chat_api_base, None, "qwen-image-3.0", {}, {})
assert url == DEFAULT_API_BASE
def test_validate_environment_sets_auth_header(self):
headers = self.cfg.validate_environment(
headers={},
@ -135,6 +153,27 @@ class TestDashScopeImageGenerationConfig:
assert messages[0]["content"][0]["text"] == "a puppy on green grass"
assert req["parameters"]["size"] == "1024*1024"
@pytest.mark.parametrize("model", ["qwen-image-3.0", "qwen-image-3.0-pro"])
def test_transform_request_qwen_image_3(self, model: str):
req = self.cfg.transform_image_generation_request(
model=model,
prompt="a poster with small multilingual text",
optional_params=self.cfg.map_openai_params(
non_default_params={"size": "2048x2048", "n": 6},
optional_params={},
model=model,
drop_params=False,
),
litellm_params={},
headers={},
)
assert req["model"] == model
assert req["input"]["messages"][0]["content"][0]["text"] == (
"a poster with small multilingual text"
)
assert req["parameters"]["size"] == "2048*2048"
assert req["parameters"]["n"] == 6
def test_transform_request_empty_params(self):
req = self.cfg.transform_image_generation_request(
model="qwen-image-2.0-pro",
@ -238,6 +277,48 @@ class TestDashScopeImageGenerationConfig:
assert result.data[0].url == "https://example.com/img1.png"
assert result.data[1].url == "https://example.com/img2.png"
def test_transform_response_multiple_images_in_one_choice(self):
body = {
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{"image": "https://example.com/img1.png", "type": "image"},
{"image": "https://example.com/img2.png", "type": "image"},
],
},
}
]
},
"usage": {
"output_width": 1024,
"output_height": 1024,
"output_image_count": 2,
},
}
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.headers = {}
mock_resp.json.return_value = body
result = self.cfg.transform_image_generation_response(
model="qwen-image-3.0",
raw_response=mock_resp,
model_response=ImageResponse(),
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert [image.url for image in result.data] == [
"https://example.com/img1.png",
"https://example.com/img2.png",
]
def test_transform_response_raises_on_non_200_status(self):
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 400
@ -294,14 +375,14 @@ class TestDashScopeImageGenerationConfig:
)
assert mapped["size"] == "1024*1024"
def test_map_openai_params_n_to_image_count(self):
def test_map_openai_params_n_passthrough(self):
mapped = self.cfg.map_openai_params(
non_default_params={"n": 2},
optional_params={},
model="qwen-image-2.0",
drop_params=False,
)
assert mapped["image_count"] == 2
assert mapped == {"n": 2}
def test_map_openai_params_unknown_size_uses_asterisk(self):
mapped = self.cfg.map_openai_params(
@ -338,7 +419,15 @@ class TestDashScopeImageGenerationConfig:
# ---------------------------------------------------------------------------
def test_litellm_image_generation_dashscope_end_to_end():
@pytest.mark.parametrize(
"model",
[
"dashscope/qwen-image-2.0",
"dashscope/qwen-image-3.0",
"dashscope/qwen-image-3.0-pro",
],
)
def test_litellm_image_generation_dashscope_end_to_end(model: str):
mock_response_body = {
"output": {
"choices": [
@ -374,7 +463,7 @@ def test_litellm_image_generation_dashscope_end_to_end():
mock_post.return_value = mock_http_response
response = litellm.image_generation(
model="dashscope/qwen-image-2.0",
model=model,
prompt="a puppy playing on green grass",
api_key="sk-test-key",
size="1024x1024",
@ -392,7 +481,7 @@ def test_litellm_image_generation_dashscope_end_to_end():
called_url = (
call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "")
)
assert "dashscope" in called_url or "aliyuncs" in called_url
assert called_url == DEFAULT_API_BASE
# Verify request body contains DashScope format
call_kwargs = call_args[1] if call_args[1] else {}
@ -400,3 +489,4 @@ def test_litellm_image_generation_dashscope_end_to_end():
body = call_kwargs["json"]
assert "input" in body
assert "messages" in body["input"]
assert body["parameters"]["size"] == "1024*1024"