diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py
index cc4ac1d94..0106d2616 100644
--- a/backend/app/agent/agent_model.py
+++ b/backend/app/agent/agent_model.py
@@ -25,6 +25,7 @@
from app.agent.listen_chat_agent import ListenChatAgent, logger
from app.model.chat import AgentModelConfig, Chat
from app.model.model_platform import (
+ aimlapi_attribution_headers,
azure_reasoning_tools_require_responses_api,
is_eigent_cloud_model_endpoint,
patch_azure_cloud_config,
@@ -416,6 +417,14 @@ def build_model(force_refresh: bool = False):
if isinstance(stream_options, dict):
stream_options.setdefault("include_usage", True)
+ # Attribution for aimlapi.com, keyed to that host so no other
+ # provider's request can carry it.
+ attribution_headers = aimlapi_attribution_headers(
+ effective_config["api_url"], init_params.get("default_headers")
+ )
+ if attribution_headers:
+ init_params["default_headers"] = attribution_headers
+
model_backend = ModelFactory.create(
model_platform=runtime_model_platform,
model_type=effective_config["model_type"],
diff --git a/backend/app/component/model_validation.py b/backend/app/component/model_validation.py
index 1ab4c3373..9a7b6945b 100644
--- a/backend/app/component/model_validation.py
+++ b/backend/app/component/model_validation.py
@@ -19,7 +19,10 @@
from camel.agents import ChatAgent
from camel.models import ModelFactory, ModelProcessingError
-from app.model.model_platform import BEDROCK_CONVERSE_REGION
+from app.model.model_platform import (
+ BEDROCK_CONVERSE_REGION,
+ aimlapi_attribution_headers,
+)
logger = logging.getLogger("model_validation")
@@ -235,6 +238,11 @@ def create_agent(
model_config_dict["max_tokens"] = 4096
if str(platform).lower() == "aws-bedrock-converse":
kwargs.setdefault("region_name", BEDROCK_CONVERSE_REGION)
+ attribution_headers = aimlapi_attribution_headers(
+ url, kwargs.get("default_headers")
+ )
+ if attribution_headers:
+ kwargs["default_headers"] = attribution_headers
model = ModelFactory.create(
model_platform=platform,
model_type=mtype,
@@ -340,6 +348,11 @@ def validate_model_with_details(
model_config_dict["max_tokens"] = 4096
if str(model_platform).lower() == "aws-bedrock-converse":
kwargs.setdefault("region_name", BEDROCK_CONVERSE_REGION)
+ attribution_headers = aimlapi_attribution_headers(
+ url, kwargs.get("default_headers")
+ )
+ if attribution_headers:
+ kwargs["default_headers"] = attribution_headers
model = ModelFactory.create(
model_platform=model_platform,
model_type=model_type,
diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py
index 99627d7e9..ed510403c 100644
--- a/backend/app/model/model_platform.py
+++ b/backend/app/model/model_platform.py
@@ -13,11 +13,13 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from typing import Annotated, Final
+from urllib.parse import urlparse
from pydantic import BeforeValidator
PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = {
"z.ai": "zhipuai",
+ "aimlapi": "openai-compatible-model",
"ant-ling": "openai-compatible-model",
"ModelArk": "openai-compatible-model",
"grok": "openai-compatible-model",
@@ -52,6 +54,55 @@
)
+# Attribution headers for aimlapi.com. `HTTP-Referer` / `X-Title` follow the
+# OpenRouter convention and identify Eigent as the calling application; the two
+# `X-AIMLAPI-*` headers are read by aimlapi.com to attribute traffic to this
+# integration. They are keyed to the request host below so they can never ride
+# a request to a different vendor, or to a proxy that merely fronts the same
+# API.
+AIMLAPI_ATTRIBUTION_HOSTS: Final[frozenset[str]] = frozenset(
+ {"api.aimlapi.com"}
+)
+
+AIMLAPI_ATTRIBUTION_HEADERS: Final[dict[str, str]] = {
+ "HTTP-Referer": "https://github.com/eigent-ai/eigent",
+ "X-Title": "Eigent",
+ "X-AIMLAPI-Partner-ID": "part_kK5bWvwrYl5A9aWdwLFoIBQV",
+ "X-AIMLAPI-Source": "agent/eigent",
+}
+
+
+def is_aimlapi_endpoint(api_url: object) -> bool:
+ """Return whether ``api_url`` points at aimlapi.com itself."""
+ if not isinstance(api_url, str):
+ return False
+ candidate = api_url.strip()
+ if not candidate:
+ return False
+ if "//" not in candidate:
+ candidate = "//" + candidate
+ host = urlparse(candidate).hostname
+ return bool(host) and host.lower() in AIMLAPI_ATTRIBUTION_HOSTS
+
+
+def aimlapi_attribution_headers(
+ api_url: object, default_headers: object = None
+) -> dict[str, str] | None:
+ """Merge aimlapi.com attribution into caller-supplied default headers.
+
+ Returns ``None`` when the request is not bound for aimlapi.com so callers
+ leave every other provider untouched. A caller's own header wins on a key
+ clash, and a new dict is built on each call so the module-level constant is
+ never mutated.
+ """
+ if not is_aimlapi_endpoint(api_url):
+ return None
+ caller_headers = (
+ default_headers if isinstance(default_headers, dict) else {}
+ )
+ return {**AIMLAPI_ATTRIBUTION_HEADERS, **caller_headers}
+
+
def patch_bedrock_cloud_config(
api_url: str, extra_params: dict
) -> tuple[str, dict]:
diff --git a/backend/tests/app/agent/test_agent_model.py b/backend/tests/app/agent/test_agent_model.py
index cc88001bf..862808308 100644
--- a/backend/tests/app/agent/test_agent_model.py
+++ b/backend/tests/app/agent/test_agent_model.py
@@ -155,6 +155,114 @@ def json(self):
assert kwargs["model_config_dict"]["store"] is False
assert kwargs["default_headers"]["originator"] == "codex_cli_rs"
+ def _create_model_via_agent_model(self, sample_chat_data, **overrides):
+ """Run agent_model with ModelFactory mocked and return its kwargs."""
+ options = Chat(**{**sample_chat_data, **overrides})
+
+ from app.service.task import task_locks
+
+ mock_task_lock = MagicMock()
+ task_locks[options.task_id] = mock_task_lock
+ mock_task_lock.put_queue = AsyncMock()
+
+ _m = sys.modules["app.agent.agent_model"]
+ with (
+ patch.object(_m, "ListenChatAgent"),
+ patch.object(_m, "ModelFactory") as mock_model_factory,
+ patch.object(_m, "get_task_lock", return_value=mock_task_lock),
+ patch("asyncio.create_task"),
+ ):
+ mock_model_factory.create.return_value = MagicMock()
+ agent_model("TestAgent", "You are helpful", options, [])
+
+ _, kwargs = mock_model_factory.create.call_args
+ return kwargs
+
+ def test_aimlapi_request_carries_attribution_headers(
+ self, sample_chat_data
+ ):
+ """aimlapi.com traffic must be attributable to this integration."""
+ kwargs = self._create_model_via_agent_model(
+ sample_chat_data,
+ model_platform="aimlapi",
+ model_type="openai/gpt-4o-mini",
+ api_key="test-key",
+ api_url="https://api.aimlapi.com/v1",
+ )
+
+ assert kwargs["model_platform"] == "openai-compatible-model"
+ headers = kwargs["default_headers"]
+ assert headers["X-AIMLAPI-Partner-ID"] == "part_kK5bWvwrYl5A9aWdwLFoIBQV"
+ assert headers["X-AIMLAPI-Source"] == "agent/eigent"
+ assert headers["HTTP-Referer"] == "https://github.com/eigent-ai/eigent"
+ assert headers["X-Title"] == "Eigent"
+
+ def test_attribution_headers_stay_off_other_providers(
+ self, sample_chat_data
+ ):
+ """Another vendor's request must never carry aimlapi attribution."""
+ kwargs = self._create_model_via_agent_model(
+ sample_chat_data,
+ model_platform="openai",
+ model_type="gpt-4o",
+ api_url="https://api.openai.com/v1",
+ )
+
+ assert "default_headers" not in kwargs
+
+ def test_user_default_headers_survive_attribution_merge(
+ self, sample_chat_data
+ ):
+ """Attribution merges into user headers, it does not replace them."""
+ kwargs = self._create_model_via_agent_model(
+ sample_chat_data,
+ model_platform="aimlapi",
+ model_type="openai/gpt-4o-mini",
+ api_url="https://api.aimlapi.com/v1",
+ extra_params={"default_headers": {"X-Team": "platform"}},
+ )
+
+ headers = kwargs["default_headers"]
+ assert headers["X-Team"] == "platform"
+ assert headers["X-AIMLAPI-Partner-ID"] == "part_kK5bWvwrYl5A9aWdwLFoIBQV"
+
+ def test_unset_request_fields_are_omitted_not_sent_as_null(
+ self, sample_chat_data
+ ):
+ """An unset optional must be omitted, never serialised as null.
+
+ OpenAI-compatible gateways type-check these fields and reject a
+ literal null with a 400, so a client that forwards `None` for an
+ option the user never set breaks every request while a mocked test
+ suite stays green. Assert on the config that is actually handed to
+ the model client.
+ """
+ null_rejecting_fields = (
+ "temperature",
+ "top_p",
+ "seed",
+ "tools",
+ "tool_choice",
+ "response_format",
+ "stream",
+ "stream_options",
+ "parallel_tool_calls",
+ "max_tokens",
+ "max_completion_tokens",
+ )
+ kwargs = self._create_model_via_agent_model(
+ sample_chat_data,
+ model_platform="aimlapi",
+ model_type="openai/gpt-4o-mini",
+ api_url="https://api.aimlapi.com/v1",
+ extra_params=dict.fromkeys(null_rejecting_fields),
+ )
+
+ model_config = kwargs["model_config_dict"] or {}
+ assert not [k for k, v in model_config.items() if v is None]
+ for field in null_rejecting_fields:
+ assert field not in model_config
+
def test_non_codex_model_does_not_inherit_subscription_runtime_params(
self, sample_chat_data
):
diff --git a/backend/tests/app/controller/test_model_controller.py b/backend/tests/app/controller/test_model_controller.py
index 0f40687db..5e2dcb755 100644
--- a/backend/tests/app/controller/test_model_controller.py
+++ b/backend/tests/app/controller/test_model_controller.py
@@ -52,6 +52,15 @@ def test_validate_model_request_maps_nebius_alias(self):
)
assert request_data.model_platform == "openai-compatible-model"
+ def test_validate_model_request_maps_aimlapi_alias(self):
+ """Test request model maps aimlapi alias to openai-compatible-model."""
+ request_data = ValidateModelRequest(
+ model_platform="aimlapi",
+ model_type="openai/gpt-4o-mini",
+ api_key="test_key",
+ )
+ assert request_data.model_platform == "openai-compatible-model"
+
def test_validate_model_request_keeps_supported_platforms_unchanged(self):
"""Test request model keeps native camel-ai platforms unchanged."""
request_data = ValidateModelRequest(
diff --git a/backend/tests/app/model/test_chat.py b/backend/tests/app/model/test_chat.py
index f33c593ae..4162f046a 100644
--- a/backend/tests/app/model/test_chat.py
+++ b/backend/tests/app/model/test_chat.py
@@ -207,6 +207,11 @@ def test_chat_maps_ant_ling_to_openai_compatible_model(self):
chat = self._create_chat("ant-ling")
assert chat.model_platform == "openai-compatible-model"
+ def test_chat_maps_aimlapi_to_openai_compatible_model(self):
+ """Test Chat maps aimlapi.com platform alias correctly."""
+ chat = self._create_chat("aimlapi")
+ assert chat.model_platform == "openai-compatible-model"
+
def test_chat_keeps_supported_platforms_unchanged(self):
"""Test Chat keeps native camel-ai platforms unchanged."""
chat = self._create_chat("mistral")
diff --git a/backend/tests/app/model/test_model_platform.py b/backend/tests/app/model/test_model_platform.py
index 62d3d6d9d..b72ca3652 100644
--- a/backend/tests/app/model/test_model_platform.py
+++ b/backend/tests/app/model/test_model_platform.py
@@ -12,6 +12,8 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
+import re
+
import httpx
import pytest
from camel.models import ModelFactory
@@ -20,8 +22,11 @@
from pydantic import BaseModel
from app.model.model_platform import (
+ AIMLAPI_ATTRIBUTION_HEADERS,
NormalizedModelPlatform,
NormalizedOptionalModelPlatform,
+ aimlapi_attribution_headers,
+ is_aimlapi_endpoint,
is_eigent_cloud_model_endpoint,
normalize_model_platform,
normalize_optional_model_platform,
@@ -36,6 +41,62 @@ def test_normalize_model_platform_maps_known_aliases():
assert normalize_model_platform("ernie") == "qianfan"
assert normalize_model_platform("llama.cpp") == "openai-compatible-model"
assert normalize_model_platform("nebius") == "openai-compatible-model"
+ assert normalize_model_platform("aimlapi") == "openai-compatible-model"
+
+
+def test_aimlapi_partner_id_matches_gateway_contract():
+ """A malformed partner id is dropped silently and earns nothing."""
+ assert re.fullmatch(
+ r"part_[A-Za-z0-9]{1,64}",
+ AIMLAPI_ATTRIBUTION_HEADERS["X-AIMLAPI-Partner-ID"],
+ )
+ assert re.fullmatch(
+ r"(web|agent|mcp)/[a-z0-9-]{1,32}",
+ AIMLAPI_ATTRIBUTION_HEADERS["X-AIMLAPI-Source"],
+ )
+
+
+def test_aimlapi_referer_and_title_identify_the_calling_app():
+ assert (
+ AIMLAPI_ATTRIBUTION_HEADERS["HTTP-Referer"]
+ == "https://github.com/eigent-ai/eigent"
+ )
+ assert AIMLAPI_ATTRIBUTION_HEADERS["X-Title"] == "Eigent"
+
+
+def test_is_aimlapi_endpoint_matches_host_not_substring():
+ assert is_aimlapi_endpoint("https://api.aimlapi.com/v1")
+ assert is_aimlapi_endpoint("api.aimlapi.com/v1")
+ assert not is_aimlapi_endpoint("https://api.aimlapi.com.evil.test/v1")
+ assert not is_aimlapi_endpoint("https://proxy.example.com/api.aimlapi.com")
+ assert not is_aimlapi_endpoint("https://openrouter.ai/api/v1")
+ assert not is_aimlapi_endpoint(None)
+ assert not is_aimlapi_endpoint("")
+
+
+def test_aimlapi_attribution_is_scoped_to_aimlapi_requests():
+ assert aimlapi_attribution_headers("https://api.openai.com/v1") is None
+ assert aimlapi_attribution_headers("https://openrouter.ai/api/v1") is None
+
+ headers = aimlapi_attribution_headers("https://api.aimlapi.com/v1")
+ assert headers == AIMLAPI_ATTRIBUTION_HEADERS
+
+
+def test_aimlapi_attribution_merges_and_never_mutates_the_constant():
+ original = dict(AIMLAPI_ATTRIBUTION_HEADERS)
+
+ headers = aimlapi_attribution_headers(
+ "https://api.aimlapi.com/v1",
+ {"X-Title": "user override", "X-Custom": "kept"},
+ )
+
+ # A caller's own headers survive, and win on a key clash.
+ assert headers["X-Custom"] == "kept"
+ assert headers["X-Title"] == "user override"
+ assert headers["X-AIMLAPI-Partner-ID"] == original["X-AIMLAPI-Partner-ID"]
+
+ headers["X-AIMLAPI-Partner-ID"] = "mutated"
+ assert AIMLAPI_ATTRIBUTION_HEADERS == original
def test_normalize_model_platform_keeps_non_alias_unchanged():
diff --git a/scripts/check-i18n-source-usage.mjs b/scripts/check-i18n-source-usage.mjs
index 189617c65..ab6214017 100644
--- a/scripts/check-i18n-source-usage.mjs
+++ b/scripts/check-i18n-source-usage.mjs
@@ -89,6 +89,7 @@ const NATIVE_LANGUAGE_LABELS = [
];
const PROVIDER_METADATA_DESCRIPTIONS = [
+ 'AI/ML API model configuration.',
'Codex subscription model configuration.',
'Google Gemini model configuration.',
'OpenAI model configuration.',
diff --git a/src/assets/model/aimlapi.svg b/src/assets/model/aimlapi.svg
new file mode 100644
index 000000000..cccd28f52
--- /dev/null
+++ b/src/assets/model/aimlapi.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/Settings/Models/localModels.ts b/src/components/Settings/Models/localModels.ts
index c071850e1..07073e0ad 100644
--- a/src/components/Settings/Models/localModels.ts
+++ b/src/components/Settings/Models/localModels.ts
@@ -83,6 +83,7 @@ export const LOCAL_MODEL_OPTIONS: LocalModelOption[] = [
// Provider logos that use dark fills (black or currentColor) and need inversion in dark mode
export const DARK_FILL_MODELS = new Set([
+ 'aimlapi',
'openai',
'anthropic',
'moonshot',
diff --git a/src/lib/llm.ts b/src/lib/llm.ts
index 3d2024f9b..fa1c2507d 100644
--- a/src/lib/llm.ts
+++ b/src/lib/llm.ts
@@ -28,6 +28,20 @@ const CODEX_SUBSCRIPTION_PROVIDER: Provider = {
};
export const INIT_PROVODERS: Provider[] = [
+ {
+ id: 'aimlapi',
+ name: 'aimlapi.com',
+ apiKey: '',
+ apiHost: 'https://api.aimlapi.com/v1',
+ description: 'AI/ML API model configuration.',
+ is_valid: false,
+ model_type: '',
+ // `include=all` is what adds the `modalities` block; without it the
+ // listing is 785 undifferentiated entries, image and speech models
+ // included.
+ modelsEndpoint: '/models?include=all',
+ websiteUrl: 'https://aimlapi.com',
+ },
{
id: 'gemini',
name: 'Gemini',
diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts
index cbab26e65..ddfd87e03 100644
--- a/src/lib/providerModels.ts
+++ b/src/lib/providerModels.ts
@@ -28,6 +28,20 @@ type RawModel = {
input_modalities?: string[] | null;
output_modalities?: string[] | null;
};
+ /**
+ * Alternative modality shape used by listings that do not publish
+ * OpenRouter's `architecture` object (e.g. aimlapi.com).
+ */
+ modalities?: {
+ input?: string[] | null;
+ output?: string[] | null;
+ };
+ /**
+ * Endpoint surface this row describes, on listings that publish one
+ * row per surface (e.g. aimlapi.com). Absent on OpenRouter-shaped and
+ * plain OpenAI-shaped listings.
+ */
+ type?: string;
context_length?: number;
max_completion_tokens?: number;
};
@@ -45,18 +59,87 @@ export type ProviderModelGroup = {
/**
* Decide whether a model is chat-capable enough to surface in the dropdown.
- * Keeps models that explicitly emit text, plus models that omit the
- * architecture field entirely (some upstream listings — e.g. deepseek-reasoner
+ * Keeps models that explicitly emit text, plus models that declare no
+ * modality metadata at all (some upstream listings — e.g. deepseek-reasoner
* — leave it null even though they are usable for chat).
*
- * Filters out: TTS / image-only / video-only outputs.
+ * Filters out: TTS / image-only / video-only outputs, and — for listings that
+ * use the `modalities` shape — transcription / OCR entries that emit text but
+ * cannot accept a text prompt.
*/
function isChatCapable(model: RawModel): boolean {
const arch = model.architecture;
- if (!arch) return true;
- const out = arch.output_modalities;
- if (out == null) return true;
- return out.includes('text');
+ if (arch) {
+ const out = arch.output_modalities;
+ if (out == null) return true;
+ return out.includes('text');
+ }
+
+ const modalities = model.modalities;
+ if (!modalities) return true;
+ const { input, output } = modalities;
+ if (output != null && !output.includes('text')) return false;
+ if (input != null && !input.includes('text')) return false;
+ return true;
+}
+
+/**
+ * Attribution headers keyed by request origin. `HTTP-Referer` / `X-Title`
+ * follow the OpenRouter convention and identify Eigent as the calling
+ * application; the `X-AIMLAPI-*` pair is read by aimlapi.com to attribute
+ * traffic to this integration. Keying on the resolved origin — rather than on
+ * the configured provider id — keeps one vendor's headers off another vendor's
+ * request, including a proxy that merely fronts the same API.
+ */
+const ATTRIBUTION_HEADERS_BY_ORIGIN: Record> = {
+ 'https://api.aimlapi.com': {
+ 'HTTP-Referer': 'https://github.com/eigent-ai/eigent',
+ 'X-Title': 'Eigent',
+ 'X-AIMLAPI-Partner-ID': 'part_kK5bWvwrYl5A9aWdwLFoIBQV',
+ 'X-AIMLAPI-Source': 'agent/eigent',
+ },
+};
+
+/** Attribution headers for `url`, or an empty object for unknown origins. */
+export function attributionHeadersForUrl(url: string): Record {
+ let origin: string;
+ try {
+ origin = new URL(url).origin;
+ } catch {
+ return {};
+ }
+ // Spread so the shared table is never handed out by reference.
+ return { ...(ATTRIBUTION_HEADERS_BY_ORIGIN[origin] ?? {}) };
+}
+
+/**
+ * The one endpoint surface this client speaks. Everything below goes through
+ * `POST /chat/completions`.
+ */
+const CHAT_COMPLETIONS_SURFACE = 'openai/chat-completions';
+
+/**
+ * Decide whether a listing row describes an endpoint this client can call.
+ *
+ * A listing that publishes one row per endpoint surface names it in `type`
+ * (`openai/chat-completions`, `openai/responses/submit`, `anthropic/messages`,
+ * `openai/embeddings`, …). Only the chat-completions surface can serve us: a
+ * model published solely behind `openai/responses/submit` answers
+ * `404 Model not found` on `/chat/completions`, so offering it in the dropdown
+ * hands the user an id that cannot work. Verified live against aimlapi.com on
+ * 2026-09-03: `openai/gpt-5-2-pro` (responses-only) 404s, while
+ * `anthropic/claude-opus-5` — which also publishes a chat-completions row —
+ * answers 200.
+ *
+ * A surface name is recognised by its `/` shape. Listings
+ * that do not describe surfaces at all (OpenRouter's, and the plain OpenAI
+ * `/v1/models` shape used by the other providers here) carry no `type`, or
+ * carry an unrelated single-word value, and are left untouched.
+ */
+function declaresNonChatEndpoint(model: RawModel): boolean {
+ const type = model.type;
+ if (typeof type !== 'string' || !type.includes('/')) return false;
+ return type !== CHAT_COMPLETIONS_SURFACE;
}
/** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */
@@ -93,6 +176,7 @@ export async function fetchProviderModels(
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
+ ...attributionHeadersForUrl(url),
},
});
@@ -109,8 +193,14 @@ export async function fetchProviderModels(
const data: RawModel[] = Array.isArray(payload?.data) ? payload.data : [];
const grouped = new Map();
+ // One id can be listed several times when a provider publishes the same
+ // model under more than one endpoint surface; the dropdown must show it once.
+ const seen = new Set();
for (const model of data) {
if (!model?.id || !isChatCapable(model)) continue;
+ if (declaresNonChatEndpoint(model)) continue;
+ if (seen.has(model.id)) continue;
+ seen.add(model.id);
const [provider] = splitProviderPrefix(model.id);
const bucket = provider || 'other';
const info: ProviderModelInfo = {
diff --git a/src/shared/modelProviderImages.ts b/src/shared/modelProviderImages.ts
index 347953139..ded1583ce 100644
--- a/src/shared/modelProviderImages.ts
+++ b/src/shared/modelProviderImages.ts
@@ -12,6 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
+import aimlapiImage from '@/assets/model/aimlapi.svg';
import antLingImage from '@/assets/model/ant-ling.svg';
import anthropicImage from '@/assets/model/anthropic.svg';
import azureImage from '@/assets/model/azure.svg';
@@ -43,6 +44,7 @@ const MODEL_PROVIDER_IMAGE_MAP: Record = {
cloud: eigentImage,
openai: openaiImage,
'codex-subscription': openaiImage,
+ aimlapi: aimlapiImage,
'ant-ling': antLingImage,
anthropic: anthropicImage,
gemini: geminiImage,
diff --git a/test/unit/lib/llm.test.ts b/test/unit/lib/llm.test.ts
index 2ba52c537..2a037bcd6 100644
--- a/test/unit/lib/llm.test.ts
+++ b/test/unit/lib/llm.test.ts
@@ -27,4 +27,20 @@ describe('INIT_PROVODERS', () => {
websiteUrl: 'https://docs.tokenfactory.nebius.com/quickstart',
});
});
+
+ it('includes aimlapi.com as an OpenAI-compatible BYOK provider', () => {
+ const provider = INIT_PROVODERS.find((item) => item.id === 'aimlapi');
+
+ expect(provider).toMatchObject({
+ // The user-facing label is the vendor's own product name.
+ name: 'aimlapi.com',
+ apiHost: 'https://api.aimlapi.com/v1',
+ modelsEndpoint: '/models?include=all',
+ websiteUrl: 'https://aimlapi.com',
+ });
+ });
+
+ it('lists aimlapi.com first in the hand-ordered provider list', () => {
+ expect(INIT_PROVODERS[0]?.id).toBe('aimlapi');
+ });
});
diff --git a/test/unit/lib/providerModels.test.ts b/test/unit/lib/providerModels.test.ts
new file mode 100644
index 000000000..3dc65b37f
--- /dev/null
+++ b/test/unit/lib/providerModels.test.ts
@@ -0,0 +1,233 @@
+// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ attributionHeadersForUrl,
+ fetchProviderModels,
+} from '@/lib/providerModels';
+
+function mockModelsResponse(data: unknown[]) {
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ object: 'list', data }),
+ }));
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('attributionHeadersForUrl', () => {
+ it('sends a partner id the aimlapi.com gateway can parse', () => {
+ const headers = attributionHeadersForUrl('https://api.aimlapi.com/models');
+
+ // A malformed partner id is dropped silently by the gateway and earns
+ // nothing, so the shape is asserted rather than trusted.
+ expect(headers['X-AIMLAPI-Partner-ID']).toMatch(/^part_[A-Za-z0-9]{1,64}$/);
+ expect(headers['X-AIMLAPI-Source']).toMatch(
+ /^(web|agent|mcp)\/[a-z0-9-]{1,32}$/
+ );
+ // HTTP-Referer / X-Title identify Eigent, not the vendor.
+ expect(headers['HTTP-Referer']).toBe('https://github.com/eigent-ai/eigent');
+ expect(headers['X-Title']).toBe('Eigent');
+ });
+
+ it('never attaches attribution to another vendor or a look-alike host', () => {
+ expect(
+ attributionHeadersForUrl('https://openrouter.ai/api/v1/models')
+ ).toEqual({});
+ expect(
+ attributionHeadersForUrl('https://api.aimlapi.com.evil.test/models')
+ ).toEqual({});
+ expect(
+ attributionHeadersForUrl('https://proxy.example.com/api.aimlapi.com')
+ ).toEqual({});
+ expect(attributionHeadersForUrl('not a url')).toEqual({});
+ });
+
+ it('returns a fresh object so the shared table cannot be mutated', () => {
+ const first = attributionHeadersForUrl('https://api.aimlapi.com/models');
+ first['X-AIMLAPI-Partner-ID'] = 'mutated';
+
+ const second = attributionHeadersForUrl('https://api.aimlapi.com/models');
+ expect(second['X-AIMLAPI-Partner-ID']).not.toBe('mutated');
+ });
+});
+
+describe('fetchProviderModels', () => {
+ it('attaches attribution alongside the caller headers for aimlapi.com', async () => {
+ const fetchMock = mockModelsResponse([]);
+
+ await fetchProviderModels('https://api.aimlapi.com/v1', '/models', 'k');
+
+ const headers = (fetchMock.mock.calls[0] as any)[1].headers;
+ expect(headers.Authorization).toBe('Bearer k');
+ expect(headers.Accept).toBe('application/json');
+ expect(headers['X-AIMLAPI-Source']).toBe('agent/eigent');
+ });
+
+ it('leaves other providers request headers untouched', async () => {
+ const fetchMock = mockModelsResponse([]);
+
+ await fetchProviderModels('https://openrouter.ai/api/v1', '/models', 'k');
+
+ const headers = (fetchMock.mock.calls[0] as any)[1].headers;
+ expect(Object.keys(headers).sort()).toEqual(['Accept', 'Authorization']);
+ });
+
+ it('keeps text-in / text-out models from a `modalities` listing', async () => {
+ mockModelsResponse([
+ {
+ id: 'openai/gpt-4o-mini',
+ modalities: { input: ['image', 'text'], output: ['text'] },
+ },
+ // Image generation: text in, image out.
+ {
+ id: 'flux/schnell',
+ modalities: { input: ['text'], output: ['image'] },
+ },
+ // Speech to text: audio in, text out.
+ {
+ id: 'deepgram/nova-3',
+ modalities: { input: ['audio'], output: ['text'] },
+ },
+ ]);
+
+ const groups = await fetchProviderModels(
+ 'https://api.aimlapi.com/v1',
+ '/models',
+ 'k'
+ );
+
+ expect(groups).toEqual([
+ { provider: 'openai', models: [{ id: 'openai/gpt-4o-mini' }] },
+ ]);
+ });
+
+ it('lists an id once when the listing repeats it', async () => {
+ mockModelsResponse([
+ {
+ id: 'anthropic/claude-sonnet-4.5',
+ type: 'openai/chat-completions',
+ modalities: { input: ['text'], output: ['text'] },
+ },
+ {
+ id: 'anthropic/claude-sonnet-4.5',
+ type: 'openai/chat-completions',
+ modalities: { input: ['text'], output: ['text'] },
+ },
+ ]);
+
+ const groups = await fetchProviderModels(
+ 'https://api.aimlapi.com/v1',
+ '/models',
+ 'k'
+ );
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].models).toHaveLength(1);
+ });
+
+ it('drops a model published only behind a non-chat endpoint surface', async () => {
+ mockModelsResponse([
+ // Responses-API only. Verified live 2026-09-03: a /chat/completions
+ // call for this id answers 404 "Model not found", so offering it in
+ // the dropdown hands the user an id that cannot work.
+ {
+ id: 'openai/gpt-5-2-pro',
+ type: 'openai/responses/submit',
+ modalities: { input: ['document', 'text'], output: ['text'] },
+ },
+ // Published on both surfaces, so it is reachable and must stay.
+ {
+ id: 'openai/gpt-5-5',
+ type: 'openai/chat-completions',
+ modalities: { input: ['image', 'text'], output: ['text'] },
+ },
+ {
+ id: 'openai/gpt-5-5',
+ type: 'openai/responses/submit',
+ modalities: { input: ['document', 'image', 'text'], output: ['text'] },
+ },
+ // Anthropic models list a messages row first; the chat-completions row
+ // is what keeps them. Verified live: this id answers 200 on
+ // /chat/completions despite advertising only `streaming`.
+ {
+ id: 'anthropic/claude-opus-5',
+ type: 'anthropic/messages',
+ modalities: { input: ['text'], output: ['text'] },
+ },
+ {
+ id: 'anthropic/claude-opus-5',
+ type: 'openai/chat-completions',
+ modalities: { input: ['text'], output: ['text'] },
+ },
+ {
+ id: 'openai/text-embedding-3-small',
+ type: 'openai/embeddings',
+ modalities: { input: ['text'], output: ['text'] },
+ },
+ ]);
+
+ const groups = await fetchProviderModels(
+ 'https://api.aimlapi.com/v1',
+ '/models',
+ 'k'
+ );
+
+ expect(groups).toEqual([
+ {
+ provider: 'anthropic',
+ models: [{ id: 'anthropic/claude-opus-5' }],
+ },
+ { provider: 'openai', models: [{ id: 'openai/gpt-5-5' }] },
+ ]);
+ });
+
+ it('ignores a `type` that is not an endpoint surface name', async () => {
+ // A single-word `type` is not the `/` surface shape,
+ // so it must not be read as one and must not filter anything out.
+ mockModelsResponse([{ id: 'some-model', type: 'model' }]);
+
+ const groups = await fetchProviderModels(
+ 'https://api.tokenfactory.nebius.com/v1',
+ '/models',
+ 'k'
+ );
+
+ expect(groups).toEqual([
+ { provider: 'other', models: [{ id: 'some-model' }] },
+ ]);
+ });
+
+ it('still keeps listings that publish no modality metadata at all', async () => {
+ mockModelsResponse([{ id: 'deepseek-reasoner' }]);
+
+ const groups = await fetchProviderModels(
+ 'https://api.tokenfactory.nebius.com/v1',
+ '/models',
+ 'k'
+ );
+
+ expect(groups).toEqual([
+ { provider: 'other', models: [{ id: 'deepseek-reasoner' }] },
+ ]);
+ });
+});