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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Fixed

- **PR #2135** by @franksong2702 — `/api/models/live?provider=custom:<slug>` now scopes custom-provider fallback models and direct `/v1/models` fetches to the requested named provider instead of leaking sibling `custom_providers` entries from the active profile. Bare `custom` only reads unnamed custom-provider entries, named custom providers also include `models` dict/list entries, and live fetch fallback uses the matched entry's `base_url` / `api_key` pair.

- **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh.

## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback)
Expand Down
82 changes: 70 additions & 12 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6104,27 +6104,85 @@ def _finish(payload: dict):
ids = []

if not ids:
custom_provider_entry = None

def _custom_provider_entries_for_request():
if not (provider == "custom" or provider.startswith("custom:")):
return []
try:
from api.config import _custom_provider_slug_from_name
_cp_entries = cfg.get("custom_providers", [])
if not isinstance(_cp_entries, list):
return []
_matches = []
for _cp in _cp_entries:
if not isinstance(_cp, dict):
continue
_slug = _custom_provider_slug_from_name(_cp.get("name", ""))
if provider.startswith("custom:"):
if _slug == provider:
_matches.append(_cp)
elif provider == "custom" and not _slug:
_matches.append(_cp)
return _matches
except Exception:
return []

def _custom_provider_model_ids(_cp):
_ids = []

def _append(_mid):
_mid = str(_mid or "").strip()
if _mid and _mid not in _ids:
_ids.append(_mid)

_append(_cp.get("model", ""))
_models = _cp.get("models")
if isinstance(_models, dict):
for _mid in _models:
if isinstance(_mid, str):
_append(_mid)
elif isinstance(_models, list):
for _item in _models:
if isinstance(_item, str):
_append(_item)
elif isinstance(_item, dict):
_append(_item.get("id") or _item.get("model") or _item.get("name"))
return _ids

def _custom_provider_api_key(_cp):
_raw = _cp.get("api_key")
if _raw is not None:
_key = str(_raw).strip()
if _key.startswith("${") and _key.endswith("}") and len(_key) > 3:
_key = os.getenv(_key[2:-1], "").strip()
if _key:
return _key
_env = str(_cp.get("key_env") or "").strip()
return os.getenv(_env, "").strip() if _env else ""

# For 'custom' and 'custom:*' providers, provider_model_ids()
# returns [] because they aren't real hermes_cli endpoints.
# Fall back to the custom_providers entries from config.yaml so
# the live-model enrichment step can add any models that weren't
# already in the static list (issue #1619).
if provider == "custom" or provider.startswith("custom:"):
try:
_cp_entries = cfg.get("custom_providers", [])
if isinstance(_cp_entries, list):
ids = [
_cp.get("model", "")
for _cp in _cp_entries
if isinstance(_cp, dict) and _cp.get("model", "")
]
except Exception:
pass
for _cp in _custom_provider_entries_for_request():
if custom_provider_entry is None:
custom_provider_entry = _cp
ids.extend(_custom_provider_model_ids(_cp))

# If still no ids, try fetching from base_url directly (OpenAI-compat endpoint)
if not ids and (provider == "custom" or provider.startswith("custom:")):
_base_url = cfg.get("model", {}).get("base_url")
_api_key = cfg.get("model", {}).get("api_key")
_base_url = None
_api_key = None
if custom_provider_entry:
_base_url = custom_provider_entry.get("base_url")
_api_key = _custom_provider_api_key(custom_provider_entry)
else:
_model_cfg = cfg.get("model", {})
_base_url = _model_cfg.get("base_url")
_api_key = _model_cfg.get("api_key")
if _base_url and _api_key:
try:
import urllib.request
Expand Down
130 changes: 130 additions & 0 deletions tests/test_byok_model_dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,32 @@ class TestLiveModelsCustomProviderFallback:
"""When provider='custom' and provider_model_ids() returns [],
/api/models/live must fall back to custom_providers entries from config.yaml."""

@staticmethod
def _install_provider_model_ids(monkeypatch, fn):
import types

hermes_cli = types.ModuleType("hermes_cli")
hermes_cli.__path__ = []
models = types.ModuleType("hermes_cli.models")
models.provider_model_ids = fn
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)

@staticmethod
def _call_live_models(monkeypatch, cfg, provider):
import api.config as c
import api.routes as r

r._clear_live_models_cache()
monkeypatch.setattr(c, "get_config", lambda: cfg)
monkeypatch.setattr(c, "_resolve_provider_alias", lambda p: p)
monkeypatch.setattr(r, "j", lambda _handler, payload, **_kw: payload)
TestLiveModelsCustomProviderFallback._install_provider_model_ids(monkeypatch, lambda _p: [])

parsed = mock.MagicMock()
parsed.query = f"provider={provider}"
return r._handle_live_models(object(), parsed)

def test_custom_fallback_code_present(self):
src = read("api/routes.py")
m = re.search(
Expand Down Expand Up @@ -241,6 +267,110 @@ def fake_j(h, data, **kw):
f"got {model_ids}"
)

def test_named_custom_fallback_returns_only_matching_provider_models(self, monkeypatch):
"""custom:<slug> must not leak sibling custom_providers models."""
cfg = {
"model": {"provider": "custom:infini-ai"},
"custom_providers": [
{
"name": "rightcode-codex",
"model": "gpt-5.5",
"models": {"gpt-5.5-mini": {}},
"base_url": "https://right.codes/codex/v1",
},
{
"name": "infini-ai",
"model": "glm-5.1",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
},
{
"name": "xiaomi-mimo",
"models": ["mimo-v2.5-pro"],
"base_url": "https://mimo.example.com/v1",
},
],
}

resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")

assert resp["provider"] == "custom:rightcode-codex"
assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"]

def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch):
"""Bare custom only represents unnamed custom entries, not named siblings."""
cfg = {
"model": {"provider": "custom"},
"custom_providers": [
{"name": "rightcode-codex", "model": "gpt-5.5"},
{"name": "infini-ai", "model": "glm-5.1"},
{"model": "unnamed-byok-model"},
],
}

resp = self._call_live_models(monkeypatch, cfg, "custom")

assert resp["provider"] == "custom"
assert [m["id"] for m in resp["models"]] == ["unnamed-byok-model"]

def test_named_custom_live_fetch_uses_matching_entry_endpoint(self, monkeypatch):
"""custom:<slug> live fetch must use that entry, not the active model config."""
import json
import urllib.request

requests = []

class Response:
def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return False

def read(self):
return json.dumps({"data": [{"id": "right-live-model"}]}).encode("utf-8")

def fake_urlopen(req, timeout=None):
requests.append(
{
"url": req.full_url,
"authorization": req.headers.get("Authorization"),
"timeout": timeout,
}
)
return Response()

cfg = {
"model": {
"provider": "custom:infini-ai",
"base_url": "https://infini.example.com/v1",
"api_key": "infini-key",
},
"custom_providers": [
{
"name": "rightcode-codex",
"base_url": "https://right.codes/codex/v1",
"api_key": "right-key",
},
{
"name": "infini-ai",
"base_url": "https://infini.example.com/v1",
"api_key": "infini-key",
},
],
}
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)

resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")

assert requests == [
{
"url": "https://right.codes/codex/v1/models",
"authorization": "Bearer right-key",
"timeout": 8,
}
]
assert [m["id"] for m in resp["models"]] == ["right-live-model"]


# ── Regression: known-good providers still work ───────────────────────────────

Expand Down
Loading