From 9abad4746e5cb63cfc34e42c49554d704eb853b5 Mon Sep 17 00:00:00 2001 From: yrk <2493404415@qq.com> Date: Thu, 16 Jul 2026 15:56:31 +0800 Subject: [PATCH] feat(providers): add ModelScope provider for LLM and image generation --- docs/configuration.md | 2 + docs/image-generation.md | 29 ++- nanobot/config/schema.py | 1 + nanobot/providers/image_generation.py | 187 ++++++++++++++ nanobot/providers/registry.py | 13 + tests/providers/test_image_generation.py | 230 ++++++++++++++++++ tests/providers/test_modelscope_provider.py | 122 ++++++++++ .../src/components/settings/SettingsView.tsx | 2 + webui/src/lib/provider-brand.ts | 2 + 9 files changed, 585 insertions(+), 3 deletions(-) create mode 100644 tests/providers/test_modelscope_provider.py diff --git a/docs/configuration.md b/docs/configuration.md index 2bf123c9..3f78d483 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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) | diff --git a/docs/image-generation.md b/docs/image-generation.md index e7629018..4bd76348 100644 --- a/docs/image-generation.md +++ b/docs/image-generation.md @@ -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..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 | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6a496d97..95e7d68c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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 diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index fe2a9f2e..75c2cb32 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -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) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index bc5efd28..59db09a4 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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( diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py index 44b80549..2f207f8e 100644 --- a/tests/providers/test_image_generation.py +++ b/tests/providers/test_image_generation.py @@ -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 + diff --git a/tests/providers/test_modelscope_provider.py b/tests/providers/test_modelscope_provider.py new file mode 100644 index 00000000..9248ab1b --- /dev/null +++ b/tests/providers/test_modelscope_provider.py @@ -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" diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 52281ed5..634f6fa7 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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 = { deepseek: Waves, zhipu: Grid3X3, dashscope: Cloud, + modelscope: Layers, moonshot: Moon, minimax: Zap, minimax_anthropic: Brain, diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts index aba77842..4660ffdb 100644 --- a/webui/src/lib/provider-brand.ts +++ b/webui/src/lib/provider-brand.ts @@ -137,6 +137,7 @@ const PROVIDER_BRANDS: Record = { ]), 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";