Skip to content
Open
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
83 changes: 82 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1975,6 +1975,72 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
return CodexAuxiliaryClient(real_client, model), model


def _build_minimax_oauth_aux_client(
model: str,
*,
explicit_base_url: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Build an ``AnthropicAuxiliaryClient`` for a MiniMax OAuth session.

MiniMax's inference endpoint is Anthropic-API compatible
(``POST {base}/v1/messages`` with bearer auth, ``x-api-key`` header),
so the same wrapper used for native Anthropic works here. The token
is installed as a per-request callable so the 15-minute MiniMax
access-token expiry is auto-refreshed by ``build_anthropic_client``'s
bearer-hook machinery — see ``build_minimax_oauth_token_provider`` in
``hermes_cli.auth`` for the refresh path.

The default model is the one registered on the provider profile
(``MiniMax-M2.7`` per ``plugins/model-providers/minimax/__init__.py``);
the caller passes the resolved model (from main model fallback or
``auxiliary.<task>.model``). Returns ``(None, None)`` when the user
is not logged in via MiniMax OAuth.
"""
from agent.anthropic_adapter import build_anthropic_client
from typing import cast
try:
from hermes_cli.auth import build_minimax_oauth_token_provider
except ImportError:
logger.warning(
"Auxiliary client: hermes_cli.auth unavailable, "
"cannot build MiniMax OAuth client"
)
return None, None
try:
token_provider = build_minimax_oauth_token_provider()
except Exception as exc: # noqa: BLE001 — auth not logged in or refresh failed
logger.debug("Auxiliary client: MiniMax OAuth token provider unavailable: %s", exc)
return None, None
base_url = (explicit_base_url or "").rstrip("/") or "https://api.minimax.io/anthropic"
if not model:
logger.debug(
"Auxiliary client: minimax-oauth requested without a model; "
"will fall back to provider default or main model"
)
# Caller did not pass a model — the universal resolver above has
# already populated ``final_model`` with the main-model fallback
# before reaching here, so an empty string only happens on a
# direct call to this builder. Return None and let the resolver
# log the warning.
return None, None
try:
# ``build_anthropic_client`` accepts either a ``str`` api_key or a
# ``Callable[[], str]`` — the latter is routed through
# ``_build_anthropic_client_with_bearer_hook`` which mints a fresh
# ``Authorization`` header on every outbound request, exactly what
# we need for MiniMax's 15-minute access-token TTL. The static
# type signature only says ``str``; cast is safe per the runtime
# contract documented in ``build_minimax_oauth_token_provider``.
real_client = build_anthropic_client(cast(str, token_provider), base_url)
except Exception as exc: # noqa: BLE001
logger.warning(
"Auxiliary client: build_anthropic_client failed for minimax-oauth: %s", exc
)
return None, None
logger.debug("Auxiliary client: MiniMax OAuth (%s) at %s", model, base_url)
return AnthropicAuxiliaryClient(real_client, model, token_provider, base_url), model


def _try_azure_foundry(
*,
model: Optional[str] = None,
Expand Down Expand Up @@ -3885,14 +3951,29 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))

elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
elif pconfig.auth_type in {"oauth_device_code", "oauth_external", "oauth_minimax"}:
# OAuth providers — route through their specific try functions
if provider == "nous":
return resolve_provider_client("nous", model, async_mode)
if provider == "openai-codex":
return resolve_provider_client("openai-codex", model, async_mode)
if provider == "xai-oauth":
return resolve_provider_client("xai-oauth", model, async_mode)
if provider == "minimax-oauth":
# MiniMax OAuth: Anthropic-compatible inference endpoint.
# Resolve the model once (caller-supplied, then main-model
# fallback) before handing to the builder.
if not model:
model = _read_main_model()
client, resolved = _build_minimax_oauth_aux_client(
model or "",
explicit_base_url=pconfig.inference_base_url,
)
if client is None:
return None, None
final_model = resolved or model or "MiniMax-M2.7"
return (_to_async_client(client, final_model, is_vision=False) if async_mode
else (client, final_model))
# Other OAuth providers not directly supported
logger.warning("resolve_provider_client: OAuth provider %s not "
"directly supported, try 'auto'", provider)
Expand Down
69 changes: 69 additions & 0 deletions docs/diagnosis/aux-title-minimax-401.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Auxiliary client: minimax-oauth returns (None, None) → HTTP 401

**Symptom:** At end of every Hermes CLI session when the main provider is
`minimax-oauth` (MiniMax M-series via browser OAuth):

⚠ Auxiliary title generation failed: HTTP 401: Invalid Authentication

Same root cause breaks `kanban specify` (issue #36091) and any other
auxiliary task that does not pin a fallback provider.

## Root cause

`hermes_cli/auth.py:301` declares MiniMax OAuth with
`auth_type="oauth_minimax"`. `agent/auxiliary_client.py` dispatches on
`pconfig.auth_type`, but the OAuth provider branch at line ~3950 was gated
on `{"oauth_device_code", "oauth_external"}` — `"oauth_minimax"` was not
in the set. The call therefore fell through to the generic warning at
the bottom of the resolver and returned `(None, None)`. Auxiliary
callers (title generation, compression, vision, session_search, …) then
raise "No LLM provider configured", which surfaces to the user as HTTP
401 because the credential-pool seeder is the one that prints that line
once the underlying request fails.

## Fix

1. Add `"oauth_minimax"` to the OAuth provider dispatch gate.
2. Add a `minimax-oauth` case in the provider switch that delegates to a
new builder, `_build_minimax_oauth_aux_client()`.
3. The builder mirrors `_try_anthropic()` (MiniMax's inference endpoint
is Anthropic-API compatible) but installs the per-request
`build_minimax_oauth_token_provider()` callable as `api_key` so
MiniMax's 15-minute access-token TTL is auto-refreshed on every
outbound request. The token-provider pattern is the same one
`agent/agent_runtime_helpers.py:1460` uses for the main runtime.

## Why not the simpler fix (re-entrantly recurse from the generic branch)?

We could just `return resolve_provider_client("minimax-oauth", ...)` and
let the OAuth branch handle the dispatch, but the universal model
resolver chain (Step 2: catalog default, Step 3: main model) has
already executed by the time we reach the dispatch. Re-entering from
the generic branch would re-run that chain and might re-pick the wrong
model. A direct call to the builder is cleaner and easier to test.

## Tests

Four new tests in `tests/agent/test_auxiliary_client.py::TestMiniMaxOAuthAuxiliaryClient`:

* `test_resolve_minimax_oauth_dispatches_to_anthropic_compat` — happy
path, asserts the builder is called with the right `inference_base_url`.
* `test_resolve_minimax_oauth_uses_main_model_when_empty` — universal
fallback chain (Step 3 main-model) still works when no model is
passed and there is no catalog default.
* `test_resolve_minimax_oauth_returns_none_when_not_logged_in` —
swallowed `AuthError` returns `(None, None)` so the chain falls
through to its next provider.
* `test_builder_uses_anthropic_sdk_not_openai` — regression guard
for the previous fix attempt (PR #35539) that wrapped an OpenAI
client in `AnthropicAuxiliaryClient` and crashed at runtime.

## Caveats

* The fix makes `minimax-oauth` work for ALL auxiliary tasks, not just
title generation. The default model is the one registered on the
provider profile (`MiniMax-M2.7` per
`plugins/model-providers/minimax/__init__.py`).
* `AnthropicAuxiliaryClient` accepts a callable `api_key` (the
bearer-hook machinery in `agent/anthropic_adapter.py` calls it
per-request), so no upstream changes to the wrapper are needed.
145 changes: 145 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3586,3 +3586,148 @@ def test_call_llm_marks_provider_unhealthy_on_402(self, monkeypatch):
)
# After the 402, OpenRouter is in the unhealthy cache.
assert _is_provider_unhealthy("openrouter") is True


class TestMiniMaxOAuthAuxiliaryClient:
"""Auxiliary client must build successfully for ``minimax-oauth`` and
reuse the same Anthropic-compatible inference path as the main runtime.

Pre-fix the OAuth dispatch gate at line ~3950 was
``{"oauth_device_code", "oauth_external"}``, so the new ``oauth_minimax``
auth_type declared in ``hermes_cli/auth.py`` fell through to the generic
"unhandled auth_type" warning and returned ``(None, None)`` — causing
title generation (and every other auxiliary task) to fail with HTTP 401
at the end of every session (#21521).
"""

@staticmethod
def _make_pconfig():
"""Build a real ProviderConfig for minimax-oauth matching the one
declared in ``hermes_cli/auth.py``."""
from hermes_cli.auth import ProviderConfig
return ProviderConfig(
id="minimax-oauth",
name="MiniMax (OAuth \u00b7 minimax.io)",
auth_type="oauth_minimax",
inference_base_url="https://api.minimax.io/anthropic",
)

@staticmethod
def _install_registry(monkeypatch, pconfig):
"""Patch the lazy-imported PROVIDER_REGISTRY inside hermes_cli.auth
so resolve_provider_client sees our minimax-oauth entry."""
from collections import OrderedDict
monkeypatch.setattr(
"hermes_cli.auth.PROVIDER_REGISTRY",
OrderedDict({"minimax-oauth": pconfig}),
)

def test_resolve_minimax_oauth_dispatches_to_anthropic_compat(self, monkeypatch):
"""resolve_provider_client("minimax-oauth", "MiniMax-M2.7") must
return a non-None client, not the (None, None) the pre-fix code
produced."""
from agent.auxiliary_client import resolve_provider_client
self._install_registry(monkeypatch, self._make_pconfig())
aux_client = MagicMock()
with patch("agent.auxiliary_client._build_minimax_oauth_aux_client",
return_value=(aux_client, "MiniMax-M2.7")) as mock_build:
client, model = resolve_provider_client(
"minimax-oauth", "MiniMax-M2.7"
)
assert client is aux_client, (
"resolve_provider_client must return a real client for "
"minimax-oauth, not (None, None). Pre-fix, the OAuth dispatch "
"gate excluded 'oauth_minimax' so the call fell through to the "
"generic 'unhandled auth_type' warning."
)
assert model == "MiniMax-M2.7"
mock_build.assert_called_once()
# explicit_base_url must come from pconfig.inference_base_url so
# the Anthropic client targets the MiniMax gateway, not native
# Anthropic.
call_kwargs = mock_build.call_args.kwargs
assert call_kwargs["explicit_base_url"] == "https://api.minimax.io/anthropic"

def test_resolve_minimax_oauth_uses_main_model_when_empty(self, monkeypatch):
"""When the caller passes no model AND there is no catalog default
for the provider, resolve_provider_client should fall back to the
main model — symmetric with xai-oauth / openai-codex (Step 3 of
the universal fallback chain documented at line ~3330)."""
from agent.auxiliary_client import resolve_provider_client
self._install_registry(monkeypatch, self._make_pconfig())
aux_client = MagicMock()
with (
# No catalog default for the minimax-oauth provider — this
# is the case where Step 3 (main model) kicks in.
patch("agent.auxiliary_client._get_aux_model_for_provider",
return_value=""),
patch("agent.auxiliary_client._read_main_model",
return_value="MiniMax-M3"),
patch("agent.auxiliary_client._build_minimax_oauth_aux_client",
return_value=(aux_client, "MiniMax-M3")) as mock_build,
):
client, model = resolve_provider_client("minimax-oauth", "")
assert client is aux_client
assert model == "MiniMax-M3"
# The builder must receive the resolved model — not the empty
# string the caller passed.
assert mock_build.call_args.args[0] == "MiniMax-M3"

def test_resolve_minimax_oauth_returns_none_when_not_logged_in(self, monkeypatch):
"""When the user is not logged in via MiniMax OAuth, the builder
raises (see build_minimax_oauth_token_provider → AuthError). The
resolver should swallow that and return (None, None) so the chain
falls through to its next provider — not crash, not 401 the
user."""
from agent.auxiliary_client import resolve_provider_client
self._install_registry(monkeypatch, self._make_pconfig())
with patch("agent.auxiliary_client._build_minimax_oauth_aux_client",
return_value=(None, None)):
client, model = resolve_provider_client(
"minimax-oauth", "MiniMax-M2.7"
)
assert client is None
assert model is None

def test_builder_uses_anthropic_sdk_not_openai(self, monkeypatch):
"""Regression guard for the previous fix attempt (PR #35539) which
wrapped an OpenAI client in AnthropicAuxiliaryClient — that
crashes at runtime with AttributeError because
AnthropicAuxiliaryClient calls self._client.messages.create()
(Anthropic SDK interface). MiniMax's inference is Anthropic-API
compatible, so the builder must call build_anthropic_client."""
from agent import auxiliary_client
captured = {}

def fake_build(token, base_url):
captured["token"] = token
captured["base_url"] = base_url
return MagicMock(name="anthropic_sdk_client")

token_provider = lambda: "fake-oauth-token"
# The function imports build_anthropic_client lazily from
# agent.anthropic_adapter — patch it at the source module so the
# ``from agent.anthropic_adapter import build_anthropic_client``
# inside the function picks up our fake.
monkeypatch.setattr(
"agent.anthropic_adapter.build_anthropic_client", fake_build
)
monkeypatch.setattr(
"hermes_cli.auth.build_minimax_oauth_token_provider",
lambda: token_provider,
)
client, model = auxiliary_client._build_minimax_oauth_aux_client(
"MiniMax-M2.7",
explicit_base_url="https://api.minimax.io/anthropic",
)
assert client is not None
# The base URL must reach build_anthropic_client unchanged — proves
# we didn't accidentally swap in native Anthropic.
assert captured["base_url"] == "https://api.minimax.io/anthropic"
# The token provider (callable, not string) is what gives us
# per-request refresh for MiniMax's 15-min TTL.
assert callable(captured["token"])
# Returned client must be wrapped in AnthropicAuxiliaryClient —
# that is the sanity check on the builder's output type.
assert isinstance(client, auxiliary_client.AnthropicAuxiliaryClient)
assert model == "MiniMax-M2.7"