opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
@@ -1794,108 +1794,6 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransientTransportRetry:
|
||||
"""call_llm retries ONCE on the same provider for a transient transport
|
||||
blip before escalating to the fallback chain.
|
||||
|
||||
Salvaged from PR #16587 (@ARegalado1). The original fixed only the
|
||||
context-compression caller; this lives in call_llm so every auxiliary
|
||||
task (compression, memory flush, title-gen, session-search, vision)
|
||||
gets the same same-target retry, and the gate reuses the canonical
|
||||
_is_connection_error detector.
|
||||
"""
|
||||
|
||||
def _patches(self, client):
|
||||
return (
|
||||
patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "some-model", None, None, None),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(client, "some-model"),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._validate_llm_response",
|
||||
side_effect=lambda resp, _task: resp,
|
||||
),
|
||||
)
|
||||
|
||||
def test_retries_streaming_close_once_same_provider(self):
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = [
|
||||
Exception(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(incomplete chunked read)"
|
||||
),
|
||||
{"ok": True},
|
||||
]
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3:
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"ok": True}
|
||||
# Same client called twice — no provider fallback needed.
|
||||
assert client.chat.completions.create.call_count == 2
|
||||
|
||||
def test_retries_5xx_once_same_provider(self):
|
||||
class _Err503(Exception):
|
||||
status_code = 503
|
||||
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = [_Err503("upstream"), {"ok": True}]
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3:
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"ok": True}
|
||||
assert client.chat.completions.create.call_count == 2
|
||||
|
||||
def test_does_not_retry_non_transient_400(self):
|
||||
class _Err400(Exception):
|
||||
status_code = 400
|
||||
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = _Err400("bad request")
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3, pytest.raises(_Err400):
|
||||
call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
# Non-transient: single attempt, no same-target retry.
|
||||
assert client.chat.completions.create.call_count == 1
|
||||
|
||||
def test_second_transient_failure_escalates_to_fallback(self):
|
||||
"""Two transient failures in a row exhaust the same-target retry and
|
||||
fall through to the existing connection-error provider fallback."""
|
||||
primary = MagicMock()
|
||||
primary.base_url = "https://openrouter.ai/api/v1"
|
||||
primary.chat.completions.create.side_effect = Exception(
|
||||
"peer closed connection without sending complete message body"
|
||||
)
|
||||
|
||||
fb_client = MagicMock()
|
||||
fb_client.base_url = "https://api.openai.com/v1"
|
||||
fb_client.chat.completions.create.return_value = {"fallback": True}
|
||||
|
||||
p1, p2, p3 = self._patches(primary)
|
||||
with (
|
||||
p1, p2, p3,
|
||||
patch(
|
||||
"agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, ""),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._try_main_agent_model_fallback",
|
||||
return_value=(fb_client, "fb-model", "openai"),
|
||||
),
|
||||
):
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"fallback": True}
|
||||
# Primary tried twice (initial + same-target retry), then fallback.
|
||||
assert primary.chat.completions.create.call_count == 2
|
||||
assert fb_client.chat.completions.create.call_count == 1
|
||||
|
||||
|
||||
class TestIsConnectionError:
|
||||
"""Tests for _is_connection_error detection."""
|
||||
|
||||
@@ -3791,82 +3689,3 @@ class TestAuxUnhealthyCache:
|
||||
)
|
||||
# After the 402, OpenRouter is in the unhealthy cache.
|
||||
assert _is_provider_unhealthy("openrouter") is True
|
||||
|
||||
|
||||
# ── auxiliary_max_tokens_param ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuxiliaryMaxTokensParam:
|
||||
"""Verify the kwarg emitted by ``auxiliary_max_tokens_param`` across
|
||||
URL / provider / model-name combinations. Regression cover: a custom
|
||||
OpenAI-compatible endpoint serving ``gpt-5.x`` was silently getting
|
||||
``max_tokens`` and 400-ing on ``unsupported_parameter``."""
|
||||
|
||||
def test_direct_openai_returns_max_completion_tokens(self):
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://api.openai.com/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096) == {"max_completion_tokens": 4096}
|
||||
|
||||
def test_local_endpoint_without_model_uses_max_tokens(self):
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="http://localhost:11434/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
|
||||
|
||||
def test_openrouter_api_key_present_keeps_max_tokens_without_model_hint(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://openrouter.ai/api/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
|
||||
|
||||
# Model-name fallback — this is the regression guard.
|
||||
|
||||
def test_custom_endpoint_serving_gpt5_uses_max_completion_tokens(self):
|
||||
"""Third-party gateway + gpt-5.x: name-based detection must kick in."""
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://my-gateway.example.com/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096, model="gpt-5.4") == {
|
||||
"max_completion_tokens": 4096
|
||||
}
|
||||
|
||||
def test_openrouter_serving_gpt4o_uses_max_completion_tokens(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://openrouter.ai/api/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096, model="openai/gpt-4o-mini") == {
|
||||
"max_completion_tokens": 4096
|
||||
}
|
||||
|
||||
def test_custom_endpoint_serving_classic_llama_keeps_max_tokens(self):
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://my-gateway.example.com/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096, model="llama3-70b") == {
|
||||
"max_tokens": 4096
|
||||
}
|
||||
|
||||
def test_empty_model_falls_back_to_url_only(self):
|
||||
"""No model hint → only the URL-based rule applies."""
|
||||
with (
|
||||
patch("agent.auxiliary_client._current_custom_base_url",
|
||||
return_value="https://my-gateway.example.com/v1"),
|
||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||
):
|
||||
assert auxiliary_max_tokens_param(4096, model="") == {"max_tokens": 4096}
|
||||
assert auxiliary_max_tokens_param(4096, model=None) == {"max_tokens": 4096}
|
||||
|
||||
Reference in New Issue
Block a user