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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user