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
9 changes: 9 additions & 0 deletions backend/app/agent/agent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down
15 changes: 14 additions & 1 deletion backend/app/component/model_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions backend/app/model/model_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand Down
108 changes: 108 additions & 0 deletions backend/tests/app/agent/test_agent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/app/controller/test_model_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions backend/tests/app/model/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
61 changes: 61 additions & 0 deletions backend/tests/app/model/test_model_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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():
Expand Down
1 change: 1 addition & 0 deletions scripts/check-i18n-source-usage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions src/assets/model/aimlapi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/components/Settings/Models/localModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions src/lib/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading