update test and multimodal

This commit is contained in:
yrk 2026-05-19 14:40:29 +08:00
parent 1b9acac18a
commit 76d880a93b
5 changed files with 390 additions and 96 deletions

View file

@ -1085,11 +1085,13 @@ dashscope_models: set = set(
]
)
nebius_embedding_models: List = [
"BAAI/bge-en-icl",
"BAAI/bge-multilingual-gemma2",
"intfloat/e5-mistral-7b-instruct",
]
nebius_embedding_models: set = set(
[
"BAAI/bge-en-icl",
"BAAI/bge-multilingual-gemma2",
"intfloat/e5-mistral-7b-instruct",
]
)
WANDB_MODELS: set = set(
[
@ -1120,45 +1122,47 @@ WANDB_MODELS: set = set(
]
)
modelscope_models: List = [
# Qwen series models
"Qwen/Qwen3-0.6B",
"Qwen/Qwen3-1.7B",
"Qwen/Qwen3-4B",
"Qwen/Qwen3-8B",
"Qwen/Qwen3-14B",
"Qwen/Qwen3-30B-A3B",
"Qwen/Qwen3-32B",
"Qwen/Qwen3-235B-A22B",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-30B-A3B-Thinking-2507",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Thinking",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-VL-8B-Instruct",
"Qwen/Qwen3-VL-8B-Thinking",
"Qwen/Qwen3.5-122B-A10B",
"Qwen/Qwen3.5-27B",
"Qwen/Qwen3.5-35B-A3B",
"Qwen/Qwen3.5-397B-A17B",
"Qwen/QwQ-32B",
"Qwen/QwQ-32B-Preview",
"Qwen/QVQ-72B-Preview",
"Qwen/Qwen-Image-Edit",
# DeepSeek series models
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
"deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-V4-Flash",
]
modelscope_models: set = set(
[
# Qwen series models
"Qwen/Qwen3-0.6B",
"Qwen/Qwen3-1.7B",
"Qwen/Qwen3-4B",
"Qwen/Qwen3-8B",
"Qwen/Qwen3-14B",
"Qwen/Qwen3-30B-A3B",
"Qwen/Qwen3-32B",
"Qwen/Qwen3-235B-A22B",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-30B-A3B-Thinking-2507",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Thinking",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-VL-8B-Instruct",
"Qwen/Qwen3-VL-8B-Thinking",
"Qwen/Qwen3.5-122B-A10B",
"Qwen/Qwen3.5-27B",
"Qwen/Qwen3.5-35B-A3B",
"Qwen/Qwen3.5-397B-A17B",
"Qwen/QwQ-32B",
"Qwen/QwQ-32B-Preview",
"Qwen/QVQ-72B-Preview",
"Qwen/Qwen-Image-Edit",
# DeepSeek series models
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
"deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-V4-Flash",
]
)
BEDROCK_INVOKE_PROVIDERS_LITERAL = Literal[
"cohere",

View file

@ -4,15 +4,20 @@ Translates from OpenAI's `/v1/chat/completions` to ModelScope's `/v1/chat/comple
from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
def _has_non_text_content(message: AllMessageValues) -> bool:
"""Check if a message has non-text content items (e.g. image_url)."""
content = message.get("content")
if not isinstance(content, list):
return False
return any(item.get("type") != "text" for item in content)
class ModelScopeChatConfig(OpenAIGPTConfig):
DEFAULT_BASE_URL: str = "https://api-inference.modelscope.cn/v1"
@ -33,9 +38,20 @@ class ModelScopeChatConfig(OpenAIGPTConfig):
self, messages: List[AllMessageValues], model: str, is_async: bool = False
) -> Union[List[AllMessageValues], Coroutine[Any, Any, List[AllMessageValues]]]:
"""
ModelScope does not support content in list format.
Flatten text-only content lists to strings for ModelScope.
Messages with non-text content (e.g. image_url for vision models)
are kept as lists so the parent class can normalize them properly.
"""
messages = handle_messages_with_content_list_to_str_conversion(messages)
for message in messages:
if _has_non_text_content(message):
continue
content = message.get("content")
if isinstance(content, list):
message["content"] = "".join(
item.get("text") or "" for item in content
)
if is_async:
return super()._transform_messages(
messages=messages, model=model, is_async=True

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, List, Optional, Union
import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
@ -176,7 +177,6 @@ class ModelScopeImageGenerationConfig(BaseImageGenerationConfig):
error_message=f"Error parsing ModelScope response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
model=model,
)
# Check for errors in response
@ -188,7 +188,6 @@ class ModelScopeImageGenerationConfig(BaseImageGenerationConfig):
error_message=f"ModelScope error: {error_msg}",
status_code=raw_response.status_code,
headers=raw_response.headers,
model=model,
)
# Extract images from response
@ -211,8 +210,7 @@ class ModelScopeImageGenerationConfig(BaseImageGenerationConfig):
error_message: str,
status_code: int,
headers: Union[dict, httpx.Headers],
model: Optional[str] = None,
) -> Exception:
) -> BaseLLMException:
"""Return the appropriate error class for ModelScope."""
from litellm.exceptions import (
AuthenticationError,
@ -221,26 +219,26 @@ class ModelScopeImageGenerationConfig(BaseImageGenerationConfig):
)
if status_code == 400:
return BadRequestError(
return BadRequestError( # type: ignore[return-value]
message=error_message,
model=model or "",
model="",
llm_provider="modelscope",
)
elif status_code == 401:
return AuthenticationError(
return AuthenticationError( # type: ignore[return-value]
message=error_message,
model=model or "",
model="",
llm_provider="modelscope",
)
elif status_code >= 500:
return InternalServerError(
return InternalServerError( # type: ignore[return-value]
message=error_message,
model=model or "",
model="",
llm_provider="modelscope",
)
else:
return BadRequestError(
return BadRequestError( # type: ignore[return-value]
message=error_message,
model=model or "",
model="",
llm_provider="modelscope",
)

View file

@ -5,6 +5,7 @@ These tests validate the ModelScopeChatConfig class which extends OpenAIGPTConfi
ModelScope is an OpenAI-compatible provider with minor customizations.
"""
import json
import os
import sys
@ -12,12 +13,18 @@ sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from unittest.mock import patch
import httpx
import pytest
import respx
import litellm
from litellm import completion
from litellm.llms.modelscope.chat.transformation import ModelScopeChatConfig
DEFAULT_MODEL = "Qwen/Qwen3.5-35B-A3B"
class TestModelScopeConfig:
"""Test class for ModelScope functionality"""
@ -28,55 +35,40 @@ class TestModelScopeConfig:
headers = {}
api_key = "fake-modelscope-key"
# Call validate_environment without specifying api_base
result = config.validate_environment(
headers=headers,
model="Qwen/Qwen3-8B",
model=DEFAULT_MODEL,
messages=[{"role": "user", "content": "Hey"}],
optional_params={},
litellm_params={},
api_key=api_key,
api_base=None, # Not providing api_base
api_base=None,
)
# Verify headers are still set correctly
assert result["Authorization"] == f"Bearer {api_key}"
assert result["Content-Type"] == "application/json"
# We can't directly test the api_base value here since validate_environment
# only returns the headers, but we can verify it doesn't raise an exception
# which would happen if api_base handling was incorrect
@pytest.mark.respx()
def test_modelscope_completion_mock(self, respx_mock):
"""
Mock test for ModelScope completion using the model format from docs.
This test mocks the actual HTTP request to test the integration properly.
"""
"""Mock test for basic ModelScope completion."""
litellm.disable_aiohttp_transport = (
True # since this uses respx, we need to set use_aiohttp_transport to False
)
litellm.disable_aiohttp_transport = True
# Set up environment variables for the test
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
model = "modelscope/Qwen/Qwen3-8B" # Use modelscope/ prefix to specify provider
model_name = "Qwen3-8B" # The actual model name without provider prefix
# Mock the HTTP request to the ModelScope API
respx_mock.post(f"{api_base}/chat/completions").respond(
json={
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": model_name,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": '```python\nprint("Hey from LiteLLM!")\n```\n\nThis simple Python code prints a greeting message from LiteLLM.',
"content": '```python\nprint("Hey from LiteLLM!")\n```',
},
"finish_reason": "stop",
}
@ -90,9 +82,8 @@ class TestModelScopeConfig:
status_code=200,
)
# Make the actual API call through LiteLLM
response = completion(
model=model,
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{"role": "user", "content": "write code for saying hey from LiteLLM"}
],
@ -100,15 +91,304 @@ class TestModelScopeConfig:
api_base=api_base,
)
# Verify response structure
assert response is not None
assert hasattr(response, "choices")
assert len(response.choices) > 0
assert hasattr(response.choices[0], "message")
assert hasattr(response.choices[0].message, "content")
assert response.choices[0].message.content is not None
# Check for specific content in the response
assert "```python" in response.choices[0].message.content
assert "Hey from LiteLLM" in response.choices[0].message.content
# ── _transform_messages tests ──────────────────────────────────────
def test_transform_messages_flattens_text_content_list(self):
"""Content lists containing only text items should be flattened to a string."""
config = ModelScopeChatConfig()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": " world"},
],
}
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hello world"
def test_transform_messages_preserves_multimodal_content_list(self):
"""Content lists with image_url should be preserved as lists for vision models."""
config = ModelScopeChatConfig()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
],
}
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert isinstance(result[0]["content"], list)
assert len(result[0]["content"]) == 2
assert result[0]["content"][0]["type"] == "text"
assert result[0]["content"][1]["type"] == "image_url"
def test_transform_messages_string_content_unchanged(self):
"""Messages with string content should pass through unchanged."""
config = ModelScopeChatConfig()
messages = [{"role": "user", "content": "Hello"}]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hello"
def test_transform_messages_multi_turn(self):
"""Multi-turn conversations should be handled correctly."""
config = ModelScopeChatConfig()
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{
"role": "user",
"content": [
{"type": "text", "text": "Tell me more"},
],
},
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hi"
assert result[1]["content"] == "Hello!"
assert result[2]["content"] == "Tell me more"
def test_transform_messages_multimodal_multi_turn(self):
"""Multi-turn with mixed text-only and multimodal messages."""
config = ModelScopeChatConfig()
messages = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"},
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}},
],
},
]
result = config._transform_messages(messages=messages, model=DEFAULT_MODEL)
assert result[0]["content"] == "Hi"
assert result[1]["content"] == "Hello!"
# Multimodal message should keep list format
assert isinstance(result[2]["content"], list)
assert result[2]["content"][1]["type"] == "image_url"
# ── get_complete_url tests ─────────────────────────────────────────
def test_get_complete_url_default(self):
"""Default api_base should append /chat/completions."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base=None,
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://api-inference.modelscope.cn/v1/chat/completions"
def test_get_complete_url_custom_base(self):
"""Custom api_base should append /chat/completions."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base="https://custom.modelscope.cn/v1",
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://custom.modelscope.cn/v1/chat/completions"
def test_get_complete_url_already_has_endpoint(self):
"""api_base already ending in /chat/completions should not be doubled."""
config = ModelScopeChatConfig()
url = config.get_complete_url(
api_base="https://api-inference.modelscope.cn/v1/chat/completions",
api_key="fake-key",
model=DEFAULT_MODEL,
optional_params={},
litellm_params={},
)
assert url == "https://api-inference.modelscope.cn/v1/chat/completions"
assert url.count("/chat/completions") == 1
# ── _get_openai_compatible_provider_info tests ─────────────────────
def test_get_provider_info_with_explicit_api_base(self):
"""Explicit api_base and api_key should be returned as-is."""
config = ModelScopeChatConfig()
api_base, api_key = config._get_openai_compatible_provider_info(
api_base="https://custom.example.com/v1",
api_key="my-key",
)
assert api_base == "https://custom.example.com/v1"
assert api_key == "my-key"
def test_get_provider_info_default_fallback(self):
"""When no api_base or env var is set, DEFAULT_BASE_URL should be used."""
config = ModelScopeChatConfig()
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("MODELSCOPE_API_BASE", None)
os.environ.pop("MODELSCOPE_API_KEY", None)
api_base, api_key = config._get_openai_compatible_provider_info(
api_base=None,
api_key=None,
)
assert api_base == "https://api-inference.modelscope.cn/v1"
assert api_key is None
def test_get_provider_info_env_var_fallback(self):
"""MODELSCOPE_API_BASE env var should be used when api_base is not provided."""
config = ModelScopeChatConfig()
with patch.dict(
os.environ,
{"MODELSCOPE_API_BASE": "https://env.modelscope.cn/v1"},
):
api_base, _ = config._get_openai_compatible_provider_info(
api_base=None,
api_key=None,
)
assert api_base == "https://env.modelscope.cn/v1"
# ── Mock HTTP tests ────────────────────────────────────────────────
@pytest.mark.respx()
def test_completion_with_text_content_list(self, respx_mock):
"""Verify that text-only content list messages are flattened before sending."""
litellm.disable_aiohttp_transport = True
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
captured_request = {}
def capture_request(request):
captured_request["body"] = request.content
return httpx.Response(
200,
json={
"id": "chatcmpl-456",
"object": "chat.completion",
"created": 1677652288,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Sure!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6},
},
)
respx_mock.post(f"{api_base}/chat/completions").mock(side_effect=capture_request)
response = completion(
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": " world"},
],
}
],
api_key=api_key,
api_base=api_base,
)
assert response.choices[0].message.content == "Sure!"
body = json.loads(captured_request["body"])
assert isinstance(body["messages"][0]["content"], str)
assert body["messages"][0]["content"] == "Hello world"
@pytest.mark.respx()
def test_completion_with_multimodal_messages(self, respx_mock):
"""Verify that multimodal messages (text + image_url) are sent as content lists."""
litellm.disable_aiohttp_transport = True
api_key = "fake-modelscope-key"
api_base = "https://api-inference.modelscope.cn/v1"
captured_request = {}
def capture_request(request):
captured_request["body"] = request.content
return httpx.Response(
200,
json={
"id": "chatcmpl-789",
"object": "chat.completion",
"created": 1677652288,
"model": DEFAULT_MODEL,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "A cat sitting on a couch.",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 8, "total_tokens": 108},
},
)
respx_mock.post(f"{api_base}/chat/completions").mock(side_effect=capture_request)
response = completion(
model=f"modelscope/{DEFAULT_MODEL}",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.jpg"},
},
],
}
],
api_key=api_key,
api_base=api_base,
)
assert response.choices[0].message.content == "A cat sitting on a couch."
body = json.loads(captured_request["body"])
msg = body["messages"][0]
# Multimodal content should remain as a list
assert isinstance(msg["content"], list)
assert len(msg["content"]) == 2
assert msg["content"][0] == {"type": "text", "text": "What is in this image?"}
assert msg["content"][1]["type"] == "image_url"
assert msg["content"][1]["image_url"]["url"] == "https://example.com/cat.jpg"

View file

@ -415,7 +415,6 @@ class TestModelScopeImageGenerationTransformation:
error_message="Bad request",
status_code=400,
headers={"Content-Type": "application/json"},
model=self.model,
)
assert isinstance(error, BadRequestError)
@ -428,7 +427,6 @@ class TestModelScopeImageGenerationTransformation:
error_message="Invalid API key",
status_code=401,
headers={"Content-Type": "application/json"},
model=self.model,
)
assert isinstance(error, AuthenticationError)
@ -441,7 +439,6 @@ class TestModelScopeImageGenerationTransformation:
error_message="Internal server error",
status_code=500,
headers={"Content-Type": "application/json"},
model=self.model,
)
assert isinstance(error, InternalServerError)
@ -454,7 +451,6 @@ class TestModelScopeImageGenerationTransformation:
error_message="Some error",
status_code=404,
headers={"Content-Type": "application/json"},
model=self.model,
)
assert isinstance(error, BadRequestError)