diff --git a/docs/configuration.md b/docs/configuration.md index b5d74f7ca..bc06588dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,6 +152,7 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) | +| `ant_ling` | LLM (Ant Ling / 蚂蚁百灵) | [developer.ant-ling.com](https://developer.ant-ling.com/en/docs/api-reference/openai/) | | `ollama` | LLM (local, Ollama) | — | | `lm_studio` | LLM (local, LM Studio) | — | | `atomic_chat` | LLM (local, [Atomic Chat](https://atomic.chat/)) | — | @@ -444,6 +445,34 @@ Official model names include `LongCat-Flash-Chat`, `LongCat-Flash-Thinking`, +
+Ant Ling (OpenAI-compatible) + +Ant Ling is available through nanobot's built-in OpenAI-compatible provider flow. +The default API base points to `https://api.ant-ling.com/v1`, so you usually +only need to set `apiKey`. + +```json +{ + "providers": { + "antLing": { + "apiKey": "${ANT_LING_API_KEY}" + } + }, + "agents": { + "defaults": { + "provider": "ant_ling", + "model": "Ling-2.6-flash" + } + } +} +``` + +Official OpenAI-compatible model names include `Ling-2.6-1T`, +`Ling-2.6-flash`, `Ling-2.5-1T`, `Ling-1T`, `Ring-2.5-1T`, and `Ring-1T`. + +
+
Custom Provider (Any OpenAI-compatible API) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 96f9014a9..6ccabea3f 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -207,6 +207,7 @@ class ProvidersConfig(Base): stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) longcat: ProviderConfig = Field(default_factory=ProviderConfig) # LongCat + ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index e6f022187..0f8e45936 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -390,6 +390,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.longcat.chat/openai/v1", ), + # Ant Ling: OpenAI-compatible API for Ling/Ring model families. + ProviderSpec( + name="ant_ling", + keywords=("ant_ling", "ant-ling", "ling-", "ring-"), + env_key="ANT_LING_API_KEY", + display_name="Ant Ling", + backend="openai_compat", + detect_by_base_keyword="ant-ling.com", + default_api_base="https://api.ant-ling.com/v1", + ), # === Local deployment (matched by config key, NOT by api_base) ========= # vLLM / any OpenAI-compatible local server ProviderSpec( diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index c6f9d66a3..0c55a229c 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -1017,6 +1017,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert providers["azure_openai"]["api_key_required"] is True assert providers["openrouter"]["configured"] is False assert providers["openrouter"]["api_key_required"] is True + assert providers["ant_ling"]["label"] == "Ant Ling" + assert providers["ant_ling"]["default_api_base"] == "https://api.ant-ling.com/v1" assert providers["atomic_chat"]["configured"] is False assert providers["atomic_chat"]["api_key_required"] is False assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1" diff --git a/tests/providers/test_ant_ling_provider.py b/tests/providers/test_ant_ling_provider.py new file mode 100644 index 000000000..64f93ccab --- /dev/null +++ b/tests/providers/test_ant_ling_provider.py @@ -0,0 +1,73 @@ +"""Tests for the Ant Ling 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_ant_ling_config_field_exists() -> None: + config = ProvidersConfig() + + assert hasattr(config, "ant_ling") + + +def test_ant_ling_provider_in_registry() -> None: + specs = {spec.name: spec for spec in PROVIDERS} + + assert "ant_ling" in specs + ant_ling = specs["ant_ling"] + assert ant_ling.backend == "openai_compat" + assert ant_ling.env_key == "ANT_LING_API_KEY" + assert ant_ling.display_name == "Ant Ling" + assert ant_ling.default_api_base == "https://api.ant-ling.com/v1" + + +def test_find_by_name_accepts_ant_ling_spellings() -> None: + spec = find_by_name("ant_ling") + + assert spec is not None + assert find_by_name("ant-ling") is spec + assert find_by_name("antLing") is spec + + +def test_ant_ling_model_auto_matches_with_default_api_base() -> None: + config = Config.model_validate({ + "providers": { + "antLing": { + "apiKey": "ling-key", + }, + }, + "agents": { + "defaults": { + "model": "Ling-2.6-flash", + }, + }, + }) + + assert config.get_provider_name("Ling-2.6-flash") == "ant_ling" + assert config.get_api_key("Ling-2.6-flash") == "ling-key" + assert config.get_api_base("Ling-2.6-flash") == "https://api.ant-ling.com/v1" + + +def test_ant_ling_preserves_official_model_name() -> None: + spec = find_by_name("ant_ling") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider( + api_key="ling-key", + default_model="Ling-2.6-flash", + spec=spec, + ) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="Ling-2.6-flash", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "Ling-2.6-flash" diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 116b67d62..96cd2b54c 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1246,6 +1246,7 @@ const PROVIDER_ICONS: Record = { byteplus: Cloud, byteplus_coding_plan: Cloud, qianfan: Database, + ant_ling: Sparkles, azure_openai: Cloud, bedrock: Database, vllm: Cpu, diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index e766bceec..2f48ba408 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -214,6 +214,13 @@ describe("App layout", () => { api_key_required: true, default_api_base: "https://openrouter.ai/api/v1", }, + { + name: "ant_ling", + label: "Ant Ling", + configured: false, + api_key_required: true, + default_api_base: "https://api.ant-ling.com/v1", + }, { name: "azure_openai", label: "Azure OpenAI", @@ -301,6 +308,7 @@ describe("App layout", () => { expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument(); expect(screen.getByText("OpenRouter")).toBeInTheDocument(); + expect(screen.getByText("Ant Ling")).toBeInTheDocument(); expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0); fireEvent.click(screen.getByText("OpenAI")); fireEvent.click(screen.getByRole("button", { name: "Edit" })); @@ -311,6 +319,8 @@ describe("App layout", () => { fireEvent.click(screen.getByText("OpenAI")); expect(screen.getByText("open••••-key")).toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Ant Ling")); + expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument(); fireEvent.click(screen.getByText("Atomic Chat")); expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();