diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 9652a5738c8..e655e2ea87d 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -11,7 +11,7 @@ Request format: "input": { "messages": [{"role": "user", "content": [{"text": ""}]}] }, - "parameters": {"size": "1024*1024", ...} + "parameters": {"size": "1024*1024", "n": 1, ...} } Response format: @@ -19,7 +19,7 @@ Response format: "output": { "choices": [{"message": {"content": [{"image": ""}]}}] }, - "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,8 @@ else: DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" +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 +61,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 +85,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 +98,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, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f0a30db5b5c..1ccaab56cdf 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f0a30db5b5c..1ccaab56cdf 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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, diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py index c9f0df4febb..119efa010e0 100644 --- a/tests/test_litellm/test_dashscope_image_generation.py +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -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"