Merge pull request #2388 from NousResearch/hermes/hermes-31d7db3b

fix(provider): prevent Anthropic fallback from inheriting non-Anthropic base_url + fix(update): reset on stash conflict
This commit is contained in:
Teknium 2026-03-21 16:20:08 -07:00 committed by GitHub
commit 55510cbad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 17 deletions

View file

@ -654,16 +654,20 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
if not token: if not token:
return None, None return None, None
# Allow base URL override from config.yaml model.base_url # Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic — otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
base_url = _ANTHROPIC_DEFAULT_BASE_URL base_url = _ANTHROPIC_DEFAULT_BASE_URL
try: try:
from hermes_cli.config import load_config from hermes_cli.config import load_config
cfg = load_config() cfg = load_config()
model_cfg = cfg.get("model") model_cfg = cfg.get("model")
if isinstance(model_cfg, dict): if isinstance(model_cfg, dict):
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_base_url: if cfg_provider == "anthropic":
base_url = cfg_base_url cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
except Exception: except Exception:
pass pass

View file

@ -2559,12 +2559,29 @@ def _restore_stashed_changes(
capture_output=True, capture_output=True,
text=True, text=True,
) )
if restore.returncode != 0:
# Check for unmerged (conflicted) files — can happen even when returncode is 0
unmerged = subprocess.run(
git_cmd + ["diff", "--name-only", "--diff-filter=U"],
cwd=cwd,
capture_output=True,
text=True,
)
has_conflicts = bool(unmerged.stdout.strip())
if restore.returncode != 0 or has_conflicts:
# Reset the working tree so Hermes is runnable with the updated code
subprocess.run(
git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd,
capture_output=True,
)
print("✗ Update pulled new code, but restoring local changes failed.") print("✗ Update pulled new code, but restoring local changes failed.")
if restore.stdout.strip(): if restore.stdout.strip():
print(restore.stdout.strip()) print(restore.stdout.strip())
if restore.stderr.strip(): if restore.stderr.strip():
print(restore.stderr.strip()) print(restore.stderr.strip())
print("The working tree has been reset to a clean state.")
print("Your changes are still preserved in git stash.") print("Your changes are still preserved in git stash.")
print(f"Resolve manually with: git stash apply {stash_ref}") print(f"Resolve manually with: git stash apply {stash_ref}")
sys.exit(1) sys.exit(1)

View file

@ -363,9 +363,14 @@ def resolve_runtime_provider(
"No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, "
"run 'claude setup-token', or authenticate with 'claude /login'." "run 'claude setup-token', or authenticate with 'claude /login'."
) )
# Allow base URL override from config.yaml model.base_url # Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic — otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
model_cfg = _get_model_config() model_cfg = _get_model_config()
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
cfg_base_url = ""
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
base_url = cfg_base_url or "https://api.anthropic.com" base_url = cfg_base_url or "https://api.anthropic.com"
return { return {
"provider": "anthropic", "provider": "anthropic",

View file

@ -68,6 +68,8 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path,
calls.append((cmd, kwargs)) calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]: if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd[1:3] == ["stash", "list"]: if cmd[1:3] == ["stash", "list"]:
return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0) return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0)
if cmd[1:3] == ["stash", "drop"]: if cmd[1:3] == ["stash", "drop"]:
@ -81,8 +83,9 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path,
assert restored is True assert restored is True
assert calls[0][0] == ["git", "stash", "apply", "abc123"] assert calls[0][0] == ["git", "stash", "apply", "abc123"]
assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"]
assert calls[2][0] == ["git", "stash", "drop", "stash@{1}"] assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"]
assert calls[3][0] == ["git", "stash", "drop", "stash@{1}"]
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Restore local changes now? [Y/n]" in out assert "Restore local changes now? [Y/n]" in out
assert "restored on top of the updated codebase" in out assert "restored on top of the updated codebase" in out
@ -117,6 +120,8 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc
calls.append((cmd, kwargs)) calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]: if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd[1:3] == ["stash", "list"]: if cmd[1:3] == ["stash", "list"]:
return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0)
if cmd[1:3] == ["stash", "drop"]: if cmd[1:3] == ["stash", "drop"]:
@ -129,8 +134,9 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc
assert restored is True assert restored is True
assert calls[0][0] == ["git", "stash", "apply", "abc123"] assert calls[0][0] == ["git", "stash", "apply", "abc123"]
assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"]
assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"]
assert calls[3][0] == ["git", "stash", "drop", "stash@{0}"]
assert "Restore local changes now?" not in capsys.readouterr().out assert "Restore local changes now?" not in capsys.readouterr().out
@ -152,6 +158,8 @@ def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved
calls.append((cmd, kwargs)) calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]: if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd[1:3] == ["stash", "list"]: if cmd[1:3] == ["stash", "list"]:
return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0) return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0)
raise AssertionError(f"unexpected command: {cmd}") raise AssertionError(f"unexpected command: {cmd}")
@ -161,10 +169,9 @@ def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved
restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
assert restored is True assert restored is True
assert calls == [ assert calls[0] == (["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True})
(["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True}), assert calls[1] == (["git", "diff", "--name-only", "--diff-filter=U"], {"cwd": tmp_path, "capture_output": True, "text": True})
(["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True}), assert calls[2] == (["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True})
]
out = capsys.readouterr().out out = capsys.readouterr().out
assert "couldn't find the stash entry to drop" in out assert "couldn't find the stash entry to drop" in out
assert "stash was left in place" in out assert "stash was left in place" in out
@ -181,6 +188,8 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa
calls.append((cmd, kwargs)) calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]: if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
if cmd[1:3] == ["stash", "list"]: if cmd[1:3] == ["stash", "list"]:
return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0)
if cmd[1:3] == ["stash", "drop"]: if cmd[1:3] == ["stash", "drop"]:
@ -192,7 +201,7 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa
restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
assert restored is True assert restored is True
assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] assert calls[3][0] == ["git", "stash", "drop", "stash@{0}"]
out = capsys.readouterr().out out = capsys.readouterr().out
assert "couldn't drop the saved stash entry" in out assert "couldn't drop the saved stash entry" in out
assert "drop failed" in out assert "drop failed" in out
@ -208,6 +217,10 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp
calls.append((cmd, kwargs)) calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]: if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="conflict output\n", stderr="conflict stderr\n", returncode=1) return SimpleNamespace(stdout="conflict output\n", stderr="conflict stderr\n", returncode=1)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="hermes_cli/main.py\n", stderr="", returncode=0)
if cmd[1:3] == ["reset", "--hard"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
raise AssertionError(f"unexpected command: {cmd}") raise AssertionError(f"unexpected command: {cmd}")
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
@ -219,7 +232,34 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Your changes are still preserved in git stash." in out assert "Your changes are still preserved in git stash." in out
assert "git stash apply abc123" in out assert "git stash apply abc123" in out
assert calls == [(["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True})] assert "working tree has been reset to a clean state" in out
# Verify reset --hard was called to clean up conflict markers
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
assert len(reset_calls) == 1
def test_restore_stashed_changes_resets_when_unmerged_files_detected(monkeypatch, tmp_path, capsys):
"""Even if stash apply returns 0, conflict markers must be cleaned up."""
calls = []
def fake_run(cmd, **kwargs):
calls.append((cmd, kwargs))
if cmd[1:3] == ["stash", "apply"]:
return SimpleNamespace(stdout="applied\n", stderr="", returncode=0)
if cmd[1:3] == ["diff", "--name-only"]:
return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0)
if cmd[1:3] == ["reset", "--hard"]:
return SimpleNamespace(stdout="", stderr="", returncode=0)
raise AssertionError(f"unexpected command: {cmd}")
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
with pytest.raises(SystemExit, match="1"):
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False)
out = capsys.readouterr().out
assert "working tree has been reset to a clean state" in out
assert "git stash apply abc123" in out
def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch, tmp_path): def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch, tmp_path):