Merge branch 'simp/r3-16-C' into simp/r3-16

This commit is contained in:
Teknium
2026-09-02 22:34:36 -07:00
12 changed files with 804 additions and 1834 deletions
+9 -31
View File
@@ -27,7 +27,6 @@ class ApprovalModeResult:
def _effective_mode() -> str:
"""Return the exact mode enforced by the terminal approval guard."""
from tools.approval import _get_approval_mode
return _get_approval_mode()
@@ -37,26 +36,15 @@ def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResu
requested = (requested_mode or "").strip().lower()
if not requested:
return ApprovalModeResult(
True,
current,
False,
f"Approval mode: {current} (persistent profile setting).",
)
return ApprovalModeResult(True, current, False, f"Approval mode: {current} (persistent profile setting).")
if requested not in VALID_APPROVAL_MODES:
return ApprovalModeResult(
False,
current,
False,
"Usage: /approvals [manual|smart|off]",
)
return ApprovalModeResult(False, current, False, "Usage: /approvals [manual|smart|off]")
# set_config_value is the canonical managed-scope/write-safety chokepoint.
# It reports managed policy through stderr + SystemExit, and the fail-closed
# write guard raises RuntimeError on an unparseable config.yaml; capture both
# for slash-command output instead of terminating the interactive worker.
# set_config_value is the canonical managed-scope/write-safety chokepoint. It reports managed
# policy through stderr + SystemExit, and the fail-closed write guard raises RuntimeError on an
# unparseable config.yaml; capture both for slash-command output instead of terminating the
# interactive worker.
from hermes_cli.config import set_config_value
output = StringIO()
try:
with redirect_stdout(output), redirect_stderr(output):
@@ -65,24 +53,14 @@ def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResu
detail = output.getvalue().strip() or "Approval mode is managed and cannot be changed."
return ApprovalModeResult(False, current, False, detail)
except Exception as exc:
return ApprovalModeResult(
False,
current,
False,
f"Failed to save approval mode: {exc}",
)
return ApprovalModeResult(False, current, False, f"Failed to save approval mode: {exc}")
effective = _effective_mode()
if effective != requested:
return ApprovalModeResult(
False,
effective,
False,
False, effective, False,
f"Approval mode remains {effective}; the requested value did not become effective.",
)
return ApprovalModeResult(
True,
effective,
effective != current,
f"Approval mode: {effective} (persistent profile setting).",
True, effective, effective != current, f"Approval mode: {effective} (persistent profile setting).",
)
+17 -36
View File
@@ -25,9 +25,7 @@ _MAX_ACTIVE_TRANSPORT_WORKERS = 8
_transport_worker_slots = threading.BoundedSemaphore(_MAX_ACTIVE_TRANSPORT_WORKERS)
ApprovalChoice = Literal["once", "session", "always", "deny"]
ApprovalPresentFn = Callable[
["ApprovalRequest"], "ApprovalDecision | Awaitable[ApprovalDecision]"
]
ApprovalPresentFn = Callable[["ApprovalRequest"], "ApprovalDecision | Awaitable[ApprovalDecision]"]
@dataclass(frozen=True)
@@ -56,16 +54,8 @@ class ApprovalRequest:
@classmethod
def create(
cls,
*,
command: str,
description: str,
pattern_key: str,
pattern_keys: tuple[str, ...],
session_key: str,
surface: str,
allow_session: bool,
allow_permanent: bool,
cls, *, command: str, description: str, pattern_key: str, pattern_keys: tuple[str, ...],
session_key: str, surface: str, allow_session: bool, allow_permanent: bool,
timeout_seconds: float = 300,
) -> "ApprovalRequest":
choices: list[ApprovalChoice] = ["once"]
@@ -81,9 +71,7 @@ class ApprovalRequest:
)
canonical = json.dumps({**fields, "session_key": session_key}, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return cls(
**{**fields, "pattern_keys": pattern_keys, "allowed_choices": tuple(choices)}, digest=digest
)
return cls(**{**fields, "pattern_keys": pattern_keys, "allowed_choices": tuple(choices)}, digest=digest)
def respond(self, choice: ApprovalChoice | str) -> ApprovalDecision:
"""Build the correlated response a transport should return."""
@@ -108,13 +96,13 @@ class RegisteredApprovalTransport:
profile_home: str
def _deny(failure: str) -> ApprovalTransportResult:
return ApprovalTransportResult("deny", failure)
def invoke_approval_transport(
present: ApprovalPresentFn,
request: ApprovalRequest,
*,
timeout_seconds: float,
poll_interval: float = 1.0,
on_poll: Callable[[], None] | None = None,
present: ApprovalPresentFn, request: ApprovalRequest, *, timeout_seconds: float,
poll_interval: float = 1.0, on_poll: Callable[[], None] | None = None,
is_interrupted: Callable[[], bool] | None = None,
) -> ApprovalTransportResult:
"""Run a sync or async transport on a bounded daemon worker.
@@ -123,10 +111,9 @@ def invoke_approval_transport(
loop. A callback must return before the host timeout; late results are discarded and cannot
authorize another request.
"""
if not _transport_worker_slots.acquire(blocking=False):
logger.warning("Approval transport worker capacity exhausted")
return ApprovalTransportResult("deny", "busy")
return _deny("busy")
results: queue.Queue[tuple[str, object, float]] = queue.Queue(maxsize=1)
deadline = time.monotonic() + max(float(timeout_seconds), 0.0)
@@ -148,27 +135,23 @@ def invoke_approval_transport(
finally:
_transport_worker_slots.release()
worker = threading.Thread(
target=_run, name=f"approval-transport-{request.request_id[:8]}", daemon=True
)
worker = threading.Thread(target=_run, name=f"approval-transport-{request.request_id[:8]}", daemon=True)
try:
worker.start()
except BaseException:
_transport_worker_slots.release()
logger.warning("Could not start approval transport worker")
return ApprovalTransportResult("deny", "error")
return _deny("error")
while True:
if is_interrupted is not None and is_interrupted():
logger.info("Approval transport wait interrupted for %s", request.request_id)
return ApprovalTransportResult("deny", "interrupted")
return _deny("interrupted")
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning("Approval transport timed out for request %s", request.request_id)
return ApprovalTransportResult("deny", "timeout")
return _deny("timeout")
try:
kind, value, completed_at = results.get(
timeout=min(max(float(poll_interval), 0.001), remaining)
)
kind, value, completed_at = results.get(timeout=min(max(float(poll_interval), 0.001), remaining))
break
except queue.Empty:
if on_poll is not None:
@@ -178,9 +161,7 @@ def invoke_approval_transport(
logger.debug("Approval transport poll callback failed", exc_info=True)
failure = _validate_decision(kind, value, completed_at, deadline, request)
if failure is not None:
return ApprovalTransportResult("deny", failure)
return ApprovalTransportResult(value.choice)
return _deny(failure) if failure is not None else ApprovalTransportResult(value.choice)
def _validate_decision(kind, value, completed_at, deadline, request) -> str | None:
+70 -148
View File
@@ -2,12 +2,10 @@
Hermes has no dedicated approval-decision ledger: ``always`` answers land in ``command_allowlist``
(config.yaml) via :func:`tools.approval.save_permanent_allowlist`, while ``once``/``session``
approvals are in-memory only.
So this module mines *implied approvals*: a command that matches a dangerous-command class (the same
:func:`tools.approval.detect_dangerous_command` classifier that triggers the prompt) AND whose tool
result is not a block/denial marker must have been approved by the user (once, session, always,
smart-approve, or yolo) before it ran.
approvals are in-memory only. So this module mines *implied approvals*: a command that matches a
dangerous-command class (the same :func:`tools.approval.detect_dangerous_command` classifier that
triggers the prompt) AND whose tool result is not a block/denial marker must have been approved by
the user (once, session, always, smart-approve, or yolo) before it ran.
"""
from __future__ import annotations
@@ -25,54 +23,28 @@ from typing import Iterable, Iterator, Optional
# Safety exclusions
# ---------------------------------------------------------------------------
# Dangerous-class descriptions matching ANY of these are never proposed,
# regardless of approval frequency. Matched case-insensitively against the
# pattern-key/description strings produced by tools.approval's
# DANGEROUS_PATTERNS / execution-flag findings. Deliberately conservative:
# a benign class accidentally excluded costs the user one manual config edit;
# a destructive class accidentally proposed costs them data.
# Dangerous-class descriptions matching ANY of these are never proposed, regardless of approval
# frequency (matched case-insensitively against tools.approval's DANGEROUS_PATTERNS / execution-flag
# descriptions). Deliberately conservative: a benign class accidentally excluded costs the user
# one manual config edit; a destructive class accidentally proposed costs them data.
_UNSAFE_CLASS_PATTERNS = [
r"delete", # recursive delete, find -delete, branch force delete, ...
r"\brm\b", # xargs with rm, find -exec rm
r"destro", # git reset --hard (destroys ...), destructive
r"wipe",
r"format", # format filesystem
r"\bdisk\b",
r"block device",
r"fork bomb",
r"kill (?:all )?process", # force/regex/all process kills
r"kill all",
r"self-termination",
r"\bsudo\b",
r"privilege",
r"credential",
r"\bssh\b",
r"shell.rc",
r"system config",
r"system file",
r"\bsql\b", # SQL DROP / TRUNCATE / DELETE without WHERE
r"\bchown\b",
r"\bchmod\b",
r"writable", # world/other-writable permissions
r"overwrite",
r"in-place edit",
r"pipe", # pipe remote/decoded content to shell
r"obfuscation",
r"remote content",
r"remote script",
r"heredoc",
r"encoded", # PowerShell encoded command execution
r"command substitution",
r"process substitution",
r"\bdd\b",
r"shutdown",
r"reboot",
# deletion / destruction: recursive delete, find -delete, xargs rm, git reset --hard, mkfs, dd
r"delete", r"\brm\b", r"destro", r"wipe", r"format", r"\bdisk\b", r"block device", r"\bdd\b",
# process / host: fork bomb, force/regex/all kills, self-termination, shutdown
r"fork bomb", r"kill (?:all )?process", r"kill all", r"self-termination", r"shutdown", r"reboot",
# privilege / credentials / system files
r"\bsudo\b", r"privilege", r"credential", r"\bssh\b", r"shell.rc", r"system config", r"system file",
# data & permissions: SQL DROP/TRUNCATE/DELETE-without-WHERE, chown/chmod, world-writable, overwrite
r"\bsql\b", r"\bchown\b", r"\bchmod\b", r"writable", r"overwrite", r"in-place edit",
# remote/obfuscated execution: pipe-to-shell, encoded PowerShell, heredoc, substitutions
r"pipe", r"obfuscation", r"remote content", r"remote script", r"heredoc", r"encoded",
r"command substitution", r"process substitution",
r"hardline",
]
_UNSAFE_CLASS_RE = re.compile("|".join(_UNSAFE_CLASS_PATTERNS), re.IGNORECASE)
# Root binaries that must never anchor a proposed command glob, even if the
# class survived the description filter. Prefix match for the mkfs family.
# Root binaries that must never anchor a proposed command glob, even if the class survived the
# description filter. Prefix match for the mkfs family.
_UNSAFE_ROOT_BINARIES = {
"rm", "rmdir", "unlink", "shred", "dd", "fdisk", "parted", "wipefs",
"sudo", "doas", "su", "chmod", "chown", "chgrp",
@@ -82,20 +54,13 @@ _UNSAFE_ROOT_BINARIES = {
}
_UNSAFE_ROOT_PREFIXES = ("mkfs",)
# Substrings in a role='tool' result that mean the command did NOT execute
# with user consent (blocked, denied, timed out, or still pending). Kept in
# sync with the message templates in tools/approval.py.
# Substrings in a role='tool' result that mean the command did NOT execute with user consent
# (blocked, denied, timed out, or still pending). Kept in sync with tools/approval.py templates.
_BLOCK_MARKERS = (
"BLOCKED (hardline)",
"BLOCKED: User denied",
"BLOCKED: Action ",
"BLOCKED: Command flagged as dangerous",
"BLOCKED: approval required",
"BLOCKED: Failed to send approval request",
"The user has NOT consented",
"Asking the user for approval",
"approval_required",
"BLOCKED by user deny rule",
"BLOCKED (hardline)", "BLOCKED: User denied", "BLOCKED: Action ",
"BLOCKED: Command flagged as dangerous", "BLOCKED: approval required",
"BLOCKED: Failed to send approval request", "The user has NOT consented",
"Asking the user for approval", "approval_required", "BLOCKED by user deny rule",
)
@@ -123,50 +88,41 @@ class Proposal:
def default_db_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "state.db"
def _connect_readonly(db_path: Path) -> sqlite3.Connection:
uri = f"file:{db_path}?mode=ro"
return sqlite3.connect(uri, uri=True)
return sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
def _fetch_rows(cur) -> Iterator[tuple]:
"""Stream cursor rows in 2000-row batches."""
while True:
rows = cur.fetchmany(2000)
if not rows:
return
while rows := cur.fetchmany(2000):
yield from rows
def _iter_terminal_calls(
con: sqlite3.Connection, since_ts: float
) -> Iterator[tuple[str, str]]:
def _json_or_none(raw):
try:
return json.loads(raw)
except (TypeError, ValueError):
return None
def _iter_terminal_calls(con: sqlite3.Connection, since_ts: float) -> Iterator[tuple[str, str]]:
"""Yield ``(tool_call_id, command)`` for every terminal tool call."""
cur = con.execute(
"SELECT tool_calls FROM messages "
"WHERE role='assistant' AND tool_calls IS NOT NULL "
"SELECT tool_calls FROM messages WHERE role='assistant' AND tool_calls IS NOT NULL "
"AND tool_calls LIKE '%terminal%' AND timestamp >= ?",
(since_ts,),
)
for (raw,) in _fetch_rows(cur):
try:
calls = json.loads(raw)
except (TypeError, ValueError):
continue
if not isinstance(calls, list):
continue
for call in calls:
if not isinstance(call, dict):
continue
fn = call.get("function") or {}
calls = _json_or_none(raw)
for call in calls if isinstance(calls, list) else ():
fn = call.get("function") or {} if isinstance(call, dict) else {}
if fn.get("name") != "terminal":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except (TypeError, ValueError):
args = _json_or_none(fn.get("arguments") or "{}")
if args is None:
continue
command = args.get("command")
if isinstance(command, str) and command.strip():
@@ -188,15 +144,11 @@ def _blocked_tool_call_ids(con: sqlite3.Connection, since_ts: float) -> set:
}
def scan_approval_history(
db_path: Optional[Path] = None, days: int = 90
) -> list[tuple[str, str]]:
"""Return ``(command, dangerous_class_description)`` records mined from the session DB —
dangerous-classified terminal commands that actually executed (i.e. carried an implied user
approval).
def scan_approval_history(db_path: Optional[Path] = None, days: int = 90) -> list[tuple[str, str]]:
"""``(command, dangerous_class_description)`` records for dangerous-classified terminal commands
that actually executed (i.e. carried an implied user approval).
"""
from tools.approval import detect_dangerous_command, detect_hardline_command
path = Path(db_path) if db_path else default_db_path()
if not path.exists():
return []
@@ -210,15 +162,13 @@ def scan_approval_history(
for tool_call_id, command in _iter_terminal_calls(con, since_ts):
if tool_call_id in blocked:
continue
is_hardline, _desc = detect_hardline_command(command)
if is_hardline:
# Hardline commands are unconditionally blocked at runtime;
# never mine them (defense in depth against stale DB rows).
# Hardline commands are unconditionally blocked at runtime; never mine them (defense in
# depth against stale DB rows).
if detect_hardline_command(command)[0]:
continue
is_dangerous, _key, description = detect_dangerous_command(command)
if not is_dangerous:
continue
records.append((command, description))
if is_dangerous:
records.append((command, description))
finally:
con.close()
return records
@@ -230,13 +180,8 @@ def scan_approval_history(
def normalize_command(command: str) -> str:
"""Fold user/hermes home prefixes and collapse whitespace."""
from tools.approval import (
_rewrite_resolved_hermes_home,
_rewrite_resolved_user_home,
)
folded = _rewrite_resolved_user_home(_rewrite_resolved_hermes_home(command))
return " ".join(folded.split())
from tools.approval import _rewrite_resolved_hermes_home, _rewrite_resolved_user_home
return " ".join(_rewrite_resolved_user_home(_rewrite_resolved_hermes_home(command)).split())
def is_unsafe_class(description: str) -> bool:
@@ -256,13 +201,8 @@ def derive_glob(normalized: str) -> Optional[str]:
those anyway) and for commands anchored on an unsafe root binary.
"""
from tools.approval import _has_allowlist_shell_operator
if _has_allowlist_shell_operator(normalized):
return None
tokens = normalized.split()
if not tokens:
return None
if _unsafe_root_binary(tokens[0]):
if _has_allowlist_shell_operator(normalized) or not tokens or _unsafe_root_binary(tokens[0]):
return None
if len(tokens) == 1:
return tokens[0]
@@ -273,9 +213,7 @@ def derive_glob(normalized: str) -> Optional[str]:
def build_proposals(
records: Iterable[tuple[str, str]],
existing: Optional[set] = None,
min_count: int = 2,
records: Iterable[tuple[str, str]], existing: Optional[set] = None, min_count: int = 2,
limit: int = 20,
) -> list[Proposal]:
"""Aggregate scan records into a ranked, safety-filtered proposal list.
@@ -292,19 +230,15 @@ def build_proposals(
continue
normalized = normalize_command(command)
glob = derive_glob(normalized)
key = (glob, "glob") if glob is not None else (description, "class")
pattern, kind = key
pattern, kind = (glob, "glob") if glob is not None else (description, "class")
if pattern in existing:
continue
proposal = by_pattern.get(key)
if proposal is None:
proposal = by_pattern[key] = Proposal(pattern=pattern, kind=kind)
proposal = by_pattern.setdefault((pattern, kind), Proposal(pattern=pattern, kind=kind))
proposal.count += 1
proposal.classes.add(description)
proposal.add_example(normalized)
ranked = [p for p in by_pattern.values() if p.count >= max(min_count, 1)]
ranked.sort(key=lambda p: (-p.count, p.pattern))
ranked = sorted((p for p in by_pattern.values() if p.count >= max(min_count, 1)), key=lambda p: (-p.count, p.pattern))
return ranked[: max(limit, 1)]
@@ -335,13 +269,10 @@ def parse_apply_indices(spec: str, total: int) -> list[int]:
def apply_proposals(proposals: list[Proposal], indices: list[int]) -> set:
"""Merge chosen proposal patterns into command_allowlist and persist."""
import tools.approval as approval_module
merged = set(approval_module.load_permanent_allowlist())
for idx in indices:
merged.add(proposals[idx].pattern)
merged = set(approval_module.load_permanent_allowlist()) | {proposals[idx].pattern for idx in indices}
approval_module.save_permanent_allowlist(merged)
# Keep the in-process allowlist consistent so a long-lived process sees
# the new entries immediately (mirrors the interactive 'always' path).
# Keep the in-process allowlist consistent so a long-lived process sees the new entries
# immediately (mirrors the interactive 'always' path).
approval_module.load_permanent(merged)
return merged
@@ -352,8 +283,7 @@ def _render_text(proposals: list[Proposal], days: int) -> None:
print(
f"No allowlist candidates found in approval history ({window}).\n"
"Either nothing dangerous was approved often enough "
"(see --min-count/--days), or the approved classes are "
"excluded for safety."
"(see --min-count/--days), or the approved classes are excluded for safety."
)
return
print(f"Proposed command_allowlist additions (from approval history, {window}):\n")
@@ -380,15 +310,12 @@ def suggest_command(args) -> int:
return 1
import tools.approval as approval_module
existing = set(approval_module.load_permanent_allowlist())
records = scan_approval_history(db_path, days=days)
proposals = build_proposals(
records,
existing=existing,
min_count=getattr(args, "min_count", 2),
limit=getattr(args, "limit", 20),
scan_approval_history(db_path, days=days), existing=existing,
min_count=getattr(args, "min_count", 2), limit=getattr(args, "limit", 20),
)
as_json = getattr(args, "json", False)
apply_spec = getattr(args, "apply_indices", None)
if apply_spec:
@@ -399,28 +326,23 @@ def suggest_command(args) -> int:
return 1
merged = apply_proposals(proposals, indices)
applied = [proposals[i].pattern for i in indices]
if getattr(args, "json", False):
if as_json:
print(json.dumps({"applied": applied, "allowlist_size": len(merged)}))
else:
print("Added to command_allowlist:")
for pattern in applied:
print(f" + {pattern}")
print(f"\ncommand_allowlist now has {len(merged)} entries "
"(~/.hermes/config.yaml).")
print(f"\ncommand_allowlist now has {len(merged)} entries (~/.hermes/config.yaml).")
return 0
if getattr(args, "json", False):
if as_json:
payload = {
"db": str(db_path),
"days": days,
"proposals": [
{
"n": i,
"pattern": p.pattern,
"kind": p.kind,
"count": p.count,
"classes": sorted(p.classes),
"examples": p.examples,
"n": i, "pattern": p.pattern, "kind": p.kind, "count": p.count,
"classes": sorted(p.classes), "examples": p.examples,
}
for i, p in enumerate(proposals, 1)
],
+11 -30
View File
@@ -7,7 +7,7 @@ order the runtime guard (``check_all_command_guards``) applies them:
1. container-skip gate (isolated backends bypass all guards), 2. hardline blocklist (never
bypassable, fires before yolo/off), 3. sudo-stdin guard (unconditional), 4. user ``approvals.deny``
rules (fire before yolo/off), 5. yolo / ``approvals.mode: off`` bypass, 6. permanent
``command_allowlist``, 7.
``command_allowlist``, 7. dangerous-pattern detection (would prompt).
"""
from __future__ import annotations
@@ -20,9 +20,7 @@ EXIT_ASK = 2
EXIT_DENY = 3
_VERDICT_EXIT = {
"allow": EXIT_ALLOW,
"ask-approval": EXIT_ASK,
"hardline-deny": EXIT_DENY,
"allow": EXIT_ALLOW, "ask-approval": EXIT_ASK, "hardline-deny": EXIT_DENY,
"user-deny": EXIT_DENY,
}
@@ -35,9 +33,8 @@ def evaluate_command(command: str, env_type: str = "local") -> dict:
de-obfuscated forms the detectors actually evaluated).
"""
import tools.approval as approval
# Sync config-persisted "always" patterns so the allowlist check below
# sees what the runtime would see (load is read-only).
# Sync config-persisted "always" patterns so the allowlist check below sees what the runtime
# would see (load is read-only).
try:
approval.load_permanent_allowlist()
except Exception:
@@ -47,17 +44,12 @@ def evaluate_command(command: str, env_type: str = "local") -> dict:
def result(verdict: str, rule=None, detail: str = "") -> dict:
return {
"command": command,
"env_type": env_type,
"verdict": verdict,
"exit_code": _VERDICT_EXIT[verdict],
"rule": rule,
"detail": detail,
"command": command, "env_type": env_type, "verdict": verdict,
"exit_code": _VERDICT_EXIT[verdict], "rule": rule, "detail": detail,
"normalized_variants": variants,
}
# 1. Isolated container backends skip every guard (runtime parity:
# this fires BEFORE the hardline floor in check_all_command_guards).
# 1. Isolated container backends skip every guard (fires BEFORE the hardline floor at runtime).
if approval._should_skip_container_guards(env_type):
return result(
"allow",
@@ -77,10 +69,7 @@ def evaluate_command(command: str, env_type: str = "local") -> dict:
# 3. Sudo stdin guard — unconditional, like the hardline floor.
is_sudo_guess, sudo_desc = approval._check_sudo_stdin_guard(command)
if is_sudo_guess:
return result(
"hardline-deny", rule=sudo_desc,
detail="sudo stdin guard (unconditional block)",
)
return result("hardline-deny", rule=sudo_desc, detail="sudo stdin guard (unconditional block)")
# 4. User-defined approvals.deny rules — fire before yolo/off.
deny_pattern = approval._match_user_deny_rule(command)
@@ -103,11 +92,7 @@ def evaluate_command(command: str, env_type: str = "local") -> dict:
# 6. Permanent command_allowlist.
if approval._command_matches_permanent_allowlist(command):
return result(
"allow",
detail="matches command_allowlist in config.yaml "
"(permanently approved)",
)
return result("allow", detail="matches command_allowlist in config.yaml (permanently approved)")
# 7. Dangerous-pattern detection → would prompt.
is_dangerous, pattern_key, description = approval.detect_dangerous_command(command)
@@ -138,18 +123,14 @@ def _render_text(verdict: dict) -> None:
def approvals_test_command(args) -> int:
"""Handle ``hermes approvals test <command...>``. Returns the exit code."""
words = list(getattr(args, "command_words", None) or [])
# argparse REMAINDER keeps a leading "--" separator; it is not part of
# the command being evaluated.
# argparse REMAINDER keeps a leading "--" separator; it is not part of the command.
if words and words[0] == "--":
words = words[1:]
if not words:
print("usage: hermes approvals test [--env-type TYPE] [--json] -- <command...>")
return EXIT_USAGE
command = " ".join(words)
env_type = getattr(args, "env_type", None) or "local"
verdict = evaluate_command(command, env_type=env_type)
verdict = evaluate_command(" ".join(words), env_type=getattr(args, "env_type", None) or "local")
if getattr(args, "json", False):
print(json.dumps(verdict, indent=2))
else:
+62 -178
View File
@@ -1,9 +1,7 @@
"""MiniMax OAuth (user-code grant) login, refresh and runtime credentials.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported
lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
"""
from __future__ import annotations
@@ -15,83 +13,46 @@ import json
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
from hermes_cli.auth_constants import (
AuthError,
MINIMAX_OAUTH_GRANT_TYPE,
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
MINIMAX_OAUTH_SCOPE,
_FORM_JSON_HEADERS,
_minimax_err,
httpx,
AuthError, MINIMAX_OAUTH_GRANT_TYPE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, MINIMAX_OAUTH_SCOPE,
_FORM_JSON_HEADERS, _minimax_err, httpx,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
from hermes_cli.auth import ProviderConfig
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_MINIMAX_OAUTH_ERROR_BODY_LIMIT = 16 * 1024
def _minimax_response_error_text(
response: httpx.Response,
*,
limit: int = _MINIMAX_OAUTH_ERROR_BODY_LIMIT,
) -> str:
def _minimax_response_error_text(response: httpx.Response, *, limit: int = _MINIMAX_OAUTH_ERROR_BODY_LIMIT) -> str:
"""Return a bounded error body from a streamed MiniMax OAuth response."""
limit = max(0, int(limit))
chunks: list[bytes] = []
total = 0
truncated = False
try:
if getattr(response, "is_stream_consumed", False):
text = response.text
return text[:limit] + ("...[truncated]" if len(text) > limit else "")
# Read at most limit+1 bytes so truncation can be detected without buffering the whole body.
chunks: list[bytes] = []
total = 0
for chunk in response.iter_bytes():
if not chunk:
continue
remaining = limit + 1 - total
if remaining <= 0:
truncated = True
chunks.append(chunk[: limit + 1 - total])
total += len(chunks[-1])
if total > limit:
break
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
total += remaining
truncated = True
break
chunks.append(chunk)
total += len(chunk)
raw = b"".join(chunks)
if len(raw) > limit:
raw = raw[:limit]
truncated = True
encoding = response.encoding or "utf-8"
text = raw.decode(encoding, errors="replace")
return text + ("...[truncated]" if truncated else "")
text = raw[:limit].decode(response.encoding or "utf-8", errors="replace")
return text + ("...[truncated]" if len(raw) > limit else "")
finally:
response.close()
def _minimax_post_form(
client: httpx.Client,
url: str,
*,
data: Dict[str, Any],
headers: Dict[str, str],
) -> httpx.Response:
def _minimax_post_form(client: httpx.Client, url: str, *, data: Dict[str, Any], headers: Dict[str, str]) -> httpx.Response:
"""POST a MiniMax OAuth form without eagerly reading error bodies."""
request = client.build_request(
"POST",
url,
data=data,
headers=headers,
)
response = client.send(request, stream=True)
response = client.send(client.build_request("POST", url, data=data, headers=headers), stream=True)
if response.status_code == 200:
response.read()
return response
@@ -101,43 +62,29 @@ def _minimax_pkce_pair() -> tuple:
"""Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
import secrets
verifier = secrets.token_urlsafe(64)[:96]
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).decode().rstrip("=")
state = secrets.token_urlsafe(16)
return verifier, challenge, state
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge, secrets.token_urlsafe(16)
def _minimax_request_user_code(
client: httpx.Client, *, portal_base_url: str, client_id: str,
code_challenge: str, state: str,
client: httpx.Client, *, portal_base_url: str, client_id: str, code_challenge: str, state: str
) -> Dict[str, Any]:
response = _minimax_post_form(
client,
f"{portal_base_url}/oauth/code",
data={
"response_type": "code",
"client_id": client_id,
"scope": MINIMAX_OAUTH_SCOPE,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": state,
"response_type": "code", "client_id": client_id, "scope": MINIMAX_OAUTH_SCOPE,
"code_challenge": code_challenge, "code_challenge_method": "S256", "state": state,
},
headers={**_FORM_JSON_HEADERS, "x-request-id": str(uuid.uuid4())},
)
if response.status_code != 200:
body = _minimax_response_error_text(response)
raise _minimax_err(
f"MiniMax OAuth authorization failed: {body or response.reason_phrase}",
"authorization_failed",
)
raise _minimax_err(f"MiniMax OAuth authorization failed: {body or response.reason_phrase}", "authorization_failed")
payload = response.json()
for field in ("user_code", "verification_uri", "expired_in"):
if field not in payload:
raise _minimax_err(
f"MiniMax OAuth response missing field: {field}",
"authorization_incomplete",
)
raise _minimax_err(f"MiniMax OAuth response missing field: {field}", "authorization_incomplete")
if payload.get("state") != state:
raise _minimax_err("MiniMax OAuth state mismatch (possible CSRF).", "state_mismatch")
return payload
@@ -151,8 +98,7 @@ def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> b
def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
"""Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
raw = int(expired_in)
now_ms = int(now.timestamp() * 1000)
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=int(now.timestamp() * 1000)):
return raw / 1000.0
return now.timestamp() + max(1, raw)
@@ -172,8 +118,7 @@ def _minimax_poll_token(
client: httpx.Client, *, portal_base_url: str, client_id: str,
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
) -> Dict[str, Any]:
# OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
# expired_in is a unix-ms timestamp upstream (OpenClaw) but small values are TTL seconds.
deadline = _minimax_resolve_token_expiry_unix(expired_in, now=datetime.now(timezone.utc))
interval = max(2.0, (interval_ms or 2000) / 1000.0)
@@ -182,14 +127,11 @@ def _minimax_poll_token(
client,
f"{portal_base_url}/oauth/token",
data={
"grant_type": MINIMAX_OAUTH_GRANT_TYPE,
"client_id": client_id,
"user_code": user_code,
"code_verifier": code_verifier,
"grant_type": MINIMAX_OAUTH_GRANT_TYPE, "client_id": client_id,
"user_code": user_code, "code_verifier": code_verifier,
},
headers=_FORM_JSON_HEADERS,
)
error_text = ""
if response.status_code != 200:
error_text = _minimax_response_error_text(response)
try:
@@ -205,16 +147,10 @@ def _minimax_poll_token(
status = payload.get("status")
if status == "error":
raise _minimax_err(
"MiniMax OAuth reported an error. Please try again later.",
"authorization_denied",
)
raise _minimax_err("MiniMax OAuth reported an error. Please try again later.", "authorization_denied")
if status == "success":
if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
raise _minimax_err(
"MiniMax OAuth success payload missing required token fields.",
"token_incomplete",
)
raise _minimax_err("MiniMax OAuth success payload missing required token fields.", "token_incomplete")
return payload
# "pending" or any other status -> keep polling
time.sleep(interval)
@@ -228,10 +164,7 @@ def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
_save_active_provider_state("minimax-oauth", auth_state)
def _minimax_oauth_login(
*, region: str = "global", open_browser: bool = True,
timeout_seconds: float = 15.0,
) -> Dict[str, Any]:
def _minimax_oauth_login(*, region: str = "global", open_browser: bool = True, timeout_seconds: float = 15.0) -> Dict[str, Any]:
"""Run MiniMax OAuth flow, persist tokens, return auth state dict."""
from hermes_cli.auth import PROVIDER_REGISTRY, _can_open_graphical_browser, _is_remote_session, _minimax_pkce_pair, _minimax_request_user_code, _minimax_save_auth_state, _print_device_code_instructions
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
@@ -250,33 +183,24 @@ def _minimax_oauth_login(
print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
print(f"Portal: {portal_base_url}")
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
headers={"Accept": "application/json"},
with httpx.Client(timeout=httpx.Timeout(timeout_seconds), headers={"Accept": "application/json"},
follow_redirects=True) as client:
code_data = _minimax_request_user_code(
client, portal_base_url=portal_base_url,
client_id=pconfig.client_id,
code_challenge=challenge, state=state,
client, portal_base_url=portal_base_url, client_id=pconfig.client_id, code_challenge=challenge, state=state,
)
verification_url = str(code_data["verification_uri"])
user_code = str(code_data["user_code"])
_print_device_code_instructions(
verification_url,
user_code,
str(code_data["verification_uri"]), str(code_data["user_code"]),
open_browser=open_browser and _can_open_graphical_browser(),
)
interval_raw = code_data.get("interval")
interval_ms = int(interval_raw) if interval_raw is not None else None
print("Waiting for approval...")
token_data = _minimax_poll_token(
client, portal_base_url=portal_base_url,
client_id=pconfig.client_id,
user_code=user_code, code_verifier=verifier,
client, portal_base_url=portal_base_url, client_id=pconfig.client_id,
user_code=str(code_data["user_code"]), code_verifier=verifier,
expired_in=int(code_data["expired_in"]),
interval_ms=interval_ms,
interval_ms=int(interval_raw) if interval_raw is not None else None,
)
auth_state = {
@@ -300,73 +224,49 @@ def _minimax_oauth_login(
return auth_state
def _refresh_minimax_oauth_state(
state: Dict[str, Any], *, timeout_seconds: float = 15.0,
force: bool = False,
) -> Dict[str, Any]:
def _refresh_minimax_oauth_state(state: Dict[str, Any], *, timeout_seconds: float = 15.0, force: bool = False) -> Dict[str, Any]:
"""Refresh MiniMax OAuth access token if close to expiry (or forced)."""
from hermes_cli.auth import _minimax_save_auth_state
if not state.get("refresh_token"):
raise _minimax_err(
"MiniMax OAuth state has no refresh_token; please re-login.",
"no_refresh_token", relogin=True,
)
raise _minimax_err("MiniMax OAuth state has no refresh_token; please re-login.", "no_refresh_token", relogin=True)
try:
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
except Exception:
expires_at = 0.0
now = time.time()
if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
if not force and (expires_at - time.time()) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
return state
portal_base_url = state["portal_base_url"]
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
follow_redirects=True) as client:
with httpx.Client(timeout=httpx.Timeout(timeout_seconds), follow_redirects=True) as client:
response = _minimax_post_form(
client,
f"{portal_base_url}/oauth/token",
data={
"grant_type": "refresh_token",
"client_id": state["client_id"],
"refresh_token": state["refresh_token"],
},
f"{state['portal_base_url']}/oauth/token",
data={"grant_type": "refresh_token", "client_id": state["client_id"], "refresh_token": state["refresh_token"]},
headers=_FORM_JSON_HEADERS,
)
# The non-200 branch reads a STREAMED body, so it must run while
# the client is still open — iter_bytes() after the client context
# closes raises (StreamClosed). The 200 path was already read by
# _minimax_post_form, so response.json() below is safe outside.
# Non-200 reads a STREAMED body, so it must run inside the client context (iter_bytes()
# after close raises StreamClosed); the 200 body was already read by _minimax_post_form.
if response.status_code != 200:
body = _minimax_response_error_text(response)
body_lower = body.lower()
relogin = any(m in body_lower for m in
("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
relogin = any(m in body_lower for m in ("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
raise _minimax_err(
f"MiniMax OAuth refresh failed: {body or response.reason_phrase}",
"refresh_failed", relogin=relogin,
f"MiniMax OAuth refresh failed: {body or response.reason_phrase}", "refresh_failed", relogin=relogin,
)
payload = response.json()
if payload.get("status") != "success":
raise _minimax_err(
"MiniMax OAuth refresh did not return success.",
"refresh_failed", relogin=True,
)
new_state = dict(state)
new_state.update({
raise _minimax_err("MiniMax OAuth refresh did not return success.", "refresh_failed", relogin=True)
new_state = {
**state,
"access_token": payload["access_token"],
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
**_minimax_expiry_fields(payload["expired_in"]),
})
}
_minimax_save_auth_state(new_state)
return new_state
def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None:
"""Wipe dead tokens from auth.json after a terminal refresh failure.
Shared by the eager-resolve path and the lazy per-request token provider. Mirrors the
Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a network retry.
"""
"""Wipe dead tokens from auth.json after a terminal refresh failure (fail fast, no network retry)."""
from hermes_cli.auth import _minimax_save_auth_state, _quarantine_flat_oauth_state
if not (exc.relogin_required and state.get("refresh_token")):
return
@@ -383,9 +283,7 @@ def _minimax_fresh_state() -> Dict[str, Any]:
state = get_provider_auth_state("minimax-oauth")
if not state or not state.get("access_token"):
raise _minimax_err(
"Not logged into MiniMax OAuth. Run `hermes model` and select "
"MiniMax (OAuth).",
"not_logged_in", relogin=True,
"Not logged into MiniMax OAuth. Run `hermes model` and select MiniMax (OAuth).", "not_logged_in", relogin=True,
)
try:
return _refresh_minimax_oauth_state(state)
@@ -395,20 +293,15 @@ def _minimax_fresh_state() -> Dict[str, Any]:
def build_minimax_oauth_token_provider() -> Callable[[], str]:
"""Return a zero-arg callable that yields a fresh MiniMax access token.
"""Zero-arg callable yielding a fresh MiniMax access token.
The Anthropic SDK caches ``api_key`` as a static string at construction time, so a session that
resolves credentials once at startup will keep sending the same bearer until MiniMax's server
returns 401 — typically ~15 minutes in, because MiniMax issues short-lived access tokens.
The Anthropic SDK caches ``api_key`` at construction; MiniMax tokens live ~15 minutes, so a
static bearer would start 401-ing mid-session.
"""
def _provide() -> str:
state = _minimax_fresh_state()
token = state.get("access_token")
token = _minimax_fresh_state().get("access_token")
if not token:
raise _minimax_err(
"MiniMax OAuth state has no access_token after refresh.",
"no_access_token", relogin=True,
)
raise _minimax_err("MiniMax OAuth state has no access_token after refresh.", "no_access_token", relogin=True)
return token
return _provide
@@ -418,19 +311,11 @@ def resolve_minimax_oauth_runtime_credentials(
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
as_token_provider: bool = False,
) -> Dict[str, Any]:
"""Return {provider, api_key, base_url, source} for minimax-oauth.
The default (string ``api_key``) preserves the historical contract for diagnostic call sites
like ``hermes status`` that just want to know whether a valid token exists right now.
"""
"""Return {provider, api_key, base_url, source}; string ``api_key`` by default (``hermes status`` contract)."""
state = _minimax_fresh_state()
if as_token_provider:
api_key: Any = build_minimax_oauth_token_provider()
else:
api_key = state["access_token"]
return {
"provider": "minimax-oauth",
"api_key": api_key,
"api_key": build_minimax_oauth_token_provider() if as_token_provider else state["access_token"],
"base_url": state["inference_base_url"].rstrip("/"),
"source": "oauth",
}
@@ -439,12 +324,11 @@ def resolve_minimax_oauth_runtime_credentials(
def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
"""CLI entry for MiniMax OAuth login."""
from hermes_cli.auth import format_auth_error
region = getattr(args, "region", None) or "global"
open_browser = not getattr(args, "no_browser", False)
timeout = getattr(args, "timeout", None) or 15.0
try:
_minimax_oauth_login(
region=region, open_browser=open_browser, timeout_seconds=timeout,
region=getattr(args, "region", None) or "global",
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=getattr(args, "timeout", None) or 15.0,
)
except AuthError as exc:
print(format_auth_error(exc))
+83 -179
View File
@@ -1,9 +1,7 @@
"""Interactive model picker used after OAuth login.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported
lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
"""
from __future__ import annotations
@@ -13,35 +11,25 @@ import subprocess
from typing import Dict, List, Optional
from hermes_cli.auth_constants import DEFAULT_NOUS_PORTAL_URL
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_CUSTOM_LABEL = "Enter custom model name"
_SKIP_LABEL = "Skip (keep current)"
_CURRENT_SUFFIX = " ← currently in use"
def _confirm_selection_guards(
model_id: str,
*,
provider: str = "",
base_url: str = "",
api_key: str = "",
model_id: str, *, provider: str = "", base_url: str = "", api_key: str = "",
include_kinds: Optional[List[str]] = None,
) -> bool:
"""Prompt before saving a model that trips any selection guard.
"""Prompt before saving a model that trips any selection guard (cost, data-policy, ...).
Runs the unified guard registry (cost, data-policy, future guards) and shows one [y/N] confirm
listing every warning that fired. Returns True to proceed, False to cancel.
Shows one [y/N] confirm listing every warning that fired. Returns True to proceed.
"""
try:
from hermes_cli.model_selection_guards import (
combined_message,
selection_warnings,
)
from hermes_cli.model_selection_guards import combined_message, selection_warnings
warnings = selection_warnings(
model_id,
provider=provider,
base_url=base_url,
api_key=api_key,
include_kinds=include_kinds,
model_id, provider=provider, base_url=base_url, api_key=api_key, include_kinds=include_kinds,
)
except Exception:
warnings = []
@@ -61,31 +49,22 @@ def _confirm_selection_guards(
class _ModelPickerRows:
"""Column-aligned model rows (name + $/Mtok prices + Nous sale chrome) for the model picker.
"""Column-aligned picker rows (name + $/Mtok + Nous sale chrome).
Sale chrome (★ / -N% / was) is drawn as curses/ANSI segments (yellow % / dim "was"), not baked
into one plain string — curses addnstr would otherwise render escape bytes literally.
Sale chrome is emitted as styled segments, not ANSI baked into one string — curses addnstr
would render escape bytes literally.
"""
def __init__(
self,
all_models: List[str],
pricing: Optional[Dict[str, Dict[str, str]]],
*,
current_model: str,
sale_chrome: bool,
self, all_models: List[str], pricing: Optional[Dict[str, Dict[str, str]]], *,
current_model: str, sale_chrome: bool,
) -> None:
from hermes_cli.models import _format_price_per_mtok, compute_sale_discount
self.current_model = current_model
self.has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
# Leave room for a leading "★ " on sale rows (Nous only).
name_pad = 3 if sale_chrome else 2
self.name_col = (
max((len(m) for m in all_models), default=0) + name_pad
if self.has_pricing
else 0
)
self.name_col = max((len(m) for m in all_models), default=0) + name_pad if self.has_pricing else 0
# (inp, out, cache, pct|None, was_inp, was_out)
self._price_cache: dict[str, tuple[str, str, str, int | None, str, str]] = {}
self.price_col = 3 # minimum width
@@ -94,10 +73,15 @@ class _ModelPickerRows:
self.any_on_sale = False
if not self.has_pricing:
return
def _was(raw: str) -> str:
return _format_price_per_mtok(raw) if raw != "" else "?"
for mid in all_models:
p = pricing.get(mid) # type: ignore[union-attr]
pct: int | None = None
was_inp = was_out = ""
inp, out, cache = "", "", ""
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
@@ -105,33 +89,14 @@ class _ModelPickerRows:
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
self.has_cache = True
if sale_chrome:
sale = compute_sale_discount(
p.get("prompt", ""),
p.get("completion", ""),
p.get("original"),
)
if sale is not None:
self.any_on_sale = True
pct, was_prompt_raw, was_out_raw = sale
# Natively-free models (no gateway original) carry
# empty was_* raws — leave them empty so the row
# shows bare "-100%" with no "was ?/?" suffix.
if was_prompt_raw == "" and was_out_raw == "":
was_inp = was_out = ""
else:
was_inp = (
_format_price_per_mtok(was_prompt_raw)
if was_prompt_raw != ""
else "?"
)
was_out = (
_format_price_per_mtok(was_out_raw)
if was_out_raw != ""
else "?"
)
else:
inp, out, cache = "", "", ""
sale = compute_sale_discount(p.get("prompt", ""), p.get("completion", ""), p.get("original")) if sale_chrome else None
if sale is not None:
self.any_on_sale = True
pct, was_prompt_raw, was_out_raw = sale
# Natively-free models (no gateway original) carry empty was_* raws — leave
# them empty so the row shows bare "-100%" with no "was ?/?" suffix.
if was_prompt_raw != "" or was_out_raw != "":
was_inp, was_out = _was(was_prompt_raw), _was(was_out_raw)
self._price_cache[mid] = (inp, out, cache, pct, was_inp, was_out)
self.price_col = max(self.price_col, len(inp), len(out))
self.cache_col = max(self.cache_col, len(cache))
@@ -140,37 +105,27 @@ class _ModelPickerRows:
def segments(self, mid: str) -> list[tuple[str, str | None]]:
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices."""
current = [(_CURRENT_SUFFIX, None)] if mid == self.current_model else []
if not self.has_pricing:
segs: list[tuple[str, str | None]] = [(mid, None)]
if mid == self.current_model:
segs.append((" ← currently in use", None))
return segs
return [(mid, None), *current]
inp, out, cache, pct, was_inp, was_out = self._price_cache.get(
mid, ("", "", "", None, "", "")
)
inp, out, cache, pct, was_inp, was_out = self._price_cache.get(mid, ("", "", "", None, "", ""))
on_sale = pct is not None
# Reserve 2 columns for "★ " so sale and non-sale names share alignment.
star_w = 2
if on_sale:
name_segs: list[tuple[str, str | None]] = [
("★ ", "yellow"),
(f"{mid:<{self.name_col - star_w}}", None),
]
segs: list[tuple[str, str | None]] = [("★ ", "yellow"), (f"{mid:<{self.name_col - 2}}", None)]
else:
name_segs = [(f"{mid:<{self.name_col}}", None)]
segs = [(f"{mid:<{self.name_col}}", None)]
price_part = f" {inp:>{self.price_col}} {out:>{self.price_col}}"
if self.has_cache:
price_part += f" {cache:>{self.cache_col}}"
segs = [*name_segs, (price_part, None)]
segs.append((price_part, None))
if on_sale:
segs.append((f" -{pct}%", "yellow"))
if was_inp or was_out:
segs.append((f" was {was_inp}/{was_out}", "dim"))
if mid == self.current_model:
segs.append((" ← currently in use", None))
return segs
return segs + current
def label(self, mid: str) -> str:
return "".join(text for text, _style in self.segments(mid))
@@ -179,15 +134,13 @@ class _ModelPickerRows:
"""``Select default model:`` plus an aligned pricing header hint when priced."""
title = "Select default model:"
if self.has_pricing:
# Align the header with the model column.
# Each choice is " {label}" (2 spaces) and we prepend
# a 3-char cursor region ("-> " or " "), so content starts at col 5.
# Each choice is " {label}" (2 spaces) plus a 3-char cursor region ("-> " or " "),
# so content starts at col 5.
pad = " " * 5
header = f"\n{pad}{'':>{self.name_col}} {'In':>{self.price_col}} {'Out':>{self.price_col}}"
if self.has_cache:
header += f" {'Cache':>{self.cache_col}}"
# Legend lives on the column-header line so it reads as a key
# (★ = on sale), not a fake menu row.
# Legend lives on the column-header line so it reads as a key, not a fake menu row.
title += header + " $/Mtok"
if self.any_on_sale:
title += " ★ = on sale"
@@ -195,14 +148,10 @@ class _ModelPickerRows:
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
model_ids: List[str], current_model: str = "",
pricing: Optional[Dict[str, Dict[str, str]]] = None,
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
unavailable_message: str = "",
confirm_provider: str = "",
confirm_base_url: str = "",
unavailable_models: Optional[List[str]] = None, portal_url: str = "",
unavailable_message: str = "", confirm_provider: str = "", confirm_base_url: str = "",
confirm_api_key: str = "",
) -> Optional[str]:
"""Interactive model picker; current_model listed first. Returns the chosen model ID or None.
@@ -211,115 +160,81 @@ def _prompt_model_selection(
*unavailable_models* render grayed out and unselectable with an upgrade link to *portal_url*.
"""
from hermes_cli.cli_output import line_input
_unavailable = unavailable_models or []
# Sale chrome (★ / -N% / was) is Nous Portal-only — never for OpenRouter
# or other providers even if pricing.original is somehow present.
# Sale chrome is Nous Portal-only, even if pricing.original is present for another provider.
sale_chrome = (confirm_provider or "").strip().lower() == "nous"
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
return None
# Unified guard registry (hermes_cli.model_selection_guards): the cost
# guard only runs when a provider is known (pricing lookups need one);
# id-keyed guards like the data-policy guard always run — they must
# fire even via a custom endpoint or gateway.
_kinds = None if confirm_provider else ["data_policy"]
if not _confirm_selection_guards(
mid,
provider=confirm_provider,
base_url=confirm_base_url,
api_key=confirm_api_key,
include_kinds=_kinds,
):
# Cost guard needs a known provider; id-keyed guards (data policy) always run.
ok = _confirm_selection_guards(
mid, provider=confirm_provider, base_url=confirm_base_url, api_key=confirm_api_key,
include_kinds=None if confirm_provider else ["data_policy"],
)
return mid if ok else None
def _custom_selection() -> Optional[str]:
try:
custom = line_input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return mid
return _confirmed_selection(custom) if custom else None
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
ordered.append(current_model)
for mid in model_ids:
if mid not in ordered:
ordered.append(mid)
ordered = list(dict.fromkeys(
([current_model] if current_model and current_model in model_ids else []) + list(model_ids)
))
# All models for column-width computation (selectable + unavailable)
rows = _ModelPickerRows(
list(ordered) + list(_unavailable), pricing,
current_model=current_model, sale_chrome=sale_chrome,
)
rows = _ModelPickerRows(ordered + list(_unavailable), pricing, current_model=current_model, sale_chrome=sale_chrome)
_DIM = "\033[2m"
_RESET = "\033[0m"
# Default cursor on the current model (index 0 if it was reordered to top)
default_idx = 0
menu_title = rows.menu_title()
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
n = len(ordered)
# Try arrow-key menu first, fall back to number input.
try:
from hermes_cli.curses_ui import curses_radiolist
choices = [rows.segments(mid) for mid in ordered]
choices.append("Enter custom model name")
choices.append("Skip (keep current)")
choices = [rows.segments(mid) for mid in ordered] + [_CUSTOM_LABEL, _SKIP_LABEL]
unavailable_footer = unavailable_message.strip()
if not unavailable_footer and _unavailable:
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
# The pricing column header (and any unavailable-models block) is shown
# as a multi-line description above the list so it survives the curses
# screen clear. menu_title already embeds the aligned price header.
desc_lines: list[str] = []
if rows.has_pricing:
# menu_title is "Select default model:\n<pad><header> $/Mtok\n…"
# Keep only the header/legend portion for the description.
header_part = menu_title.split("\n", 1)
if len(header_part) > 1:
desc_lines.extend(header_part[1].splitlines())
# Header/legend + unavailable block go in the description so they survive the curses clear.
desc_lines: list[str] = menu_title.split("\n", 1)[1].splitlines() if rows.has_pricing else []
if _unavailable:
for mid in _unavailable:
desc_lines.append(f" {rows.label(mid)}")
desc_lines.extend(f" {rows.label(mid)}" for mid in _unavailable)
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
# Search haystacks keep pricing labels visible while adding aliases
# for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi").
# Search haystack = label + aliases for brand-less wire ids (Kimi `k3` ↔ "kimi"); skip when
# model_search_text adds nothing beyond the bare id.
from hermes_cli.model_search import model_search_text
model_search_labels = []
for mid in ordered:
label = rows.label(mid)
haystack = model_search_text(mid)
# model_search_text always starts with the wire id; only append when
# aliases add tokens beyond the bare id already in the label.
model_search_labels.append(
label if haystack == mid else f"{label} {haystack}"
)
model_search_labels.append("Enter custom model name")
model_search_labels.append("Skip (keep current)")
label, haystack = rows.label(mid), model_search_text(mid)
model_search_labels.append(label if haystack == mid else f"{label} {haystack}")
model_search_labels += [_CUSTOM_LABEL, _SKIP_LABEL]
idx = curses_radiolist(
"Select default model:",
choices,
selected=default_idx,
selected=0, # cursor on the current model (index 0 if it was reordered to top)
cancel_returns=-1,
description=description,
description="\n".join(desc_lines) if desc_lines else None,
searchable=True,
search_labels=model_search_labels,
)
if idx < 0:
return None
print()
if idx < len(ordered):
if idx < n:
return _confirmed_selection(ordered[idx])
elif idx == len(ordered):
try:
custom = line_input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return _confirmed_selection(custom) if custom else None
if idx == n:
return _custom_selection()
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
@@ -327,18 +242,13 @@ def _prompt_model_selection(
# Fallback: numbered list (ANSI colors for sale chrome)
from hermes_cli.curses_ui import format_radio_item_ansi
from hermes_cli.colors import Colors, color
for line in menu_title.splitlines():
if "★" in line:
print(line.replace("★", color("★", Colors.YELLOW), 1))
else:
print(line)
num_width = len(str(len(ordered) + 2))
print(line.replace("★", color("★", Colors.YELLOW), 1) if "★" in line else line)
num_width = len(str(n + 2))
for i, mid in enumerate(ordered, 1):
print(f" {i:>{num_width}}. {format_radio_item_ansi(rows.segments(mid))}")
n = len(ordered)
print(f" {n + 1:>{num_width}}. Enter custom model name")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
print(f" {n + 1:>{num_width}}. {_CUSTOM_LABEL}")
print(f" {n + 2:>{num_width}}. {_SKIP_LABEL}")
if _unavailable:
unavailable_footer = unavailable_message.strip() or (
@@ -358,10 +268,9 @@ def _prompt_model_selection(
idx = int(choice)
if 1 <= idx <= n:
return _confirmed_selection(ordered[idx - 1])
elif idx == n + 1:
custom = line_input("Enter model name: ").strip()
return _confirmed_selection(custom) if custom else None
elif idx == n + 2:
if idx == n + 1:
return _custom_selection()
if idx == n + 2:
return None
print(f"Please enter 1-{n + 2}")
except ValueError:
@@ -371,13 +280,8 @@ def _prompt_model_selection(
def _save_model_choice(model_id: str) -> None:
"""Save the selected model to config.yaml (single source of truth).
The model is stored in config.yaml only — NOT in .env. This avoids conflicts in multi-agent
setups where env vars would stomp each other.
"""
"""Save the selected model to config.yaml only — NOT .env, which would stomp in multi-agent setups."""
from hermes_cli.config import save_config, load_config
config = load_config()
# Always use dict format so provider/base_url can be stored alongside
if isinstance(config.get("model"), dict):
+26 -79
View File
@@ -1,9 +1,7 @@
"""Qwen OAuth (qwen-cli token file) runtime credentials and status.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported
lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
"""
from __future__ import annotations
@@ -15,19 +13,14 @@ import time
from pathlib import Path
from typing import Any, Dict
from hermes_cli.auth_constants import (
AuthError,
DEFAULT_QWEN_BASE_URL,
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
QWEN_OAUTH_CLIENT_ID,
QWEN_OAUTH_TOKEN_URL,
_FORM_JSON_HEADERS,
_qwen_err,
httpx,
AuthError, DEFAULT_QWEN_BASE_URL, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_OAUTH_CLIENT_ID,
QWEN_OAUTH_TOKEN_URL, _FORM_JSON_HEADERS, _qwen_err, httpx,
)
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_RERUN = "Re-run 'qwen auth qwen-oauth'."
def _qwen_cli_auth_path() -> Path:
return Path.home() / ".qwen" / "oauth_creds.json"
@@ -37,16 +30,12 @@ def _read_qwen_cli_tokens() -> Dict[str, Any]:
from hermes_cli.auth import _qwen_cli_auth_path
auth_path = _qwen_cli_auth_path()
if not auth_path.exists():
raise _qwen_err(
"Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.",
"qwen_auth_missing",
)
raise _qwen_err("Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.", "qwen_auth_missing")
try:
data = json.loads(auth_path.read_text(encoding="utf-8"))
except Exception as exc:
raise _qwen_err(
f"Failed to read Qwen CLI credentials from {auth_path}: {exc}",
"qwen_auth_read_failed",
f"Failed to read Qwen CLI credentials from {auth_path}: {exc}", "qwen_auth_read_failed",
) from exc
if not isinstance(data, dict):
raise _qwen_err(f"Invalid Qwen CLI credentials in {auth_path}.", "qwen_auth_invalid")
@@ -71,20 +60,12 @@ def _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN
def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20.0) -> Dict[str, Any]:
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if not refresh_token:
raise _qwen_err(
"Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.",
"qwen_refresh_token_missing",
)
raise _qwen_err(f"Qwen OAuth refresh token missing. {_RERUN}", "qwen_refresh_token_missing")
try:
response = httpx.post(
QWEN_OAUTH_TOKEN_URL,
headers=_FORM_JSON_HEADERS,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": QWEN_OAUTH_CLIENT_ID,
},
QWEN_OAUTH_TOKEN_URL, headers=_FORM_JSON_HEADERS,
data={"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": QWEN_OAUTH_CLIENT_ID},
timeout=timeout_seconds,
)
except Exception as exc:
@@ -93,28 +74,19 @@ def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20
if response.status_code >= 400:
body = response.text.strip()
raise _qwen_err(
"Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'."
+ (f" Response: {body}" if body else ""),
"qwen_refresh_failed",
f"Qwen OAuth refresh failed. {_RERUN}" + (f" Response: {body}" if body else ""), "qwen_refresh_failed",
)
try:
payload = response.json()
except Exception as exc:
raise _qwen_err(
f"Qwen OAuth refresh returned invalid JSON: {exc}",
"qwen_refresh_invalid_json",
) from exc
raise _qwen_err(f"Qwen OAuth refresh returned invalid JSON: {exc}", "qwen_refresh_invalid_json") from exc
if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
raise _qwen_err(
"Qwen OAuth refresh response missing access_token.",
"qwen_refresh_invalid_response",
)
raise _qwen_err("Qwen OAuth refresh response missing access_token.", "qwen_refresh_invalid_response")
expires_in = payload.get("expires_in")
try:
expires_in_seconds = int(expires_in)
expires_in_seconds = int(payload.get("expires_in"))
except Exception:
expires_in_seconds = 6 * 60 * 60
@@ -130,50 +102,34 @@ def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20
def _mark_qwen_oauth_active(creds: Dict[str, Any]) -> None:
"""Set active_provider to qwen-oauth in auth.json.
Qwen tokens live in the Qwen CLI credential file, so this writes only a minimal provider-state
entry (base_url for display) and sets active_provider so ``get_active_provider()`` and the
setup wizard's credential check detect the provider.
"""
"""Set active_provider to qwen-oauth with a minimal state entry (tokens stay in the Qwen CLI file)."""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store, _save_provider_state
with _auth_store_lock():
auth_store = _load_auth_store()
state: Dict[str, Any] = {}
if creds.get("base_url"):
state["base_url"] = str(creds["base_url"])
state: Dict[str, Any] = {"base_url": str(creds["base_url"])} if creds.get("base_url") else {}
_save_provider_state(auth_store, "qwen-oauth", state)
_save_auth_store(auth_store)
def resolve_qwen_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
*, force_refresh: bool = False, refresh_if_expiring: bool = True,
refresh_skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
from hermes_cli.auth import _qwen_cli_auth_path, _refresh_qwen_cli_tokens
tokens = _read_qwen_cli_tokens()
access_token = str(tokens.get("access_token", "") or "").strip()
should_refresh = bool(force_refresh)
if not should_refresh and refresh_if_expiring:
should_refresh = _qwen_access_token_is_expiring(tokens.get("expiry_date"), refresh_skew_seconds)
if should_refresh:
tokens = _refresh_qwen_cli_tokens(tokens)
access_token = str(tokens.get("access_token", "") or "").strip()
access_token = str(tokens.get("access_token", "") or "").strip()
if not access_token:
raise _qwen_err(
"Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.",
"qwen_access_token_missing",
)
raise _qwen_err(f"Qwen OAuth access token missing. {_RERUN}", "qwen_access_token_missing")
base_url = os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL
return {
"provider": "qwen-oauth",
"base_url": base_url,
"api_key": access_token,
"source": "qwen-cli",
"expires_at_ms": tokens.get("expiry_date"),
"base_url": os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL,
"api_key": access_token, "source": "qwen-cli", "expires_at_ms": tokens.get("expiry_date"),
"auth_file": str(_qwen_cli_auth_path()),
}
@@ -182,20 +138,11 @@ def get_qwen_auth_status() -> Dict[str, Any]:
from hermes_cli.auth import _qwen_cli_auth_path, resolve_qwen_runtime_credentials
auth_path = _qwen_cli_auth_path()
try:
# Validate the runtime credentials, including refresh when the cached
# CLI token is expired. Otherwise stale tokens show up as "logged in"
# and `hermes model` walks users into a broken Qwen setup flow.
# Refresh-validate: otherwise stale CLI tokens read as "logged in" and break `hermes model`.
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=True)
return {
"logged_in": True,
"auth_file": str(auth_path),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
"expires_at_ms": creds.get("expires_at_ms"),
"logged_in": True, "auth_file": str(auth_path), "source": creds.get("source"),
"api_key": creds.get("api_key"), "expires_at_ms": creds.get("expires_at_ms"),
}
except AuthError as exc:
return {
"logged_in": False,
"auth_file": str(auth_path),
"error": str(exc),
}
return {"logged_in": False, "auth_file": str(auth_path), "error": str(exc)}
+124 -292
View File
@@ -1,9 +1,7 @@
"""Spotify OAuth (loopback PKCE) login, refresh and runtime credentials.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported
lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
"""
from __future__ import annotations
@@ -18,61 +16,40 @@ import uuid
import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qs, urlencode, urlparse
from hermes_cli.auth_constants import (
AuthError,
DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL,
DEFAULT_SPOTIFY_API_BASE_URL,
DEFAULT_SPOTIFY_REDIRECT_URI,
DEFAULT_SPOTIFY_SCOPE,
SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
SPOTIFY_DASHBOARD_URL,
SPOTIFY_DOCS_URL,
_spotify_err,
httpx,
AuthError, DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, DEFAULT_SPOTIFY_REDIRECT_URI,
DEFAULT_SPOTIFY_SCOPE, SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, SPOTIFY_DASHBOARD_URL, SPOTIFY_DOCS_URL,
_spotify_err, httpx,
)
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_CALLBACK_HTML = "<html><body><h1>Spotify authorization {}.</h1>You can close this tab.</body></html>"
def _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]:
scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip()
scopes = [part for part in scope_text.split() if part]
seen: set[str] = set()
ordered: List[str] = []
for scope in scopes:
if scope not in seen:
seen.add(scope)
ordered.append(scope)
return ordered
def _clean(value: Any) -> str:
return str(value or "").strip()
def _spotify_scope_string(raw_scope: Optional[str] = None) -> str:
return " ".join(_spotify_scope_list(raw_scope))
"""Requested scope, whitespace-normalized and de-duplicated (order kept)."""
return " ".join(dict.fromkeys((raw_scope or DEFAULT_SPOTIFY_SCOPE).split()))
def _spotify_setting(
state: Optional[Dict[str, Any]],
state_key: str,
env_vars: Tuple[str, ...],
default: str,
*,
explicit: Optional[str] = None,
strip_slash: bool = False,
state: Optional[Dict[str, Any]], state_key: str, env_vars: Tuple[str, ...], default: str, *,
explicit: Optional[str] = None, strip_slash: bool = False,
) -> str:
"""First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*."""
from hermes_cli.config import get_env_value
candidates = (
explicit,
*(get_env_value(var) for var in env_vars),
state.get(state_key) if isinstance(state, dict) else None,
default,
explicit, *(get_env_value(var) for var in env_vars),
state.get(state_key) if isinstance(state, dict) else None, default,
)
for candidate in candidates:
cleaned = str(candidate or "").strip()
cleaned = _clean(candidate)
if strip_slash:
cleaned = cleaned.rstrip("/")
if cleaned:
@@ -80,10 +57,7 @@ def _spotify_setting(
return default
def _spotify_client_id(
explicit: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> str:
def _spotify_client_id(explicit: Optional[str] = None, state: Optional[Dict[str, Any]] = None) -> str:
client_id = _spotify_setting(
state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit,
)
@@ -95,10 +69,7 @@ def _spotify_client_id(
)
def _spotify_redirect_uri(
explicit: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> str:
def _spotify_redirect_uri(explicit: Optional[str] = None, state: Optional[Dict[str, Any]] = None) -> str:
return _spotify_setting(
state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"),
DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit,
@@ -107,21 +78,19 @@ def _spotify_redirect_uri(
def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str:
return _spotify_setting(
state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",),
DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True,
state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True,
)
def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str:
return _spotify_setting(
state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",),
DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, strip_slash=True,
state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL,
strip_slash=True,
)
def _spotify_code_verifier(length: int = 64) -> str:
raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii")
return raw.rstrip("=")[:128]
return base64.urlsafe_b64encode(os.urandom(length)).decode("ascii").rstrip("=")[:128]
def _spotify_code_challenge(code_verifier: str) -> str:
@@ -130,21 +99,12 @@ def _spotify_code_challenge(code_verifier: str) -> str:
def _spotify_build_authorize_url(
*,
client_id: str,
redirect_uri: str,
scope: str,
state: str,
code_challenge: str,
*, client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: str,
accounts_base_url: str,
) -> str:
query = urlencode({
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"code_challenge_method": "S256",
"client_id": client_id, "response_type": "code", "redirect_uri": redirect_uri,
"scope": scope, "state": state, "code_challenge_method": "S256",
"code_challenge": code_challenge,
})
return f"{accounts_base_url}/authorize?{query}"
@@ -152,32 +112,20 @@ def _spotify_build_authorize_url(
def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]:
parsed = urlparse(redirect_uri)
if parsed.scheme != "http":
raise _spotify_err(
"Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.",
"spotify_redirect_invalid",
)
host = parsed.hostname or ""
if host not in {"127.0.0.1", "localhost"}:
raise _spotify_err(
"Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.",
"spotify_redirect_invalid",
)
if not parsed.port:
raise _spotify_err(
"Spotify PKCE redirect_uri must include an explicit localhost port.",
"spotify_redirect_invalid",
)
problem = (
"must use http://localhost or http://127.0.0.1." if parsed.scheme != "http"
else "must point to localhost or 127.0.0.1." if host not in {"127.0.0.1", "localhost"}
else "must include an explicit localhost port." if not parsed.port
else None
)
if problem:
raise _spotify_err(f"Spotify PKCE redirect_uri {problem}", "spotify_redirect_invalid")
return host, parsed.port, parsed.path or "/"
def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:
result: dict[str, Any] = {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
result: dict[str, Any] = {"code": None, "state": None, "error": None, "error_description": None}
class _SpotifyCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
@@ -189,19 +137,13 @@ def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPReq
return
params = parse_qs(parsed.query)
result["code"] = params.get("code", [None])[0]
result["state"] = params.get("state", [None])[0]
result["error"] = params.get("error", [None])[0]
result["error_description"] = params.get("error_description", [None])[0]
for key in result:
result[key] = params.get(key, [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
if result["error"]:
body = "<html><body><h1>Spotify authorization failed.</h1>You can close this tab.</body></html>"
else:
body = "<html><body><h1>Spotify authorization received.</h1>You can close this tab.</body></html>"
self.wfile.write(body.encode("utf-8"))
self.wfile.write(_CALLBACK_HTML.format("failed" if result["error"] else "received").encode("utf-8"))
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
@@ -209,11 +151,7 @@ def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPReq
return _SpotifyCallbackHandler, result
def _spotify_wait_for_callback(
redirect_uri: str,
*,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
def _spotify_wait_for_callback(redirect_uri: str, *, timeout_seconds: float = 180.0) -> dict[str, Any]:
host, port, path = _spotify_validate_redirect_uri(redirect_uri)
handler_cls, result = _make_spotify_callback_handler(path)
@@ -224,8 +162,7 @@ def _spotify_wait_for_callback(
server = _ReuseHTTPServer((host, port), handler_cls)
except OSError as exc:
raise _spotify_err(
f"Could not bind Spotify callback server on {host}:{port}: {exc}",
"spotify_callback_bind_failed",
f"Could not bind Spotify callback server on {host}:{port}: {exc}", "spotify_callback_bind_failed",
) from exc
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
@@ -240,21 +177,12 @@ def _spotify_wait_for_callback(
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
raise _spotify_err(
"Spotify authorization timed out waiting for the local callback.",
"spotify_callback_timeout",
)
raise _spotify_err("Spotify authorization timed out waiting for the local callback.", "spotify_callback_timeout")
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
client_id: str,
redirect_uri: str,
requested_scope: str,
accounts_base_url: str,
api_base_url: str,
previous_state: Optional[Dict[str, Any]] = None,
token_payload: Dict[str, Any], *, client_id: str, redirect_uri: str, requested_scope: str,
accounts_base_url: str, api_base_url: str, previous_state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
from hermes_cli.auth import _coerce_ttl_seconds
now = datetime.now(timezone.utc)
@@ -262,37 +190,22 @@ def _spotify_token_payload_to_state(
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
state = dict(previous_state or {})
state.update({
"client_id": client_id,
"redirect_uri": redirect_uri,
"accounts_base_url": accounts_base_url,
"api_base_url": api_base_url,
"client_id": client_id, "redirect_uri": redirect_uri,
"accounts_base_url": accounts_base_url, "api_base_url": api_base_url,
"scope": requested_scope,
"granted_scope": str(token_payload.get("scope") or requested_scope).strip(),
"token_type": str(token_payload.get("token_type", "Bearer") or "Bearer").strip() or "Bearer",
"access_token": str(token_payload.get("access_token", "") or "").strip(),
"refresh_token": str(
token_payload.get("refresh_token")
or state.get("refresh_token")
or ""
).strip(),
"obtained_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"expires_in": expires_in,
"auth_type": "oauth_pkce",
"token_type": _clean(token_payload.get("token_type", "Bearer") or "Bearer") or "Bearer",
"access_token": _clean(token_payload.get("access_token")),
"refresh_token": _clean(token_payload.get("refresh_token") or state.get("refresh_token")),
"obtained_at": now.isoformat(), "expires_at": expires_at.isoformat(),
"expires_in": expires_in, "auth_type": "oauth_pkce",
})
return state
def _spotify_token_post(
accounts_base_url: str,
data: Dict[str, str],
*,
timeout_seconds: float,
what: str,
failed_code: str,
invalid_code: str,
invalid_message: str,
failed_suffix: str = "",
accounts_base_url: str, data: Dict[str, str], *, timeout_seconds: float, what: str,
failed_code: str, invalid_code: str, invalid_message: str, failed_suffix: str = "",
relogin_required: bool = False,
) -> Dict[str, Any]:
"""POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError."""
@@ -309,48 +222,17 @@ def _spotify_token_post(
if response.status_code >= 400:
detail = response.text.strip()
raise _spotify_err(
f"Spotify {what} failed.{failed_suffix}"
+ (f" Response: {detail}" if detail else ""),
f"Spotify {what} failed.{failed_suffix}" + (f" Response: {detail}" if detail else ""),
failed_code, relogin=relogin_required,
)
payload = response.json()
if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
if not isinstance(payload, dict) or not _clean(payload.get("access_token")):
raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required)
return payload
def _spotify_exchange_code_for_tokens(
*,
client_id: str,
code: str,
redirect_uri: str,
code_verifier: str,
accounts_base_url: str,
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
return _spotify_token_post(
accounts_base_url,
{
"client_id": client_id,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
timeout_seconds=timeout_seconds,
what="token exchange",
failed_code="spotify_token_exchange_failed",
invalid_code="spotify_token_exchange_invalid",
invalid_message="Spotify token response did not include an access_token.",
)
def _refresh_spotify_oauth_state(
state: Dict[str, Any],
*,
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
refresh_token = str(state.get("refresh_token", "") or "").strip()
def _refresh_spotify_oauth_state(state: Dict[str, Any], *, timeout_seconds: float = 20.0) -> Dict[str, Any]:
refresh_token = _clean(state.get("refresh_token"))
if not refresh_token:
raise _spotify_err(
"Spotify refresh token missing. Run `hermes auth spotify` again.",
@@ -361,35 +243,23 @@ def _refresh_spotify_oauth_state(
accounts_base_url = _spotify_accounts_base_url(state)
payload = _spotify_token_post(
accounts_base_url,
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
},
timeout_seconds=timeout_seconds,
what="token refresh",
failed_code="spotify_refresh_failed",
{"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id},
timeout_seconds=timeout_seconds, what="token refresh", failed_code="spotify_refresh_failed",
invalid_code="spotify_refresh_invalid",
invalid_message="Spotify refresh response did not include an access_token.",
failed_suffix=" Run `hermes auth spotify` again.",
relogin_required=True,
failed_suffix=" Run `hermes auth spotify` again.", relogin_required=True,
)
return _spotify_token_payload_to_state(
payload,
client_id=client_id,
redirect_uri=_spotify_redirect_uri(state=state),
payload, client_id=client_id, redirect_uri=_spotify_redirect_uri(state=state),
requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE),
accounts_base_url=accounts_base_url,
api_base_url=_spotify_api_base_url(state),
accounts_base_url=accounts_base_url, api_base_url=_spotify_api_base_url(state),
previous_state=state,
)
def resolve_spotify_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
*, force_refresh: bool = False, refresh_if_expiring: bool = True,
refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
from hermes_cli.auth import _auth_store_lock, _is_expiring, _load_auth_store, _load_provider_state, _quarantine_flat_oauth_state, _refresh_spotify_oauth_state, _save_auth_store, _store_provider_state
@@ -398,8 +268,7 @@ def resolve_spotify_runtime_credentials(
state = _load_provider_state(auth_store, "spotify")
if not state:
raise _spotify_err(
"Spotify is not authenticated. Run `hermes auth spotify` first.",
"spotify_auth_missing", relogin=True,
"Spotify is not authenticated. Run `hermes auth spotify` first.", "spotify_auth_missing", relogin=True,
)
should_refresh = bool(force_refresh)
@@ -420,7 +289,7 @@ def resolve_spotify_runtime_credentials(
logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc)
raise
access_token = str(state.get("access_token", "") or "").strip()
access_token = _clean(state.get("access_token"))
if not access_token:
raise _spotify_err(
"Spotify access token missing. Run `hermes auth spotify` again.",
@@ -428,16 +297,13 @@ def resolve_spotify_runtime_credentials(
)
return {
"provider": "spotify",
"access_token": access_token,
"api_key": access_token,
"provider": "spotify", "access_token": access_token, "api_key": access_token,
"token_type": str(state.get("token_type", "Bearer") or "Bearer"),
"base_url": _spotify_api_base_url(state),
"scope": str(state.get("granted_scope") or state.get("scope") or "").strip(),
"scope": _clean(state.get("granted_scope") or state.get("scope")),
"client_id": _spotify_client_id(state=state),
"redirect_uri": _spotify_redirect_uri(state=state),
"expires_at": state.get("expires_at"),
"refresh_token": str(state.get("refresh_token", "") or "").strip(),
"redirect_uri": _spotify_redirect_uri(state=state), "expires_at": state.get("expires_at"),
"refresh_token": _clean(state.get("refresh_token")),
}
@@ -448,48 +314,37 @@ def get_spotify_auth_status() -> Dict[str, Any]:
return {"logged_in": False}
expires_at = state.get("expires_at")
refresh_token = str(state.get("refresh_token", "") or "").strip()
refresh_token = _clean(state.get("refresh_token"))
return {
"logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)),
"auth_type": state.get("auth_type", "oauth_pkce"),
"client_id": state.get("client_id"),
"auth_type": state.get("auth_type", "oauth_pkce"), "client_id": state.get("client_id"),
"redirect_uri": state.get("redirect_uri"),
"scope": state.get("granted_scope") or state.get("scope"),
"expires_at": expires_at,
"api_base_url": state.get("api_base_url"),
"has_refresh_token": bool(refresh_token),
"scope": state.get("granted_scope") or state.get("scope"), "expires_at": expires_at,
"api_base_url": state.get("api_base_url"), "has_refresh_token": bool(refresh_token),
}
def _spotify_interactive_setup(redirect_uri_hint: str) -> str:
"""Walk the user through creating a Spotify developer app, persist the resulting client_id to
~/.hermes/.env, and return it.
"""
"""Walk the user through creating a Spotify developer app; persist the client_id to ~/.hermes/.env."""
from hermes_cli.auth import _is_remote_session
from hermes_cli.config import save_env_value
print()
print("=" * 70)
print("Spotify first-time setup")
print("=" * 70)
print()
print("Spotify requires every user to register their own lightweight")
print("developer app. This takes about two minutes and only has to be")
print("done once per machine.")
print()
print(f"Full guide: {SPOTIFY_DOCS_URL}")
print()
print("Steps:")
print(f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...")
print(" 2. Click 'Create app' and fill in:")
print(" App name: anything (e.g. hermes-agent)")
print(" Description: anything")
print(f" Redirect URI: {redirect_uri_hint}")
print(" API/SDK: Web API")
print(" 3. Agree to the terms, click Save.")
print(" 4. Open the app's Settings page and copy the Client ID.")
print(" 5. Paste it below.")
print()
print(
f"\n{'=' * 70}\nSpotify first-time setup\n{'=' * 70}\n\n"
"Spotify requires every user to register their own lightweight\n"
"developer app. This takes about two minutes and only has to be\n"
"done once per machine.\n\n"
f"Full guide: {SPOTIFY_DOCS_URL}\n\n"
"Steps:\n"
f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...\n"
" 2. Click 'Create app' and fill in:\n"
" App name: anything (e.g. hermes-agent)\n"
" Description: anything\n"
f" Redirect URI: {redirect_uri_hint}\n"
" API/SDK: Web API\n"
" 3. Agree to the terms, click Save.\n"
" 4. Open the app's Settings page and copy the Client ID.\n"
" 5. Paste it below.\n"
)
if not _is_remote_session():
try:
@@ -498,7 +353,6 @@ def _spotify_interactive_setup(redirect_uri_hint: str) -> str:
pass
from hermes_cli.cli_output import line_input
try:
raw = line_input("Spotify Client ID: ").strip()
except (EOFError, KeyboardInterrupt):
@@ -506,20 +360,15 @@ def _spotify_interactive_setup(redirect_uri_hint: str) -> str:
raise SystemExit("Spotify setup cancelled.")
if not raw:
print()
print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.")
print(f"\nNo Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.")
raise SystemExit("Spotify setup cancelled: empty Client ID.")
# Persist so subsequent `hermes auth spotify` runs skip the wizard.
# Persist so later runs skip the wizard; only pin a NON-default redirect URI.
save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw)
# Only persist the redirect URI if it's non-default, to avoid pinning
# users to a value the default might later change to.
if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI:
save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint)
print()
print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env")
print()
print("\nSaved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env\n")
return raw
@@ -527,12 +376,9 @@ def login_spotify_command(args) -> None:
from hermes_cli.auth import _auth_store_lock, _can_open_graphical_browser, _is_remote_session, _load_auth_store, _print_loopback_ssh_hint, _save_auth_store, _store_provider_state, get_provider_auth_state
existing_state = get_provider_auth_state("spotify") or {}
# Interactive wizard: if no client_id is configured anywhere, walk the
# user through creating the Spotify developer app instead of crashing
# with "HERMES_SPOTIFY_CLIENT_ID is required".
explicit_client_id = getattr(args, "client_id", None)
# No client_id anywhere -> wizard instead of "HERMES_SPOTIFY_CLIENT_ID is required".
try:
client_id = _spotify_client_id(explicit_client_id, existing_state)
client_id = _spotify_client_id(getattr(args, "client_id", None), existing_state)
except AuthError as exc:
if getattr(exc, "code", "") != "spotify_client_id_missing":
raise
@@ -547,27 +393,17 @@ def login_spotify_command(args) -> None:
open_browser = not getattr(args, "no_browser", False)
code_verifier = _spotify_code_verifier()
code_challenge = _spotify_code_challenge(code_verifier)
state_nonce = uuid.uuid4().hex
authorize_url = _spotify_build_authorize_url(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state_nonce,
code_challenge=code_challenge,
accounts_base_url=accounts_base_url,
client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state_nonce,
code_challenge=_spotify_code_challenge(code_verifier), accounts_base_url=accounts_base_url,
)
print("Starting Spotify PKCE login...")
print(f"Client ID: {client_id}")
print(f"Redirect URI: {redirect_uri}")
print("Make sure this redirect URI is allow-listed in your Spotify app settings.")
print()
print("Open this URL to authorize Hermes:")
print(authorize_url)
print()
print(f"Full setup guide: {SPOTIFY_DOCS_URL}")
print()
print(
f"Starting Spotify PKCE login...\nClient ID: {client_id}\nRedirect URI: {redirect_uri}\n"
"Make sure this redirect URI is allow-listed in your Spotify app settings.\n\n"
f"Open this URL to authorize Hermes:\n{authorize_url}\n\nFull setup guide: {SPOTIFY_DOCS_URL}\n"
)
_print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL)
@@ -576,36 +412,32 @@ def login_spotify_command(args) -> None:
opened = webbrowser.open(authorize_url)
except Exception:
opened = False
if opened:
print("Browser opened for Spotify authorization.")
else:
print("Could not open the browser automatically; use the URL above.")
print(
"Browser opened for Spotify authorization." if opened
else "Could not open the browser automatically; use the URL above."
)
callback = _spotify_wait_for_callback(
redirect_uri,
timeout_seconds=float(getattr(args, "timeout", None) or 180.0),
)
callback = _spotify_wait_for_callback(redirect_uri, timeout_seconds=float(getattr(args, "timeout", None) or 180.0))
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
raise SystemExit(f"Spotify authorization failed: {detail}")
raise SystemExit(f"Spotify authorization failed: {callback.get('error_description') or callback['error']}")
if callback.get("state") != state_nonce:
raise SystemExit("Spotify authorization failed: state mismatch.")
token_payload = _spotify_exchange_code_for_tokens(
client_id=client_id,
code=str(callback.get("code") or ""),
redirect_uri=redirect_uri,
code_verifier=code_verifier,
accounts_base_url=accounts_base_url,
token_payload = _spotify_token_post(
accounts_base_url,
{
"client_id": client_id, "grant_type": "authorization_code",
"code": str(callback.get("code") or ""), "redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
timeout_seconds=float(getattr(args, "timeout", None) or 20.0),
what="token exchange", failed_code="spotify_token_exchange_failed",
invalid_code="spotify_token_exchange_invalid",
invalid_message="Spotify token response did not include an access_token.",
)
spotify_state = _spotify_token_payload_to_state(
token_payload,
client_id=client_id,
redirect_uri=redirect_uri,
requested_scope=scope,
accounts_base_url=accounts_base_url,
api_base_url=api_base_url,
token_payload, client_id=client_id, redirect_uri=redirect_uri, requested_scope=scope,
accounts_base_url=accounts_base_url, api_base_url=api_base_url,
)
with _auth_store_lock():
@@ -613,7 +445,7 @@ def login_spotify_command(args) -> None:
_store_provider_state(auth_store, "spotify", spotify_state, set_active=False)
saved_to = _save_auth_store(auth_store)
print("Spotify login successful!")
print(f" Auth state: {saved_to}")
print(" Provider state saved under providers.spotify")
print(f" Docs: {SPOTIFY_DOCS_URL}")
print(
f"Spotify login successful!\n Auth state: {saved_to}\n"
f" Provider state saved under providers.spotify\n Docs: {SPOTIFY_DOCS_URL}"
)
+231 -486
View File
File diff suppressed because it is too large Load Diff
+68 -140
View File
@@ -1,9 +1,7 @@
"""Kimi Code and Z.AI endpoint auto-detection, LM Studio base-URL normalization.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported
lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
"""
from __future__ import annotations
@@ -13,87 +11,49 @@ import hashlib
from typing import Dict, Optional
from hermes_cli.auth_constants import httpx
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
# Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work
# on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on
# api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set
# KIMI_BASE_URL explicitly.
#
# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint
# speaks the Anthropic Messages protocol, and the anthropic SDK appends
# "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages"
# (the correct target). Using "/coding/v1" here would produce
# "/coding/v1/v1/messages" (a 404).
# "sk-kimi-" keys only work on api.kimi.com/coding; legacy moonshot keys use the old default.
# NO /v1 suffix: the anthropic SDK appends "/v1/messages" itself ("/coding/v1" would 404).
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding"
def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:
"""Return the correct Kimi base URL based on the API key prefix.
If the user has explicitly set KIMI_BASE_URL, that always wins. Otherwise, sk-kimi- prefixed
keys route to api.kimi.com/coding/v1.
"""
"""Kimi base URL from the key prefix; an explicit KIMI_BASE_URL always wins."""
if env_override:
return env_override
# No key → nothing to infer from. Return default without inspecting.
if not api_key:
return default_url
if api_key.startswith("sk-kimi-"):
if api_key and api_key.startswith("sk-kimi-"):
return KIMI_CODE_BASE_URL
return default_url
# Z.AI has separate billing for general vs coding plans, and global vs China
# endpoints. A key that works on one may return "Insufficient balance" on
# another. We probe at setup time and store the working endpoint.
# Each entry lists candidate models to try in order — newer coding plan accounts
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
# ones still use glm-4.7.
# Z.AI bills general/coding plans and global/China endpoints separately ("Insufficient balance" on
# the wrong one), so probe once and cache. Candidate models are tried in order: newer coding-plan
# accounts may only have recent GLM slugs, older ones still glm-4.7.
_ZAI_CODING_PROBE_MODELS = ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"]
ZAI_ENDPOINTS = [
# (id, base_url, probe_models, label)
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", _ZAI_CODING_PROBE_MODELS, "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", _ZAI_CODING_PROBE_MODELS, "China (Coding Plan)"),
]
def _probe_single_zai_endpoint(
api_key: str, endpoint: tuple, timeout: float,
) -> Optional[Dict[str, str]]:
"""Probe a single Z.AI endpoint. Returns endpoint info dict or None.
Preserves the per-endpoint candidate-model loop: endpoints carry a ``probe_models`` LIST and
each model is tried in order until one succeeds (some plans only accept newer/older GLM slugs).
"""
def _probe_single_zai_endpoint(api_key: str, endpoint: tuple, timeout: float) -> Optional[Dict[str, str]]:
"""Probe one Z.AI endpoint, trying its candidate models in order; None when none succeeds."""
ep_id, base_url, probe_models, label = endpoint
for model in probe_models:
try:
resp = httpx.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"stream": False,
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
},
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model, "stream": False, "max_tokens": 1, "messages": [{"role": "user", "content": "ping"}]},
timeout=timeout,
)
if resp.status_code == 200:
logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
return {
"id": ep_id,
"base_url": base_url,
"model": model,
"label": label,
}
return {"id": ep_id, "base_url": base_url, "model": model, "label": label}
logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
except Exception as exc:
logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
@@ -101,124 +61,92 @@ def _probe_single_zai_endpoint(
def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:
"""Probe z.ai endpoints in parallel to find one that accepts this API key.
Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the first working endpoint
(in ZAI_ENDPOINTS priority order), or None if all fail. For endpoints with multiple candidate
models, each worker tries its endpoint's models in order and returns the first that succeeds.
"""
"""Probe z.ai endpoints in parallel; first working one in ZAI_ENDPOINTS priority order, or None."""
from concurrent.futures import ThreadPoolExecutor, as_completed
# No `with` block: a context manager would join ALL probe threads on
# exit, defeating the early return below. shutdown(wait=False) lets the
# surviving daemon-style probes drain in the background instead of
# blocking the caller on slow/unreachable endpoints.
# No `with`: it would join ALL probes on exit, defeating the early return below.
pool = ThreadPoolExecutor(max_workers=len(ZAI_ENDPOINTS))
try:
futures = {
pool.submit(_probe_single_zai_endpoint, api_key, ep, timeout): ep[0]
for ep in ZAI_ENDPOINTS
}
futures = {pool.submit(_probe_single_zai_endpoint, api_key, ep, timeout): ep[0] for ep in ZAI_ENDPOINTS}
by_id = {ep_id: f for f, ep_id in futures.items()}
results: Dict[str, Dict[str, str]] = {}
def _first_ready(require_done: bool) -> Optional[Dict[str, str]]:
# Walk endpoints in PRIORITY order; a lower-priority success only wins once every
# higher-priority probe has finished without success.
for ep in ZAI_ENDPOINTS:
if require_done and not by_id[ep[0]].done():
return None # a higher-priority probe is still in flight
if ep[0] in results:
return results[ep[0]]
return None
for future in as_completed(futures):
ep_id = futures[future]
try:
result = future.result()
if result is not None:
results[ep_id] = result
results[futures[future]] = result
except Exception:
pass
# Early exit in PRIORITY order: walk endpoints highest-priority
# first; if one has succeeded and every higher-priority probe
# has already finished (without success), no later completion
# can win — return now instead of waiting out slow endpoints
# (main's sequential loop also stopped at first success).
for ep in ZAI_ENDPOINTS:
if not by_id[ep[0]].done():
break # a higher-priority probe is still in flight
if ep[0] in results:
return results[ep[0]]
# All probes finished: first match in priority order, if any.
for ep in ZAI_ENDPOINTS:
if ep[0] in results:
return results[ep[0]]
return None
winner = _first_ready(require_done=True)
if winner is not None:
return winner
return _first_ready(require_done=False)
finally:
pool.shutdown(wait=False)
def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:
"""Return the correct Z.AI base URL by probing endpoints.
"""Z.AI base URL by probing endpoints; an explicit GLM_BASE_URL always wins.
If the user has explicitly set GLM_BASE_URL, that always wins. Otherwise, probe the candidate
endpoints to find one that accepts the key. The detected endpoint is cached in provider state
(auth.json) keyed on a hash of the API key so subsequent starts skip the probe.
The detected endpoint is cached in provider state (auth.json) keyed on a hash of the API key so
subsequent starts skip the probe.
"""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state, detect_zai_endpoint
if env_override:
return env_override
# No API key set → don't probe (would fire N×M HTTPS requests with an
# empty Bearer token, all returning 401). This path is hit during
# auxiliary-client auto-detection when the user has no Z.AI credentials
# at all — the caller discards the result immediately, so the probe is
# pure latency for every AIAgent construction.
# No key -> don't probe (N×M 401s); auxiliary-client auto-detection hits this for everyone.
if not api_key:
return default_url
# Check provider-state cache for a previously-detected endpoint.
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "zai") or {}
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
state = _load_provider_state(_load_auth_store(), "zai") or {}
cached = state.get("detected_endpoint")
if isinstance(cached, dict) and cached.get("base_url"):
key_hash = cached.get("key_hash", "")
if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:
logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
return cached["base_url"]
if isinstance(cached, dict) and cached.get("base_url") and cached.get("key_hash", "") == key_hash:
logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
return cached["base_url"]
# Probe — may take up to ~8s per endpoint.
detected = detect_zai_endpoint(api_key)
if detected and detected.get("base_url"):
# Persist the detection result keyed on the API key hash.
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
detected_endpoint = {
"base_url": detected["base_url"],
"endpoint_id": detected.get("id", ""),
"model": detected.get("model", ""),
"label": detected.get("label", ""),
"key_hash": key_hash,
}
# Persist failure (disk full, permissions, lock timeout) must not
# break resolution — detection already succeeded; worst case the
# next start re-probes.
try:
with _auth_store_lock():
# Reload auth_store under lock to avoid overwriting concurrent changes
auth_store = _load_auth_store()
state_under_lock = _load_provider_state(auth_store, "zai") or {}
state_under_lock["detected_endpoint"] = detected_endpoint
# set_active=False: this runs from credential-pool env seeding
# (agent/credential_pool.py) for ANY user with a Z.AI key in env,
# and caching a probe result must not flip their active provider.
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
_save_auth_store(auth_store)
except Exception as exc:
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
return detected["base_url"]
if not (detected and detected.get("base_url")):
logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
return default_url
logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
return default_url
detected_endpoint = {
"base_url": detected["base_url"], "endpoint_id": detected.get("id", ""),
"model": detected.get("model", ""), "label": detected.get("label", ""),
"key_hash": key_hash,
}
# Persist failure must not break resolution; worst case the next start re-probes.
try:
with _auth_store_lock():
auth_store = _load_auth_store() # reload under lock to avoid overwriting concurrent changes
state_under_lock = _load_provider_state(auth_store, "zai") or {}
state_under_lock["detected_endpoint"] = detected_endpoint
# set_active=False: runs from credential-pool env seeding; must not flip active provider.
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
_save_auth_store(auth_store)
except Exception as exc:
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
return detected["base_url"]
def _normalize_lmstudio_runtime_base_url(base_url: str) -> str:
"""Return the OpenAI-compatible LM Studio runtime base URL.
LM Studio's native management API lives under ``/api/v1`` while its OpenAI-compatible chat
endpoint lives under ``/v1``. Users often paste either form into ``LM_BASE_URL`` or
``model.base_url``; normalize before the OpenAI SDK appends ``/chat/completions``.
endpoint lives under ``/v1``; users paste either form, so normalize before the SDK appends
``/chat/completions``.
"""
root = str(base_url or "").strip().rstrip("/")
for suffix in ("/api/v1", "/api", "/v1"):
+58 -143
View File
@@ -20,17 +20,16 @@ from hermes_cli.urllib_security import open_credentialed_url
logger = logging.getLogger(__name__)
TokenProvider = Optional[Callable[[], str]]
# Default Azure OpenAI ``api-version`` to probe with. The v1 GA endpoint
# accepts requests without ``api-version`` entirely, so this is only used
# as a fallback for pre-v1 resources that still require it.
# Azure OpenAI ``api-version`` fallbacks for pre-v1 resources; the v1 GA endpoint accepts requests
# without ``api-version`` entirely, so these are only probed second.
_AZURE_OPENAI_PROBE_API_VERSIONS = (
"2025-04-01-preview",
"2024-10-21", # oldest GA that supports /models
)
# Default Azure Anthropic ``api-version``. Matches the value used by
# ``agent/anthropic_adapter.py`` when building the Anthropic client.
# Matches the value ``agent/anthropic_adapter.py`` uses when building the Anthropic client.
_AZURE_ANTHROPIC_API_VERSION = "2025-04-15"
@@ -38,37 +37,25 @@ _AZURE_ANTHROPIC_API_VERSION = "2025-04-15"
class DetectionResult:
"""Everything auto-detection could gather from a base URL + API key."""
#: Detected API transport: ``"chat_completions"``,
#: ``"anthropic_messages"``, or ``None`` when detection failed.
#: ``"chat_completions"``, ``"anthropic_messages"``, or ``None`` when detection failed.
api_mode: Optional[str] = None
#: Deployment / model IDs returned by ``/models`` (best effort).
#: Empty when the endpoint doesn't expose the list with an API key.
#: Deployment / model IDs returned by ``/models`` (best effort; empty when not exposed).
models: list[str] = field(default_factory=list)
#: Lowercased host from the base URL (used for display messages).
hostname: str = ""
#: Human-readable reason the detector chose ``api_mode``. Useful
#: for explaining auto-detection to the user in the wizard.
#: Human-readable reason the detector chose ``api_mode`` (shown by the wizard).
reason: str = ""
#: ``True`` when ``/models`` returned a valid OpenAI-shaped payload.
models_probe_ok: bool = False
#: ``True`` when the URL was determined to be an Anthropic-style
#: endpoint (from path suffix or live probe).
#: ``True`` when the URL was determined to be Anthropic-style (path suffix or live probe).
is_anthropic: bool = False
def _resolve_credential(api_key: Any,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[Optional[str], str]:
"""Coerce wizard inputs into a (token, mode) pair.
def _resolve_credential(api_key: Any, token_provider: TokenProvider = None) -> tuple[Optional[str], str]:
"""Coerce wizard inputs into ``(token_or_None, mode)``.
Returns ``(token_or_None, mode)`` where ``mode`` is: - ``"entra_id"`` when a callable token
provider was supplied — the returned token is a freshly minted bearer JWT, sent ONLY in
``Authorization: Bearer``.
``mode`` is ``"entra_id"`` when a callable token provider was supplied (the token is a freshly
minted bearer JWT, sent ONLY in ``Authorization: Bearer``), else ``"api_key"``.
"""
# Token-provider path (callable wins when both supplied).
for provider, label in ((token_provider, "token_provider"), (api_key, "api_key callable")):
@@ -79,7 +66,6 @@ def _resolve_credential(api_key: Any,
except Exception as exc:
logger.debug("azure_detect: %s failed: %s", label, exc)
return None, "entra_id"
# API-key path.
if isinstance(api_key, str) and api_key:
return api_key, "api_key"
return None, "api_key"
@@ -91,25 +77,18 @@ def _authed_request(url: str, api_key: Any, token_provider, *, method: str = "GE
token, mode = _resolve_credential(api_key, token_provider)
req = urllib_request.Request(url, method=method, data=data)
if token:
# Legacy broad-compat behaviour sends both headers so we land on any Azure resource. In
# entra_id mode send Bearer ONLY — api-key would log a JWT in a slot meant for static keys.
if mode != "entra_id":
# Legacy broad-compat behaviour: send both headers so we land on
# any Azure resource regardless of which it accepts.
req.add_header("api-key", token)
# Bearer-only in entra_id mode: do NOT also set api-key, which would
# log a JWT in a header slot intended for static keys.
req.add_header("Authorization", f"Bearer {token}")
req.add_header("User-Agent", "hermes-agent/azure-detect")
return req
def _http_get_json(url: str,
api_key: Any,
timeout: float = 6.0,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[int, Optional[dict]]:
"""GET a URL with the appropriate auth headers. Return
``(status_code, parsed_json_or_None)``. Never raises."""
def _http_get_json(url: str, api_key: Any, timeout: float = 6.0, *,
token_provider: TokenProvider = None) -> tuple[int, Optional[dict]]:
"""GET with auth headers; return ``(status_code, parsed_json_or_None)``. Never raises."""
req = _authed_request(url, api_key, token_provider)
try:
with open_credentialed_url(req, timeout=timeout) as resp:
@@ -134,166 +113,115 @@ def _strip_trailing_v1(url: str) -> str:
def _looks_like_anthropic_path(url: str) -> bool:
"""Return True when the URL's path ends in ``/anthropic`` or contains a ``/anthropic/`` segment.
Used by Azure Foundry resources that route Claude traffic through a dedicated path.
"""
"""True when the path ends in ``/anthropic`` or contains a ``/anthropic/`` segment (Foundry Claude routes)."""
try:
parsed = urlparse(url)
path = (parsed.path or "").lower().rstrip("/")
path = (urlparse(url).path or "").lower().rstrip("/")
return path.endswith("/anthropic") or "/anthropic/" in path + "/"
except Exception:
return False
def _extract_model_ids(payload: dict) -> list[str]:
"""Extract a list of model IDs from an OpenAI-shaped ``/models``
response. Returns ``[]`` on any shape mismatch."""
"""Model IDs from an OpenAI-shaped ``/models`` response; ``[]`` on any shape mismatch."""
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list):
return []
ids: list[str] = []
for item in data:
if not isinstance(item, dict):
continue
# OpenAI shape: {"id": "gpt-5.4", "object": "model", ...}
mid = item.get("id") or item.get("model") or item.get("name")
if isinstance(mid, str) and mid:
ids.append(mid)
if isinstance(item, dict):
mid = item.get("id") or item.get("model") or item.get("name")
if isinstance(mid, str) and mid:
ids.append(mid)
return ids
def _probe_openai_models(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[bool, list[str]]:
def _probe_openai_models(base_url: str, api_key: Any, *, token_provider: TokenProvider = None) -> tuple[bool, list[str]]:
"""Probe ``<base>/models`` for an OpenAI-shaped response."""
base_url = base_url.rstrip("/")
# Azure OpenAI v1: {resource}.openai.azure.com/openai/v1 — no
# api-version required for GA paths, so probe without first.
# Fallback: explicit api-version for pre-v1 resources
candidates = [f"{base_url}/models"] + [
f"{base_url}/models?api-version={v}" for v in _AZURE_OPENAI_PROBE_API_VERSIONS
]
# Azure OpenAI v1 needs no api-version for GA paths, so probe without first; then fall back to
# explicit api-versions for pre-v1 resources.
candidates = [f"{base_url}/models"] + [f"{base_url}/models?api-version={v}" for v in _AZURE_OPENAI_PROBE_API_VERSIONS]
for url in candidates:
status, body = _http_get_json(url, api_key, token_provider=token_provider)
if status == 200 and body is not None:
ids = _extract_model_ids(body)
if ids:
logger.info(
"azure_detect: /models probe OK at %s (%d models)",
url, len(ids),
)
logger.info("azure_detect: /models probe OK at %s (%d models)", url, len(ids))
return True, ids
# 200 + empty list still counts as "OpenAI shape, no models
# listed" — let the user proceed with manual entry.
# 200 + empty list still counts as "OpenAI shape, no models listed".
if isinstance(body, dict) and "data" in body:
return True, []
return False, []
def _probe_anthropic_messages(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> bool:
"""Send a zero-token request to ``<base>/v1/messages`` and check whether the endpoint at least
*recognises* the Anthropic Messages shape (any 4xx that mentions ``messages`` or ``model``, or a
400 ``invalid_request`` with an Anthropic error shape). Never completes a real chat.
def _probe_anthropic_messages(base_url: str, api_key: Any, *, token_provider: TokenProvider = None) -> bool:
"""Zero-token POST to ``<base>/v1/messages``: does the endpoint *recognise* the Anthropic shape?
Any 4xx mentioning ``messages``/``model``, or an Anthropic-shaped error body, counts. Never
completes a real chat.
"""
base = _strip_trailing_v1(base_url)
url = f"{base}/v1/messages?api-version={_AZURE_ANTHROPIC_API_VERSION}"
payload = json.dumps({
"model": "probe",
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
}).encode("utf-8")
url = f"{_strip_trailing_v1(base_url)}/v1/messages?api-version={_AZURE_ANTHROPIC_API_VERSION}"
payload = json.dumps({"model": "probe", "max_tokens": 1, "messages": [{"role": "user", "content": "ping"}]}).encode("utf-8")
req = _authed_request(url, api_key, token_provider, method="POST", data=payload)
req.add_header("anthropic-version", "2023-06-01")
req.add_header("content-type", "application/json")
try:
with open_credentialed_url(req, timeout=6.0) as resp:
# Should never 200 — "probe" isn't a real deployment. But
# if it does, the endpoint definitely speaks Anthropic.
# Should never 200 — "probe" isn't a real deployment — but if it does, it speaks Anthropic.
return resp.status < 500
except HTTPError as exc:
# 4xx with an Anthropic-shaped error body = Anthropic endpoint.
try:
body = exc.read().decode("utf-8", errors="replace")
lowered = body.lower()
lowered = exc.read().decode("utf-8", errors="replace").lower()
if "anthropic" in lowered or '"type"' in lowered and '"error"' in lowered:
return True
# Pre-Azure-v1 Azure Foundry returns a plain 404 for
# Anthropic-style calls on non-Anthropic deployments. A
# 400 "model not found" IS Anthropic though.
if exc.code == 400 and ("messages" in lowered or "model" in lowered):
return True
return False
# Pre-Azure-v1 Foundry returns a plain 404 for Anthropic-style calls on non-Anthropic
# deployments. A 400 "model not found" IS Anthropic though.
return exc.code == 400 and ("messages" in lowered or "model" in lowered)
except Exception:
return False
except (URLError, TimeoutError, OSError):
return False
except Exception: # pragma: no cover
except Exception: # URLError, TimeoutError, OSError, anything else
return False
def detect(base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> DetectionResult:
"""Inspect an Azure endpoint and describe its transport + models.
Call this from the wizard before asking the user to pick an API mode manually. The caller should
treat the returned :class:`DetectionResult` as *advisory* — if ``api_mode`` is None, fall back
to asking the user.
def detect(base_url: str, api_key: Any = "", *, token_provider: TokenProvider = None) -> DetectionResult:
"""Inspect an Azure endpoint and describe its transport + models (advisory — None api_mode means ask the user).
``api_key`` may be a string (legacy API-key auth — sends both ``api-key:`` and ``Authorization:
Bearer``) or a callable returning a bearer JWT (Entra ID auth — sends ONLY ``Authorization:
Bearer``). ``token_provider`` is an alternative explicit name for the callable form; if both are
supplied the callable wins.
Bearer``). ``token_provider`` is an explicit name for the callable form; the callable wins.
"""
result = DetectionResult()
try:
parsed = urlparse(base_url)
result.hostname = (parsed.hostname or "").lower()
result.hostname = (urlparse(base_url).hostname or "").lower()
except Exception:
result.hostname = ""
# 1. Path sniff. Azure Foundry exposes Anthropic-style deployments
# under a dedicated ``/anthropic`` path.
# 1. Path sniff: Foundry exposes Anthropic-style deployments under a dedicated /anthropic path.
if _looks_like_anthropic_path(base_url):
result.is_anthropic = True
result.api_mode = "anthropic_messages"
result.reason = "URL path ends in /anthropic → Anthropic Messages API"
return result
# 2. Try the OpenAI-style /models probe. If this works, the
# endpoint definitely speaks OpenAI wire.
# 2. OpenAI-style /models probe — success means the endpoint definitely speaks OpenAI wire.
ok, models = _probe_openai_models(base_url, api_key, token_provider=token_provider)
if ok:
result.models_probe_ok = True
result.models = models
result.api_mode = "chat_completions"
result.reason = (
f"GET /models returned {len(models)} model(s) — OpenAI-style endpoint"
if models
f"GET /models returned {len(models)} model(s) — OpenAI-style endpoint" if models
else "GET /models returned an OpenAI-shaped empty list — OpenAI-style endpoint"
)
return result
# 3. Fallback: probe the Anthropic Messages shape. Slower and more
# intrusive than /models, so only run it when the OpenAI probe
# failed.
# 3. Anthropic Messages probe — slower and more intrusive, so only when /models failed.
if _probe_anthropic_messages(base_url, api_key, token_provider=token_provider):
result.is_anthropic = True
result.api_mode = "anthropic_messages"
result.reason = "Endpoint accepts Anthropic Messages shape"
return result
# Nothing matched. Caller falls back to manual selection.
result.reason = (
"Could not probe endpoint (private network, missing model list, or "
"non-standard path) — falling back to manual API-mode selection"
@@ -301,40 +229,27 @@ def detect(base_url: str,
return result
def lookup_context_length(model: str,
base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> Optional[int]:
"""Thin wrapper around :func:`agent.model_metadata.get_model_context_length` that returns ``None``
when only the fallback default (128k) would fire, so the wizard can distinguish "we actually
know this" from "we guessed.
def lookup_context_length(model: str, base_url: str, api_key: Any = "", *,
token_provider: TokenProvider = None) -> Optional[int]:
"""``get_model_context_length`` that returns None when only the fallback default would fire, so
the wizard can distinguish "we actually know this" from "we guessed".
"""
model_id = str(model or "").strip()
if not model_id:
return None
try:
from agent.model_metadata import (
DEFAULT_FALLBACK_CONTEXT,
get_model_context_length,
)
from agent.model_metadata import DEFAULT_FALLBACK_CONTEXT, get_model_context_length
except Exception:
return None
# Resolve the credential once. For Entra mode this calls the token
# provider; for legacy api_key this is a no-op string pass-through.
# Resolve the credential once: Entra mode calls the provider; api_key is a string pass-through.
token, _mode = _resolve_credential(api_key, token_provider)
try:
n = get_model_context_length(model_id, base_url=base_url, api_key=token or "")
except Exception as exc:
logger.debug("azure_detect: context length lookup failed: %s", exc)
return None
if isinstance(n, int) and n > 0 and n != DEFAULT_FALLBACK_CONTEXT:
return n
return None
return n if isinstance(n, int) and n > 0 and n != DEFAULT_FALLBACK_CONTEXT else None
__all__ = ["DetectionResult", "detect", "lookup_context_length"]
+45 -92
View File
@@ -29,13 +29,11 @@ def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, An
return explicit
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if platform and chat_id:
return {
"platform": platform,
"chat_id": chat_id,
"platform": platform, "chat_id": chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
}
@@ -49,25 +47,31 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
values: Dict[str, str] = {}
leftovers = []
for tok in tokens:
if "=" in tok:
k, _, v = tok.partition("=")
k = k.strip()
if k:
values[k] = v.strip()
continue
leftovers.append(tok)
k, sep, v = tok.partition("=")
if sep and k.strip():
values[k.strip()] = v.strip()
else:
leftovers.append(tok)
return values, leftovers
def _pick(candidates: List[Any]) -> Optional[Tuple[Optional[Any], List[Any]]]:
"""One candidate -> (it, []); several -> (None, all); none -> None (keep searching)."""
if len(candidates) == 1:
return candidates[0], []
if candidates:
return None, candidates
return None
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed blueprint name to a blueprint.
Matching is forgiving because chat-line users type the name (unlike the dashboard/Discord where
it's picked): exact key first, then case-insensitive prefix on key or title, then a difflib
fuzzy pass.
it's picked): exact key first, then case-insensitive prefix on key or title, then substring
anywhere in key/title/description, then a difflib fuzzy pass on keys.
"""
from cron.blueprint_catalog import CATALOG, get_blueprint
q = (query or "").strip().lower()
if not q:
return None, []
@@ -76,41 +80,20 @@ def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
if exact is not None:
return exact, []
# Prefix match on key or title word-start.
prefix = [
r for r in CATALOG
if r.key.lower().startswith(q)
or any(w.lower().startswith(q) for w in r.title.split())
]
if len(prefix) == 1:
return prefix[0], []
if len(prefix) > 1:
return None, prefix
# Substring match anywhere in key/title/description.
substr = [
r for r in CATALOG
if q in r.key.lower() or q in r.title.lower() or q in r.description.lower()
]
if len(substr) == 1:
return substr[0], []
if len(substr) > 1:
return None, substr
# Fuzzy on keys (typo tolerance).
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
if len(close) == 1:
return get_blueprint(close[0]), []
if len(close) > 1:
return None, [get_blueprint(k) for k in close]
passes = (
lambda: [r for r in CATALOG if r.key.lower().startswith(q) or any(w.lower().startswith(q) for w in r.title.split())],
lambda: [r for r in CATALOG if q in r.key.lower() or q in r.title.lower() or q in r.description.lower()],
lambda: [get_blueprint(k) for k in difflib.get_close_matches(q, [r.key for r in CATALOG], n=3, cutoff=0.6)],
)
for candidates in passes:
picked = _pick(candidates())
if picked is not None:
return picked
return None, []
def _humanize_schedule(blueprint) -> str:
from cron.blueprint_catalog import _humanize_schedule as _h
try:
return _h(blueprint)
except Exception:
@@ -125,17 +108,13 @@ def build_blueprint_seed(blueprint) -> str:
rendered prompt. Defaults are stated so the agent can offer them.
"""
from cron.blueprint_catalog import WEEKDAY_PRESETS
lines: List[str] = []
lines.append(
lines: List[str] = [
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
f"'{blueprint.key}'). {blueprint.description}"
)
lines.append("")
lines.append(
f"'{blueprint.key}'). {blueprint.description}",
"",
"Ask me for each of these, one at a time, offering the default in "
"brackets if I don't have a preference:"
)
"brackets if I don't have a preference:",
]
for s in blueprint.slots:
bits = [f"- {s.label} ({s.name})"]
if s.options:
@@ -164,7 +143,6 @@ def build_blueprint_seed(blueprint) -> str:
def _fmt_catalog() -> str:
from cron.blueprint_catalog import CATALOG
lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
for r in CATALOG:
lines.append(f" • {r.key} — {r.title}")
@@ -178,48 +156,34 @@ def _fmt_catalog() -> str:
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
lines = [f"'{query}' matches several blueprints — which one?\n"]
for r in candidates:
lines.append(f" • {r.key} — {r.title}")
lines.extend(f" • {r.key} — {r.title}" for r in candidates)
lines.append("\nRun `/blueprint <name>` with one of the names above.")
return "\n".join(lines)
def _fmt_no_match(query: str) -> str:
from cron.blueprint_catalog import CATALOG
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
close = difflib.get_close_matches((query or "").lower(), [r.key for r in CATALOG], n=3, cutoff=0.4)
msg = f"No automation blueprint matches '{query}'."
if close:
msg += " Did you mean: " + ", ".join(close) + "?"
msg += " Run /blueprint to see the catalog."
return msg
return msg + " Run /blueprint to see the catalog."
def _manage_hint(surface: str) -> str:
"""Post-create management hint. /cron is a CLI-only slash command; on gateway platforms the user
manages jobs by asking the agent (cronjob tool) or from the dashboard.
"""
if surface == "cli":
return "Manage it with /cron."
return "Ask me to list, pause, or remove it any time."
"""/cron is CLI-only; on gateway platforms jobs are managed via the agent (cronjob tool) or dashboard."""
return "Manage it with /cron." if surface == "cli" else "Ask me to list, pause, or remove it any time."
def handle_blueprint_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
surface: str = "cli",
args: str, *, origin: Optional[Dict[str, Any]] = None, surface: str = "cli"
) -> BlueprintCommandResult:
"""Dispatch a ``/blueprint`` invocation.
Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the caller must feed it to
the agent as the next user turn; otherwise the command is fully handled and only ``text`` is
shown.
``args`` is everything after ``/blueprint``. ``origin`` lets a directly created job deliver back
to the chat it was set up from. ``surface`` (``"cli"`` | ``"gateway"``) picks the right wording
for follow-up hints — ``/cron`` only exists on the CLI.
When ``agent_seed`` is set on the result the caller must feed it to the agent as the next user
turn; otherwise the command is fully handled and only ``text`` is shown. ``args`` is everything
after ``/blueprint``; ``origin`` lets a directly created job deliver back to the chat it was set
up from; ``surface`` (``"cli"`` | ``"gateway"``) picks the follow-up hint wording.
"""
try:
from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
@@ -232,7 +196,6 @@ def handle_blueprint_command(
except ValueError:
tokens = (args or "").split()
# Bare -> list catalog.
if not tokens:
return BlueprintCommandResult(_fmt_catalog())
@@ -241,18 +204,12 @@ def handle_blueprint_command(
blueprint, candidates = match_blueprint(query)
if blueprint is None:
if candidates:
return BlueprintCommandResult(_fmt_candidates(query, candidates))
return BlueprintCommandResult(_fmt_no_match(query))
return BlueprintCommandResult(_fmt_candidates(query, candidates) if candidates else _fmt_no_match(query))
# `<name>` with no inline slot values -> seed the agent to ask for them.
if not values:
seed = build_blueprint_seed(blueprint)
text = (
f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
"I'll ask you a couple of things…"
)
return BlueprintCommandResult(text, agent_seed=seed)
text = f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). I'll ask you a couple of things…"
return BlueprintCommandResult(text, agent_seed=build_blueprint_seed(blueprint))
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
try:
@@ -264,11 +221,7 @@ def handle_blueprint_command(
)
try:
from cron.scheduler import (
CronSchedulerRegistrationError,
create_job_with_scheduler_registration,
)
from cron.scheduler import CronSchedulerRegistrationError, create_job_with_scheduler_registration
job = create_job_with_scheduler_registration(**spec)
except CronSchedulerRegistrationError as e:
return BlueprintCommandResult(e.user_message())