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
460 changes: 460 additions & 0 deletions agent/auth.py

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1956,7 +1956,21 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if not api_key:
continue

raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
# ── Unified resolver ──────────────────────────────────────
# Delegate to agent.auth.resolve_provider_credentials() — the
# SINGLE source of truth for provider-specific credential
# resolution. Inline import avoids a circular dep: agent/ is
# imported by both CLI and Gateway, so a module-level import
# would cycle through hermes_cli.
from agent.auth import resolve_provider_credentials as _resolve
from hermes_cli.runtime_provider import _get_model_config
_resolved: "ResolvedCredential" = _resolve(
provider=provider_id,
entry=entry,
model_cfg=_get_model_config(),
)
# ResolvedCredential fields consumed here: base_url only.
raw_base_url: str = _resolved.base_url
base_url = _to_openai_base_url(raw_base_url)
model = _get_aux_model_for_provider(provider_id) or None
if model is None:
Expand Down
74 changes: 71 additions & 3 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def auth_add_command(args) -> None:
priority=0,
source=SOURCE_MANUAL,
access_token=token,
base_url=_provider_base_url(provider),
base_url=(getattr(args, "base_url", None) or "").strip() or _provider_base_url(provider),
)
pool.add_entry(entry)
print(f'Added {provider} credential #{len(pool.entries())}: "{label}"')
Expand Down Expand Up @@ -434,6 +434,68 @@ def auth_add_command(args) -> None:
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")


def _short_endpoint(base_url: str, provider: str = "") -> str:
"""Compact endpoint tag for auth list display.

Maps known multi-endpoint base URLs to short tags (coding, anthropic,
zai-cn, etc.). For single-endpoint providers where the URL matches the
PROVIDER_REGISTRY default, returns empty string (no noise). Falls back
to hostname for custom URLs.
"""
url = (base_url or "").strip().rstrip("/")
if not url:
return ""

# ── Multi-endpoint providers (URL differs from registry default) ──
_MULTI_ENDPOINT_TAGS: Dict[str, str] = {
# Z.AI — 6 known endpoints
"https://api.z.ai/api/coding/paas/v4": "coding",
"https://open.bigmodel.cn/api/coding/paas/v4": "coding-cn",
"https://api.z.ai/api/paas/v4": "zai",
"https://open.bigmodel.cn/api/paas/v4": "zai-cn",
"https://api.z.ai/api/anthropic": "zai-anthropic",
"https://open.bigmodel.cn/api/anthropic": "zai-anthropic-cn",
# MiniMax — 2 regions
"https://api.minimax.io/anthropic": "minimax",
"https://api.minimaxi.com/anthropic": "minimax-cn",
# Kimi — 2 regions
"https://api.moonshot.ai/v1": "kimi",
"https://api.moonshot.cn/v1": "kimi-cn",
# Alibaba — standard + coding
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1": "alibaba",
"https://coding-intl.dashscope.aliyuncs.com/v1": "alibaba-coding",
# LM Studio — localhost variants
"http://127.0.0.1:1234/v1": "lmstudio",
"http://localhost:1234/v1": "lmstudio",
# Copilot ACP
"acp://copilot": "copilot-acp",
}

# Check multi-endpoint tags first
tag = _MULTI_ENDPOINT_TAGS.get(url)
if tag:
return tag

# If URL matches the provider's registry default, don't show it
if provider:
try:
pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig and pconfig.inference_base_url:
default = pconfig.inference_base_url.rstrip("/")
if url == default:
return ""
except Exception:
pass

# Custom URL — extract hostname
from urllib.parse import urlparse
hostname = urlparse(url).hostname or ""
if hostname:
hostname = hostname.replace("api.", "").replace("www.", "")
return hostname[:12] + "..." if len(hostname) > 12 else hostname
return url[:12] + "..." if len(url) > 12 else url


def auth_list_command(args) -> None:
provider_filter = _normalize_provider(getattr(args, "provider", "") or "")
if provider_filter:
Expand All @@ -450,14 +512,20 @@ def auth_list_command(args) -> None:
if not entries:
continue
current = pool.peek()
print(f"{provider} ({len(entries)} credentials):")
strategy = get_pool_strategy(provider)
header_strategy = f", strategy: {strategy}" if strategy else ""
print(f"{provider} ({len(entries)} credentials{header_strategy}):")
for idx, entry in enumerate(entries, start=1):
marker = " "
if current is not None and entry.id == current.id:
marker = "← "
status = _format_exhausted_status(entry)
source = _display_source(entry.source)
print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip())
ep = _short_endpoint(getattr(entry, "base_url", None) or "", provider=provider)
ep_str = f" {ep}" if ep else ""
shown_label = entry.label[:12]
idx_str = f"#{idx:>2}"
print(f" {idx_str} {shown_label:<12} {entry.auth_type} {source}{ep_str}{status} {marker}".rstrip())
print()


Expand Down
136 changes: 23 additions & 113 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,120 +412,30 @@ def _resolve_runtime_from_pool_entry(
# opencode-zen /v1 to be stripped for chat_completions requests when
# config.default was still a Claude model.
effective_model = (target_model or model_cfg.get("default") or "")
base_url = (getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or "").rstrip("/")
api_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
api_mode = "chat_completions"
if provider == "openai-codex":
api_mode = "codex_responses"
base_url = base_url or DEFAULT_CODEX_BASE_URL
elif provider == "xai-oauth":
api_mode = "codex_responses"
base_url = base_url or DEFAULT_XAI_OAUTH_BASE_URL
elif provider == "qwen-oauth":
api_mode = "chat_completions"
base_url = base_url or DEFAULT_QWEN_BASE_URL
elif provider == "minimax-oauth":
# MiniMax OAuth tokens are valid only against the Anthropic Messages
# compatible endpoint. Do not honor stale model.api_mode values from a
# prior OpenAI-compatible provider, or the client will hit
# /chat/completions under /anthropic and receive a bare nginx 404.
api_mode = "anthropic_messages"
pconfig = PROVIDER_REGISTRY.get(provider)
base_url = base_url or (pconfig.inference_base_url if pconfig else "")
elif provider == "anthropic":
api_mode = "anthropic_messages"
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
cfg_base_url = ""
if cfg_provider == "anthropic":
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
if not _anthropic_base_url_override_ok(cfg_base_url):
cfg_base_url = ""
base_url = cfg_base_url or base_url or "https://api.anthropic.com"
elif provider == "openrouter":
base_url = base_url or OPENROUTER_BASE_URL
elif provider == "xai":
api_mode = "codex_responses"
elif provider == "nous":
api_mode = "chat_completions"
base_url = _nous_inference_base_url_override() or base_url
elif provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url
elif provider == "azure-foundry":
# Azure Foundry: read api_mode and base_url from config
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "azure-foundry":
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode:
api_mode = configured_mode
# Model-family inference for GPT-5.x / codex / o1-o4: Azure rejects
# /chat/completions on these with 400 "operation unsupported" — see
# azure_foundry_model_api_mode() for rationale. Skip when the user
# explicitly picked anthropic_messages (Anthropic-style endpoint).
if effective_model and api_mode != "anthropic_messages":
try:
from hermes_cli.models import azure_foundry_model_api_mode

inferred = azure_foundry_model_api_mode(effective_model)
except Exception:
inferred = None
if inferred:
api_mode = inferred
# For Anthropic-style endpoints, strip /v1 suffix
if api_mode == "anthropic_messages":
base_url = re.sub(r"/v1/?$", "", base_url)
else:
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
# Honour model.base_url from config.yaml when the configured provider
# matches this provider — same pattern as the Anthropic branch above.
# Only override when the pool entry has no explicit base_url (i.e. it
# fell back to the hardcoded default). Env var overrides win (#6039).
pconfig = PROVIDER_REGISTRY.get(provider)
pool_url_is_default = pconfig and base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/")
if configured_provider == provider and pool_url_is_default:
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if provider in {"opencode-zen", "opencode-go"}:
# Re-derive api_mode from the effective model rather than the
# persisted api_mode: the opencode providers serve both
# anthropic_messages and chat_completions models, so the previous
# session's mode must not leak across /model switches.
# Refs #16878.
from hermes_cli.models import opencode_model_api_mode
api_mode = opencode_model_api_mode(provider, effective_model)
elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
api_mode = configured_mode
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
# Kimi /coding, api.openai.com → codex_responses, api.x.ai →
# codex_responses).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected

# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Normalize
# symmetrically: strip /v1 for anthropic_messages, re-append it for
# chat_completions / codex_responses (heals a stripped URL persisted to
# model.base_url by an earlier switch into an anthropic-routed model).
if provider in {"opencode-zen", "opencode-go"}:
from hermes_cli.models import normalize_opencode_base_url

base_url = normalize_opencode_base_url(provider, api_mode, base_url)

# Optional opt-in: route OpenAI/Codex turns through `codex app-server`.
# Inert when `model.openai_runtime` is unset or "auto".
api_mode = _maybe_apply_codex_app_server_runtime(
provider=provider, api_mode=api_mode, model_cfg=model_cfg
)

if provider == "lmstudio":
base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url)
# ── Unified resolver ──────────────────────────────────────────────
# Delegate to agent.auth.resolve_provider_credentials() — the SINGLE
# source of truth for provider-specific credential resolution. This
# replaces ~120 lines of inline if/elif branches that used to be
# duplicated between this file and agent/auxiliary_client.py, and
# keeps the Gateway/Desktop path and the CLI (auxiliary) path in
# lock-step on the SSRF guard, precedence chain, and /v1 stripping.
#
# The inline ``from agent.auth import ...`` is intentional: agent/
# is imported by both the CLI and the Gateway, and hoisting the
# import to module scope would create a cycle through hermes_cli.
from agent.auth import resolve_provider_credentials as _resolve
_resolved: "ResolvedCredential" = _resolve(
provider=provider,
entry=entry,
model_cfg=model_cfg,
target_model=target_model,
)
# ResolvedCredential fields consumed here: api_key, base_url, api_mode.
# (source / expires_at / extras are dropped — downstream doesn't need them.)
base_url: str = _resolved.base_url
api_key: str = _resolved.api_key
api_mode: str = _resolved.api_mode

return {
"provider": provider,
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/subcommands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
auth_add.add_argument(
"--api-key", help="API key value (otherwise prompted securely)"
)
auth_add.add_argument(
"--base-url",
dest="base_url",
help="Override the inference base URL for this credential "
"(e.g. https://api.z.ai/api/coding/paas/v4 for Z.AI Coding Plan keys)",
)
auth_add.add_argument("--portal-url", help="Nous portal base URL")
auth_add.add_argument("--inference-url", help="Nous inference base URL")
auth_add.add_argument("--client-id", help="OAuth client id")
Expand Down
Loading