fix(hermes_cli): keep TTS/setup xAI OAuth from switching active chat provider

Save side-tool OAuth tokens without promoting xai-oauth via active_provider
or model.provider so hermes setup tts login no longer hijacks inference routing.
This commit is contained in:
Fangliquan 2026-07-25 20:41:57 +08:00 committed by Teknium
parent a54067a6fd
commit fce06e909d
5 changed files with 134 additions and 6 deletions

View file

@ -4452,7 +4452,15 @@ def _save_xai_oauth_tokens(
redirect_uri: str = "",
last_refresh: Optional[str] = None,
auth_mode: str = "oauth_device_code",
set_active: bool = True,
) -> None:
"""Persist xAI OAuth tokens into the auth store.
When *set_active* is True (default), also promote ``xai-oauth`` to
``active_provider`` appropriate for intentional model/auth login.
Pass ``set_active=False`` for side-tool credential bootstrap (TTS, STT,
dashboard token save, token refresh) so inference routing is unchanged.
"""
if last_refresh is None:
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _auth_store_lock():
@ -4470,7 +4478,9 @@ def _save_xai_oauth_tokens(
state["discovery"] = discovery
if redirect_uri:
state["redirect_uri"] = redirect_uri
_save_provider_state(auth_store, "xai-oauth", state)
_store_provider_state(
auth_store, "xai-oauth", state, set_active=set_active
)
_save_auth_store(auth_store)
if write_through_to_root:
_write_through_xai_oauth_to_global_root(state)
@ -4808,6 +4818,9 @@ def _refresh_xai_oauth_tokens(
redirect_uri=redirect_uri,
last_refresh=refreshed["last_refresh"],
auth_mode=auth_mode,
# Refresh must not flip active_provider — TTS/side tools can refresh
# xAI tokens while chat still routes through another provider.
set_active=False,
)
return updated_tokens

View file

@ -896,15 +896,17 @@ def _xai_oauth_logged_in_for_setup() -> bool:
def _run_xai_oauth_login_from_setup() -> bool:
"""Run the xAI Grok OAuth device-code login from inside the setup wizard.
Saves OAuth tokens only. Does **not** switch the active inference
provider or rewrite ``model.provider`` callers (TTS setup, tools
config) only need credentials for side tools.
Returns True on success, False on any failure (the caller falls back
to whatever the user picked next, e.g. Edge TTS).
"""
try:
from hermes_cli.auth import (
DEFAULT_XAI_OAUTH_BASE_URL,
_is_remote_session,
_save_xai_oauth_tokens,
_update_config_for_provider,
_xai_oauth_device_code_login,
)
except Exception as exc:
@ -922,9 +924,7 @@ def _run_xai_oauth_login_from_setup() -> bool:
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
auth_mode="oauth_device_code",
)
_update_config_for_provider(
"xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)
set_active=False,
)
return True
except Exception as exc:

View file

@ -11153,6 +11153,9 @@ def _xai_device_poller(session_id: str) -> None:
discovery=discovery,
last_refresh=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
auth_mode="oauth_device_code",
# Dashboard OAuth only bootstraps credentials for side tools;
# do not hijack the active chat inference provider.
set_active=False,
)
# The singleton write above is the single source of truth: the
# credential-pool load seeds it as the canonical ``device_code``

View file

@ -281,6 +281,40 @@ def test_save_and_read_xai_oauth_tokens_roundtrip(tmp_path, monkeypatch):
assert data["discovery"]["token_endpoint"] == "https://auth.x.ai/oauth2/token"
def test_save_xai_oauth_tokens_set_active_false_preserves_active_provider(
tmp_path, monkeypatch
):
"""Side-tool credential saves must not flip auth.json active_provider."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
auth_path = hermes_home / "auth.json"
auth_path.write_text(
json.dumps(
{
"version": 1,
"active_provider": "openrouter",
"providers": {},
}
)
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_xai_oauth_tokens(
{
"access_token": "side-tool-at",
"refresh_token": "side-tool-rt",
"id_token": "",
"token_type": "Bearer",
},
discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"},
set_active=False,
)
raw = json.loads(auth_path.read_text())
assert raw["active_provider"] == "openrouter"
assert raw["providers"]["xai-oauth"]["tokens"]["access_token"] == "side-tool-at"
def test_read_xai_oauth_tokens_missing(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)

View file

@ -0,0 +1,78 @@
"""Regression: TTS/setup xAI OAuth must not hijack the active chat provider."""
import json
import yaml
def test_run_xai_oauth_login_from_setup_does_not_hijack_active_provider(
tmp_path, monkeypatch
):
"""TTS/setup OAuth must save tokens without switching chat inference routing.
Regression: `_run_xai_oauth_login_from_setup` used to call
`_update_config_for_provider("xai-oauth")` (and token save flipped
`active_provider`), so `hermes setup tts` OAuth login hijacked the main
chat provider.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
auth_path = hermes_home / "auth.json"
auth_path.write_text(
json.dumps(
{
"version": 1,
"active_provider": "openrouter",
"providers": {},
}
),
encoding="utf-8",
)
config_path = hermes_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "openrouter",
"default": "anthropic/claude-sonnet-4",
"base_url": "https://openrouter.ai/api/v1",
},
"tts": {"provider": "edge"},
}
),
encoding="utf-8",
)
monkeypatch.setattr(
"hermes_cli.auth._xai_oauth_device_code_login",
lambda **kwargs: {
"tokens": {
"access_token": "tts-xai-access",
"refresh_token": "tts-xai-refresh",
"id_token": "",
"token_type": "Bearer",
},
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
"redirect_uri": "",
"base_url": "https://api.x.ai/v1",
"last_refresh": "2026-07-25T12:00:00Z",
},
)
monkeypatch.setattr("hermes_cli.auth._is_remote_session", lambda: True)
from hermes_cli.setup import _run_xai_oauth_login_from_setup
assert _run_xai_oauth_login_from_setup() is True
auth = json.loads(auth_path.read_text(encoding="utf-8"))
assert auth["active_provider"] == "openrouter"
xai_state = auth["providers"]["xai-oauth"]
assert xai_state["tokens"]["access_token"] == "tts-xai-access"
assert xai_state["tokens"]["refresh_token"] == "tts-xai-refresh"
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert config["model"]["provider"] == "openrouter"
assert config["model"]["base_url"] == "https://openrouter.ai/api/v1"
assert config["model"]["default"] == "anthropic/claude-sonnet-4"