From 6d3a9b7249df224bed4e011d2eb8a1182b45b731 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 06:05:33 +0500 Subject: [PATCH 1/5] feat: add aimlapi.com as an OpenAI-compatible BYOK provider Eigent can already reach aimlapi.com, but only through the generic "OpenAI Compatible" entry, which means the user has to know and type the base URL by hand and gets no model picker. Registering it as its own provider follows the same shape as the Ant Ling and Nebius Token Factory additions, so the searchable dropdown comes from the vendor's public /v1/models listing with no new fetching code. Two details of that listing had to be handled or the picker is unusable. It describes models with a `modalities` object rather than OpenRouter's `architecture`, and it only emits that block when asked with `include=all`; and it repeats an id once per endpoint surface the model serves. Read naively the picker offered 785 undifferentiated entries, image, video and speech models included, many of them duplicated. Teaching the shared parser the second modality shape and de-duplicating by id narrows that to the 362 entries a chat agent can actually drive. Both parser changes are generic and leave the existing providers' output byte-identical, since neither publishes `modalities`. --- backend/app/model/model_platform.py | 1 + .../app/controller/test_model_controller.py | 9 ++ backend/tests/app/model/test_chat.py | 5 + .../tests/app/model/test_model_platform.py | 1 + scripts/check-i18n-source-usage.mjs | 1 + src/assets/model/aimlapi.svg | 1 + src/components/Settings/Models/localModels.ts | 1 + src/lib/llm.ts | 14 +++ src/lib/providerModels.ts | 37 +++++-- src/shared/modelProviderImages.ts | 2 + test/unit/lib/llm.test.ts | 12 +++ test/unit/lib/providerModels.test.ts | 101 ++++++++++++++++++ 12 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/assets/model/aimlapi.svg create mode 100644 test/unit/lib/providerModels.test.ts diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index 99627d7e9..04201953c 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -18,6 +18,7 @@ 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", 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..762a63cb6 100644 --- a/backend/tests/app/model/test_model_platform.py +++ b/backend/tests/app/model/test_model_platform.py @@ -36,6 +36,7 @@ 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_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..1f0b8a88a 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -254,6 +254,20 @@ export const INIT_PROVODERS: Provider[] = [ modelsEndpoint: '/models', websiteUrl: 'https://developer.ant-ling.com', }, + { + 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: 'openai-compatible-model', name: 'OpenAI Compatible', diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts index cbab26e65..7d3078d8f 100644 --- a/src/lib/providerModels.ts +++ b/src/lib/providerModels.ts @@ -28,6 +28,14 @@ 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; + }; context_length?: number; max_completion_tokens?: number; }; @@ -45,18 +53,28 @@ 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; } /** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */ @@ -109,8 +127,13 @@ 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 (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..1c4086cd7 100644 --- a/test/unit/lib/llm.test.ts +++ b/test/unit/lib/llm.test.ts @@ -27,4 +27,16 @@ 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', + }); + }); }); diff --git a/test/unit/lib/providerModels.test.ts b/test/unit/lib/providerModels.test.ts new file mode 100644 index 000000000..1b79a2339 --- /dev/null +++ b/test/unit/lib/providerModels.test.ts @@ -0,0 +1,101 @@ +// ========= 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 { 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('fetchProviderModels', () => { + 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 it appears under several endpoint surfaces', async () => { + mockModelsResponse([ + { + id: 'anthropic/claude-sonnet-4.5', + type: 'openai/chat-completions', + modalities: { input: ['text'], output: ['text'] }, + }, + { + id: 'anthropic/claude-sonnet-4.5', + type: 'anthropic/messages', + 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('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' }] }, + ]); + }); +}); From 051d9d220c073a22d33db956b41af2c46eab0fb7 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 06:07:22 +0500 Subject: [PATCH 2/5] feat: attribute Eigent's own aimlapi.com traffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aimlapi.com credits an integration for the traffic it sends, and does so only when the request carries the partner headers. Eigent already has the mechanism: `default_headers` is a declared init param on the model client and the Codex subscription runtime uses it, so this reuses that path rather than adding new plumbing. The headers are keyed on the request host, not on the configured provider id, so a user who points the aimlapi entry at a different endpoint — or another provider at a proxy — never leaks them to a third party. Merging lets a user's own `default_headers` win on a clash, and a new dict is built per request so the module constant cannot be mutated by a caller. A wrong partner id is not rejected; it is silently treated as untagged traffic, so the id and source shapes are asserted in tests rather than trusted. The same tests pin that no unset request field is serialised as a literal null: the gateway type-checks temperature, top_p, seed, tools, tool_choice, response_format, stream, stream_options, parallel_tool_calls and the max_tokens pair, and answers 400 for null on any of them, which a mocked transport would never reveal. --- backend/app/agent/agent_model.py | 9 ++ backend/app/component/model_validation.py | 15 ++- backend/app/model/model_platform.py | 50 ++++++++ backend/tests/app/agent/test_agent_model.py | 108 ++++++++++++++++++ .../tests/app/model/test_model_platform.py | 60 ++++++++++ src/lib/providerModels.ts | 30 +++++ test/unit/lib/providerModels.test.ts | 62 +++++++++- 7 files changed, 332 insertions(+), 2 deletions(-) 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 04201953c..fbe90e814 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= from typing import Annotated, Final +from urllib.parse import urlparse from pydantic import BeforeValidator @@ -53,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_eigent", + "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..fee117eea 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_eigent" + 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_eigent" + + 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/model/test_model_platform.py b/backend/tests/app/model/test_model_platform.py index 762a63cb6..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, @@ -39,6 +44,61 @@ def test_normalize_model_platform_maps_known_aliases(): 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(): assert normalize_model_platform("openai") == "openai" assert normalize_model_platform("mistral") == "mistral" diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts index 7d3078d8f..36311766d 100644 --- a/src/lib/providerModels.ts +++ b/src/lib/providerModels.ts @@ -77,6 +77,35 @@ function isChatCapable(model: RawModel): boolean { 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_eigent', + '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] ?? {}) }; +} + /** Split `anthropic/claude-opus-4.6` into `["anthropic", "claude-opus-4.6"]`. */ function splitProviderPrefix(id: string): [string, string] { const idx = id.indexOf('/'); @@ -111,6 +140,7 @@ export async function fetchProviderModels( headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', + ...attributionHeadersForUrl(url), }, }); diff --git a/test/unit/lib/providerModels.test.ts b/test/unit/lib/providerModels.test.ts index 1b79a2339..4f134b533 100644 --- a/test/unit/lib/providerModels.test.ts +++ b/test/unit/lib/providerModels.test.ts @@ -14,7 +14,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchProviderModels } from '@/lib/providerModels'; +import { + attributionHeadersForUrl, + fetchProviderModels, +} from '@/lib/providerModels'; function mockModelsResponse(data: unknown[]) { const fetchMock = vi.fn(async () => ({ @@ -31,7 +34,64 @@ 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([ { From 43b86eeeb1fdb7719b3535d74353128164b12bf9 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 07:36:24 +0500 Subject: [PATCH 3/5] fix: keep Responses-only models out of the chat model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listing that publishes one row per endpoint surface names that surface in `type`, and a model can be published behind several. Filtering the listing on modality alone let through ten ids that aimlapi.com serves only from the Responses API — the gpt-5 codex and pro variants. Eigent drives an OpenAI-compatible provider through /chat/completions, so picking one of those got the user `404 Model not found` from a dropdown that had offered it. Rows are now dropped when they name an endpoint surface other than `openai/chat-completions`. A surface is recognised by its `/` shape, so listings that publish no `type` — the OpenRouter shape and the plain OpenAI /v1/models shape used by the other providers here — are unaffected, as is a listing whose `type` is an unrelated single word. This keeps models that are reachable on more than one surface: the Anthropic entries lead with an `anthropic/messages` row but also publish a chat-completions row, and were confirmed live to answer 200 there. Verified against the live listing: 352 models in 51 groups, no duplicates, gpt-5-5 and claude-opus-5 present, gpt-5-2-pro gone. --- src/lib/providerModels.ts | 37 ++++++++++++++ test/unit/lib/providerModels.test.ts | 76 +++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts index 36311766d..98d116878 100644 --- a/src/lib/providerModels.ts +++ b/src/lib/providerModels.ts @@ -36,6 +36,12 @@ type RawModel = { 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; }; @@ -106,6 +112,36 @@ export function attributionHeadersForUrl(url: string): Record { 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"]`. */ function splitProviderPrefix(id: string): [string, string] { const idx = id.indexOf('/'); @@ -162,6 +198,7 @@ export async function fetchProviderModels( 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); diff --git a/test/unit/lib/providerModels.test.ts b/test/unit/lib/providerModels.test.ts index 4f134b533..3dc65b37f 100644 --- a/test/unit/lib/providerModels.test.ts +++ b/test/unit/lib/providerModels.test.ts @@ -121,7 +121,7 @@ describe('fetchProviderModels', () => { ]); }); - it('lists an id once when it appears under several endpoint surfaces', async () => { + it('lists an id once when the listing repeats it', async () => { mockModelsResponse([ { id: 'anthropic/claude-sonnet-4.5', @@ -130,7 +130,7 @@ describe('fetchProviderModels', () => { }, { id: 'anthropic/claude-sonnet-4.5', - type: 'anthropic/messages', + type: 'openai/chat-completions', modalities: { input: ['text'], output: ['text'] }, }, ]); @@ -145,6 +145,78 @@ describe('fetchProviderModels', () => { 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' }]); From 6105144ac4450835cd98489dbd32ba405c79e19a Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 06:13:18 +0500 Subject: [PATCH 4/5] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the aimlapi.com card to the top of INIT_PROVODERS, the hand-ordered list that both the Settings model page and the chat model selector render in array order. This is placement for our own distribution only, and is isolated in a single commit so it can be dropped before anything is proposed upstream. Nothing else is reordered. The per-provider model dropdown is sorted alphabetically inside fetchProviderModels, and the Add Worker list follows whatever order the backend returns from /api/v1/providers. Both are generated, so both are left as they are. --- src/lib/llm.ts | 28 ++++++++++++++-------------- test/unit/lib/llm.test.ts | 4 ++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 1f0b8a88a..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', @@ -254,20 +268,6 @@ export const INIT_PROVODERS: Provider[] = [ modelsEndpoint: '/models', websiteUrl: 'https://developer.ant-ling.com', }, - { - 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: 'openai-compatible-model', name: 'OpenAI Compatible', diff --git a/test/unit/lib/llm.test.ts b/test/unit/lib/llm.test.ts index 1c4086cd7..2a037bcd6 100644 --- a/test/unit/lib/llm.test.ts +++ b/test/unit/lib/llm.test.ts @@ -39,4 +39,8 @@ describe('INIT_PROVODERS', () => { websiteUrl: 'https://aimlapi.com', }); }); + + it('lists aimlapi.com first in the hand-ordered provider list', () => { + expect(INIT_PROVODERS[0]?.id).toBe('aimlapi'); + }); }); From 2dc11d805861855ce4fa84d763b57b59f645edf0 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:11:32 +0500 Subject: [PATCH 5/5] fix(aimlapi): use the registered partner id The placeholder part_eigent was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_kK5bWvwrYl5A9aWdwLFoIBQV. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- backend/app/model/model_platform.py | 2 +- backend/tests/app/agent/test_agent_model.py | 4 ++-- src/lib/providerModels.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index fbe90e814..ed510403c 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -67,7 +67,7 @@ AIMLAPI_ATTRIBUTION_HEADERS: Final[dict[str, str]] = { "HTTP-Referer": "https://github.com/eigent-ai/eigent", "X-Title": "Eigent", - "X-AIMLAPI-Partner-ID": "part_eigent", + "X-AIMLAPI-Partner-ID": "part_kK5bWvwrYl5A9aWdwLFoIBQV", "X-AIMLAPI-Source": "agent/eigent", } diff --git a/backend/tests/app/agent/test_agent_model.py b/backend/tests/app/agent/test_agent_model.py index fee117eea..862808308 100644 --- a/backend/tests/app/agent/test_agent_model.py +++ b/backend/tests/app/agent/test_agent_model.py @@ -192,7 +192,7 @@ def test_aimlapi_request_carries_attribution_headers( assert kwargs["model_platform"] == "openai-compatible-model" headers = kwargs["default_headers"] - assert headers["X-AIMLAPI-Partner-ID"] == "part_eigent" + 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" @@ -224,7 +224,7 @@ def test_user_default_headers_survive_attribution_merge( headers = kwargs["default_headers"] assert headers["X-Team"] == "platform" - assert headers["X-AIMLAPI-Partner-ID"] == "part_eigent" + assert headers["X-AIMLAPI-Partner-ID"] == "part_kK5bWvwrYl5A9aWdwLFoIBQV" def test_unset_request_fields_are_omitted_not_sent_as_null( self, sample_chat_data diff --git a/src/lib/providerModels.ts b/src/lib/providerModels.ts index 98d116878..ddfd87e03 100644 --- a/src/lib/providerModels.ts +++ b/src/lib/providerModels.ts @@ -95,7 +95,7 @@ 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_eigent', + 'X-AIMLAPI-Partner-ID': 'part_kK5bWvwrYl5A9aWdwLFoIBQV', 'X-AIMLAPI-Source': 'agent/eigent', }, };