fix: a chat switched to Codex no longer sends its model to the previous provider's endpoint

The messaging gateway's /model wrote only model + provider to the session row. A row the
Desktop/TUI had persisted with the Nous Portal route kept that base_url and api_mode, so resuming
a chat switched to openai-codex built provider=openai-codex on the Portal URL and posted
gpt-6-luna-900k to inference-api.nousresearch.com/v1/chat/completions: "Model 'gpt-6-luna-900k'
isn't available on ChatGPT or Codex Subscription".

- SessionDB.update_session_model writes the whole route (provider, base_url, api_mode) in both
  shapes resume reads (top-level for TUI/Desktop, gateway_runtime for the CLI) whenever a
  provider is given; the gateway /model passes the switch result's endpoint.
- Resume readers (TUI/Desktop, CLI, gateway rehydrate) drop a persisted base_url that is another
  built-in provider's canonical endpoint, so rows already written by older builds heal.
- A gateway session override whose credentials failed to re-resolve is resolved for its own
  provider on the turn instead of being layered over the default provider's runtime (which
  produced openai-codex + Nous key + Nous URL). If it still cannot resolve, that turn runs on the
  whole default route with the existing one-shot "Provider fallback" notice; the override is kept
  and retried next turn.
This commit is contained in:
teknium1
2026-09-23 03:43:12 -07:00
committed by Teknium
parent 24f03c41c1
commit 07646a7f72
9 changed files with 127 additions and 10 deletions

View File

@@ -160,10 +160,13 @@ class GatewayAgentCacheMixin:
return
override: Dict[str, Any] = {k: persisted.get(k) for k in ("model", "provider", "base_url")}
provider = persisted.get("provider")
from hermes_cli.runtime_provider import is_foreign_provider_endpoint
if is_foreign_provider_endpoint(provider, override.get("base_url")):
override["base_url"] = None # left over from a switch that kept the previous provider's URL
if provider:
# Re-resolve credentials for the persisted provider. On failure (e.g. credentials removed
# since the switch) keep the credential-less override — _resolve_session_agent_runtime
# falls back to env resolution and layers model/provider.
# retries the resolution for that provider on each turn (default route + notice meanwhile).
try:
runtime = _resolve_runtime_agent_kwargs_for_provider(provider, target_model=persisted.get("model") or None)
for k in ("api_key", "api_mode", "credential_pool", "requested_provider", "max_tokens"):

View File

@@ -206,7 +206,8 @@ class GatewayTurnMixin:
skey or "", model, override_model, override_runtime.get("provider"),
)
return override_model, override_runtime
# No api_key on the override: env-based resolution below, override model/provider on top.
# No api_key on the override (credentials failed to re-resolve at rehydrate): resolve them
# for the override's own provider below, never layer it over the default provider's runtime.
logger.debug(
"Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
skey or "", model, override_model,
@@ -221,7 +222,19 @@ class GatewayTurnMixin:
][:5] or "[]",
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
runtime_kwargs, unavailable_override = None, None
if override and override.get("provider"):
try:
runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(
override["provider"], target_model=override.get("model") or None)
except Exception as exc:
# Layering the override on the default runtime sent its model to the default provider's
# endpoint (openai-codex on the Nous URL). Run this turn on the whole default route and say
# so; the persisted override is kept, so the next turn retries it.
logger.warning("Session /model override provider %s unavailable: %s", override["provider"], exc)
unavailable_override, override = override, None
if runtime_kwargs is None:
runtime_kwargs = _resolve_runtime_agent_kwargs()
# Private notice metadata must never reach an ``AIAgent(**runtime_kwargs)`` spread; the turn
# runner surfaces it through the agent's one-shot fallback notice (#74349).
self._pre_agent_fallback_notice = runtime_kwargs.pop("_fallback_notice", None)
@@ -229,6 +242,10 @@ class GatewayTurnMixin:
if runtime_model:
logger.info("Runtime provider supplied explicit model override: %s -> %s", model, runtime_model)
model = runtime_model
if unavailable_override and not self._pre_agent_fallback_notice:
from hermes_cli.fallback_config import pre_agent_fallback_notice
self._pre_agent_fallback_notice = pre_agent_fallback_notice(
unavailable_override["provider"], unavailable_override.get("model"), runtime_kwargs.get("provider"), model)
cfg = getattr(self, "config", None) # getattr: bare object.__new__ test runners
if cfg and source is not None:

View File

@@ -215,6 +215,7 @@ class GatewayModelCommandsMixin:
_sess_entry.was_auto_reset = False
await _sess_db.update_session_model(
_sess_entry.session_id, result.new_model, provider=result.target_provider,
base_url=result.base_url, api_mode=result.api_mode,
)
except Exception as exc:
logger.debug("Failed to persist model switch to DB: %s", exc)

View File

@@ -61,6 +61,10 @@ def stored_session_route(session_meta, *, current_model, current_provider):
if stored_model == current_model and not provider_changed:
return None
api_mode = runtime.get("api_mode") or None
from hermes_cli.runtime_provider import is_foreign_provider_endpoint
if is_foreign_provider_endpoint(provider, base_url):
# The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
base_url = api_mode = None
# A row's api_mode/base_url were written for whichever model the session last ran. Providers that
# pick the wire per model (OpenCode Zen/Go, Copilot, Nous) re-derive both from the stored model, or a
# resumed opencode-go session keeps a MiniMax-era anthropic_messages route for a chat_completions

View File

@@ -317,6 +317,20 @@ def _config_base_url_for_provider(model_cfg: Dict[str, Any], provider: str) -> s
return str(model_cfg.get("base_url") or "").strip().rstrip("/") if _same_registered_provider(provider, configured_provider) else ""
def is_foreign_provider_endpoint(provider: Optional[str], base_url: Optional[str]) -> bool:
"""True when ``base_url`` is another built-in provider's canonical endpoint, not ``provider``'s.
A persisted session route that pairs one provider with another's endpoint is left over from a
switch that kept the old URL (openai-codex + the Nous Portal URL sent the Codex slug to the Portal).
Only registered providers are judged: a custom or proxy URL is never another provider's canonical one.
"""
pconfig = PROVIDER_REGISTRY.get(str(provider or "").strip().lower())
url = str(base_url or "").strip().rstrip("/")
if pconfig is None or not url or url == (pconfig.inference_base_url or "").rstrip("/"):
return False
return any(url == (other.inference_base_url or "").rstrip("/") for other in PROVIDER_REGISTRY.values())
def _anthropic_base_url_override_ok(base_url: str) -> bool:
"""Whether a configured ``model.base_url`` plausibly speaks the Anthropic Messages protocol:
official Anthropic/Claude hosts, Azure Foundry, or ``/anthropic`` / Kimi ``/coding`` proxies

View File

@@ -667,15 +667,20 @@ class SessionSessionsMixin:
payload = json.dumps(list(tool_names)) if tool_names is not None else None
self._write_sql("UPDATE sessions SET tool_names = ? WHERE id = ?", (payload, session_id))
def update_session_model(self, session_id: str, model: str, provider: Optional[str] = None) -> None:
def update_session_model(
self, session_id: str, model: str, provider: Optional[str] = None, *,
base_url: Optional[str] = None, api_mode: Optional[str] = None,
) -> None:
"""Set the model after a mid-session /model switch (unconditionally), null system_prompt so
stale Model:/Provider: footers rebuild, and drop any Browser runtime lock (lineage markers
survive). *provider* is merged into model_config so resume recombines model and provider.
survive).
When *provider* is given, it is merged into ``model_config`` alongside the model (``$.model`` /
``$.provider``) so a later resume recombines the persisted model with the provider that actually
serves it instead of the config.yaml primary provider (#79536). Callers without provider knowledge
leave any stored provider untouched.
When *provider* is given the whole route is written, in both shapes resume reads (top-level
keys for the TUI/Desktop, ``gateway_runtime`` for the CLI), so a later resume recombines the
model with the provider that serves it (#79536). ``base_url``/``api_mode`` are always
replaced then (``None`` deletes): the previous provider's endpoint must not survive a switch,
or resume sends the new provider's model to the old host. Callers without provider knowledge
leave the stored route untouched.
"""
# Flush first: a still-queued pre-switch delta applied after this UPDATE would trip the
# first_accounted_route overwrite and resurrect the old route.
@@ -684,7 +689,8 @@ class SessionSessionsMixin:
if model:
patch["model"] = model
if provider:
patch["provider"] = provider
route = {"provider": provider, "base_url": base_url or None, "api_mode": api_mode or None}
patch.update(route, gateway_runtime=route)
self._write_model_config_patch(
session_id, patch, "UPDATE sessions SET model = ?, model_config = ?, "
"system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?",

View File

@@ -182,6 +182,39 @@ def test_rehydrate_opencode_override_heals_relay_url_for_rederived_wire(store_fa
assert (override["api_mode"], override["base_url"]) == ("chat_completions", "https://opencode.ai/zen/go/v1")
@pytest.mark.parametrize("codex_on_turn", ["recovers", "still_unavailable"])
def test_codex_override_never_runs_on_the_default_providers_endpoint(store_factory, codex_on_turn):
"""A persisted openai-codex override whose credentials fail to re-resolve used to be layered over the
DEFAULT provider's runtime (Nous URL + Nous key + chat_completions). The turn runs on ONE coherent
route: the override's own provider when it resolves, else the whole default route with a notice."""
store = store_factory()
session_key = store.get_or_create_session(_make_source()).session_key
store.set_model_override(session_key, {"model": "gpt-6-luna-900k", "provider": "openai-codex",
"base_url": "https://inference-api.nousresearch.com/v1"})
runner = _make_runner(store_factory())
codex = {"provider": "openai-codex", "api_key": "codex-tok", "api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex"}
nous = {"provider": "nous", "api_key": "nous-key", "api_mode": "chat_completions",
"base_url": "https://inference-api.nousresearch.com/v1"}
calls = iter([RuntimeError("refresh blip"), codex if codex_on_turn == "recovers" else RuntimeError("gone")])
def _for_provider(provider, target_model=None):
assert provider == "openai-codex"
nxt = next(calls)
if isinstance(nxt, Exception):
raise nxt
return dict(nxt)
with patch("gateway.run._resolve_runtime_agent_kwargs_for_provider", side_effect=_for_provider), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value=dict(nous)):
model, runtime = runner._resolve_session_agent_runtime(
session_key=session_key, user_config={"model": {"default": "openai/gpt-6-luna", "provider": "nous"}})
expected_model, expected = ("gpt-6-luna-900k", codex) if codex_on_turn == "recovers" else ("openai/gpt-6-luna", nous)
assert (model, {k: runtime[k] for k in expected}) == (expected_model, expected)
assert bool(runner._pre_agent_fallback_notice) is (codex_on_turn == "still_unavailable")
def test_sanitize_model_override():
assert sanitize_model_override(None) is None
assert sanitize_model_override({}) is None

View File

@@ -0,0 +1,35 @@
"""A chat switched to another provider must resume on THAT provider's endpoint.
The messaging gateway's /model wrote only ``model``/``provider`` over a row the Desktop had persisted
with the Nous Portal route, so resuming a chat switched to openai-codex sent the Codex slug to the
Portal's chat/completions ("Model 'gpt-6-luna-900k' isn't available on ChatGPT or Codex").
"""
import json
import pytest
from hermes_cli.cli_model_switch_mixin import stored_session_route
from hermes_state import SessionDB
from tui_gateway.server import _stored_session_runtime_overrides
NOUS_ROUTE = {"base_url": "https://inference-api.nousresearch.com/v1", "api_mode": "chat_completions"}
@pytest.mark.parametrize("row_origin", ["gateway_model_switch", "row_written_by_older_build"])
def test_resume_drops_previous_providers_endpoint(tmp_path, row_origin):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="telegram", model="openai/gpt-6-luna")
if row_origin == "gateway_model_switch":
db.update_session_meta("s1", json.dumps({"model": "openai/gpt-6-luna", "provider": "nous", **NOUS_ROUTE}),
"openai/gpt-6-luna")
db.update_session_model("s1", "gpt-6-luna-900k", provider="openai-codex")
else:
db.update_session_meta("s1", json.dumps({"model": "gpt-6-luna-900k", "provider": "openai-codex", **NOUS_ROUTE}),
"gpt-6-luna-900k")
row = db.get_session("s1")
desktop = _stored_session_runtime_overrides(row)["model_override"]
assert (desktop["provider"], desktop["base_url"], desktop["api_mode"]) == ("openai-codex", None, None)
assert stored_session_route(row, current_model="openai/gpt-6-luna", current_provider="nous") == (
"gpt-6-luna-900k", "openai-codex", None, None, True)

View File

@@ -1545,6 +1545,10 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
provider = billing_provider
base_url, api_mode, service_tier = field("base_url"), field("api_mode"), field("service_tier")
reasoning_config = model_config.get("reasoning_config")
from hermes_cli.runtime_provider import is_foreign_provider_endpoint
if is_foreign_provider_endpoint(provider, base_url):
# The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
base_url = api_mode = ""
# Heal a stale provider persisted by an older build (renamed/removed custom provider → "Unknown provider"):
# recover ``custom:<name>`` from the stored base_url, then from the entry serving the model; else drop it.
if provider and not _is_routable_provider(provider):