feat(providers): add ModelScope provider for LLM and image generation
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user