Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions deeptutor/services/llm/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@
"supports_vision": True, # Depends on underlying model
"system_in_messages": True,
},
# AI/ML API (aggregator, OpenAI-compatible chat completions)
"aimlapi": {
"supports_response_format": True, # Depends on underlying model
"supports_streaming": True,
"supports_tools": True,
"supports_vision": True, # Depends on underlying model
"system_in_messages": True,
},
# OrcaRouter (aggregator, generally OpenAI-compatible)
"orcarouter": {
"supports_response_format": True, # Depends on underlying model
Expand Down
53 changes: 52 additions & 1 deletion deeptutor/services/llm/openai_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import threading
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
import uuid

import httpx
Expand All @@ -25,6 +26,23 @@
"X-OpenRouter-Title": "DeepTutor",
}

# AI/ML API attributes traffic the same way OpenRouter does, plus two headers
# of its own. HTTP-Referer / X-Title identify DeepTutor as the calling app —
# they are not AI/ML API's own URL and title.
AIMLAPI_ATTRIBUTION_HEADERS: dict[str, str] = {
"HTTP-Referer": "https://github.com/HKUDS/DeepTutor",
"X-Title": "DeepTutor",
"X-AIMLAPI-Partner-ID": "part_ItAs0L5uSTvV2dFDOZZaS1BL",
"X-AIMLAPI-Source": "agent/deeptutor",
}

# Exact hosts these headers may be sent to. Matching the *host* of the resolved
# endpoint — not a substring of the URL and not the configured provider name —
# is what keeps attribution off a look-alike domain ("api.aimlapi.com.evil.io",
# "notaimlapi.com") and off a self-hosted proxy that merely fronts the same API
# under a binding still typed as "aimlapi".
_AIMLAPI_ATTRIBUTION_HOSTS: frozenset[str] = frozenset({"api.aimlapi.com"})

_warning_lock = threading.Lock()
_warning_logged = False

Expand Down Expand Up @@ -114,6 +132,36 @@ def _uses_openrouter(spec: "ProviderSpec | None", api_base: str | None) -> bool:
return bool(api_base and "openrouter" in api_base.lower())


def _endpoint_host(spec: "ProviderSpec | None", api_base: str | None) -> str:
"""Host of the endpoint a client will actually call, lowercased.

Args:
spec: The resolved provider spec, whose ``default_api_base`` applies
when the profile carries no explicit endpoint.
api_base: The profile's configured endpoint, if any.

Returns:
The hostname, or an empty string when no endpoint resolves or the
value does not parse as a URL.
"""
resolved = (api_base or (spec.default_api_base if spec is not None else "") or "").strip()
if not resolved:
return ""
# A bare "api.example.com/v1" has no scheme, so urlsplit would read it all
# as a path; "//" makes it a netloc without guessing http vs https.
if "//" not in resolved:
resolved = f"//{resolved}"
try:
return (urlsplit(resolved).hostname or "").lower()
except ValueError:
return ""


def _uses_aimlapi(spec: "ProviderSpec | None", api_base: str | None) -> bool:
"""Whether the resolved endpoint is an AI/ML API host we may attribute to."""
return _endpoint_host(spec, api_base) in _AIMLAPI_ATTRIBUTION_HOSTS


def openai_sdk_client_kwargs(
*,
api_key: str | None,
Expand All @@ -127,7 +175,7 @@ def openai_sdk_client_kwargs(
"""Constructor kwargs for ``AsyncOpenAI`` / ``AsyncAzureOpenAI``.

The one place that decides what every OpenAI-SDK client DeepTutor builds
looks like on the wire: default headers (session affinity, OpenRouter
looks like on the wire: default headers (session affinity, gateway
attribution, the profile's extra headers), the SDK retry budget, and the
TLS-verification bypass. ``disable_ssl_verify=None`` reads the system
setting; callers that already hold the flag pass it through.
Expand All @@ -138,6 +186,8 @@ def openai_sdk_client_kwargs(
headers["x-session-affinity"] = uuid.uuid4().hex
if _uses_openrouter(spec, base_url):
headers.update(OPENROUTER_ATTRIBUTION_HEADERS)
if _uses_aimlapi(spec, base_url):
headers.update(AIMLAPI_ATTRIBUTION_HEADERS)
if extra_headers:
headers.update(extra_headers)
kwargs: dict[str, Any] = {
Expand All @@ -159,6 +209,7 @@ def openai_sdk_client_kwargs(


__all__ = [
"AIMLAPI_ATTRIBUTION_HEADERS",
"OPENROUTER_ATTRIBUTION_HEADERS",
"build_openai_http_client",
"disable_ssl_verify_enabled",
Expand Down
18 changes: 16 additions & 2 deletions deeptutor/services/llm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,25 @@ def _normalize_model_name(entry: object) -> str | None:


def collect_model_names(entries: Sequence[object]) -> list[str]:
"""Collect model names from provider payloads."""
"""Collect model names from provider payloads, first occurrence wins.

A ``/models`` payload may list one model id once per endpoint family it
serves, so the same name arrives several times: AI/ML API returns 936 rows
for 785 distinct ids. Without the de-duplication the picker shows the
repeats as separate, identical choices.

Args:
entries: Raw provider payload entries, each a mapping or a bare string.

Returns:
Model names in payload order, without repeats.
"""
names: list[str] = []
seen: set[str] = set()
for entry in entries:
name = _normalize_model_name(entry)
if name:
if name and name not in seen:
seen.add(name)
names.append(name)
return names

Expand Down
17 changes: 17 additions & 0 deletions deeptutor/services/provider_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ def label(self) -> str:
"atlas_cloud": "atlascloud",
"atlas-cloud": "atlascloud",
"eden_ai": "edenai",
"aiml": "aimlapi",
"aiml_api": "aimlapi",
"novita_ai": "novita",
"orca_router": "orcarouter",
"orca-router": "orcarouter",
Expand Down Expand Up @@ -200,6 +202,21 @@ def canonical_provider_name(name: str | None) -> str | None:
is_direct=True,
),
# === Gateways (detected by api_key / api_base, route any model) ========
# AI/ML API issues opaque keys with no distinguishing prefix, so the
# endpoint is the only reliable signal — hence no detect_by_key_prefix.
# Model ids keep their vendor prefix ("openai/gpt-4o-mini"), so no
# strip_model_prefix either. Only POST /v1/chat/completions and
# /v1/responses exist; there is no /v1/completions endpoint.
ProviderSpec(
name="aimlapi",
keywords=("aimlapi",),
env_key="AIMLAPI_API_KEY",
display_name="aimlapi.com",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="aimlapi",
default_api_base="https://api.aimlapi.com/v1",
),
ProviderSpec(
name="openrouter",
keywords=("openrouter",),
Expand Down
9 changes: 9 additions & 0 deletions deeptutor_cli/init_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
# via the "Show all" option. Names match ProviderSpec.name in provider_registry.

FEATURED_LLM_PROVIDERS: tuple[str, ...] = (
"aimlapi",
"openai",
"anthropic",
"deepseek",
Expand Down Expand Up @@ -74,6 +75,14 @@
"anthropic/claude-sonnet-4-6",
"deepseek/deepseek-chat",
),
# Verified against GET https://api.aimlapi.com/v1/models on 2026-09-03,
# filtered to type == "openai/chat-completions".
"aimlapi": (
"openai/gpt-4o-mini",
"openai/gpt-5-5",
"anthropic/claude-sonnet-4.5",
"deepseek/deepseek-chat",
),
"orcarouter": (
"orcarouter/auto",
"anthropic/claude-sonnet-4-6",
Expand Down
113 changes: 113 additions & 0 deletions tests/services/llm/test_openai_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

from __future__ import annotations

import re
from types import SimpleNamespace
from typing import Any

import pytest

from deeptutor.services.llm import openai_http_client
from deeptutor.services.llm.exceptions import LLMConfigError
from deeptutor.services.provider_registry import PROVIDERS, find_by_name


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -131,3 +133,114 @@ def test_embedding_sdk_passes_disable_ssl_http_client(

assert captured[0]["http_client"] is clients[0]
assert clients[0].kwargs == {"verify": False, "timeout": 60}


# --- AI/ML API attribution ---------------------------------------------------
# Attribution is scoped to the *host* of the resolved endpoint. A substring
# match on the URL, or a match on the configured provider name, would also fire
# for a look-alike domain and for a self-hosted proxy fronting the same API.

_AIMLAPI_SPEC = find_by_name("aimlapi")

_PARTNER_ID_PATTERN = re.compile(r"^part_[A-Za-z0-9]{1,64}$")
_SOURCE_PATTERN = re.compile(r"^(web|agent|mcp)/[a-z0-9-]{1,32}$")


def _default_headers(**kwargs: Any) -> dict[str, str]:
return openai_http_client.openai_sdk_client_kwargs(api_key="sk-test", **kwargs)[
"default_headers"
]


def test_aimlapi_partner_id_and_source_match_the_gateway_contract() -> None:
"""A malformed partner id is accepted silently and earns nothing, so assert its shape."""
headers = openai_http_client.AIMLAPI_ATTRIBUTION_HEADERS

assert _PARTNER_ID_PATTERN.match(headers["X-AIMLAPI-Partner-ID"])
assert _SOURCE_PATTERN.match(headers["X-AIMLAPI-Source"])
# HTTP-Referer / X-Title identify the calling app, not the gateway.
assert headers["HTTP-Referer"] == "https://github.com/HKUDS/DeepTutor"
assert headers["X-Title"] == "DeepTutor"


def test_aimlapi_attribution_sent_for_the_registry_endpoint() -> None:
headers = _default_headers(base_url=None, spec=_AIMLAPI_SPEC)

assert headers["X-AIMLAPI-Partner-ID"] == "part_ItAs0L5uSTvV2dFDOZZaS1BL"
assert headers["X-AIMLAPI-Source"] == "agent/deeptutor"
assert headers["X-Title"] == "DeepTutor"


@pytest.mark.parametrize(
"base_url",
[
"https://api.aimlapi.com/v1",
"https://API.AIMLAPI.COM/v1",
"api.aimlapi.com/v1",
],
)
def test_aimlapi_attribution_sent_for_equivalent_spellings(base_url: str) -> None:
headers = _default_headers(base_url=base_url, spec=None)

assert headers["X-AIMLAPI-Partner-ID"] == "part_ItAs0L5uSTvV2dFDOZZaS1BL"


@pytest.mark.parametrize(
"base_url",
[
# Suffix look-alike: a substring check on the URL would send our
# partner id to whoever controls evil.io.
"https://api.aimlapi.com.evil.io/v1",
"https://notaimlapi.com/v1",
"https://aimlapi.com.attacker.example/v1",
# A proxy that merely fronts the same API is still someone else's host.
"https://gateway.internal.example/aimlapi/v1",
"https://openrouter.ai/api/v1",
"https://api.openai.com/v1",
],
)
def test_aimlapi_attribution_withheld_from_other_hosts(base_url: str) -> None:
headers = _default_headers(base_url=base_url, spec=None)

assert not [key for key in headers if key.lower().startswith("x-aimlapi-")]


def test_aimlapi_attribution_withheld_when_binding_points_at_a_proxy() -> None:
"""An aimlapi-typed profile pointed elsewhere must not carry our headers."""
headers = _default_headers(base_url="https://proxy.example.com/v1", spec=_AIMLAPI_SPEC)

assert not [key for key in headers if key.lower().startswith("x-aimlapi-")]


def test_aimlapi_attribution_does_not_override_caller_headers() -> None:
headers = _default_headers(
base_url="https://api.aimlapi.com/v1",
spec=_AIMLAPI_SPEC,
extra_headers={"X-Title": "Caller Wins", "X-Custom": "1"},
)

assert headers["X-Title"] == "Caller Wins"
assert headers["X-Custom"] == "1"
assert headers["X-AIMLAPI-Partner-ID"] == "part_ItAs0L5uSTvV2dFDOZZaS1BL"


def test_aimlapi_attribution_constant_is_never_mutated() -> None:
before = dict(openai_http_client.AIMLAPI_ATTRIBUTION_HEADERS)

_default_headers(
base_url="https://api.aimlapi.com/v1",
spec=_AIMLAPI_SPEC,
extra_headers={"X-Title": "Caller Wins"},
)

assert openai_http_client.AIMLAPI_ATTRIBUTION_HEADERS == before


def test_no_other_provider_spec_carries_aimlapi_headers() -> None:
"""Attribution must never ride a request to a different vendor."""
for spec in PROVIDERS:
if spec.name == "aimlapi":
continue
headers = _default_headers(base_url=None, spec=spec)
leaked = [key for key in headers if key.lower().startswith("x-aimlapi-")]
assert not leaked, f"{spec.name} leaks {leaked}"
10 changes: 10 additions & 0 deletions tests/services/llm/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ def test_collect_model_names() -> None:
assert collect_model_names(entries) == ["m1", "m2", "m3", "m4"]


def test_collect_model_names_drops_repeats_in_payload_order() -> None:
"""One id listed once per endpoint family must not become several choices."""
entries = [
{"id": "m1", "type": "openai/chat-completions"},
{"id": "m2", "type": "openai/chat-completions"},
{"id": "m1", "type": "openai/embeddings"},
]
assert collect_model_names(entries) == ["m1", "m2"]


def test_build_auth_headers() -> None:
"""Auth headers should vary by provider binding."""
assert build_auth_headers("key", binding="anthropic")["x-api-key"] == "key"
Expand Down
29 changes: 28 additions & 1 deletion tests/services/test_provider_registry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from deeptutor.services.provider_registry import find_by_name, find_gateway
from deeptutor.services.provider_registry import find_by_model, find_by_name, find_gateway


def test_nvidia_nim_gateway_detection_by_key_and_base() -> None:
Expand Down Expand Up @@ -53,6 +53,33 @@ def test_novita_provider_aliases_and_base_detection() -> None:
assert find_gateway(api_base="https://api.novita.ai/openai") == spec


def test_aimlapi_provider_aliases_and_base_detection() -> None:
spec = find_by_name("aimlapi")

assert spec is not None
# The user-facing label is the product's own name, lowercase domain form.
assert spec.display_name == "aimlapi.com"
assert spec.label == "aimlapi.com"
assert spec.env_key == "AIMLAPI_API_KEY"
assert spec.backend == "openai_compat"
assert spec.mode == "gateway"
assert spec.default_api_base == "https://api.aimlapi.com/v1"
# Keys carry no distinguishing prefix, so the endpoint is the only signal.
assert spec.detect_by_key_prefix == ""
# Model ids keep their vendor prefix ("openai/gpt-4o-mini").
assert spec.strip_model_prefix is False
assert find_by_name("aiml") == spec
assert find_by_name("aiml-api") == spec
assert find_by_name("AIMLAPI") == spec
assert find_gateway(api_base="https://api.aimlapi.com/v1") == spec


def test_aimlapi_does_not_capture_unrelated_model_names() -> None:
"""Gateways route any model; they must not claim one by keyword."""
assert find_by_model("openai/gpt-4o-mini") != find_by_name("aimlapi")
assert find_by_model("aimlapi") is None


def test_openai_codex_is_not_detected_from_api_base() -> None:
assert find_gateway(api_base="https://codex.example.com/v1") is None

Expand Down