diff --git a/docs/configuration.md b/docs/configuration.md index 53f570f7ea7..608b6e95aea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -267,6 +267,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client | Provider | Purpose | Get API Key | |----------|---------|-------------| | `custom` | Any OpenAI-compatible endpoint | — | +| `aimlapi` | LLM gateway for aimlapi.com's OpenAI-compatible model catalog | [aimlapi.com](https://aimlapi.com) | | `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) | | `edenai` | LLM gateway for Eden AI's OpenAI-compatible model catalog | [app.edenai.run](https://app.edenai.run/) | | `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) | diff --git a/docs/providers.md b/docs/providers.md index 56db7211f6c..3a70b479fd0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -71,6 +71,45 @@ Use `proxy` when one provider must send HTTP traffic through a proxy without cha ## Common Provider Patterns +### aimlapi.com Gateway + +[aimlapi.com](https://aimlapi.com) exposes an OpenAI-compatible chat-completions +endpoint at `https://api.aimlapi.com/v1`. Configure the built-in `aimlapi` +provider and use the full `vendor/model` identifier from its catalog: + +```json +{ + "providers": { + "aimlapi": { + "apiKey": "${AIMLAPI_API_KEY}" + } + }, + "modelPresets": { + "primary": { + "provider": "aimlapi", + "model": "openai/gpt-5", + "maxTokens": 8192 + } + }, + "agents": { + "defaults": { + "modelPreset": "primary" + } + } +} +``` + +Nanobot sends the model ID unchanged, including its vendor prefix, and passes +`reasoning_effort` as the normal top-level Chat Completions parameter. Other +valid IDs include `anthropic/claude-sonnet-4-6` and `google/gemini-3.1-pro-preview`. +The public catalog at `https://api.aimlapi.com/v1/models` lists the current IDs; +entries with `"type": "openai/chat-completions"` are the chat models. The WebUI +can also load that catalog after the API key is saved under **Settings → Models**. + +Note that `https://api.aimlapi.com/v1/models` answers `200` for any credential, +including an invalid one, so a successful model list does not prove the key +works. The first chat completion is what verifies it. + ### OpenRouter Gateway Gateway-style setup for model IDs served through OpenRouter. diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 8438352f43d..2c1dc3c5bb9 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -280,6 +280,7 @@ class ProvidersConfig(Base): aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) edenai: ProviderConfig = Field(default_factory=ProviderConfig) # Eden AI API gateway + aimlapi: ProviderConfig = Field(default_factory=ProviderConfig) # AI/ML API gateway novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index bbfa65789b0..90a68294181 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -188,6 +188,26 @@ def label(self) -> str: ), # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. + # AI/ML API: OpenAI-compatible gateway. Models use the "vendor/model" naming + # scheme (e.g. "openai/gpt-5"); the full id is sent upstream. The default + # headers identify nanobot to the gateway, the same way the OpenRouter + # attribution headers do; they ride only on this provider's requests. + ProviderSpec( + name="aimlapi", + keywords=("aimlapi",), + env_key="AIMLAPI_API_KEY", + display_name="aimlapi.com", + backend="openai_compat", + default_extra_headers=( + ("HTTP-Referer", "https://github.com/HKUDS/nanobot"), + ("X-Title", "nanobot"), + ("X-AIMLAPI-Source", "agent/hkuds-nanobot"), + ("X-AIMLAPI-Partner-ID", "part_TcTxHfamJ2kkNiFsYzEVELTy"), + ), + is_gateway=True, + detect_by_base_keyword="aimlapi", + default_api_base="https://api.aimlapi.com/v1", + ), # OpenRouter: global gateway, keys start with "sk-or-" ProviderSpec( name="openrouter", diff --git a/tests/providers/test_aimlapi_provider.py b/tests/providers/test_aimlapi_provider.py new file mode 100644 index 00000000000..7acd8c72a29 --- /dev/null +++ b/tests/providers/test_aimlapi_provider.py @@ -0,0 +1,119 @@ +"""Tests for the aimlapi.com provider registration.""" + +import re +from unittest.mock import patch + +from nanobot.config.schema import Config, ProviderConfig, ProvidersConfig +from nanobot.providers.factory import _provider_extra_headers +from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import PROVIDERS, find_by_name + +# The gateway silently drops a malformed partner id, so only a test catches a typo. +PARTNER_ID_PATTERN = re.compile(r"^part_[A-Za-z0-9]{1,64}$") +SOURCE_PATTERN = re.compile(r"^(web|agent|mcp)/[a-z0-9-]{1,32}$") + + +def test_aimlapi_config_field_exists() -> None: + assert hasattr(ProvidersConfig(), "aimlapi") + + +def test_aimlapi_registry_contract() -> None: + specs = {spec.name: spec for spec in PROVIDERS} + + assert "aimlapi" in specs + aimlapi = specs["aimlapi"] + assert aimlapi.backend == "openai_compat" + assert aimlapi.env_key == "AIMLAPI_API_KEY" + assert aimlapi.display_name == "aimlapi.com" + assert aimlapi.is_gateway is True + assert aimlapi.detect_by_base_keyword == "aimlapi" + assert aimlapi.default_api_base == "https://api.aimlapi.com/v1" + assert aimlapi.strip_model_prefix is False + # aimlapi.com accepts OpenAI's top-level reasoning_effort parameter. Do not add + # OpenRouter's separate {"reasoning": {"effort": ...}} request shape. + assert aimlapi.gateway_reasoning_style == "" + + +def test_aimlapi_forced_provider_uses_default_api_base() -> None: + config = Config.model_validate( + { + "providers": {"aimlapi": {"apiKey": "aimlapi-key"}}, + "agents": { + "defaults": { + "provider": "aimlapi", + "model": "openai/gpt-5", + } + }, + } + ) + + model = "openai/gpt-5" + assert config.get_provider_name(model) == "aimlapi" + assert config.get_api_key(model) == "aimlapi-key" + assert config.get_api_base(model) == "https://api.aimlapi.com/v1" + + +def test_aimlapi_preserves_model_id_and_reasoning_effort() -> None: + spec = find_by_name("aimlapi") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider( + api_key="aimlapi-key", + default_model="openai/gpt-5", + spec=spec, + ) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="openai/gpt-5", + max_tokens=1024, + temperature=0.7, + reasoning_effort="medium", + tool_choice=None, + ) + + assert kwargs["model"] == "openai/gpt-5" + assert kwargs["reasoning_effort"] == "medium" + assert "reasoning" not in kwargs.get("extra_body", {}) + + +def test_aimlapi_default_headers_identify_nanobot() -> None: + spec = find_by_name("aimlapi") + + assert spec is not None + headers = _provider_extra_headers(spec, ProviderConfig()) + assert headers == { + "HTTP-Referer": "https://github.com/HKUDS/nanobot", + "X-Title": "nanobot", + "X-AIMLAPI-Source": "agent/hkuds-nanobot", + "X-AIMLAPI-Partner-ID": "part_TcTxHfamJ2kkNiFsYzEVELTy", + } + assert PARTNER_ID_PATTERN.match(headers["X-AIMLAPI-Partner-ID"]) + assert SOURCE_PATTERN.match(headers["X-AIMLAPI-Source"]) + + +def test_aimlapi_default_headers_are_scoped_to_this_provider() -> None: + for spec in PROVIDERS: + if spec.name == "aimlapi": + continue + assert not any( + name.lower().startswith("x-aimlapi-") for name, _ in spec.default_extra_headers + ) + + +def test_aimlapi_user_headers_win_without_mutating_the_spec() -> None: + spec = find_by_name("aimlapi") + assert spec is not None + before = dict(spec.default_extra_headers) + + provider = ProviderConfig.model_validate({ + "extraHeaders": {"X-Title": "my-fork", "X-Custom": "1"}, + }) + headers = _provider_extra_headers(spec, provider) + + assert headers["X-Title"] == "my-fork" + assert headers["X-Custom"] == "1" + assert headers["X-AIMLAPI-Partner-ID"] == "part_TcTxHfamJ2kkNiFsYzEVELTy" + # The registry entry is a shared constant; building the per-request dict + # must not write back into it. + assert dict(spec.default_extra_headers) == before diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index a6b8c29e612..13822ddabf5 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -98,6 +98,26 @@ def test_settings_payload_includes_versioned_docs( } +def test_settings_payload_exposes_aimlapi_provider( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.aimlapi.api_key = "aimlapi-test-key" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = settings_payload() + aimlapi = next(row for row in payload["providers"] if row["name"] == "aimlapi") + + assert aimlapi["label"] == "aimlapi.com" + assert aimlapi["configured"] is True + assert aimlapi["default_api_base"] == "https://api.aimlapi.com/v1" + assert aimlapi["model_catalog"] == "catalog" + assert aimlapi["model_selectable"] is True + + def test_settings_payload_exposes_edenai_provider( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/shared/ModelControls.tsx b/webui/src/components/settings/shared/ModelControls.tsx index f42f7018c35..3aaab33c148 100644 --- a/webui/src/components/settings/shared/ModelControls.tsx +++ b/webui/src/components/settings/shared/ModelControls.tsx @@ -43,6 +43,7 @@ import { cn } from "@/lib/utils"; const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "aihubmix", + "aimlapi", "atomic_chat", "byteplus", "byteplus_coding_plan", @@ -583,6 +584,7 @@ export const PROVIDER_ICONS: Record = { orcarouter: Sparkles, skywork: Sparkles, aihubmix: Triangle, + aimlapi: Sparkles, anthropic: Brain, openai: Bot, deepseek: Waves, diff --git a/webui/src/lib/provider-brand.ts b/webui/src/lib/provider-brand.ts index 743cb4a63b1..b6065c5e391 100644 --- a/webui/src/lib/provider-brand.ts +++ b/webui/src/lib/provider-brand.ts @@ -150,6 +150,7 @@ const PROVIDER_LABEL_ALIASES: Record = { const PROVIDER_BRANDS: Record = { aihubmix: brand("aihubmix.com", "#111827", "AH"), + aimlapi: brand("aimlapi.com", "#111827", "AM"), ant_ling: brand("ant-ling.com", "#7C3AED", "AL"), anthropic: brand("anthropic.com", "#D97757", "A"), assemblyai: brand("assemblyai.com", "#111827", "AA"),