diff --git a/.env.example b/.env.example index ad02bee04..d7749d775 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/QUICKSTART.md b/QUICKSTART.md index 52e5216e9..ef469ab9b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,10 +1,10 @@ # Quickstart -> Five minutes from one OpenRouter API key to durable Markdown memory and +> Five minutes from one aimlapi.com API key to durable Markdown memory and > keyword recall. EverOS runs as a local service. The minimum production path needs only an LLM: -configure one OpenRouter key, start the server, then call the HTTP API. +configure one aimlapi.com key, start the server, then call the HTTP API. ## What the one-key setup includes @@ -21,7 +21,8 @@ They are not required for this walkthrough. ## Prerequisites - Python 3.12+ -- One [OpenRouter API key](https://openrouter.ai/keys) +- One [aimlapi.com API key](https://aimlapi.com/app/keys) (recommended), or an + [OpenRouter API key](https://openrouter.ai/keys) ## 1. Install @@ -82,19 +83,21 @@ This creates two files under the default memory root: To use another root, run `everos init --root ` and pass the same `--root ` to subsequent commands. -## 4. Add your OpenRouter key +## 4. Add your API key -Open `~/.everos/everos.toml`. The generated -`[llm]` section already contains the recommended model and base URL; replace -only the empty `api_key`: +Open `~/.everos/everos.toml`. The generated `[llm]` section already contains +the recommended model slug; set `api_key` and `base_url`: ```toml [llm] model = "openai/gpt-4.1-mini" -api_key = "" -base_url = "https://openrouter.ai/api/v1" +api_key = "" +base_url = "https://api.aimlapi.com/v1" ``` +The same slug works on OpenRouter — swap `base_url` for +`https://openrouter.ai/api/v1` and use an OpenRouter key instead. + Leave `[embedding]`, `[rerank]`, and `[multimodal]` unchanged for this walkthrough. Their empty keys do not prevent the server from starting; this setup uses keyword search. @@ -208,7 +211,7 @@ Cap. If the first search is empty, wait a moment for cascade indexing and retry. Keyword retrieval returns matching episodes from the local BM25 index. Atomic facts are created by an embedding-dependent strategy, so they are not expected -in the OpenRouter Tier 1 response. +in the Tier 1 response. ## 9. Read the Markdown source of truth @@ -254,7 +257,7 @@ EverOS reports unavailable features through `/health`. Requests that require a missing provider fail fast with a descriptive HTTP 422 instead of silently degrading to a different search method. -You can replace OpenRouter with another OpenAI-compatible LLM endpoint by +You can replace aimlapi.com with another OpenAI-compatible LLM endpoint by changing the `[llm]` model, base URL, and key. ## Stop the server diff --git a/README.md b/README.md index e4f21d405..838020bd8 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,15 @@ built into Raven. Choose an integration to open its setup guide. ## Quick Start -> One OpenRouter API key is enough to start EverOS, write durable memories, +> One aimlapi.com API key is enough to start EverOS, write durable memories, > and retrieve them with keyword search. ### Prerequisites - Python 3.12+ -- One [OpenRouter API key](https://openrouter.ai/keys) +- One [aimlapi.com API key](https://aimlapi.com/app/keys) (recommended), or an + [OpenRouter API key](https://openrouter.ai/keys) — the shipped model slugs + are spelled the same on both, so only `base_url` differs. ### 1. Install @@ -139,23 +141,26 @@ the memory move through ingest -> extract -> index -> recall. -### 3. Initialize and add your OpenRouter key +### 3. Initialize and add your API key ```bash everos init ``` This creates `~/.everos/everos.toml` and `~/.everos/ome.toml`. Open -`~/.everos/everos.toml`; the generated model and OpenRouter URL are already -correct, so replace only the empty `api_key`: +`~/.everos/everos.toml`; the generated model slug is already correct, so set +`api_key` and `base_url`: ```toml [llm] model = "openai/gpt-4.1-mini" -api_key = "" -base_url = "https://openrouter.ai/api/v1" +api_key = "" +base_url = "https://api.aimlapi.com/v1" ``` +To use OpenRouter instead, keep the same `model` and set +`base_url = "https://openrouter.ai/api/v1"`. + This is the smallest Tier 1 setup: memory add, flush, Markdown persistence, cascade indexing, and keyword search. @@ -242,7 +247,7 @@ For annotated responses and the Markdown files EverOS creates, see ### What works with one key? -The OpenRouter one-key setup is EverOS Tier 1. It supports server startup, +The one-key setup above is EverOS Tier 1. It supports server startup, memory add and flush, durable Markdown storage, cascade indexing, and keyword search. Add optional providers only when you need the features below: @@ -273,7 +278,9 @@ uv pip install 'everos[multimodal]' # or: pip install 'everos[multimodal]' This pulls in `everalgo-parser` (with the `[svg]` bundle for SVG support via cairosvg). Configure the `[multimodal]` section in `everos.toml`; its default -model is `google/gemini-3-flash-preview` via OpenRouter. +model is `google/gemini-3-flash-preview` via OpenRouter. Point `[multimodal]` +at OpenRouter rather than aimlapi.com — see the limitation noted in +[docs/configuration.md](docs/configuration.md). **Office document support requires LibreOffice as a system dependency.** The parser shells out to `soffice` (LibreOffice's headless renderer) to diff --git a/config.example.toml b/config.example.toml index f8043127f..25ed247f4 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 98fdf3479..18c5c34cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -107,6 +107,27 @@ everos init --root /data/everos | `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 `[llm]` and `[multimodal]` defaults work unchanged. | +| OpenRouter | `https://openrouter.ai/api/v1` | Historical default. | + +Both `openai/gpt-4.1-mini` and `google/gemini-3-flash-preview` were 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 b556b94b3..a83302ca9 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.observability.tracing import memory_span, set_generation_usage from .protocol import EmbeddingServiceError @@ -67,11 +68,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 6d48804fb..48a052cb9 100644 --- a/src/everos/component/llm/client.py +++ b/src/everos/component/llm/client.py @@ -17,6 +17,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 load_settings from everos.core.observability.logging import get_logger @@ -101,6 +102,9 @@ def get_llm_client() -> LLMClient: model=llm_cfg.model, api_key=api_key, base_url=llm_cfg.base_url, + # Empty for every other endpoint, so no key is added to the + # request body and no header can reach a foreign provider. + extra=aimlapi_request_extra(llm_cfg.base_url), ) ) # Wrap for OTel token capture only when tracing is on — keeps the @@ -141,6 +145,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 c73d2f5af..917717929 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 @@ -54,10 +56,13 @@ def __init__( self._model = model self._temperature = temperature self._max_tokens = max_tokens + # 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, + default_headers=aimlapi_headers(base_url) or None, ) 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 e2137f84a..72be4f39e 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -54,6 +54,10 @@ cache_size_kb = 2048 # 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 ad02bee04..d7749d775 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..4edd20a2a 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,12 @@ 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") + assert ours._client.default_headers["X-AIMLAPI-Partner-ID"] == "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..ef9ac2d86 --- /dev/null +++ b/tests/unit/test_component/test_llm/test_attribution_wiring.py @@ -0,0 +1,120 @@ +"""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" + + +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 +) -> 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, + ) + ), + ) + 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-flash-preview", + 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"] == "part_VxTyAUvoIVbl30dPrB7kbRZk" + 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_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"] == "part_VxTyAUvoIVbl30dPrB7kbRZk" + + +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"] == "part_VxTyAUvoIVbl30dPrB7kbRZk" + # 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"