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
63 changes: 63 additions & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import List, NamedTuple, Optional

from hermes_cli.providers import (
ProviderDef,
custom_provider_slug,
determine_api_mode,
get_label,
Expand All @@ -46,6 +47,23 @@
logger = logging.getLogger(__name__)


def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
base_url = str(current_base_url or "").strip()
if not base_url:
return None
return ProviderDef(
id="custom",
name="Custom endpoint",
transport="openai_chat",
api_key_env_vars=(),
base_url=base_url,
is_aggregator=False,
auth_type="api_key",
source="model-config",
)


# ---------------------------------------------------------------------------
# Non-agentic model warning
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -676,6 +694,8 @@ def switch_model(
user_providers,
custom_providers,
)
if pdef is None and explicit_provider.strip().lower() == "custom":
pdef = _bare_custom_provider_def(current_base_url)
if pdef is None:
_switch_err = (
f"Unknown provider '{explicit_provider}'. "
Expand Down Expand Up @@ -881,6 +901,8 @@ def switch_model(

provider_changed = target_provider != current_provider
provider_label = get_label(target_provider)
if target_provider == "custom" and current_base_url:
provider_label = "Custom endpoint"
if target_provider.startswith("custom:"):
custom_pdef = resolve_provider_full(
target_provider,
Expand Down Expand Up @@ -932,6 +954,10 @@ def switch_model(
api_key = _ukey
base_url = _user_pdef.base_url
api_mode = ""
elif target_provider == "custom" and current_base_url:
api_key = current_api_key
base_url = current_base_url
api_mode = determine_api_mode(target_provider, base_url)
else:
try:
runtime = resolve_runtime_provider(
Expand Down Expand Up @@ -1748,6 +1774,43 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if _pair[0] and _pair[1]:
_section3_emitted_pairs.add(_pair)

# --- 3b. Active bare custom endpoint from model config ---
# A config can still use the direct one-off form:
# model.provider: custom
# model.base_url: https://some-openai-compatible/v1
# In that shape there is no named providers:/custom_providers row for the
# picker to render, but the gateway only passes this current model slice to
# list_authenticated_providers(). Surface the active endpoint explicitly so
# /model does not look like it ignored config.yaml.
_current_provider_norm = str(current_provider or "").strip().lower()
if (
_current_provider_norm == "custom"
and current_base_url
and "custom" not in seen_slugs
and not any(
isinstance(_cp, dict)
and str(
_cp.get("base_url", "")
or _cp.get("url", "")
or _cp.get("api", "")
).strip().rstrip("/").lower()
== str(current_base_url).strip().rstrip("/").lower()
for _cp in (custom_providers or [])
)
):
_models = [current_model] if current_model else []
results.append({
"slug": "custom",
"name": "Custom endpoint",
"is_current": True,
"is_user_defined": True,
"models": _models[:max_models] if max_models else _models,
"total_models": len(_models),
"source": "model-config",
"api_url": str(current_base_url).strip().rstrip("/"),
})
seen_slugs.add("custom")

# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same endpoint, credential identity, and
Expand Down
55 changes: 55 additions & 0 deletions tests/hermes_cli/test_model_switch_custom_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,61 @@ def test_resolve_provider_full_finds_named_custom_provider():
assert resolved.source == "user-config"


def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
"""Bare model.provider=custom + model.base_url should still populate /model.

Users can configure a one-off OpenAI-compatible endpoint directly under
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
The gateway picker receives only the current model/base_url slice, so it
must surface that active endpoint rather than looking like config was
ignored.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
max_models=50,
)

bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
assert bare_custom is not None
assert bare_custom["name"] == "Custom endpoint"
assert bare_custom["is_current"] is True
assert bare_custom["is_user_defined"] is True
assert bare_custom["models"] == ["gpt-4o"]
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"


def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
"""Picker selections for bare custom endpoints should route to current base_url."""
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

result = switch_model(
raw_input="gpt-4o-mini",
current_provider="custom",
current_model="gpt-4o",
current_base_url="https://www.ccsub.net/v1",
current_api_key="sk-test",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)

assert result.success is True
assert result.target_provider == "custom"
assert result.provider_label == "Custom endpoint"
assert result.new_model == "gpt-4o-mini"
assert result.base_url == "https://www.ccsub.net/v1"
assert result.api_key == "sk-test"


def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
Expand Down
Loading