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' }] }, + ]); + }); +});