Skip to content
Merged
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
73 changes: 61 additions & 12 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2766,6 +2766,31 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
return merged


def _openai_discovery_base_url(provider: str) -> str:
"""Effective OpenAI endpoint for model discovery.

Mirrors the runtime precedence so discovery probes the SAME endpoint
inference uses: ``$OPENAI_BASE_URL`` (explicit env override) →
``model.base_url`` from config.yaml when the configured provider matches
→ the canonical default. Previously this read the env var only, so a
config-set data-residency host (``us.api.openai.com``) was ignored and
the catalog kept coming from ``api.openai.com``.
"""
env_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
if env_raw:
return env_raw
try:
model_cfg = _get_model_config_dict()
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider in ("openai", "openai-api") and normalize_provider(provider) == normalize_provider(cfg_provider):
cfg_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_url:
return cfg_url
except Exception:
pass
return "https://api.openai.com/v1"


def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
"""Return the best known model catalog for a provider.

Expand Down Expand Up @@ -2881,19 +2906,19 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if normalized in ("openai", "openai-api"):
api_key = os.getenv("OPENAI_API_KEY", "").strip()
if api_key:
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
base = base_raw or "https://api.openai.com/v1"
base = _openai_discovery_base_url(normalized)
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
# may serve a small curated catalog — use the live list verbatim so
# discovery works. But the canonical api.openai.com /v1/models dump
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
# legacy chat models — none of which belong in the agent model picker.
# For the default endpoint, intersect the live list with our curated
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
is_default_openai = base.rstrip("/") in (
"https://api.openai.com/v1",
"https://api.openai.com",
)
# discovery works. But the official OpenAI hosts (canonical AND the
# data-residency regional hosts, which serve the identical dump)
# return 120+ entries of embeddings, whisper, tts, dall-e,
# moderation and legacy chat models — none of which belong in the
# agent model picker. For official hosts, intersect the live list
# with our curated agentic catalog so ``/model`` matches what
# ``hermes model`` shows.
from hermes_cli.providers import is_official_openai_host

is_default_openai = is_official_openai_host(base)
try:
live = fetch_api_models(api_key, base)
if live:
Expand Down Expand Up @@ -3077,6 +3102,17 @@ def _credential_fingerprint(provider: str) -> str:
except Exception:
pass

# Effective configured endpoint: config.yaml's model.base_url changes the
# endpoint discovery probes (data-residency hosts) without touching any
# env var, so it must change the fingerprint too or `hermes config set
# model.base_url ...` keeps serving the previous endpoint's cached
# catalog until TTL expiry.
if provider in ("openai", "openai-api"):
try:
parts.append(f"effective_base={_openai_discovery_base_url(provider)}")
except Exception:
pass

# OAuth / external-file mtimes that change on re-auth
try:
from hermes_constants import get_hermes_home
Expand Down Expand Up @@ -5045,7 +5081,20 @@ def validate_requested_model(
# listing that are still valid (stale cache, partial rollout,
# gated previews). Use the pure-catalog helper (no extra live
# fetch) so we only accept models Hermes actually ships. (#46850)
if _model_in_provider_catalog(
#
# EXCEPTION: official OpenAI hosts (canonical api.openai.com and
# the data-residency regional hosts). Their /v1/models listing is
# access-scoped and authoritative — a model absent from it is one
# this key CANNOT serve, so the curated soft-accept would
# manufacture a selection that 400s at first use. Custom
# OpenAI-compatible proxies keep the fallback (incomplete
# listings are common there).
_openai_listing_is_authoritative = False
if normalized in ("openai", "openai-api"):
from hermes_cli.providers import is_official_openai_host

_openai_listing_is_authoritative = is_official_openai_host(base_url)
if not _openai_listing_is_authoritative and _model_in_provider_catalog(
requested_for_lookup.lower(), _provider_keys(normalized)
):
return {
Expand Down
27 changes: 26 additions & 1 deletion hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,27 @@ def is_routing_aggregator(provider: str) -> bool:
return is_aggregator(provider_norm)


def is_official_openai_host(base_url: str) -> bool:
"""True when *base_url* points at OpenAI's official API host family.

Matches the canonical host (``api.openai.com``) and OpenAI's documented
data-residency / regional hosts (``us.api.openai.com``,
``eu.api.openai.com``, and any future ``<region>.api.openai.com``) —
those serve the same API surface with the same transport requirements
and the same access-scoped ``/v1/models`` listing.

Hostname-parsed matching only — never substring — so lookalike hosts
(``api.openai.com.attacker.test``) and path-segment spoofs
(``proxy.test/api.openai.com/v1``) are rejected. A genuine
``*.api.openai.com`` subdomain requires control of openai.com DNS, so
the dot-suffix match does not reopen the #32243 spoofing hole.
Delegates to ``utils.base_url_host_matches``, which owns the
exact-or-dot-suffix hostname contract (userinfo/port stripped,
lowercased, trailing dot removed) — one implementation, not two.
"""
return base_url_host_matches(base_url, "api.openai.com")


def host_mandated_api_mode(base_url: str = "") -> Optional[str]:
"""Return the wire protocol a specific endpoint *requires*, or None.

Expand Down Expand Up @@ -605,7 +626,11 @@ def host_mandated_api_mode(base_url: str = "") -> Optional[str]:
return "anthropic_messages"
if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"):
return "anthropic_messages"
if hostname == "api.openai.com":
# Official OpenAI host family: canonical + data-residency regional hosts
# (us./eu.api.openai.com) all mandate the Responses API for reasoning
# models with tools. Shared predicate keeps this lane in lockstep with
# catalog filtering and listing authority.
if is_official_openai_host(base_url):
return "codex_responses"
if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
return "bedrock_converse"
Expand Down
64 changes: 46 additions & 18 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
normalize_extra_headers,
)
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.providers import is_official_openai_host
from utils import base_url_host_matches, base_url_hostname, env_int


Expand Down Expand Up @@ -123,7 +124,11 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
hostname = base_url_hostname(base_url)
if hostname == "api.x.ai":
return "codex_responses"
if hostname == "api.openai.com":
# Official OpenAI host family: canonical api.openai.com plus the
# data-residency regional hosts (us./eu.api.openai.com). Same API
# surface, same Responses-API mandate. Shared predicate — see
# providers.is_official_openai_host for the spoof-rejection contract.
if is_official_openai_host(base_url):
return "codex_responses"
# Direct native Anthropic host: realign with providers.determine_api_mode,
# which already maps this host to anthropic_messages. The exact-hostname
Expand All @@ -139,6 +144,31 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
return None


def _fallback_api_mode(provider: str, base_url: str, model: str = "") -> str:
"""Resolve api_mode when no explicit/persisted mode applies.

Precedence: URL detection (host-mandated wire shapes) first, then the
transport the provider overlay itself declares via
``providers.determine_api_mode`` — which already handles host mandates,
dual-wire providers, and the registry transport map — and only then the
``chat_completions`` default for genuinely unknown providers/endpoints.

Before this helper the runtime paths consulted URL detection ONLY and
silently landed reasoning providers on ``chat_completions`` whenever the
hostname wasn't literally recognized. That is how ``openai-api`` pointed
at OpenAI's data-residency hosts (``us.api.openai.com``) 400'd on every
tool-calling turn: the provider declares ``codex_responses`` but the
declaration was never consulted. Same latent class covered the other
non-chat overlays (MiniMax family, copilot-acp).
"""
detected = _detect_api_mode_for_url(base_url)
if detected:
return detected
from hermes_cli.providers import determine_api_mode

return determine_api_mode(provider, base_url, model) or "chat_completions"


def _resolve_plain_custom_api_mode(model_cfg: Dict[str, Any], base_url: str) -> str:
"""Resolve api_mode for legacy/plain ``provider: custom`` endpoints.

Expand Down Expand Up @@ -518,12 +548,10 @@ def _resolve_runtime_from_pool_entry(
elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
api_mode = configured_mode
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
# Kimi /coding, api.openai.com → codex_responses, api.x.ai →
# codex_responses).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
# URL detection first (Anthropic /anthropic suffix, Kimi /coding,
# official OpenAI hosts → codex_responses, api.x.ai →
# codex_responses), then the provider's own declared transport.
api_mode = _fallback_api_mode(provider, base_url, effective_model)

# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Normalize
Expand Down Expand Up @@ -1606,11 +1634,11 @@ def _resolve_explicit_runtime(
if configured_mode:
api_mode = configured_mode
else:
# Auto-detect from URL (Anthropic /anthropic suffix,
# api.openai.com → Responses, Kimi /coding, etc.).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
# URL detection first, then the provider's declared transport
# (fixes regional OpenAI hosts and other non-chat overlays).
api_mode = _fallback_api_mode(
provider, base_url, target_model or model_cfg.get("default", "")
)

return {
"provider": provider,
Expand Down Expand Up @@ -2201,12 +2229,12 @@ def resolve_runtime_provider(
elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
api_mode = configured_mode
else:
# Auto-detect Anthropic-compatible endpoints by URL convention
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
# plus api.openai.com → codex_responses and api.x.ai → codex_responses.
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
# URL detection first (e.g. https://api.minimax.io/anthropic,
# official OpenAI hosts → codex_responses, api.x.ai →
# codex_responses), then the provider's declared transport.
api_mode = _fallback_api_mode(
provider, base_url, target_model or model_cfg.get("default", "")
)
# Normalize the /v1 suffix for OpenCode by API mode (see comment above).
if provider in {"opencode-zen", "opencode-go"}:
from hermes_cli.models import normalize_opencode_base_url
Expand Down
62 changes: 62 additions & 0 deletions tests/hermes_cli/test_official_openai_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Security + parity contract for ``is_official_openai_host``.

One predicate decides "is this endpoint OpenAI's official API surface?"
for every lane that branches on it: transport mandates
(``host_mandated_api_mode``), URL auto-detection in the runtime resolver,
model-catalog filtering, and live-listing authority. OpenAI's documented
data-residency hosts (``us.api.openai.com``, ``eu.api.openai.com``, and any
future ``<region>.api.openai.com``) are the same API surface as the
canonical host and must match; lookalike/spoof hosts must not (#32243).
"""

from __future__ import annotations

import pytest

from hermes_cli.providers import is_official_openai_host


class TestOfficialHosts:
@pytest.mark.parametrize(
"url",
[
"https://api.openai.com/v1",
"https://api.openai.com",
"https://us.api.openai.com/v1",
"https://eu.api.openai.com/v1",
"https://US.api.OpenAI.com/v1", # case-insensitive hostname
"https://in.api.openai.com/v1", # future regional variants
"https://api.openai.com:443/v1", # port stripped by hostname parse
"https://api.openai.com./v1", # trailing dot normalized
"https://attacker.test@us.api.openai.com/v1", # userinfo stripped; real host wins
],
)
def test_official_hosts_match(self, url):
assert is_official_openai_host(url) is True


class TestSpoofRejection:
@pytest.mark.parametrize(
"url",
[
# Lookalike host suffix: registrable domain is attacker.test.
"https://api.openai.com.attacker.test/v1",
"https://us.api.openai.com.attacker.test/v1",
# Path-segment spoofing: host is proxy.test.
"https://proxy.test/api.openai.com/v1",
"https://proxy.test/us.api.openai.com/v1",
# Prefix tricks that are NOT dot-separated subdomains of
# api.openai.com (fooapi.openai.com is an openai.com host but
# not the official API host family this predicate is scoped to).
"https://evilapi.openai.com.attacker.test/v1",
"https://fooapi.openai.com/v1",
# Unrelated hosts.
"https://openrouter.ai/api/v1",
"https://api.anthropic.com/v1",
# IPv6 literal and empty input.
"https://[::1]:8080/v1",
"",
],
)
def test_spoof_and_unrelated_hosts_rejected(self, url):
assert is_official_openai_host(url) is False
Loading
Loading