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
36 changes: 18 additions & 18 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -816,9 +817,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if "api.kimi.com" in base_url.lower():
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
elif "api.githubcopilot.com" in base_url.lower():
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

extra["default_headers"] = copilot_default_headers()
Expand All @@ -842,9 +843,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if "api.kimi.com" in base_url.lower():
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
elif "api.githubcopilot.com" in base_url.lower():
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

extra["default_headers"] = copilot_default_headers()
Expand Down Expand Up @@ -993,7 +994,7 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st
return None, None, None

custom_base = custom_base.strip().rstrip("/")
if "openrouter.ai" in custom_base.lower():
if base_url_host_matches(custom_base, "openrouter.ai"):
# requested='custom' falls back to OpenRouter when no custom endpoint is
# configured. Treat that as "no custom endpoint" for auxiliary routing.
return None, None, None
Expand Down Expand Up @@ -1432,14 +1433,14 @@ def _to_async_client(sync_client, model: str):
"api_key": sync_client.api_key,
"base_url": str(sync_client.base_url),
}
base_lower = str(sync_client.base_url).lower()
if "openrouter" in base_lower:
sync_base_url = str(sync_client.base_url)
if base_url_host_matches(sync_base_url, "openrouter.ai"):
async_kwargs["default_headers"] = dict(_OR_HEADERS)
elif "api.githubcopilot.com" in base_lower:
elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

async_kwargs["default_headers"] = copilot_default_headers()
elif "api.kimi.com" in base_lower:
elif base_url_host_matches(sync_base_url, "api.kimi.com"):
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
return AsyncOpenAI(**async_kwargs), model

Expand Down Expand Up @@ -1516,8 +1517,7 @@ def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
# Auto-detect: api.openai.com + codex model name pattern
if api_mode and api_mode != "codex_responses":
return False # explicit non-codex mode
normalized_base = (base_url_str or "").strip().lower()
if "api.openai.com" in normalized_base and "openrouter" not in normalized_base:
if base_url_hostname(base_url_str) == "api.openai.com":
model_lower = (model_str or "").lower()
if "codex" in model_lower:
return True
Expand Down Expand Up @@ -1621,9 +1621,9 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
provider,
)
extra = {}
if "api.kimi.com" in custom_base.lower():
if base_url_host_matches(custom_base, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
elif "api.githubcopilot.com" in custom_base.lower():
elif base_url_host_matches(custom_base, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
client = OpenAI(api_key=custom_key, base_url=custom_base, **extra)
Expand Down Expand Up @@ -1728,9 +1728,9 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):

# Provider-specific headers
headers = {}
if "api.kimi.com" in base_url.lower():
if base_url_host_matches(base_url, "api.kimi.com"):
headers["User-Agent"] = "KimiCLI/1.30.0"
elif "api.githubcopilot.com" in base_url.lower():
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

headers.update(copilot_default_headers())
Expand Down Expand Up @@ -2025,7 +2025,7 @@ def auxiliary_max_tokens_param(value: int) -> dict:
# Only use max_completion_tokens for direct OpenAI custom endpoints
if (not or_key
and _read_nous_auth() is None
and "api.openai.com" in custom_base.lower()):
and base_url_hostname(custom_base) == "api.openai.com"):
return {"max_completion_tokens": value}
return {"max_tokens": value}

Expand Down Expand Up @@ -2154,7 +2154,7 @@ def cleanup_stale_async_clients() -> None:

def _is_openrouter_client(client: Any) -> bool:
for obj in (client, getattr(client, "_client", None), getattr(client, "client", None)):
if obj and "openrouter" in str(getattr(obj, "base_url", "") or "").lower():
if obj and base_url_host_matches(str(getattr(obj, "base_url", "") or ""), "openrouter.ai"):
return True
return False

Expand Down Expand Up @@ -2460,7 +2460,7 @@ def _build_call_kwargs(
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
if provider == "custom":
custom_base = base_url or _current_custom_base_url()
if "api.openai.com" in custom_base.lower():
if base_url_hostname(custom_base) == "api.openai.com":
kwargs["max_completion_tokens"] = max_tokens
else:
kwargs["max_tokens"] = max_tokens
Expand Down
12 changes: 9 additions & 3 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import requests
import yaml

from utils import base_url_host_matches, base_url_hostname

from hermes_constants import OPENROUTER_MODELS_URL

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -218,7 +220,7 @@ def _auth_headers(api_key: str = "") -> Dict[str, str]:


def _is_openrouter_base_url(base_url: str) -> bool:
return "openrouter.ai" in _normalize_base_url(base_url).lower()
return base_url_host_matches(base_url, "openrouter.ai")


def _is_custom_endpoint(base_url: str) -> bool:
Expand Down Expand Up @@ -1078,7 +1080,7 @@ def get_model_context_length(

# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
if provider == "anthropic" or (
base_url and "api.anthropic.com" in base_url
base_url and base_url_hostname(base_url) == "api.anthropic.com"
):
ctx = _query_anthropic_context_length(model, base_url or "https://api.anthropic.com", api_key)
if ctx:
Expand All @@ -1087,7 +1089,11 @@ def get_model_context_length(
# 4b. AWS Bedrock — use static context length table.
# Bedrock's ListFoundationModels doesn't expose context window sizes,
# so we maintain a curated table in bedrock_adapter.py.
if provider == "bedrock" or (base_url and "bedrock-runtime" in base_url):
if provider == "bedrock" or (
base_url
and base_url_hostname(base_url).startswith("bedrock-runtime.")
and base_url_host_matches(base_url, "amazonaws.com")
):
try:
from agent.bedrock_adapter import get_bedrock_context_length
return get_bedrock_context_length(model)
Expand Down
3 changes: 2 additions & 1 deletion agent/usage_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any, Dict, Literal, Optional

from agent.model_metadata import fetch_endpoint_model_metadata, fetch_model_metadata
from utils import base_url_host_matches

DEFAULT_PRICING = {"input": 0.0, "output": 0.0}

Expand Down Expand Up @@ -393,7 +394,7 @@ def resolve_billing_route(

if provider_name == "openai-codex":
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or "openrouter.ai" in base:
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
Expand Down
7 changes: 4 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
# User-managed env files should override stale shell exports on restart.
from hermes_constants import get_hermes_home, display_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
from utils import base_url_host_matches

_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
Expand Down Expand Up @@ -1836,7 +1837,7 @@ def __init__(
# Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY,
# custom endpoint → prefer OPENAI_API_KEY (issue #560).
# Note: _ensure_runtime_credentials() re-resolves this before first use.
if self.base_url and "openrouter.ai" in self.base_url:
if self.base_url and base_url_host_matches(self.base_url, "openrouter.ai"):
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
else:
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
Expand Down Expand Up @@ -4996,7 +4997,7 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:
pass

cache_enabled = (
("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower())
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
Expand Down Expand Up @@ -5224,7 +5225,7 @@ def _handle_model_switch(self, cmd_original: str):

# Cache notice
cache_enabled = (
("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower())
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
Expand Down
4 changes: 2 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def _ensure_ssl_certs() -> None:

# Resolve Hermes home directory (respects HERMES_HOME override)
from hermes_constants import get_hermes_home
from utils import atomic_yaml_write, is_truthy_value
from utils import atomic_yaml_write, base_url_host_matches, is_truthy_value
_hermes_home = get_hermes_home()

# Load environment variables from ~/.hermes/.env first.
Expand Down Expand Up @@ -5661,7 +5661,7 @@ async def _on_model_selected(

# Cache notice
cache_enabled = (
("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower())
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
Expand Down
3 changes: 2 additions & 1 deletion hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from hermes_cli.colors import Colors, color
from hermes_constants import OPENROUTER_MODELS_URL
from utils import base_url_host_matches


_PROVIDER_ENV_HINTS = (
Expand Down Expand Up @@ -952,7 +953,7 @@ def run_doctor(args):
_base = _to_openai_base_url(_base)
_url = (_base.rstrip("/") + "/models") if _base else _default_url
_headers = {"Authorization": f"Bearer {_key}"}
if "api.kimi.com" in _url.lower():
if base_url_host_matches(_base, "api.kimi.com"):
_headers["User-Agent"] = "KimiCLI/1.30.0"
_resp = httpx.get(
_url,
Expand Down
9 changes: 6 additions & 3 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple

from utils import base_url_host_matches, base_url_hostname

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -434,11 +436,12 @@ def determine_api_mode(provider: str, base_url: str = "") -> str:
# URL-based heuristics for custom / unknown providers
if base_url:
url_lower = base_url.rstrip("/").lower()
if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
hostname = base_url_hostname(base_url)
if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com":
return "anthropic_messages"
if "api.openai.com" in url_lower:
if hostname == "api.openai.com":
return "codex_responses"
if "bedrock-runtime" in url_lower and "amazonaws.com" in url_lower:
if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
return "bedrock_converse"

return "chat_completions"
Expand Down
8 changes: 5 additions & 3 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
)
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname


def _normalize_custom_provider_name(value: str) -> str:
Expand All @@ -47,9 +48,10 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
``chat_completions``.
"""
normalized = (base_url or "").strip().lower().rstrip("/")
if "api.x.ai" in normalized:
hostname = base_url_hostname(base_url)
if hostname == "api.x.ai":
return "codex_responses"
if "api.openai.com" in normalized and "openrouter" not in normalized:
if hostname == "api.openai.com":
return "codex_responses"
if normalized.endswith("/anthropic"):
return "anthropic_messages"
Expand Down Expand Up @@ -480,7 +482,7 @@ def _resolve_openrouter_runtime(
# When hitting a custom endpoint (e.g. Z.ai, local LLM), prefer
# OPENAI_API_KEY so the OpenRouter key doesn't leak to an unrelated
# provider (issues #420, #560).
_is_openrouter_url = "openrouter.ai" in base_url
_is_openrouter_url = base_url_host_matches(base_url, "openrouter.ai")
if _is_openrouter_url:
api_key_candidates = [
explicit_api_key,
Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from hermes_cli.nous_subscription import get_nous_subscription_features
from tools.tool_backend_helpers import managed_nous_tools_enabled
from utils import base_url_hostname
from hermes_constants import get_optional_skills_dir

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -803,15 +804,16 @@ def setup_model_provider(config: dict, *, quick: bool = False):
elif _vision_idx == 1: # OpenAI-compatible endpoint
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
_api_key_label = " API key"
if "api.openai.com" in _base_url.lower():
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
if _is_native_openai:
_api_key_label = " OpenAI API key"
_oai_key = prompt(_api_key_label, password=True).strip()
if _oai_key:
save_env_value("OPENAI_API_KEY", _oai_key)
# Save vision base URL to config (not .env — only secrets go there)
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
_vaux["base_url"] = _base_url
if "api.openai.com" in _base_url.lower():
if _is_native_openai:
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
get_nous_subscription_features,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
from utils import base_url_hostname

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1179,7 +1180,8 @@ def _configure_simple_requirements(ts_key: str):
_print_warning(" Skipped")
elif idx == 1:
base_url = _prompt(" OPENAI_BASE_URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
key_label = " OPENAI_API_KEY" if "api.openai.com" in base_url.lower() else " API key"
is_native_openai = base_url_hostname(base_url) == "api.openai.com"
key_label = " OPENAI_API_KEY" if is_native_openai else " API key"
api_key = _prompt(key_label, password=True)
if api_key and api_key.strip():
save_env_value("OPENAI_API_KEY", api_key.strip())
Expand All @@ -1189,7 +1191,7 @@ def _configure_simple_requirements(ts_key: str):
_aux = _cfg.setdefault("auxiliary", {}).setdefault("vision", {})
_aux["base_url"] = base_url
save_config(_cfg)
if "api.openai.com" in base_url.lower():
if is_native_openai:
save_env_value("AUXILIARY_VISION_MODEL", "gpt-4o-mini")
_print_success(" Saved")
else:
Expand Down
Loading
Loading