diff --git a/.env.example b/.env.example index 45f9b7abc..97004cd04 100644 --- a/.env.example +++ b/.env.example @@ -23,8 +23,9 @@ # ─── LLM (OpenAI-protocol compatible) ──────────────── # Any OpenAI-API-compatible endpoint plugs in via base_url. Defaults # below target OpenRouter (one key, broad model catalogue); switch to -# OpenAI, vLLM, Ollama (OpenAI bridge), or any other compatible endpoint -# by changing model + base_url + api_key. +# aimlapi.com (https://api.aimlapi.com/v1 — same slug spelling, so the +# model below is unchanged), OpenAI, vLLM, Ollama (OpenAI bridge), or any +# other compatible endpoint by changing model + base_url + api_key. EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= diff --git a/config.example.toml b/config.example.toml index 48cff25fd..616731bd1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -20,6 +20,9 @@ # ── LLM ─────────────────────────────────────────────── # OpenAI-protocol chat-completions endpoint used by the algo extractors. +# Alternatives, same protocol — swap all three fields together: +# aimlapi.com model = "openai/gpt-4.1-mini", base_url = "https://api.aimlapi.com/v1" +# OpenRouter model = "openai/gpt-4.1-mini", base_url = "https://openrouter.ai/api/v1" [llm] model = "gpt-4.1-mini" api_key = "sk-..." diff --git a/docs/configuration.md b/docs/configuration.md index 5cf85a198..ae211b71a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -130,6 +130,27 @@ Zilliz Cloud endpoint; a Milvus Lite filesystem path is rejected. | `api_key` | string | — | **Yes** | API key for the LLM provider. | | `base_url` | string | — | No | Custom endpoint URL (OpenAI-compatible). | +#### Provider endpoints + +Any OpenAI-protocol chat-completions endpoint works. Two aggregators cover +the shipped model slugs without rewriting them: + +| Provider | `base_url` | Notes | +|---|---|---| +| aimlapi.com | `https://api.aimlapi.com/v1` | Same `vendor/model` slug convention, so the shipped `[llm]` default works unchanged. | +| OpenRouter | `https://openrouter.ai/api/v1` | Historical default. | + +The shipped `[llm]` default `openai/gpt-4.1-mini` was called live against +aimlapi.com and answered, including the structured-output +(`response_format`) path the extractors use. + +> **Known limitation — multimodal against aimlapi.com.** The image content +> parts EverOS sends carry `image_url.detail = null`, which aimlapi.com +> rejects with HTTP 400 (`messages.0.content` / `invalid_union`) where +> OpenAI and OpenRouter accept it. Text-only calls to `[multimodal]` are +> fine; keep `[multimodal]` on a provider that tolerates the null field +> until either side changes. + ### `[multimodal]` | Field | Type | Default | Required | Description | diff --git a/src/everos/component/embedding/openai_provider.py b/src/everos/component/embedding/openai_provider.py index b62ec5d83..73dc32227 100644 --- a/src/everos/component/embedding/openai_provider.py +++ b/src/everos/component/embedding/openai_provider.py @@ -24,6 +24,7 @@ import openai +from everos.component.utils.attribution import aimlapi_headers from everos.core.errors import EmbeddingInputError from everos.core.observability.tracing import memory_span, set_generation_usage @@ -87,11 +88,14 @@ def __init__( self._model = model self._batch_size = batch_size self._semaphore = asyncio.Semaphore(max_concurrent) + # Partner attribution, merged into (not over) the SDK's own + # defaults and empty unless ``base_url`` is an aimlapi.com host. self._client = openai.AsyncOpenAI( api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, + default_headers=aimlapi_headers(base_url) or None, ) async def embed(self, text: str) -> list[float]: diff --git a/src/everos/component/llm/client.py b/src/everos/component/llm/client.py index 8defa6d82..04021e691 100644 --- a/src/everos/component/llm/client.py +++ b/src/everos/component/llm/client.py @@ -18,6 +18,7 @@ from everalgo.llm.types import ChatMessage, ChatResponse from pydantic import BaseModel +from everos.component.utils.attribution import aimlapi_request_extra from everos.component.utils.config_hints import missing_config_error from everos.config import Settings, load_settings from everos.core.observability.logging import get_logger @@ -105,7 +106,12 @@ def get_llm_client() -> LLMClient: api_key=api_key, base_url=llm_cfg.base_url, timeout=llm_cfg.timeout_seconds, - extra=dict(llm_cfg.extra), + # Attribution first, ``[llm].extra`` second, so a configured + # key keeps winning on collision. For every non-aimlapi + # endpoint the attribution mapping is empty, so no key is + # added to the request and no header can reach a foreign + # provider. + extra={**aimlapi_request_extra(llm_cfg.base_url), **llm_cfg.extra}, ) ) # Wrap for OTel token capture only when tracing is on — keeps the @@ -251,6 +257,7 @@ def get_multimodal_llm_client() -> LLMClient: model=cfg.model, api_key=api_key, base_url=cfg.base_url, + extra=aimlapi_request_extra(cfg.base_url), ) ) logger.info("multimodal_llm_client_built", model=cfg.model) diff --git a/src/everos/component/llm/openai_provider.py b/src/everos/component/llm/openai_provider.py index a68c3ba42..c22e62674 100644 --- a/src/everos/component/llm/openai_provider.py +++ b/src/everos/component/llm/openai_provider.py @@ -18,6 +18,8 @@ import openai +from everos.component.utils.attribution import aimlapi_headers + from .protocol import ChatMessage, ChatResponse, LLMError, Usage @@ -75,6 +77,10 @@ def __init__( } if max_retries is not None: client_kwargs["max_retries"] = max_retries + # Partner attribution, merged into (not over) the SDK's own + # defaults and absent unless ``base_url`` is an aimlapi.com host. + if attribution := aimlapi_headers(base_url): + client_kwargs["default_headers"] = attribution self._client = openai.AsyncOpenAI(**client_kwargs) # type: ignore[arg-type] async def chat( diff --git a/src/everos/component/utils/__init__.py b/src/everos/component/utils/__init__.py index e9cada50e..9b5228172 100644 --- a/src/everos/component/utils/__init__.py +++ b/src/everos/component/utils/__init__.py @@ -19,4 +19,11 @@ tokens_for_query, join_tokens, ) + from everos.component.utils.attribution import ( + AIMLAPI_BASE_URL, + AIMLAPI_DISPLAY_NAME, + aimlapi_headers, + aimlapi_request_extra, + is_aimlapi_base_url, + ) """ diff --git a/src/everos/component/utils/attribution.py b/src/everos/component/utils/attribution.py new file mode 100644 index 000000000..3021b27a6 --- /dev/null +++ b/src/everos/component/utils/attribution.py @@ -0,0 +1,94 @@ +"""Partner attribution headers for aimlapi.com endpoints. + +aimlapi.com credits the projects that send it traffic, but only when the +request carries the partner headers below. EverOS reaches every model +provider through the same OpenAI-protocol clients, so the headers cannot +be attached at the SDK layer without leaking to whichever endpoint the +user happens to configure. Instead every helper here is *origin-scoped*: +it inspects the configured ``base_url`` and returns an empty mapping for +anything that is not an aimlapi.com host, so the headers can never ride a +request to OpenRouter, DeepInfra, OpenAI or a proxy in front of them. + +The returned mapping is always a fresh ``dict`` — callers merge it into +their own header set, and no shared constant is ever handed out for +mutation. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlsplit + +AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1" +"""Chat-completions / embeddings base URL for aimlapi.com.""" + +AIMLAPI_DISPLAY_NAME = "aimlapi.com" +"""Human-facing provider label, as the provider spells it.""" + +_AIMLAPI_DOMAIN = "aimlapi.com" + +# Identifies EverOS to aimlapi.com. Must match ``^part_[A-Za-z0-9]{1,64}$`` +# — a malformed id is accepted by the API and then silently unattributed. +_PARTNER_ID = "part_VxTyAUvoIVbl30dPrB7kbRZk" +_SOURCE = "agent/everos" + +# ``HTTP-Referer`` / ``X-Title`` name the *host* project (EverOS), the +# same convention OpenRouter uses for app attribution. +_REFERER = "https://github.com/EverMind-AI/EverOS" +_TITLE = "EverOS" + + +def is_aimlapi_base_url(base_url: str | None) -> bool: + """Return whether ``base_url`` points at an aimlapi.com host. + + Matches on the parsed hostname only, on a dot boundary, so lookalike + hosts such as ``api.aimlapi.com.example.net`` do not match. + + Args: + base_url: Configured OpenAI-protocol endpoint, or ``None``. + + Returns: + ``True`` when the host is ``aimlapi.com`` or a subdomain of it. + """ + if not base_url: + return False + host = (urlsplit(base_url).hostname or "").lower() + return host == _AIMLAPI_DOMAIN or host.endswith(f".{_AIMLAPI_DOMAIN}") + + +def aimlapi_headers(base_url: str | None) -> dict[str, str]: + """Return the partner attribution headers for an aimlapi.com endpoint. + + Args: + base_url: Configured OpenAI-protocol endpoint, or ``None``. + + Returns: + A new ``dict`` of headers when ``base_url`` is an aimlapi.com + host, otherwise an empty ``dict``. Never returns a shared object. + """ + if not is_aimlapi_base_url(base_url): + return {} + return { + "X-AIMLAPI-Partner-ID": _PARTNER_ID, + "X-AIMLAPI-Source": _SOURCE, + "HTTP-Referer": _REFERER, + "X-Title": _TITLE, + } + + +def aimlapi_request_extra(base_url: str | None) -> dict[str, Any]: + """Return per-request kwargs carrying the attribution headers. + + Shaped for clients that only accept extra *request* options (the + everalgo ``LLMConfig.extra`` passthrough), where ``extra_headers`` is + forwarded by the openai SDK as headers rather than as body fields. + + Args: + base_url: Configured OpenAI-protocol endpoint, or ``None``. + + Returns: + ``{"extra_headers": {...}}`` for an aimlapi.com host, otherwise an + empty ``dict`` so no key is added to the request at all. + """ + headers = aimlapi_headers(base_url) + return {"extra_headers": headers} if headers else {} diff --git a/src/everos/config/default.toml b/src/everos/config/default.toml index 3dceee2f4..f99345de6 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -66,6 +66,10 @@ collection_prefix = "everos" # Provider-agnostic OpenAI-protocol client config. Override via env: # EVEROS_LLM__MODEL, EVEROS_LLM__API_KEY, EVEROS_LLM__BASE_URL # Or set the field directly in this file (/everos.toml). +# The model slug below is spelled the same way by both aggregators, so +# only base_url + api_key change: +# aimlapi.com -> https://api.aimlapi.com/v1 +# OpenRouter -> https://openrouter.ai/api/v1 model = "openai/gpt-4.1-mini" api_key = "" base_url = "https://openrouter.ai/api/v1" diff --git a/src/everos/templates/env.template b/src/everos/templates/env.template index 45f9b7abc..97004cd04 100755 --- a/src/everos/templates/env.template +++ b/src/everos/templates/env.template @@ -23,8 +23,9 @@ # ─── LLM (OpenAI-protocol compatible) ──────────────── # Any OpenAI-API-compatible endpoint plugs in via base_url. Defaults # below target OpenRouter (one key, broad model catalogue); switch to -# OpenAI, vLLM, Ollama (OpenAI bridge), or any other compatible endpoint -# by changing model + base_url + api_key. +# aimlapi.com (https://api.aimlapi.com/v1 — same slug spelling, so the +# model below is unchanged), OpenAI, vLLM, Ollama (OpenAI bridge), or any +# other compatible endpoint by changing model + base_url + api_key. EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= diff --git a/tests/unit/test_component/test_embedding/test_openai_provider.py b/tests/unit/test_component/test_embedding/test_openai_provider.py index abc6c4232..d64b93968 100644 --- a/tests/unit/test_component/test_embedding/test_openai_provider.py +++ b/tests/unit/test_component/test_embedding/test_openai_provider.py @@ -35,3 +35,13 @@ async def test_empty_response_data_raises_embedding_error() -> None: with pytest.raises(EmbeddingServiceError, match="empty data"): await provider.embed("hello") + + +def test_attribution_headers_sent_only_to_aimlapi() -> None: + """Partner headers ride aimlapi.com requests and no others.""" + ours = _make_provider(base_url="https://api.aimlapi.com/v1") + sent = ours._client.default_headers["X-AIMLAPI-Partner-ID"] + assert sent == "part_VxTyAUvoIVbl30dPrB7kbRZk" + + theirs = _make_provider(base_url="https://api.deepinfra.com/v1/openai") + assert "X-AIMLAPI-Partner-ID" not in theirs._client.default_headers diff --git a/tests/unit/test_component/test_llm/test_attribution_wiring.py b/tests/unit/test_component/test_llm/test_attribution_wiring.py new file mode 100644 index 000000000..3d664f66a --- /dev/null +++ b/tests/unit/test_component/test_llm/test_attribution_wiring.py @@ -0,0 +1,137 @@ +"""Attribution headers reach the wire, and only for aimlapi.com. + +The header helper is unit-tested separately; what breaks silently is the +*wiring* — a client built without the headers still works, just +unattributed, so nothing fails loudly. These tests pin that each client +construction site actually forwards them, and that a non-aimlapi +``base_url`` adds no request key at all (a stray ``extra_headers`` or a +``None`` valued key is a 400 on some upstreams). +""" + +from __future__ import annotations + +import importlib +from typing import Any + +import pytest +from pydantic import SecretStr + +from everos.component.llm.openai_provider import OpenAIProvider +from everos.config import Settings +from everos.config.settings import LLMSettings, MultimodalSettings + +_client_mod = importlib.import_module("everos.component.llm.client") + +_AIMLAPI = "https://api.aimlapi.com/v1" +_OPENROUTER = "https://openrouter.ai/api/v1" +_PARTNER_ID = "part_VxTyAUvoIVbl30dPrB7kbRZk" + + +def _recorder(captured: dict[str, Any]) -> Any: + """Return a ``build_client`` stub that records the config it is given.""" + + def _build(cfg: Any) -> object: + captured["cfg"] = cfg + return object() + + return _build + + +def _capture_llm_config( + monkeypatch: pytest.MonkeyPatch, + *, + base_url: str, + config_extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the LLM singleton against ``base_url`` and return its config.""" + captured: dict[str, Any] = {} + monkeypatch.setattr(_client_mod, "_llm_client", None, raising=False) + monkeypatch.setattr( + _client_mod, + "load_settings", + lambda: Settings( + llm=LLMSettings( + model="openai/gpt-4.1-mini", + api_key=SecretStr("sk-test"), + base_url=base_url, + extra=config_extra or {}, + ) + ), + ) + monkeypatch.setattr(_client_mod, "build_client", _recorder(captured)) + _client_mod.get_llm_client() + return captured["cfg"].extra + + +def _capture_multimodal_config( + monkeypatch: pytest.MonkeyPatch, *, base_url: str +) -> dict[str, Any]: + """Build the multimodal singleton and return its config ``extra``.""" + captured: dict[str, Any] = {} + monkeypatch.setattr(_client_mod, "_multimodal_client", None, raising=False) + monkeypatch.setattr( + _client_mod, + "load_settings", + lambda: Settings( + multimodal=MultimodalSettings( + model="google/gemini-3.8-flash", + api_key=SecretStr("sk-test"), + base_url=base_url, + ) + ), + ) + monkeypatch.setattr(_client_mod, "build_client", _recorder(captured)) + _client_mod.get_multimodal_llm_client() + return captured["cfg"].extra + + +def test_llm_client_sends_attribution_to_aimlapi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + extra = _capture_llm_config(monkeypatch, base_url=_AIMLAPI) + assert extra["extra_headers"]["X-AIMLAPI-Partner-ID"] == _PARTNER_ID + assert extra["extra_headers"]["X-AIMLAPI-Source"] == "agent/everos" + + +def test_llm_client_adds_no_request_key_for_other_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert _capture_llm_config(monkeypatch, base_url=_OPENROUTER) == {} + + +def test_llm_client_config_extra_wins_over_attribution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A key set in ``[llm].extra`` overrides the attribution value.""" + extra = _capture_llm_config( + monkeypatch, + base_url=_AIMLAPI, + config_extra={"extra_headers": {"X-Custom": "user"}}, + ) + assert extra["extra_headers"] == {"X-Custom": "user"} + + +def test_multimodal_client_sends_attribution_to_aimlapi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + extra = _capture_multimodal_config(monkeypatch, base_url=_AIMLAPI) + assert extra["extra_headers"]["X-AIMLAPI-Partner-ID"] == _PARTNER_ID + + +def test_multimodal_client_adds_no_request_key_for_other_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert _capture_multimodal_config(monkeypatch, base_url=_OPENROUTER) == {} + + +def test_openai_provider_sets_default_headers_for_aimlapi() -> None: + provider = OpenAIProvider(model="m", api_key="sk-test", base_url=_AIMLAPI) + sent = provider._client.default_headers + assert sent["X-AIMLAPI-Partner-ID"] == _PARTNER_ID + # The SDK's own defaults survive — the partner headers merge in. + assert "Content-Type" in sent + + +def test_openai_provider_sends_no_partner_headers_elsewhere() -> None: + provider = OpenAIProvider(model="m", api_key="sk-test", base_url=_OPENROUTER) + assert "X-AIMLAPI-Partner-ID" not in provider._client.default_headers diff --git a/tests/unit/test_component/test_utils/test_attribution.py b/tests/unit/test_component/test_utils/test_attribution.py new file mode 100644 index 000000000..a80a479f9 --- /dev/null +++ b/tests/unit/test_component/test_utils/test_attribution.py @@ -0,0 +1,99 @@ +"""Attribution headers are well-formed and scoped to aimlapi.com only. + +Pins three contracts that fail silently in production if broken: + +1. A malformed partner id is accepted by the API and then earns nothing, + so the id is asserted against the documented pattern. +2. The headers must never be attached to a request bound for another + provider, so every non-aimlapi host must yield an empty mapping. +3. Callers merge the result into their own header set, so a fresh dict + must be returned each call and never a shared constant. +""" + +from __future__ import annotations + +import re + +import pytest + +from everos.component.utils.attribution import ( + AIMLAPI_BASE_URL, + AIMLAPI_DISPLAY_NAME, + aimlapi_headers, + aimlapi_request_extra, + is_aimlapi_base_url, +) + +_PARTNER_ID_PATTERN = re.compile(r"^part_[A-Za-z0-9]{1,64}$") + +_EXPECTED_KEYS = { + "X-AIMLAPI-Partner-ID", + "X-AIMLAPI-Source", + "HTTP-Referer", + "X-Title", +} + + +def test_partner_id_matches_documented_pattern() -> None: + headers = aimlapi_headers(AIMLAPI_BASE_URL) + assert _PARTNER_ID_PATTERN.match(headers["X-AIMLAPI-Partner-ID"]) + + +def test_all_four_attribution_headers_are_present() -> None: + assert set(aimlapi_headers(AIMLAPI_BASE_URL)) == _EXPECTED_KEYS + + +def test_referer_and_title_name_the_host_project_not_the_provider() -> None: + headers = aimlapi_headers(AIMLAPI_BASE_URL) + assert headers["HTTP-Referer"] == "https://github.com/EverMind-AI/EverOS" + assert headers["X-Title"] == "EverOS" + + +def test_display_name_is_the_provider_spelling() -> None: + assert AIMLAPI_DISPLAY_NAME == "aimlapi.com" + + +@pytest.mark.parametrize( + "base_url", + [ + "https://api.aimlapi.com/v1", + "https://api.aimlapi.com/v1/", + "https://AIMLAPI.com/v1", + "http://api.aimlapi.com/v1", + ], +) +def test_aimlapi_hosts_are_recognised(base_url: str) -> None: + assert is_aimlapi_base_url(base_url) + assert set(aimlapi_headers(base_url)) == _EXPECTED_KEYS + + +@pytest.mark.parametrize( + "base_url", + [ + None, + "", + "https://openrouter.ai/api/v1", + "https://api.openai.com/v1", + "https://api.deepinfra.com/v1/openai", + # Lookalike hosts: a proxy fronting us, or an outright imposter. + "https://api.aimlapi.com.example.net/v1", + "https://not-aimlapi.com/v1", + "https://proxy.example.net/?upstream=api.aimlapi.com", + ], +) +def test_no_headers_leak_to_other_origins(base_url: str | None) -> None: + assert not is_aimlapi_base_url(base_url) + assert aimlapi_headers(base_url) == {} + assert aimlapi_request_extra(base_url) == {} + + +def test_request_extra_wraps_headers_for_the_sdk() -> None: + extra = aimlapi_request_extra(AIMLAPI_BASE_URL) + assert set(extra) == {"extra_headers"} + assert set(extra["extra_headers"]) == _EXPECTED_KEYS + + +def test_each_call_returns_a_fresh_mapping() -> None: + first = aimlapi_headers(AIMLAPI_BASE_URL) + first["X-Title"] = "mutated" + assert aimlapi_headers(AIMLAPI_BASE_URL)["X-Title"] == "EverOS"