fix(codex): an in-place provider switch retires the app-server thread

turn/start can change the model but not modelProvider, which is fixed when the thread starts. With the model now forwarded per turn, switching in place between codex's own provider and a named custom provider would send the new provider's model to the old provider's thread. Record the thread's model provider next to its prompt composition and retire the thread when either changes.
This commit is contained in:
kshitijk4poor
2026-10-01 22:58:27 +05:30
committed by kshitij
parent 8b3e565936
commit 8eb8f2da0a
2 changed files with 30 additions and 8 deletions

View File

@@ -535,7 +535,9 @@ def _start_codex_thread(agent) -> str:
def _codex_model_provider(agent) -> str | None:
"""codex's ``[model_providers.<id>]`` for a named custom provider; None means codex's own provider."""
"""codex's ``[model_providers.<id>]`` for a named custom provider (``providers.<name>``); None means codex's
own provider. Only the stable id is sent and codex resolves base_url/env_key itself, so Hermes' credential
never enters the JSON-RPC payload (#75186)."""
if str(getattr(agent, "provider", "") or "").strip().lower() != "custom":
return None
from hermes_cli.runtime_provider_custom import codex_model_provider_id
@@ -563,10 +565,14 @@ def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) -
today's fresh-thread behaviour and overwrites the binding once its turn is committed. ``messages`` is the
turn's transcript (current user row last); a thread started from scratch is seeded with the prior turns."""
developer_instructions = _codex_developer_instructions(agent)
model_provider = _codex_model_provider(agent)
if getattr(agent, "_codex_session", None) is not None:
# Only a session whose recorded composition differs is stale; one attached without a record is kept.
# The provider is fixed per thread (turn/start can switch the model, not the provider), so an
# in-place switch to or from a named custom provider retires the thread too.
recorded = getattr(agent, "_codex_session_prompt", None)
if recorded is None or recorded == developer_instructions:
if recorded is None or (recorded == developer_instructions
and getattr(agent, "_codex_session_model_provider", None) == model_provider):
return
_close_codex_session(agent)
resume_thread_id = None if getattr(agent, "_codex_session_prompt", None) is not None else _stored_codex_thread_id(agent)
@@ -599,13 +605,11 @@ def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) -
# once, so a /model switch into codex or a retired thread does not start blind (#74712, #26035).
# The recorded composition stays the bare prompt: the seed must not make the next turn retire the thread.
agent._codex_session_prompt = developer_instructions
agent._codex_session_model_provider = model_provider
from agent.codex_runtime_history_seed import render_history_seed
history_seed = render_history_seed(messages) or None
# A named custom provider (``providers.<name>``) maps onto codex's own ``[model_providers.<name>]``
# table: send the stable id and let codex resolve base_url/env_key itself, so Hermes' credential never
# enters the JSON-RPC payload (#75186). The model always rides along: codex's home is shared, while the
# Hermes model is per profile/session, so omitting it ran codex's own default instead of the selection.
model_provider = _codex_model_provider(agent)
# The model always rides along: codex's home is shared, while the Hermes model is per profile/session,
# so omitting it ran codex's own default instead of the selection.
agent._codex_session = CodexAppServerSession(
cwd=getattr(agent, "session_cwd", None) or str(resolve_agent_cwd()), approval_callback=approval_callback,
codex_bin=get_configured_codex_binary(load_config()),
@@ -691,7 +695,7 @@ def run_codex_app_server_turn(agent, *, user_message: str, original_user_message
_start_codex_thread(agent)
turn = agent._codex_session.run_turn(
user_input=user_message,
model=_codex_wire_model(agent, _codex_model_provider(agent)))
model=_codex_wire_model(agent, getattr(agent, "_codex_session_model_provider", None)))
except Exception as exc:
logger.exception("codex app-server turn failed")
_close_codex_session(agent)

View File

@@ -101,3 +101,21 @@ def test_each_turn_sends_the_agent_s_current_wire_model(monkeypatch):
codex_runtime.run_codex_app_server_turn(agent, user_message="hi", original_user_message="hi",
messages=[], effective_task_id="t")
assert turns == ["gpt-5.6-sol", "gpt-5.5"]
def test_runtime_retires_thread_when_an_in_place_switch_changes_the_codex_provider(monkeypatch):
"""turn/start can change the model but not the provider: switching between codex's own provider and a
named custom provider (``[model_providers.<id>]``) starts a thread carrying the new modelProvider."""
import hermes_cli.runtime_provider as rp
monkeypatch.setattr(rp, "load_config", lambda: {"providers": {"my-gateway": {"api": "https://gw.example/v1"}}})
client = _FakeClient()
monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client)
agent = _agent(provider="openai-codex", requested_provider="openai-codex", model="gpt-5.5")
for provider, requested in (("openai-codex", "openai-codex"), ("openai-codex", "openai-codex"),
("custom", "custom:my-gateway")):
agent.provider, agent.requested_provider = provider, requested
codex_runtime._ensure_codex_session(agent)
agent._codex_session.ensure_started()
starts = [p.get("modelProvider") for (m, p) in client.requests if m == "thread/start"]
assert starts == [None, "my-gateway"]
assert client.closed == 1