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
28 changes: 27 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4949,6 +4949,7 @@ def _format_session_info(self) -> str:
provider = None
base_url = None
api_key = None
data = {}

try:
cfg_path = _hermes_home / "config.yaml"
Expand All @@ -4967,7 +4968,7 @@ def _format_session_info(self) -> str:
provider = model_cfg.get("provider") or None
base_url = model_cfg.get("base_url") or None
except Exception:
pass
data = {}

# Resolve runtime credentials for probing
try:
Expand All @@ -4978,6 +4979,31 @@ def _format_session_info(self) -> str:
except Exception:
pass

if config_context_length is None and base_url:
try:
from hermes_cli.config import get_compatible_custom_providers as _gw_gcp
custom_providers = _gw_gcp(data)
except Exception:
custom_providers = data.get("custom_providers")
if not isinstance(custom_providers, list):
custom_providers = []
try:
for cp in custom_providers:
if not isinstance(cp, dict):
continue
cp_url = (cp.get("base_url") or "").rstrip("/")
if cp_url and cp_url == base_url.rstrip("/"):
cp_models = cp.get("models", {})
if isinstance(cp_models, dict):
cp_model_cfg = cp_models.get(model, {})
if isinstance(cp_model_cfg, dict):
cp_ctx = cp_model_cfg.get("context_length")
if cp_ctx is not None:
config_context_length = int(cp_ctx)
break
except (TypeError, ValueError):
pass

context_length = get_model_context_length(
model,
base_url=base_url or "",
Expand Down
11 changes: 5 additions & 6 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# Fall back to inline api_key when key_env is absent or unresolvable
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
resolved_api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))

if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
# Found match by provider key
Expand All @@ -360,9 +361,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
if resolved_api_mode:
result["api_mode"] = resolved_api_mode
return result
# Also check the 'name' field if present
display_name = entry.get("name", "")
Expand All @@ -378,9 +378,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
if resolved_api_mode:
result["api_mode"] = resolved_api_mode
return result

# Fall back to custom_providers: list (legacy format)
Expand Down
3 changes: 3 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3172,6 +3172,9 @@ def _run_review():
quiet_mode=True,
platform=self.platform,
provider=self.provider,
base_url=self.base_url or None,
api_key=self.api_key or None,
api_mode=self.api_mode or None,
parent_session_id=self.session_id,
)
review_agent._memory_write_origin = "background_review"
Expand Down
45 changes: 45 additions & 0 deletions tests/gateway/test_session_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,51 @@ def test_config_context_length(self, runner, tmp_path):
assert "32K" in info
assert "config" in info

def test_config_context_length_from_providers_dict(self, runner, tmp_path):
p1, p2, p3 = _patch_info(
tmp_path,
(
"model:\n"
" default: gpt-5.4\n"
" provider: newapi-openai\n"
"providers:\n"
" newapi-openai:\n"
" api: https://example.invalid/v1\n"
" name: newapi-openai\n"
" models:\n"
" gpt-5.4:\n"
" context_length: 1050000\n"
),
"gpt-5.4",
{"provider": "newapi-openai", "base_url": "https://example.invalid/v1", "api_key": "***"},
)
with p1, p2, p3:
info = runner._format_session_info()
assert "1.1M" in info
assert "config" in info

def test_config_context_length_from_custom_providers_list(self, runner, tmp_path):
p1, p2, p3 = _patch_info(
tmp_path,
(
"model:\n"
" default: gpt-5.4\n"
" provider: newapi-openai\n"
"custom_providers:\n"
" - name: newapi-openai\n"
" base_url: https://example.invalid/v1\n"
" models:\n"
" gpt-5.4:\n"
" context_length: 1050000\n"
),
"gpt-5.4",
{"provider": "newapi-openai", "base_url": "https://example.invalid/v1", "api_key": "***"},
)
with p1, p2, p3:
info = runner._format_session_info()
assert "1.1M" in info
assert "config" in info

def test_default_fallback_hint(self, runner, tmp_path):
p1, p2, p3 = _patch_info(tmp_path, "model:\n default: unknown-model-xyz\n",
"unknown-model-xyz",
Expand Down
82 changes: 82 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,88 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):
assert resolved["model"] == "acme-large"


def test_named_custom_provider_uses_transport_from_providers_dict(monkeypatch):
"""providers dict transport should propagate into runtime api_mode."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "dir-key")
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"providers": {
"anthropic-proxy": {
"base_url": "https://proxy.example.com/messages",
"api_key": "***",
"default_model": "claude-proxy",
"name": "Anthropic Proxy",
"transport": "anthropic_messages",
}
}
},
)
monkeypatch.setattr(
rp,
"resolve_provider",
lambda *a, **k: (_ for _ in ()).throw(
AssertionError(
"resolve_provider should not be called for named custom providers"
)
),
)

resolved = rp.resolve_runtime_provider(requested="anthropic-proxy")

assert resolved["provider"] == "custom"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["base_url"] == "https://proxy.example.com/messages"
assert resolved["api_key"] == "dir-key"
assert resolved["requested_provider"] == "anthropic-proxy"
assert resolved["source"] == "custom_provider:Anthropic Proxy"
assert resolved["model"] == "claude-proxy"


def test_named_custom_provider_uses_api_mode_from_providers_dict(monkeypatch):
"""providers dict api_mode should propagate into runtime api_mode."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "dir-key")
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"providers": {
"responses-proxy": {
"base_url": "https://proxy.example.com/v1",
"api_key": "***",
"default_model": "gpt-5-mini",
"name": "Responses Proxy",
"api_mode": "codex_responses",
}
}
},
)
monkeypatch.setattr(
rp,
"resolve_provider",
lambda *a, **k: (_ for _ in ()).throw(
AssertionError(
"resolve_provider should not be called for named custom providers"
)
),
)

resolved = rp.resolve_runtime_provider(requested="responses-proxy")

assert resolved["provider"] == "custom"
assert resolved["api_mode"] == "codex_responses"
assert resolved["base_url"] == "https://proxy.example.com/v1"
assert resolved["api_key"] == "dir-key"
assert resolved["requested_provider"] == "responses-proxy"
assert resolved["source"] == "custom_provider:Responses Proxy"
assert resolved["model"] == "gpt-5-mini"


def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
Expand Down
123 changes: 123 additions & 0 deletions tests/run_agent/test_background_review_runtime_inheritance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Regression tests for background review runtime inheritance.

Background review forks a secondary AIAgent after the main reply. When the
main session uses a named custom provider resolved to provider='custom' with
explicit runtime credentials, the fork must inherit those runtime fields.
Otherwise the review agent re-initializes as a bare custom/main provider and
fails with "No LLM provider configured".
"""

from __future__ import annotations

import threading

import pytest
import yaml

import run_agent as run_agent_module
from hermes_cli.runtime_provider import resolve_runtime_provider
from run_agent import AIAgent


@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = {
"model": {
"default": "gpt-5.4",
"provider": "newapi-openai",
"api_mode": "codex_responses",
},
"providers": {
"newapi-openai": {
"api": "https://newapi.example.invalid/v1",
"name": "newapi-openai",
"api_key": "test-key",
"default_model": "gpt-5.4",
"transport": "codex_responses",
"api_mode": "codex_responses",
}
},
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
},
"skills": {
"creation_nudge_interval": 10,
},
}
(hermes_home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")


class _ImmediateThread:
def __init__(self, *, target=None, **kwargs):
self._target = target

def start(self):
if self._target:
self._target()


class _SpyReviewAgent:
init_kwargs: dict | None = None
prompts: list[str] = []

def __init__(self, **kwargs):
type(self).init_kwargs = kwargs
self._session_messages = []
self.closed = False

def run_conversation(self, user_message, conversation_history=None):
type(self).prompts.append(user_message)
return {"final_response": "Nothing to save."}

def close(self):
self.closed = True


@pytest.mark.parametrize(
("review_memory", "review_skills", "expected_prompt"),
[
(True, False, AIAgent._MEMORY_REVIEW_PROMPT),
(False, True, AIAgent._SKILL_REVIEW_PROMPT),
(True, True, AIAgent._COMBINED_REVIEW_PROMPT),
],
)
def test_background_review_inherits_runtime_fields(
monkeypatch,
review_memory,
review_skills,
expected_prompt,
):
runtime = resolve_runtime_provider(requested="newapi-openai")
agent = AIAgent(
model="gpt-5.4",
provider=runtime.get("provider"),
base_url=runtime.get("base_url"),
api_key=runtime.get("api_key"),
api_mode=runtime.get("api_mode"),
quiet_mode=True,
max_iterations=1,
)

_SpyReviewAgent.init_kwargs = None
_SpyReviewAgent.prompts = []
monkeypatch.setattr(threading, "Thread", _ImmediateThread)
monkeypatch.setattr(run_agent_module, "AIAgent", _SpyReviewAgent)

agent._spawn_background_review(
messages_snapshot=[{"role": "user", "content": "hi"}],
review_memory=review_memory,
review_skills=review_skills,
)

assert _SpyReviewAgent.init_kwargs is not None
assert _SpyReviewAgent.init_kwargs["provider"] == "custom"
assert _SpyReviewAgent.init_kwargs["base_url"] == "https://newapi.example.invalid/v1"
assert _SpyReviewAgent.init_kwargs["api_key"] == "test-key"
assert _SpyReviewAgent.init_kwargs["api_mode"] == "codex_responses"
assert _SpyReviewAgent.prompts == [expected_prompt]

agent.close()
Loading