MiniMax auth broken by Anthropic token refresh logic
NousResearch/hermes-agent · cluster 2094 · agent
Data as of
Nothing open on GitHub. Fix merged in #2103. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #2103. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #2103. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address a bug where Anthropic's token refresh mechanism overrides authentication for third-party providers using the anthropic_messages format, particularly MiniMax, causing 401 errors. Fixes focus on gating Anthropic token injection to native Anthropic endpoints only.
Open issues0
Open PRs0
Fixes on record
-
#2103 fix: resolve MiniMax 401 auth error by defaulting to anthropic_messages
files:
hermes_cli/auth.py,hermes_cli/runtime_provider.py,tests/test_api_key_providers.py,tests/test_runtime_provider_resolution.pyShow the diff
-
hermes_cli/auth.py@@ -145,7 +145,7 @@ class ProviderConfig: id="minimax", name="MiniMax", auth_type="api_key", - inference_base_url="https://api.minimax.io/v1", + inference_base_url="https://api.minimax.io/anthropic", api_key_env_vars=("MINIMAX_API_KEY",), base_url_env_var="MINIMAX_BASE_URL", ), @@ -168,7 +168,7 @@ class ProviderConfig: id="minimax-cn", name="MiniMax (China)", auth_type="api_key", - inference_base_url="https://api.minimaxi.com/v1", + inference_base_url="https://api.minimaxi.com/anthropic", api_key_env_vars=("MINIMAX_CN_API_KEY",), base_url_env_var="MINIMAX_CN_BASE_URL", ), -
hermes_cli/runtime_provider.py@@ -387,6 +387,12 @@ def resolve_runtime_provider( # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) elif base_url.rstrip("/").endswith("/anthropic"): api_mode = "anthropic_messages" + # MiniMax providers always use Anthropic Messages API. + # Auto-correct stale /v1 URLs (from old .env or config) to /anthropic. + elif provider in ("minimax", "minimax-cn"): + api_mode = "anthropic_messages" + if base_url.rstrip("/").endswith("/v1"): + base_url = base_url.rstrip("/")[:-3] + "/anthropic" return { "provider": provider, "api_mode": api_mode, -
tests/test_api_key_providers.py@@ -92,8 +92,8 @@ def test_base_urls(self): assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot" assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" - assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1" - assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1" + assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic" + assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic" assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1" assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway" @@ -399,14 +399,14 @@ def test_resolve_minimax_with_key(self, monkeypatch): creds = resolve_api_key_provider_credentials("minimax") assert creds["provider"] == "minimax" assert creds["api_key"] == "mm-secret-key" - assert creds["base_url"] == "https://api.minimax.io/v1" + assert creds["base_url"] == "https://api.minimax.io/anthropic" def test_resolve_minimax_cn_with_key(self, monkeypatch): monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key") creds = resolve_api_key_provider_credentials("minimax-cn") assert creds["provider"] == "minimax-cn" assert creds["api_key"] == "mmcn-secret-key" - assert creds["base_url"] == "https://api.minimaxi.com/v1" + assert creds["base_url"] == "https://api.minimaxi.com/anthropic" def test_resolve_ai_gateway_with_key(self, monkeypatch): monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key") -
tests/test_runtime_provider_resolution.py@@ -479,8 +479,8 @@ def test_api_key_provider_explicit_api_mode_config(monkeypatch): assert resolved["api_mode"] == "anthropic_messages" -def test_api_key_provider_default_url_stays_chat_completions(monkeypatch): - """API-key providers with default /v1 URL should stay on chat_completions.""" +def test_minimax_default_url_uses_anthropic_messages(monkeypatch): + """MiniMax with default /anthropic URL should auto-detect anthropic_messages mode.""" monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax") monkeypatch.setattr(rp, "_get_model_config", lambda: {}) monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key") @@ -488,9 +488,50 @@ def test_api_key_provider_default_url_stays_chat_completions(monkeypatch): resolved = rp.resolve_runtime_provider(requested="minimax") + assert resolved["provider"] == "minimax" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimax.io/anthropic" + + +def test_minimax_stale_v1_url_auto_corrected(monkeypatch): + """MiniMax with stale /v1 base URL should be auto-corrected to /anthropic.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key") + monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/v1") + + resolved = rp.resolve_runtime_provider(requested="minimax") + + assert resolved["provider"] == "minimax" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimax.io/anthropic" + + +def test_minimax_cn_stale_v1_url_auto_corrected(monkeypatch): + """MiniMax-CN with stale /v1 base URL should be auto-corrected to /anthropic.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-cn") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-minimax-cn-key") + monkeypatch.setenv("MINIMAX_CN_BASE_URL", "https://api.minimaxi.com/v1") + + resolved = rp.resolve_runtime_provider(requested="minimax-cn") + + assert resolved["provider"] == "minimax-cn" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimaxi.com/anthropic" + + +def test_minimax_explicit_api_mode_respected(monkeypatch): + """Explicit api_mode config should override MiniMax auto-detection.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax") + monkeypatch.setattr(rp, "_get_model_config", lambda: {"api_mode": "chat_completions"}) + monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key") + monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) + + resolved = rp.resolve_runtime_provider(requested="minimax") + assert resolved["provider"] == "minimax" assert resolved["api_mode"] == "chat_completions" - assert resolved["base_url"] == "https://api.minimax.io/v1" def test_named_custom_provider_anthropic_api_mode(monkeypatch):
-
-
#2388 fix(provider): prevent Anthropic fallback from inheriting non-Anthropic base_url + fix(update): reset on stash conflict
files:
agent/auxiliary_client.py,hermes_cli/main.py,hermes_cli/runtime_provider.py,tests/hermes_cli/test_update_autostash.pyShow the diff
-
agent/auxiliary_client.py@@ -654,16 +654,20 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: if not token: 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 try: from hermes_cli.config import load_config cfg = load_config() model_cfg = cfg.get("model") if isinstance(model_cfg, dict): - cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") - if cfg_base_url: - base_url = cfg_base_url + cfg_provider = str(model_cfg.get("provider") or "").strip().lower() + if cfg_provider == "anthropic": + cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") + if cfg_base_url: + base_url = cfg_base_url except Exception: pass -
hermes_cli/main.py@@ -2559,12 +2559,29 @@ def _restore_stashed_changes( capture_output=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.") if restore.stdout.strip(): print(restore.stdout.strip()) if 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(f"Resolve manually with: git stash apply {stash_ref}") sys.exit(1) -
hermes_cli/runtime_provider.py@@ -359,9 +359,14 @@ def resolve_runtime_provider( "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " "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() - 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" return { "provider": "anthropic", -
tests/hermes_cli/test_update_autostash.py@@ -68,6 +68,8 @@ 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="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: @@ -81,8 +83,9 @@ def fake_run(cmd, **kwargs): assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] - assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] - assert calls[2][0] == ["git", "stash", "drop", "stash@{1}"] + assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"] + assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"] + assert calls[3][0] == ["git", "stash", "drop", "stash@{1}"] out = capsys.readouterr().out assert "Restore local changes now? [Y/n]" in out assert "restored on top of the updated codebase" in out @@ -117,6 +120,8 @@ 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="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: @@ -129,8 +134,9 @@ def fake_run(cmd, **kwargs): assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] - assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] - assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] + assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"] + 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 @@ -152,6 +158,8 @@ 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="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") @@ -161,10 +169,9 @@ def fake_run(cmd, **kwargs): restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) assert restored is True - assert calls == [ - (["git", "stash", "apply", "abc123"], {"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[0] == (["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}) + assert calls[2] == (["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True}) out = capsys.readouterr().out assert "couldn't find the stash entry to drop" in out assert "stash was left in place" in out @@ -181,6 +188,8 @@ 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="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: @@ -192,7 +201,7 @@ def fake_run(cmd, **kwargs): restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) 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 assert "couldn't drop the saved stash entry" in out assert "drop failed" in out @@ -208,6 +217,10 @@ def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: 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}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) @@ -219,7 +232,34 @@ def fake_run(cmd, **kwargs): out = capsys.readouterr().out assert "Your changes are still preserved in git stash." 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):
-
4 PRs closed without merging
From the close comments: merged via another change 2 · other 2
-
#2100 Fix: Resolve Minimax 401 Auth error by defaulting to anthropic_messages · merged via another change →
#2103 -
#2375 fix: MiniMax (/anthropic) auth gets overridden by Anthropic token refr · merged via another change →
#2388 - #2377 fix(auth): gate Anthropic token refresh to native Anthropic endpoints only · other
- #2396 fix(auth): gate Anthropic token refresh to native Anthropic endpoints only · other
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab