Skip to content
Closed
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
6 changes: 4 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1901,7 +1901,8 @@ async def _on_model_selected_scoped(
event.source
)
await _sess_db.update_session_model(
_sess_entry.session_id, result.new_model
_sess_entry.session_id, result.new_model,
provider=result.target_provider,
)
except Exception as exc:
logger.debug(
Expand Down Expand Up @@ -2211,7 +2212,8 @@ async def _finish_switch() -> str:
if getattr(_sess_entry, "was_auto_reset", False):
_sess_entry.was_auto_reset = False
await _sess_db.update_session_model(
_sess_entry.session_id, result.new_model
_sess_entry.session_id, result.new_model,
provider=result.target_provider,
)
except Exception as exc:
logger.debug(
Expand Down
66 changes: 51 additions & 15 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4328,7 +4328,13 @@ def _do(conn):
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)

def update_session_model(self, session_id: str, model: str) -> None:
def update_session_model(
self,
session_id: str,
model: str,
*,
provider: Optional[str] = None,
) -> None:
"""Update the model for a session after a mid-session switch.

Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
Expand All @@ -4338,6 +4344,10 @@ def update_session_model(self, session_id: str, model: str) -> None:
footer metadata is rebuilt on the next turn. A successful /model
switch explicitly replaces any confirmed Browser runtime lock while
preserving unrelated lineage markers in ``model_config``.

When *provider* is given, the provider and model are also persisted
into the ``model_config`` JSON blob so that session resume can
recombine the correct provider + model pair (#79536).
"""
# This write bypasses the token queue, so deltas enqueued before the
# switch must land first: a still-queued first delta carries the
Expand All @@ -4347,21 +4357,47 @@ def update_session_model(self, session_id: str, model: str) -> None:
# model/provider. Flushing here restores the pre-queue ordering.
self.flush_token_counts()

_provider = str(provider).strip() if provider else None

def _do(conn):
conn.execute(
"""UPDATE sessions SET
model = ?,
model_config = CASE
WHEN model_config IS NULL THEN NULL
WHEN json_valid(model_config)
THEN json_remove(model_config, '$.browser_model_lock')
ELSE model_config
END,
system_prompt = NULL,
system_prompt_hash = NULL
WHERE id = ?""",
(model, session_id),
)
if _provider:
# Persist provider + model into model_config so session
# resume recombines the correct provider/model pair even
# when the fallback provider differs from the primary
# config (#79536).
conn.execute(
"""UPDATE sessions SET
model = ?,
model_config = CASE
WHEN model_config IS NULL THEN NULL
WHEN json_valid(model_config)
THEN json_set(
json_remove(model_config, '$.browser_model_lock'),
'$.provider', ?,
'$.model', ?
)
ELSE model_config
END,
system_prompt = NULL,
system_prompt_hash = NULL
WHERE id = ?""",
(model, _provider, model, session_id),
)
else:
conn.execute(
"""UPDATE sessions SET
model = ?,
model_config = CASE
WHEN model_config IS NULL THEN NULL
WHEN json_valid(model_config)
THEN json_remove(model_config, '$.browser_model_lock')
ELSE model_config
END,
system_prompt = NULL,
system_prompt_hash = NULL
WHERE id = ?""",
(model, session_id),
)
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)

Expand Down
45 changes: 43 additions & 2 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,53 @@ def test_update_session_model_clears_browser_lock_and_preserves_lineage(self, db
assert "browser_model_lock" not in model_config
assert model_config["_branched_from"] == "parent-session"

def test_update_session_model_persists_provider_in_model_config(self, db):
"""update_session_model with provider= persists provider+model into model_config (#79536)."""
db.create_session(
session_id="s-prov",
source="cli",
model="primary-model",
model_config={
"provider": "nvidia",
"reasoning_config": {"effort": "low"},
},
)

# Switch model with a different provider
db.update_session_model(
"s-prov", "deepseek-v4-flash-free",
provider="custom:opencode-zen",
)

session = db.get_session("s-prov")
model_config = json.loads(session["model_config"])
assert session["model"] == "deepseek-v4-flash-free"
assert model_config["provider"] == "custom:opencode-zen"
assert model_config["model"] == "deepseek-v4-flash-free"
# Existing keys preserved
assert model_config["reasoning_config"] == {"effort": "low"}

def test_update_session_model_without_provider_preserves_existing_behavior(self, db):
"""update_session_model without provider= keeps backward-compatible behavior."""
db.create_session(
session_id="s-noprov",
source="cli",
model="old-model",
model_config={
"provider": "nvidia",
"browser_model_lock": {"model": "x"},
},
)

db.update_session_model("s-noprov", "new-model")



session = db.get_session("s-noprov")
model_config = json.loads(session["model_config"])
assert session["model"] == "new-model"
# browser_model_lock removed
assert "browser_model_lock" not in model_config
# provider NOT overwritten (backward compat)
assert model_config["provider"] == "nvidia"

def test_first_accounted_route_replaces_all_route_fields_atomically(self, db):
db.create_session(session_id="route", source="cli", model="primary")
Expand Down
5 changes: 4 additions & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3779,7 +3779,10 @@ def _persist_live_session_runtime(session: dict | None) -> None:
if hasattr(db, "update_session_meta"):
db.update_session_meta(session_key, json.dumps(model_config), model or None)
elif model and hasattr(db, "update_session_model"):
db.update_session_model(session_key, model)
db.update_session_model(
session_key, model,
provider=model_config.get("provider") or None,
)
except Exception:
logger.debug("failed to persist live session runtime", exc_info=True)

Expand Down
Loading