Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) |
Expand Down
39 changes: 39 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions nanobot/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions nanobot/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions tests/providers/test_aimlapi_provider.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/webui/test_settings_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions webui/src/components/settings/shared/ModelControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { cn } from "@/lib/utils";

const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"aimlapi",
"atomic_chat",
"byteplus",
"byteplus_coding_plan",
Expand Down Expand Up @@ -583,6 +584,7 @@ export const PROVIDER_ICONS: Record<string, LucideIcon> = {
orcarouter: Sparkles,
skywork: Sparkles,
aihubmix: Triangle,
aimlapi: Sparkles,
anthropic: Brain,
openai: Bot,
deepseek: Waves,
Expand Down
1 change: 1 addition & 0 deletions webui/src/lib/provider-brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const PROVIDER_LABEL_ALIASES: Record<string, string> = {

const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
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"),
Expand Down