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.
|
> - **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.
|
> - **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.
|
> - **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`.
|
> - **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.
|
> - **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.
|
> - **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) |
|
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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]
|
> [!TIP]
|
||||||
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
|
> 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 |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
|
| `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.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.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` |
|
| `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.
|
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
|
## Artifacts
|
||||||
|
|
||||||
Generated images are stored under the active nanobot instance's media directory:
|
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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
modelscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
|
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
|
||||||
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio 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
|
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
|
# Provider registration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1766,3 +1952,4 @@ register_image_gen_provider(OpenAIImageGenerationClient)
|
|||||||
register_image_gen_provider(OpenRouterImageGenerationClient)
|
register_image_gen_provider(OpenRouterImageGenerationClient)
|
||||||
register_image_gen_provider(StepFunImageGenerationClient)
|
register_image_gen_provider(StepFunImageGenerationClient)
|
||||||
register_image_gen_provider(ZhipuImageGenerationClient)
|
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",
|
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
thinking_style="enable_thinking",
|
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;
|
# Moonshot (月之暗面): Kimi K2.5/K2.6 choose temperature from thinking mode;
|
||||||
# the OpenAI-compatible provider omits it. K2.7 models require 1.0.
|
# the OpenAI-compatible provider omits it. K2.7 models require 1.0.
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from nanobot.providers.image_generation import (
|
|||||||
GeneratedImageResponse,
|
GeneratedImageResponse,
|
||||||
ImageGenerationError,
|
ImageGenerationError,
|
||||||
MiniMaxImageGenerationClient,
|
MiniMaxImageGenerationClient,
|
||||||
|
ModelScopeImageGenerationClient,
|
||||||
OllamaImageGenerationClient,
|
OllamaImageGenerationClient,
|
||||||
OpenAIImageGenerationClient,
|
OpenAIImageGenerationClient,
|
||||||
OpenRouterImageGenerationClient,
|
OpenRouterImageGenerationClient,
|
||||||
@@ -1524,3 +1525,232 @@ async def test_zhipu_image_generation_rejects_reference_images() -> None:
|
|||||||
model="glm-image",
|
model="glm-image",
|
||||||
reference_images=["ref.png"],
|
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",
|
"byteplus_coding_plan",
|
||||||
"huggingface",
|
"huggingface",
|
||||||
"lm_studio",
|
"lm_studio",
|
||||||
|
"modelscope",
|
||||||
"novita",
|
"novita",
|
||||||
"ollama",
|
"ollama",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
@@ -7889,6 +7890,7 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
|
|||||||
deepseek: Waves,
|
deepseek: Waves,
|
||||||
zhipu: Grid3X3,
|
zhipu: Grid3X3,
|
||||||
dashscope: Cloud,
|
dashscope: Cloud,
|
||||||
|
modelscope: Layers,
|
||||||
moonshot: Moon,
|
moonshot: Moon,
|
||||||
minimax: Zap,
|
minimax: Zap,
|
||||||
minimax_anthropic: Brain,
|
minimax_anthropic: Brain,
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
|
|||||||
]),
|
]),
|
||||||
minimax: brand("minimax.io", "#111827", "MM"),
|
minimax: brand("minimax.io", "#111827", "MM"),
|
||||||
mistral: brand("mistral.ai", "#FA520F", "M"),
|
mistral: brand("mistral.ai", "#FA520F", "M"),
|
||||||
|
modelscope: brand("modelscope.cn", "#5B5BF6", "MS"),
|
||||||
moonshot: brand("moonshot.ai", "#111827", "MS"),
|
moonshot: brand("moonshot.ai", "#111827", "MS"),
|
||||||
novita: brand("novita.ai", "#7C3AED", "N"),
|
novita: brand("novita.ai", "#7C3AED", "N"),
|
||||||
olostep: brand("olostep.com", "#111827", "O"),
|
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 (/gpt-|^o\d|chatgpt|openai/.test(normalized)) return "openai";
|
||||||
if (/deepseek/.test(normalized)) return "deepseek";
|
if (/deepseek/.test(normalized)) return "deepseek";
|
||||||
if (/gemini/.test(normalized)) return "gemini";
|
if (/gemini/.test(normalized)) return "gemini";
|
||||||
|
if (/modelscope/.test(normalized)) return "modelscope";
|
||||||
if (/qwen|dashscope/.test(normalized)) return "dashscope";
|
if (/qwen|dashscope/.test(normalized)) return "dashscope";
|
||||||
if (/kimi|moonshot/.test(normalized)) return "moonshot";
|
if (/kimi|moonshot/.test(normalized)) return "moonshot";
|
||||||
if (/minimax/.test(normalized)) return "minimax";
|
if (/minimax/.test(normalized)) return "minimax";
|
||||||
|
|||||||
Reference in New Issue
Block a user