feat(providers): add ModelScope provider for LLM and image generation

This commit is contained in:
yrk
2026-07-22 01:35:20 +08:00
committed by Xubin Ren
parent b32d673ead
commit 9abad4746e
9 changed files with 585 additions and 3 deletions
+2
View File
@@ -254,6 +254,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **ModelScope (魔搭社区)**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config.
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
@@ -288,6 +289,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `modelscope` | LLM (ModelScope/魔搭) + Image generation | [modelscope.cn](https://modelscope.cn) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
+26 -3
View File
@@ -34,7 +34,7 @@ This snippet uses the current built-in image-generation default so the JSON has
}
```
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples.
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, Zhipu, and ModelScope configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -55,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `modelscope` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -319,6 +319,29 @@ Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be speci
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
### ModelScope
ModelScope (魔搭社区) API-Inference supports text-to-image generation and image editing via an async task pattern.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1536x1024`) or using aspect ratio presets.
```json
{
"providers": {
"modelscope": {
"apiKey": "${MODELSCOPE_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "modelscope",
"model": "Qwen/Qwen-Image-2512"
}
}
}
```
## Artifacts
Generated images are stored under the active nanobot instance's media directory:
@@ -373,7 +396,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+1
View File
@@ -245,6 +245,7 @@ class ProvidersConfig(Base):
groq: ProviderConfig = Field(default_factory=ProviderConfig)
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
modelscope: ProviderConfig = Field(default_factory=ProviderConfig)
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
+187
View File
@@ -1752,6 +1752,192 @@ async def _zhipu_images_from_payload(
return images
# ---------------------------------------------------------------------------
# ModelScope (魔搭) image generation
# ---------------------------------------------------------------------------
_MODELSCOPE_TIMEOUT_S = 300.0
_MODELSCOPE_POLL_INTERVAL_S = 5.0
_MODELSCOPE_POLL_MAX_ATTEMPTS = 60 # 5 min at 5s intervals
_MODELSCOPE_ASPECT_RATIOS = {
"1:1": "1024x1024",
"16:9": "1536x1024",
"9:16": "1024x1536",
"3:4": "1024x1536",
"4:3": "1536x1024",
}
def _modelscope_size(
aspect_ratio: str | None,
image_size: str | None,
) -> str:
"""Resolve aspect ratio / image_size to a ModelScope size string."""
if image_size and "x" in image_size.lower():
return image_size
if aspect_ratio and aspect_ratio in _MODELSCOPE_ASPECT_RATIOS:
return _MODELSCOPE_ASPECT_RATIOS[aspect_ratio]
return "1024x1024"
class ModelScopeImageGenerationClient(ImageGenerationProvider):
"""Async client for ModelScope (魔搭) AIGC image generation.
ModelScope uses an async task pattern: POST submits the job and returns
a task_id, then the client polls GET /tasks/{task_id} until
task_status is SUCCEED or FAILED.
"""
provider_name = "modelscope"
missing_key_message = (
"ModelScope API key is not configured. Set providers.modelscope.apiKey."
)
default_timeout = _MODELSCOPE_TIMEOUT_S
def _default_base_url(self) -> str:
return "https://api-inference.modelscope.cn/v1"
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"X-ModelScope-Async-Mode": "true",
**self.extra_headers,
}
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
}
size = _modelscope_size(aspect_ratio, image_size)
if size:
body["size"] = size
refs = list(reference_images or [])
if refs:
image_refs = [image_path_to_data_url(path) for path in refs]
body["image_url"] = image_refs[0] if len(image_refs) == 1 else image_refs
body.update(self.extra_body)
url = f"{self.api_base}/images/generations"
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
return await self._generate_with_client(
client,
url=url,
headers=headers,
body=body,
)
finally:
if self._client is None:
await client.aclose()
async def _generate_with_client(
self,
client: httpx.AsyncClient,
*,
url: str,
headers: dict[str, str],
body: dict[str, Any],
) -> GeneratedImageResponse:
try:
response = await client.post(url, headers=headers, json=body)
except httpx.TimeoutException as exc:
raise ImageGenerationError("ModelScope image generation request timed out") from exc
except httpx.RequestError as exc:
raise ImageGenerationError(f"ModelScope image generation request failed: {exc}") from exc
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = _http_error_detail(response)
raise ImageGenerationError(f"ModelScope image generation failed: {detail}") from exc
task_data = response.json()
task_id = task_data.get("task_id")
if not task_id:
raise ImageGenerationError(
f"ModelScope did not return a task_id: {response.text[:500]}"
)
images = await self._poll_task(client, task_id, headers)
self._require_images(images, task_data)
return GeneratedImageResponse(images=images, content="", raw=task_data)
async def _poll_task(
self,
client: httpx.AsyncClient,
task_id: str,
submit_headers: dict[str, str],
) -> list[str]:
poll_headers = {
"Authorization": submit_headers["Authorization"],
"X-ModelScope-Task-Type": "image_generation",
**self.extra_headers,
}
poll_url = f"{self.api_base}/tasks/{task_id}"
for _ in range(_MODELSCOPE_POLL_MAX_ATTEMPTS):
try:
response = await client.get(poll_url, headers=poll_headers)
except httpx.RequestError:
await asyncio.sleep(_MODELSCOPE_POLL_INTERVAL_S)
continue
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = _http_error_detail(response)
raise ImageGenerationError(
f"ModelScope task polling failed: {detail}"
) from exc
data = response.json()
status = data.get("task_status")
if status == "SUCCEED":
return await self._collect_images(client, data)
if status == "FAILED":
raise ImageGenerationError(
f"ModelScope image generation task failed: {data}"
)
await asyncio.sleep(_MODELSCOPE_POLL_INTERVAL_S)
raise ImageGenerationError(
f"ModelScope image generation timed out after "
f"{_MODELSCOPE_POLL_MAX_ATTEMPTS} polls"
)
@staticmethod
async def _collect_images(
client: httpx.AsyncClient,
data: dict[str, Any],
) -> list[str]:
images: list[str] = []
for url in data.get("output_images") or []:
if isinstance(url, str) and url:
if url.startswith("data:image/"):
images.append(url)
else:
images.append(await _download_image_data_url(client, url))
return images
# ---------------------------------------------------------------------------
# Provider registration
# ---------------------------------------------------------------------------
@@ -1766,3 +1952,4 @@ register_image_gen_provider(OpenAIImageGenerationClient)
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(StepFunImageGenerationClient)
register_image_gen_provider(ZhipuImageGenerationClient)
register_image_gen_provider(ModelScopeImageGenerationClient)
+13
View File
@@ -471,6 +471,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
thinking_style="enable_thinking",
),
# ModelScope (魔搭社区): OpenAI-compatible API
ProviderSpec(
name="modelscope",
keywords=("modelscope",),
env_key="MODELSCOPE_API_KEY",
display_name="ModelScope",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="modelscope",
default_api_base="https://api-inference.modelscope.cn/v1",
strip_model_prefixes=("modelscope",),
thinking_style="enable_thinking",
),
# Moonshot (月之暗面): Kimi K2.5/K2.6 choose temperature from thinking mode;
# the OpenAI-compatible provider omits it. K2.7 models require 1.0.
ProviderSpec(
+230
View File
@@ -15,6 +15,7 @@ from nanobot.providers.image_generation import (
GeneratedImageResponse,
ImageGenerationError,
MiniMaxImageGenerationClient,
ModelScopeImageGenerationClient,
OllamaImageGenerationClient,
OpenAIImageGenerationClient,
OpenRouterImageGenerationClient,
@@ -1524,3 +1525,232 @@ async def test_zhipu_image_generation_rejects_reference_images() -> None:
model="glm-image",
reference_images=["ref.png"],
)
# ---------------------------------------------------------------------------
# ModelScope (魔搭) image generation tests
# ---------------------------------------------------------------------------
class ModelScopeFakeClient:
"""Fake httpx client for ModelScope async task pattern.
Returns submit_response for POST, and serves poll_responses in sequence
for GET /tasks/{id} calls. Image download GETs return PNG content.
"""
def __init__(
self,
submit_response: FakeResponse,
poll_responses: list[FakeResponse],
download_content: bytes = PNG_BYTES,
) -> None:
self.submit_response = submit_response
self.poll_responses = poll_responses
self.poll_idx = 0
self.download_content = download_content
self.calls: list[dict[str, Any]] = []
self.get_calls: list[dict[str, Any]] = []
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append({"url": url, **kwargs})
return self.submit_response
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
self.get_calls.append({"url": url, **kwargs})
if "/tasks/" in url:
idx = min(self.poll_idx, len(self.poll_responses) - 1)
resp = self.poll_responses[idx]
self.poll_idx += 1
return resp
return FakeResponse({}, content=self.download_content)
@pytest.fixture(autouse=True)
def _modelscope_fast_poll(monkeypatch) -> None:
"""Skip the real asyncio.sleep between ModelScope poll attempts."""
monkeypatch.setattr(
"nanobot.providers.image_generation._MODELSCOPE_POLL_INTERVAL_S", 0.0
)
@pytest.mark.asyncio
async def test_modelscope_image_generation_submit_and_poll() -> None:
submit = FakeResponse({"task_id": "abc123"})
poll_responses = [
FakeResponse({"task_status": "PENDING"}),
FakeResponse({
"task_status": "SUCCEED",
"output_images": ["https://cdn.example/image.png"],
}),
]
fake = ModelScopeFakeClient(submit, poll_responses)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
api_base="https://api-inference.modelscope.cn/v1",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="A golden cat",
model="Qwen/Qwen-Image",
)
assert response.images[0].startswith("data:image/png;base64,")
# Verify POST request
post_call = fake.calls[0]
assert post_call["url"] == "https://api-inference.modelscope.cn/v1/images/generations"
assert post_call["headers"]["Authorization"] == "Bearer ms-token"
assert post_call["headers"]["X-ModelScope-Async-Mode"] == "true"
body = post_call["json"]
assert body["model"] == "Qwen/Qwen-Image"
assert body["prompt"] == "A golden cat"
# Verify task polling GET
assert "/tasks/abc123" in fake.get_calls[0]["url"]
poll_headers = fake.get_calls[0]["headers"]
assert poll_headers["X-ModelScope-Task-Type"] == "image_generation"
@pytest.mark.asyncio
async def test_modelscope_image_generation_with_size() -> None:
submit = FakeResponse({"task_id": "t1"})
poll = [FakeResponse({"task_status": "SUCCEED", "output_images": ["https://cdn/img.png"]})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="test",
model="Qwen/Qwen-Image-2512",
image_size="768x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "768x1024"
@pytest.mark.asyncio
async def test_modelscope_image_generation_aspect_ratio_mapping() -> None:
submit = FakeResponse({"task_id": "t1"})
poll = [FakeResponse({"task_status": "SUCCEED", "output_images": ["https://cdn/img.png"]})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="test", model="m", aspect_ratio="16:9")
assert fake.calls[0]["json"]["size"] == "1536x1024"
@pytest.mark.asyncio
async def test_modelscope_image_generation_task_failed() -> None:
submit = FakeResponse({"task_id": "bad-task"})
poll = [FakeResponse({"task_status": "FAILED", "errors": "oom"})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="task failed"):
await client.generate(prompt="test", model="m")
@pytest.mark.asyncio
async def test_modelscope_image_generation_requires_api_key() -> None:
client = ModelScopeImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="API key"):
await client.generate(prompt="draw", model="m")
@pytest.mark.asyncio
async def test_modelscope_image_generation_missing_task_id() -> None:
submit = FakeResponse({"unexpected": "response"})
fake = ModelScopeFakeClient(submit, [])
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="task_id"):
await client.generate(prompt="draw", model="m")
@pytest.mark.asyncio
async def test_modelscope_image_generation_with_reference_image() -> None:
"""Reference images are converted to base64 data URLs for image editing models."""
submit = FakeResponse({"task_id": "t1"})
poll = [FakeResponse({"task_status": "SUCCEED", "output_images": ["https://cdn/img.png"]})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
# Create a temporary image file
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(PNG_BYTES)
ref_path = f.name
try:
await client.generate(
prompt="edit this image",
model="Qwen/Qwen-Image-Edit",
reference_images=[ref_path],
)
body = fake.calls[0]["json"]
assert "image_url" in body
assert body["image_url"].startswith("data:image/png;base64,")
finally:
Path(ref_path).unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_modelscope_image_generation_extra_body_passthrough() -> None:
"""Extra body fields like loras are passed through to the API."""
submit = FakeResponse({"task_id": "t1"})
poll = [FakeResponse({"task_status": "SUCCEED", "output_images": ["https://cdn/img.png"]})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
extra_body={"loras": "lora-repo-1", "seed": 42},
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="test", model="m")
body = fake.calls[0]["json"]
assert body["loras"] == "lora-repo-1"
assert body["seed"] == 42
@pytest.mark.asyncio
async def test_modelscope_image_generation_poll_timeout(monkeypatch) -> None:
"""Polling that never reaches SUCCEED/FAILED raises a timeout error."""
monkeypatch.setattr(
"nanobot.providers.image_generation._MODELSCOPE_POLL_MAX_ATTEMPTS", 3
)
submit = FakeResponse({"task_id": "t1"})
# Always PENDING — never resolves.
poll = [FakeResponse({"task_status": "PENDING"})]
fake = ModelScopeFakeClient(submit, poll)
client = ModelScopeImageGenerationClient(
api_key="ms-token",
client=fake, # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="timed out"):
await client.generate(prompt="test", model="m")
# Should have polled up to the (patched) attempt limit.
assert len(fake.get_calls) == 3
+122
View File
@@ -0,0 +1,122 @@
"""Tests for the ModelScope (魔搭) provider registration."""
from unittest.mock import patch
from nanobot.config.schema import Config, ProvidersConfig
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import PROVIDERS, find_by_name
def test_modelscope_config_field_exists() -> None:
config = ProvidersConfig()
assert hasattr(config, "modelscope")
def test_modelscope_provider_in_registry() -> None:
specs = {spec.name: spec for spec in PROVIDERS}
assert "modelscope" in specs
ms = specs["modelscope"]
assert ms.backend == "openai_compat"
assert ms.env_key == "MODELSCOPE_API_KEY"
assert ms.display_name == "ModelScope"
assert ms.is_gateway is True
assert ms.default_api_base == "https://api-inference.modelscope.cn/v1"
assert ms.strip_model_prefixes == ("modelscope",)
assert ms.thinking_style == "enable_thinking"
def test_find_by_name_modelscope() -> None:
spec = find_by_name("modelscope")
assert spec is not None
assert spec.name == "modelscope"
def test_modelscope_forced_provider_uses_default_api_base() -> None:
config = Config.model_validate(
{
"providers": {
"modelscope": {
"apiKey": "ms-token",
},
},
"agents": {
"defaults": {
"model": "Qwen/Qwen3.5-35B-A3B",
"provider": "modelscope",
},
},
}
)
assert config.get_provider_name("Qwen/Qwen3.5-35B-A3B") == "modelscope"
assert config.get_api_key("Qwen/Qwen3.5-35B-A3B") == "ms-token"
assert config.get_api_base("Qwen/Qwen3.5-35B-A3B") == "https://api-inference.modelscope.cn/v1"
def test_modelscope_keyword_matches_prefixed_model() -> None:
config = Config.model_validate(
{
"providers": {
"modelscope": {
"apiKey": "ms-token",
},
},
"agents": {
"defaults": {
"model": "modelscope/Qwen/Qwen3.5-35B-A3B",
},
},
}
)
assert config.get_provider_name("modelscope/Qwen/Qwen3.5-35B-A3B") == "modelscope"
assert config.get_api_key("modelscope/Qwen/Qwen3.5-35B-A3B") == "ms-token"
def test_modelscope_strips_prefix_in_request_model() -> None:
spec = find_by_name("modelscope")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="ms-token",
default_model="modelscope/Qwen/Qwen3.5-35B-A3B",
spec=spec,
)
kwargs = provider._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="modelscope/Qwen/Qwen3.5-35B-A3B",
max_tokens=1024,
temperature=0.7,
reasoning_effort=None,
tool_choice=None,
)
# strip_model_prefixes removes "modelscope/" → "Qwen/Qwen3.5-35B-A3B"
assert kwargs["model"] == "Qwen/Qwen3.5-35B-A3B"
assert kwargs["max_tokens"] == 1024
assert "max_completion_tokens" not in kwargs
def test_modelscope_routes_unprefixed_models_when_configured() -> None:
config = Config.model_validate(
{
"providers": {
"modelscope": {
"apiKey": "ms-token",
"apiBase": "https://api-inference.modelscope.cn/v1",
},
},
"agents": {
"defaults": {
"model": "Qwen/Qwen3.5-35B-A3B",
},
},
}
)
name = config.get_provider_name("Qwen/Qwen3.5-35B-A3B")
assert name == "modelscope"
@@ -232,6 +232,7 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"byteplus_coding_plan",
"huggingface",
"lm_studio",
"modelscope",
"novita",
"ollama",
"openrouter",
@@ -7889,6 +7890,7 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
deepseek: Waves,
zhipu: Grid3X3,
dashscope: Cloud,
modelscope: Layers,
moonshot: Moon,
minimax: Zap,
minimax_anthropic: Brain,
+2
View File
@@ -137,6 +137,7 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
]),
minimax: brand("minimax.io", "#111827", "MM"),
mistral: brand("mistral.ai", "#FA520F", "M"),
modelscope: brand("modelscope.cn", "#5B5BF6", "MS"),
moonshot: brand("moonshot.ai", "#111827", "MS"),
novita: brand("novita.ai", "#7C3AED", "N"),
olostep: brand("olostep.com", "#111827", "O"),
@@ -189,6 +190,7 @@ export function inferProviderFromModelName(modelName: string | null | undefined)
if (/gpt-|^o\d|chatgpt|openai/.test(normalized)) return "openai";
if (/deepseek/.test(normalized)) return "deepseek";
if (/gemini/.test(normalized)) return "gemini";
if (/modelscope/.test(normalized)) return "modelscope";
if (/qwen|dashscope/.test(normalized)) return "dashscope";
if (/kimi|moonshot/.test(normalized)) return "moonshot";
if (/minimax/.test(normalized)) return "minimax";