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
57 changes: 56 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3327,6 +3327,49 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
return CodexAuxiliaryClient(real_client, model), model


def _build_minimax_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
"""Build an Anthropic auxiliary client for MiniMax OAuth."""
if not model:
logger.warning(
"Auxiliary client: minimax-oauth requested without a model; "
"pass model explicitly (auxiliary.<task>.model in config.yaml)."
)
return None, None
try:
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
except ImportError:
logger.debug("hermes_cli.auth not available for minimax-oauth")
return None, None
try:
creds = resolve_minimax_oauth_runtime_credentials(as_token_provider=True)
except Exception as exc:
logger.warning(
"resolve_provider_client: minimax-oauth requested but no valid "
"MiniMax OAuth token found (run: hermes model -> MiniMax OAuth): %s",
exc,
)
return None, None
api_key = creds["api_key"]
base_url = creds["base_url"].rstrip("/")
logger.debug("Auxiliary client: MiniMax OAuth (%s via Anthropic API)", model)
try:
from agent.anthropic_adapter import build_anthropic_client
real_client = build_anthropic_client(api_key, base_url)
except ImportError as exc:
logger.warning(
"resolve_provider_client: minimax-oauth requested but the anthropic "
"SDK is not installed: %s", exc,
)
return None, None
except Exception as exc:
logger.warning(
"resolve_provider_client: minimax-oauth failed to build Anthropic "
"client: %s", exc,
)
return None, None
return AnthropicAuxiliaryClient(real_client, model, api_key, base_url, is_oauth=True), model


def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
"""Build a CodexAuxiliaryClient for an explicitly-requested model.

Expand Down Expand Up @@ -5959,6 +6002,16 @@ 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))

# MiniMax OAuth uses the Anthropic-compatible Messages endpoint and a
# refreshable bearer token supplied by the runtime credential resolver.
if provider == "minimax-oauth":
client, default = _build_minimax_oauth_aux_client(model)
if client is None:
return None, None
final_model = _normalize_resolved_model(model or default, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))

# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
if provider == "custom":
custom_base = ""
Expand Down Expand Up @@ -6454,14 +6507,16 @@ 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":
return resolve_provider_client("minimax-oauth", model, async_mode)
# Other OAuth providers not directly supported
if provider not in _LOGGED_UNSUPPORTED_OAUTH_KEYS:
_LOGGED_UNSUPPORTED_OAUTH_KEYS.add(provider)
Expand Down
39 changes: 33 additions & 6 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3865,6 +3865,27 @@ def _collect_query_images(query: str | None, image_arg: str | None = None) -> tu
return message, deduped




def _history_navigation_action(buffer, direction):
"""Return ``history`` only for a genuinely empty buffer.

A non-empty buffer must never invoke prompt_toolkit's ``auto_up`` /
``auto_down`` because a visually wrapped single logical line can otherwise
recall command history instead of moving the cursor. Whitespace-only input
is treated as empty by contract.
"""
try:
text = getattr(buffer, "text", "") or ""
except Exception:
text = ""
if text.strip() and direction in ("up", "down"):
return "cursor"
if direction not in ("up", "down"):
return "cursor"
return "history"


# Strip OSC escape sequences (e.g. OSC-8 hyperlinks) that prompt_toolkit's
# ANSI parser can't handle — it strips \x1b but passes the payload through
# as literal text, garbling the TUI output.
Expand Down Expand Up @@ -15877,8 +15898,8 @@ def _recall_without_recollapse(buf, move):
"""Run a history-navigation move, suppressing paste-collapse.

Recalled history can hold the full text of a paste that was
collapsed to a placeholder at submit time. Loading it back into the
buffer looks exactly like a fresh large paste to ``_on_text_changed``
collapsed to a placeholder at submit time. Loading it back into
the buffer looks exactly like a fresh large paste to ``_on_text_changed``
and would be re-collapsed. Set the skip flag around the move; if the
move didn't change the text (plain cursor movement), clear the flag
so a later real paste still collapses.
Expand All @@ -15891,15 +15912,21 @@ def _recall_without_recollapse(buf, move):

@kb.add('up', filter=_normal_input)
def history_up(event):
"""Up arrow: browse history when on first line, else move cursor up."""
"""Up arrow: browse history only with an empty buffer."""
buf = event.app.current_buffer
_recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg))
if _history_navigation_action(buf, "up") == "history":
_recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg))
else:
buf.cursor_up()

@kb.add('down', filter=_normal_input)
def history_down(event):
"""Down arrow: browse history when on last line, else move cursor down."""
"""Down arrow: browse history only with an empty buffer."""
buf = event.app.current_buffer
_recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg))
if _history_navigation_action(buf, "down") == "history":
_recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg))
else:
buf.cursor_down()

@kb.add('c-l')
def handle_ctrl_l(event):
Expand Down
94 changes: 91 additions & 3 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5954,6 +5954,34 @@ def _bind_api_server_session(
cron_session="",
)

async def _prune_failed_session_if_empty(
self,
session_id: Optional[str],
*,
request_profile: Optional[str],
) -> None:
"""Best-effort cleanup of an empty session left by a failed API turn."""
if not session_id:
return
try:
with self._profile_scope(request_profile):
from hermes_constants import get_hermes_home

sessions_dir = get_hermes_home() / "sessions"
db = await self._ensure_session_db_async()
if db is not None:
await asyncio.to_thread(
db.delete_session_if_empty,
session_id,
sessions_dir=sessions_dir,
)
except Exception:
logger.debug(
"Could not prune failed API session %s",
session_id,
exc_info=True,
)

async def _run_agent(
self,
user_message: str,
Expand Down Expand Up @@ -6175,7 +6203,25 @@ def _run():
self._activate_admitted_request()
self._inflight_agent_runs += 1
try:
return await loop.run_in_executor(None, _run)
result, usage = await loop.run_in_executor(None, _run)
if isinstance(result, dict) and result.get("failed"):
await self._prune_failed_session_if_empty(
result.get("session_id") or session_id,
request_profile=request_profile,
)
return result, usage
except asyncio.CancelledError:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
raise
except Exception:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
raise
finally:
self._inflight_agent_runs -= 1

Expand Down Expand Up @@ -6436,6 +6482,10 @@ async def _run_and_close():
try:
self._set_run_status(run_id, "running")
if run_id in self._stopping_run_ids:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
_put_event_if_active({
"event": "run.cancelled",
"run_id": run_id,
Expand Down Expand Up @@ -6561,6 +6611,10 @@ def _run_sync():

result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
if run_id in self._stopping_run_ids:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
_put_event_if_active({
"event": "run.cancelled",
"run_id": run_id,
Expand All @@ -6575,6 +6629,10 @@ def _run_sync():
# 401/400 return failed=True instead of raising, so the except
# block below never fires — issue #15561).
elif isinstance(result, dict) and result.get("failed"):
await self._prune_failed_session_if_empty(
result.get("session_id") or session_id,
request_profile=request_profile,
)
error_msg = _redact_api_error_text(result.get("error") or "agent run failed")
_put_event_if_active({
"event": "run.failed",
Expand All @@ -6588,8 +6646,8 @@ def _run_sync():
error=error_msg,
last_event="run.failed",
)
else:
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
elif isinstance(result, dict) and "final_response" in result:
final_response = result.get("final_response", "")
_put_event_if_active({
"event": "run.completed",
"run_id": run_id,
Expand All @@ -6604,7 +6662,29 @@ def _run_sync():
usage=usage,
last_event="run.completed",
)
else:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
error_msg = "agent returned a malformed result"
_put_event_if_active({
"event": "run.failed",
"run_id": run_id,
"timestamp": time.time(),
"error": error_msg,
})
self._set_run_status(
run_id,
"failed",
error=error_msg,
last_event="run.failed",
)
except asyncio.CancelledError:
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
self._set_run_status(
run_id,
"cancelled",
Expand All @@ -6628,6 +6708,10 @@ def _run_sync():
# failure, instead of falling through to the generic
# except-Exception branch below.
logger.warning("Provider authentication failed for run=%s: %s", run_id, exc)
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
error_msg = f"⚠️ Provider authentication failed: {exc}"
self._set_run_status(
run_id,
Expand All @@ -6646,6 +6730,10 @@ def _run_sync():
pass
except Exception as exc:
logger.exception("[api_server] run %s failed", run_id)
await self._prune_failed_session_if_empty(
session_id,
request_profile=request_profile,
)
self._set_run_status(
run_id,
"failed",
Expand Down
32 changes: 32 additions & 0 deletions tests/agent/test_auxiliary_minimax_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Regression tests for MiniMax OAuth auxiliary-client routing."""
from unittest.mock import MagicMock, patch


def test_minimax_oauth_builds_anthropic_auxiliary_client():
import agent.auxiliary_client as aux

token_provider = lambda: "fresh-token"
real_client = MagicMock(name="real_anthropic_client")

with patch(
"hermes_cli.auth.resolve_minimax_oauth_runtime_credentials",
return_value={
"provider": "minimax-oauth",
"api_key": token_provider,
"base_url": "https://api.minimax.io/anthropic",
"source": "oauth",
},
), patch(
"agent.anthropic_adapter.build_anthropic_client",
return_value=real_client,
) as build_client:
client, model = aux.resolve_provider_client(
"minimax-oauth", model="MiniMax-M3"
)

assert isinstance(client, aux.AnthropicAuxiliaryClient)
assert model == "MiniMax-M3"
assert client.base_url == "https://api.minimax.io/anthropic"
assert client.api_key is token_provider
build_client.assert_called_once()
assert build_client.call_args.args[0] is token_provider
Loading