Merge remote-tracking branch 'origin/main' into hermes/hermes-6bb9911e
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# Hermes Agent v0.3.0 (v2026.3.17)
|
||||
|
||||
**Release Date:** March 17, 2026
|
||||
|
||||
> The streaming, plugins, and provider release — unified real-time token delivery, first-class plugin architecture, rebuilt provider system with Vercel AI Gateway, native Anthropic provider, smart approvals, live Chrome CDP browser connect, ACP IDE integration, Honcho memory, voice mode, persistent shell, and 50+ bug fixes across every platform.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Unified Streaming Infrastructure** — Real-time token-by-token delivery in CLI and all gateway platforms. Responses stream as they're generated instead of arriving as a block. ([#1538](https://github.com/NousResearch/hermes-agent/pull/1538))
|
||||
|
||||
- **First-Class Plugin Architecture** — Drop Python files into `~/.hermes/plugins/` to extend Hermes with custom tools, commands, and hooks. No forking required. ([#1544](https://github.com/NousResearch/hermes-agent/pull/1544), [#1555](https://github.com/NousResearch/hermes-agent/pull/1555))
|
||||
|
||||
- **Native Anthropic Provider** — Direct Anthropic API calls with Claude Code credential auto-discovery, OAuth PKCE flows, and native prompt caching. No OpenRouter middleman needed. ([#1097](https://github.com/NousResearch/hermes-agent/pull/1097))
|
||||
|
||||
- **Smart Approvals + /stop Command** — Codex-inspired approval system that learns which commands are safe and remembers your preferences. `/stop` kills the current agent run immediately. ([#1543](https://github.com/NousResearch/hermes-agent/pull/1543))
|
||||
|
||||
- **Honcho Memory Integration** — Async memory writes, configurable recall modes, session title integration, and multi-user isolation in gateway mode. By @erosika. ([#736](https://github.com/NousResearch/hermes-agent/pull/736))
|
||||
|
||||
- **Voice Mode** — Push-to-talk in CLI, voice notes in Telegram/Discord, Discord voice channel support, and local Whisper transcription via faster-whisper. ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299), [#1185](https://github.com/NousResearch/hermes-agent/pull/1185), [#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
|
||||
|
||||
- **Concurrent Tool Execution** — Multiple independent tool calls now run in parallel via ThreadPoolExecutor, significantly reducing latency for multi-tool turns. ([#1152](https://github.com/NousResearch/hermes-agent/pull/1152))
|
||||
|
||||
- **PII Redaction** — When `privacy.redact_pii` is enabled, personally identifiable information is automatically scrubbed before sending context to LLM providers. ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
|
||||
|
||||
- **`/browser connect` via CDP** — Attach browser tools to a live Chrome instance through Chrome DevTools Protocol. Debug, inspect, and interact with pages you already have open. ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
|
||||
|
||||
- **Vercel AI Gateway Provider** — Route Hermes through Vercel's AI Gateway for access to their model catalog and infrastructure. ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
|
||||
|
||||
- **Centralized Provider Router** — Rebuilt provider system with `call_llm` API, unified `/model` command, auto-detect provider on model switch, and direct endpoint overrides for auxiliary/delegation clients. ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003), [#1506](https://github.com/NousResearch/hermes-agent/pull/1506), [#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
|
||||
|
||||
- **ACP Server (IDE Integration)** — VS Code, Zed, and JetBrains can now connect to Hermes as an agent backend, with full slash command support. ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254), [#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
|
||||
|
||||
- **Persistent Shell Mode** — Local and SSH terminal backends can maintain shell state across tool calls — cd, env vars, and aliases persist. By @alt-glitch. ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
|
||||
|
||||
- **Agentic On-Policy Distillation (OPD)** — New RL training environment for distilling agent policies, expanding the Atropos training ecosystem. ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
- **Centralized provider router** with `call_llm` API and unified `/model` command — switch models and providers seamlessly ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003))
|
||||
- **Vercel AI Gateway** provider support ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
|
||||
- **Auto-detect provider** when switching models via `/model` ([#1506](https://github.com/NousResearch/hermes-agent/pull/1506))
|
||||
- **Direct endpoint overrides** for auxiliary and delegation clients — point vision/subagent calls at specific endpoints ([#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
|
||||
- **Native Anthropic auxiliary vision** — use Claude's native vision API instead of routing through OpenAI-compatible endpoints ([#1377](https://github.com/NousResearch/hermes-agent/pull/1377))
|
||||
- Anthropic OAuth flow improvements — auto-run `claude setup-token`, reauthentication, PKCE state persistence, identity fingerprinting ([#1132](https://github.com/NousResearch/hermes-agent/pull/1132), [#1360](https://github.com/NousResearch/hermes-agent/pull/1360), [#1396](https://github.com/NousResearch/hermes-agent/pull/1396), [#1597](https://github.com/NousResearch/hermes-agent/pull/1597))
|
||||
- Fix adaptive thinking without `budget_tokens` for Claude 4.6 models — by @ASRagab ([#1128](https://github.com/NousResearch/hermes-agent/pull/1128))
|
||||
- Fix Anthropic cache markers through adapter — by @brandtcormorant ([#1216](https://github.com/NousResearch/hermes-agent/pull/1216))
|
||||
- Retry Anthropic 429/529 errors and surface details to users — by @0xbyt4 ([#1585](https://github.com/NousResearch/hermes-agent/pull/1585))
|
||||
- Fix Anthropic adapter max_tokens, fallback crash, proxy base_url — by @0xbyt4 ([#1121](https://github.com/NousResearch/hermes-agent/pull/1121))
|
||||
- Fix DeepSeek V3 parser dropping multiple parallel tool calls — by @mr-emmett-one ([#1365](https://github.com/NousResearch/hermes-agent/pull/1365), [#1300](https://github.com/NousResearch/hermes-agent/pull/1300))
|
||||
- Accept unlisted models with warning instead of rejecting ([#1047](https://github.com/NousResearch/hermes-agent/pull/1047), [#1102](https://github.com/NousResearch/hermes-agent/pull/1102))
|
||||
- Skip reasoning params for unsupported OpenRouter models ([#1485](https://github.com/NousResearch/hermes-agent/pull/1485))
|
||||
- MiniMax Anthropic API compatibility fix ([#1623](https://github.com/NousResearch/hermes-agent/pull/1623))
|
||||
- Custom endpoint `/models` verification and `/v1` base URL suggestion ([#1480](https://github.com/NousResearch/hermes-agent/pull/1480))
|
||||
- Resolve delegation providers from `custom_providers` config ([#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
|
||||
- Kimi model additions and User-Agent fix ([#1039](https://github.com/NousResearch/hermes-agent/pull/1039))
|
||||
- Strip `call_id`/`response_item_id` for Mistral compatibility ([#1058](https://github.com/NousResearch/hermes-agent/pull/1058))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Anthropic Context Editing API** support ([#1147](https://github.com/NousResearch/hermes-agent/pull/1147))
|
||||
- Improved context compaction handoff summaries — compressor now preserves more actionable state ([#1273](https://github.com/NousResearch/hermes-agent/pull/1273))
|
||||
- Sync session_id after mid-run context compression ([#1160](https://github.com/NousResearch/hermes-agent/pull/1160))
|
||||
- Session hygiene threshold tuned to 50% for more proactive compression ([#1096](https://github.com/NousResearch/hermes-agent/pull/1096), [#1161](https://github.com/NousResearch/hermes-agent/pull/1161))
|
||||
- Include session ID in system prompt via `--pass-session-id` flag ([#1040](https://github.com/NousResearch/hermes-agent/pull/1040))
|
||||
- Prevent closed OpenAI client reuse across retries ([#1391](https://github.com/NousResearch/hermes-agent/pull/1391))
|
||||
- Sanitize chat payloads and provider precedence ([#1253](https://github.com/NousResearch/hermes-agent/pull/1253))
|
||||
- Handle dict tool call arguments from Codex and local backends ([#1393](https://github.com/NousResearch/hermes-agent/pull/1393), [#1440](https://github.com/NousResearch/hermes-agent/pull/1440))
|
||||
|
||||
### Memory & Sessions
|
||||
- **Improve memory prioritization** — user preferences and corrections weighted above procedural knowledge ([#1548](https://github.com/NousResearch/hermes-agent/pull/1548))
|
||||
- Tighter memory and session recall guidance in system prompts ([#1329](https://github.com/NousResearch/hermes-agent/pull/1329))
|
||||
- Persist CLI token counts to session DB for `/insights` ([#1498](https://github.com/NousResearch/hermes-agent/pull/1498))
|
||||
- Keep Honcho recall out of the cached system prefix ([#1201](https://github.com/NousResearch/hermes-agent/pull/1201))
|
||||
- Correct `seed_ai_identity` to use `session.add_messages()` ([#1475](https://github.com/NousResearch/hermes-agent/pull/1475))
|
||||
- Isolate Honcho session routing for multi-user gateway ([#1500](https://github.com/NousResearch/hermes-agent/pull/1500))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### Gateway Core
|
||||
- **System gateway service mode** — run as a system-level systemd service, not just user-level ([#1371](https://github.com/NousResearch/hermes-agent/pull/1371))
|
||||
- **Gateway install scope prompts** — choose user vs system scope during setup ([#1374](https://github.com/NousResearch/hermes-agent/pull/1374))
|
||||
- **Reasoning hot reload** — change reasoning settings without restarting the gateway ([#1275](https://github.com/NousResearch/hermes-agent/pull/1275))
|
||||
- Default group sessions to per-user isolation — no more shared state across users in group chats ([#1495](https://github.com/NousResearch/hermes-agent/pull/1495), [#1417](https://github.com/NousResearch/hermes-agent/pull/1417))
|
||||
- Harden gateway restart recovery ([#1310](https://github.com/NousResearch/hermes-agent/pull/1310))
|
||||
- Cancel active runs during shutdown ([#1427](https://github.com/NousResearch/hermes-agent/pull/1427))
|
||||
- SSL certificate auto-detection for NixOS and non-standard systems ([#1494](https://github.com/NousResearch/hermes-agent/pull/1494))
|
||||
- Auto-detect D-Bus session bus for `systemctl --user` on headless servers ([#1601](https://github.com/NousResearch/hermes-agent/pull/1601))
|
||||
- Auto-enable systemd linger during gateway install on headless servers ([#1334](https://github.com/NousResearch/hermes-agent/pull/1334))
|
||||
- Fall back to module entrypoint when `hermes` is not on PATH ([#1355](https://github.com/NousResearch/hermes-agent/pull/1355))
|
||||
- Fix dual gateways on macOS launchd after `hermes update` ([#1567](https://github.com/NousResearch/hermes-agent/pull/1567))
|
||||
- Remove recursive ExecStop from systemd units ([#1530](https://github.com/NousResearch/hermes-agent/pull/1530))
|
||||
- Prevent logging handler accumulation in gateway mode ([#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
|
||||
- Restart on retryable startup failures — by @jplew ([#1517](https://github.com/NousResearch/hermes-agent/pull/1517))
|
||||
- Backfill model on gateway sessions after agent runs ([#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
|
||||
- PID-based gateway kill and deferred config write ([#1499](https://github.com/NousResearch/hermes-agent/pull/1499))
|
||||
|
||||
### Telegram
|
||||
- Buffer media groups to prevent self-interruption from photo bursts ([#1341](https://github.com/NousResearch/hermes-agent/pull/1341), [#1422](https://github.com/NousResearch/hermes-agent/pull/1422))
|
||||
- Retry on transient TLS failures during connect and send ([#1535](https://github.com/NousResearch/hermes-agent/pull/1535))
|
||||
- Harden polling conflict handling ([#1339](https://github.com/NousResearch/hermes-agent/pull/1339))
|
||||
- Escape chunk indicators and inline code in MarkdownV2 ([#1478](https://github.com/NousResearch/hermes-agent/pull/1478), [#1626](https://github.com/NousResearch/hermes-agent/pull/1626))
|
||||
- Check updater/app state before disconnect ([#1389](https://github.com/NousResearch/hermes-agent/pull/1389))
|
||||
|
||||
### Discord
|
||||
- `/thread` command with `auto_thread` config and media metadata fixes ([#1178](https://github.com/NousResearch/hermes-agent/pull/1178))
|
||||
- Auto-thread on @mention, skip mention text in bot threads ([#1438](https://github.com/NousResearch/hermes-agent/pull/1438))
|
||||
- Retry without reply reference for system messages ([#1385](https://github.com/NousResearch/hermes-agent/pull/1385))
|
||||
- Preserve native document and video attachment support ([#1392](https://github.com/NousResearch/hermes-agent/pull/1392))
|
||||
- Defer discord adapter annotations to avoid optional import crashes ([#1314](https://github.com/NousResearch/hermes-agent/pull/1314))
|
||||
|
||||
### Slack
|
||||
- Thread handling overhaul — progress messages, responses, and session isolation all respect threads ([#1103](https://github.com/NousResearch/hermes-agent/pull/1103))
|
||||
- Formatting, reactions, user resolution, and command improvements ([#1106](https://github.com/NousResearch/hermes-agent/pull/1106))
|
||||
- Fix MAX_MESSAGE_LENGTH 3900 → 39000 ([#1117](https://github.com/NousResearch/hermes-agent/pull/1117))
|
||||
- File upload fallback preserves thread context — by @0xbyt4 ([#1122](https://github.com/NousResearch/hermes-agent/pull/1122))
|
||||
- Improve setup guidance ([#1387](https://github.com/NousResearch/hermes-agent/pull/1387))
|
||||
|
||||
### Email
|
||||
- Fix IMAP UID tracking and SMTP TLS verification ([#1305](https://github.com/NousResearch/hermes-agent/pull/1305))
|
||||
- Add `skip_attachments` option via config.yaml ([#1536](https://github.com/NousResearch/hermes-agent/pull/1536))
|
||||
|
||||
### Home Assistant
|
||||
- Event filtering closed by default ([#1169](https://github.com/NousResearch/hermes-agent/pull/1169))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### Interactive CLI
|
||||
- **Persistent CLI status bar** — always-visible model, provider, and token counts ([#1522](https://github.com/NousResearch/hermes-agent/pull/1522))
|
||||
- **File path autocomplete** in the input prompt ([#1545](https://github.com/NousResearch/hermes-agent/pull/1545))
|
||||
- **`/plan` command** — generate implementation plans from specs ([#1372](https://github.com/NousResearch/hermes-agent/pull/1372), [#1381](https://github.com/NousResearch/hermes-agent/pull/1381))
|
||||
- **Major `/rollback` improvements** — richer checkpoint history, clearer UX ([#1505](https://github.com/NousResearch/hermes-agent/pull/1505))
|
||||
- **Preload CLI skills on launch** — skills are ready before the first prompt ([#1359](https://github.com/NousResearch/hermes-agent/pull/1359))
|
||||
- **Centralized slash command registry** — all commands defined once, consumed everywhere ([#1603](https://github.com/NousResearch/hermes-agent/pull/1603))
|
||||
- `/bg` alias for `/background` ([#1590](https://github.com/NousResearch/hermes-agent/pull/1590))
|
||||
- Prefix matching for slash commands — `/mod` resolves to `/model` ([#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
|
||||
- `/new`, `/reset`, `/clear` now start genuinely fresh sessions ([#1237](https://github.com/NousResearch/hermes-agent/pull/1237))
|
||||
- Accept session ID prefixes for session actions ([#1425](https://github.com/NousResearch/hermes-agent/pull/1425))
|
||||
- TUI prompt and accent output now respect active skin ([#1282](https://github.com/NousResearch/hermes-agent/pull/1282))
|
||||
- Centralize tool emoji metadata in registry + skin integration ([#1484](https://github.com/NousResearch/hermes-agent/pull/1484))
|
||||
- "View full command" option added to dangerous command approval — by @teknium1 based on design by community ([#887](https://github.com/NousResearch/hermes-agent/pull/887))
|
||||
- Non-blocking startup update check and banner deduplication ([#1386](https://github.com/NousResearch/hermes-agent/pull/1386))
|
||||
- `/reasoning` command output ordering and inline think extraction fixes ([#1031](https://github.com/NousResearch/hermes-agent/pull/1031))
|
||||
- Verbose mode shows full untruncated output ([#1472](https://github.com/NousResearch/hermes-agent/pull/1472))
|
||||
- Fix `/status` to report live state and tokens ([#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
|
||||
- Seed a default global SOUL.md ([#1311](https://github.com/NousResearch/hermes-agent/pull/1311))
|
||||
|
||||
### Setup & Configuration
|
||||
- **OpenClaw migration** during first-time setup — by @kshitijk4poor ([#981](https://github.com/NousResearch/hermes-agent/pull/981))
|
||||
- `hermes claw migrate` command + migration docs ([#1059](https://github.com/NousResearch/hermes-agent/pull/1059))
|
||||
- Smart vision setup that respects the user's chosen provider ([#1323](https://github.com/NousResearch/hermes-agent/pull/1323))
|
||||
- Handle headless setup flows end-to-end ([#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
|
||||
- Prefer curses over `simple_term_menu` in setup.py ([#1487](https://github.com/NousResearch/hermes-agent/pull/1487))
|
||||
- Show effective model and provider in `/status` ([#1284](https://github.com/NousResearch/hermes-agent/pull/1284))
|
||||
- Config set examples use placeholder syntax ([#1322](https://github.com/NousResearch/hermes-agent/pull/1322))
|
||||
- Reload .env over stale shell overrides ([#1434](https://github.com/NousResearch/hermes-agent/pull/1434))
|
||||
- Fix is_coding_plan NameError crash — by @0xbyt4 ([#1123](https://github.com/NousResearch/hermes-agent/pull/1123))
|
||||
- Add missing packages to setuptools config — by @alt-glitch ([#912](https://github.com/NousResearch/hermes-agent/pull/912))
|
||||
- Installer: clarify why sudo is needed at every prompt ([#1602](https://github.com/NousResearch/hermes-agent/pull/1602))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### Terminal & Execution
|
||||
- **Persistent shell mode** for local and SSH backends — maintain shell state across tool calls — by @alt-glitch ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
|
||||
- **Tirith pre-exec command scanning** — security layer that analyzes commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
|
||||
- Strip Hermes provider env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419)) — initial fix by @eren-karakus0
|
||||
- SSH preflight check ([#1486](https://github.com/NousResearch/hermes-agent/pull/1486))
|
||||
- Docker backend: make cwd workspace mount explicit opt-in ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
|
||||
- Add project root to PYTHONPATH in execute_code sandbox ([#1383](https://github.com/NousResearch/hermes-agent/pull/1383))
|
||||
- Eliminate execute_code progress spam on gateway platforms ([#1098](https://github.com/NousResearch/hermes-agent/pull/1098))
|
||||
- Clearer docker backend preflight errors ([#1276](https://github.com/NousResearch/hermes-agent/pull/1276))
|
||||
|
||||
### Browser
|
||||
- **`/browser connect`** — attach browser tools to a live Chrome instance via CDP ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
|
||||
- Improve browser cleanup, local browser PATH setup, and screenshot recovery ([#1333](https://github.com/NousResearch/hermes-agent/pull/1333))
|
||||
|
||||
### MCP
|
||||
- **Selective tool loading** with utility policies — filter which MCP tools are available ([#1302](https://github.com/NousResearch/hermes-agent/pull/1302))
|
||||
- Auto-reload MCP tools when `mcp_servers` config changes without restart ([#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
|
||||
- Resolve npx stdio connection failures ([#1291](https://github.com/NousResearch/hermes-agent/pull/1291))
|
||||
- Preserve MCP toolsets when saving platform tool config ([#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
|
||||
|
||||
### Vision
|
||||
- Unify vision backend gating ([#1367](https://github.com/NousResearch/hermes-agent/pull/1367))
|
||||
- Surface actual error reason instead of generic message ([#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
|
||||
- Make Claude image handling work end-to-end ([#1408](https://github.com/NousResearch/hermes-agent/pull/1408))
|
||||
|
||||
### Cron
|
||||
- **Compress cron management into one tool** — single `cronjob` tool replaces multiple commands ([#1343](https://github.com/NousResearch/hermes-agent/pull/1343))
|
||||
- Suppress duplicate cron sends to auto-delivery targets ([#1357](https://github.com/NousResearch/hermes-agent/pull/1357))
|
||||
- Persist cron sessions to SQLite ([#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
|
||||
- Per-job runtime overrides (provider, model, base_url) ([#1398](https://github.com/NousResearch/hermes-agent/pull/1398))
|
||||
- Atomic write in `save_job_output` to prevent data loss on crash ([#1173](https://github.com/NousResearch/hermes-agent/pull/1173))
|
||||
- Preserve thread context for `deliver=origin` ([#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
|
||||
|
||||
### Patch Tool
|
||||
- Avoid corrupting pipe chars in V4A patch apply ([#1286](https://github.com/NousResearch/hermes-agent/pull/1286))
|
||||
- Permissive `block_anchor` thresholds and unicode normalization ([#1539](https://github.com/NousResearch/hermes-agent/pull/1539))
|
||||
|
||||
### Delegation
|
||||
- Add observability metadata to subagent results (model, tokens, duration, tool trace) ([#1175](https://github.com/NousResearch/hermes-agent/pull/1175))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### Skills System
|
||||
- **Integrate skills.sh** as a hub source alongside ClawHub ([#1303](https://github.com/NousResearch/hermes-agent/pull/1303))
|
||||
- Secure skill env setup on load ([#1153](https://github.com/NousResearch/hermes-agent/pull/1153))
|
||||
- Honor policy table for dangerous verdicts ([#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
|
||||
- Harden ClawHub skill search exact matches ([#1400](https://github.com/NousResearch/hermes-agent/pull/1400))
|
||||
- Fix ClawHub skill install — use `/download` ZIP endpoint ([#1060](https://github.com/NousResearch/hermes-agent/pull/1060))
|
||||
- Avoid mislabeling local skills as builtin — by @arceus77-7 ([#862](https://github.com/NousResearch/hermes-agent/pull/862))
|
||||
|
||||
### New Skills
|
||||
- **Linear** project management ([#1230](https://github.com/NousResearch/hermes-agent/pull/1230))
|
||||
- **X/Twitter** via x-cli ([#1285](https://github.com/NousResearch/hermes-agent/pull/1285))
|
||||
- **Telephony** — Twilio, SMS, and AI calls ([#1289](https://github.com/NousResearch/hermes-agent/pull/1289))
|
||||
- **1Password** — by @arceus77-7 ([#883](https://github.com/NousResearch/hermes-agent/pull/883), [#1179](https://github.com/NousResearch/hermes-agent/pull/1179))
|
||||
- **NeuroSkill BCI** integration ([#1135](https://github.com/NousResearch/hermes-agent/pull/1135))
|
||||
- **Blender MCP** for 3D modeling ([#1531](https://github.com/NousResearch/hermes-agent/pull/1531))
|
||||
- **OSS Security Forensics** ([#1482](https://github.com/NousResearch/hermes-agent/pull/1482))
|
||||
- **Parallel CLI** research skill ([#1301](https://github.com/NousResearch/hermes-agent/pull/1301))
|
||||
- **OpenCode** CLI skill ([#1174](https://github.com/NousResearch/hermes-agent/pull/1174))
|
||||
- **ASCII Video** skill refactored — by @SHL0MS ([#1213](https://github.com/NousResearch/hermes-agent/pull/1213), [#1598](https://github.com/NousResearch/hermes-agent/pull/1598))
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ Voice Mode
|
||||
|
||||
- Voice mode foundation — push-to-talk CLI, Telegram/Discord voice notes ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299))
|
||||
- Free local Whisper transcription via faster-whisper ([#1185](https://github.com/NousResearch/hermes-agent/pull/1185))
|
||||
- Discord voice channel reliability fixes ([#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
|
||||
- Restore local STT fallback for gateway voice notes ([#1490](https://github.com/NousResearch/hermes-agent/pull/1490))
|
||||
- Honor `stt.enabled: false` across gateway transcription ([#1394](https://github.com/NousResearch/hermes-agent/pull/1394))
|
||||
- Fix bogus incapability message on Telegram voice notes (Issue [#1033](https://github.com/NousResearch/hermes-agent/issues/1033))
|
||||
|
||||
---
|
||||
|
||||
## 🔌 ACP (IDE Integration)
|
||||
|
||||
- Restore ACP server implementation ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254))
|
||||
- Support slash commands in ACP adapter ([#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 RL Training
|
||||
|
||||
- **Agentic On-Policy Distillation (OPD)** environment — new RL training environment for agent policy distillation ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
|
||||
- Make tinker-atropos RL training fully optional ([#1062](https://github.com/NousResearch/hermes-agent/pull/1062))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Security Hardening
|
||||
- **Tirith pre-exec command scanning** — static analysis of terminal commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
|
||||
- **PII redaction** when `privacy.redact_pii` is enabled ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
|
||||
- Strip Hermes provider/gateway/tool env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419))
|
||||
- Docker cwd workspace mount now explicit opt-in — never auto-mount host directories ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
|
||||
- Escape parens and braces in fork bomb regex pattern ([#1397](https://github.com/NousResearch/hermes-agent/pull/1397))
|
||||
- Harden `.worktreeinclude` path containment ([#1388](https://github.com/NousResearch/hermes-agent/pull/1388))
|
||||
- Use description as `pattern_key` to prevent approval collisions ([#1395](https://github.com/NousResearch/hermes-agent/pull/1395))
|
||||
|
||||
### Reliability
|
||||
- Guard init-time stdio writes ([#1271](https://github.com/NousResearch/hermes-agent/pull/1271))
|
||||
- Session log writes reuse shared atomic JSON helper ([#1280](https://github.com/NousResearch/hermes-agent/pull/1280))
|
||||
- Atomic temp cleanup protected on interrupts ([#1401](https://github.com/NousResearch/hermes-agent/pull/1401))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- **`/status` always showing 0 tokens** — now reports live state (Issue [#1465](https://github.com/NousResearch/hermes-agent/issues/1465), [#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
|
||||
- **Custom model endpoints not working** — restored config-saved endpoint resolution (Issue [#1460](https://github.com/NousResearch/hermes-agent/issues/1460), [#1373](https://github.com/NousResearch/hermes-agent/pull/1373))
|
||||
- **MCP tools not visible until restart** — auto-reload on config change (Issue [#1036](https://github.com/NousResearch/hermes-agent/issues/1036), [#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
|
||||
- **`hermes tools` removing MCP tools** — preserve MCP toolsets when saving (Issue [#1247](https://github.com/NousResearch/hermes-agent/issues/1247), [#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
|
||||
- **Terminal subprocesses inheriting `OPENAI_BASE_URL`** breaking external tools (Issue [#1002](https://github.com/NousResearch/hermes-agent/issues/1002), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399))
|
||||
- **Background process lost on gateway restart** — improved recovery (Issue [#1144](https://github.com/NousResearch/hermes-agent/issues/1144))
|
||||
- **Cron jobs not persisting state** — now stored in SQLite (Issue [#1416](https://github.com/NousResearch/hermes-agent/issues/1416), [#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
|
||||
- **Cronjob `deliver: origin` not preserving thread context** (Issue [#1219](https://github.com/NousResearch/hermes-agent/issues/1219), [#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
|
||||
- **Gateway systemd service failing to auto-restart** when browser processes orphaned (Issue [#1617](https://github.com/NousResearch/hermes-agent/issues/1617))
|
||||
- **`/background` completion report cut off in Telegram** (Issue [#1443](https://github.com/NousResearch/hermes-agent/issues/1443))
|
||||
- **Model switching not taking effect** (Issue [#1244](https://github.com/NousResearch/hermes-agent/issues/1244), [#1183](https://github.com/NousResearch/hermes-agent/pull/1183))
|
||||
- **`hermes doctor` reporting cronjob as unavailable** (Issue [#878](https://github.com/NousResearch/hermes-agent/issues/878), [#1180](https://github.com/NousResearch/hermes-agent/pull/1180))
|
||||
- **WhatsApp bridge messages not received** from mobile (Issue [#1142](https://github.com/NousResearch/hermes-agent/issues/1142))
|
||||
- **Setup wizard hanging on headless SSH** (Issue [#905](https://github.com/NousResearch/hermes-agent/issues/905), [#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
|
||||
- **Log handler accumulation** degrading gateway performance (Issue [#990](https://github.com/NousResearch/hermes-agent/issues/990), [#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
|
||||
- **Gateway NULL model in DB** (Issue [#987](https://github.com/NousResearch/hermes-agent/issues/987), [#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
|
||||
- **Strict endpoints rejecting replayed tool_calls** (Issue [#893](https://github.com/NousResearch/hermes-agent/issues/893))
|
||||
- **Remaining hardcoded `~/.hermes` paths** — all now respect `HERMES_HOME` (Issue [#892](https://github.com/NousResearch/hermes-agent/issues/892), [#1233](https://github.com/NousResearch/hermes-agent/pull/1233))
|
||||
- **Delegate tool not working with custom inference providers** (Issue [#1011](https://github.com/NousResearch/hermes-agent/issues/1011), [#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
|
||||
- **Skills Guard blocking official skills** (Issue [#1006](https://github.com/NousResearch/hermes-agent/issues/1006), [#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
|
||||
- **Setup writing provider before model selection** (Issue [#1182](https://github.com/NousResearch/hermes-agent/issues/1182))
|
||||
- **`GatewayConfig.get()` AttributeError** crashing all message handling (Issue [#1158](https://github.com/NousResearch/hermes-agent/issues/1158), [#1287](https://github.com/NousResearch/hermes-agent/pull/1287))
|
||||
- **`/update` hard-failing with "command not found"** (Issue [#1049](https://github.com/NousResearch/hermes-agent/issues/1049))
|
||||
- **Image analysis failing silently** (Issue [#1034](https://github.com/NousResearch/hermes-agent/issues/1034), [#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
|
||||
- **API `BadRequestError` from `'dict'` object has no attribute `'strip'`** (Issue [#1071](https://github.com/NousResearch/hermes-agent/issues/1071))
|
||||
- **Slash commands requiring exact full name** — now uses prefix matching (Issue [#928](https://github.com/NousResearch/hermes-agent/issues/928), [#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
|
||||
- **Gateway stops responding when terminal is closed on headless** (Issue [#1005](https://github.com/NousResearch/hermes-agent/issues/1005))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Cover empty cached Anthropic tool-call turns ([#1222](https://github.com/NousResearch/hermes-agent/pull/1222))
|
||||
- Fix stale CI assumptions in parser and quick-command coverage ([#1236](https://github.com/NousResearch/hermes-agent/pull/1236))
|
||||
- Fix gateway async tests without implicit event loop ([#1278](https://github.com/NousResearch/hermes-agent/pull/1278))
|
||||
- Make gateway async tests xdist-safe ([#1281](https://github.com/NousResearch/hermes-agent/pull/1281))
|
||||
- Cross-timezone naive timestamp regression for cron ([#1319](https://github.com/NousResearch/hermes-agent/pull/1319))
|
||||
- Isolate codex provider tests from local env ([#1335](https://github.com/NousResearch/hermes-agent/pull/1335))
|
||||
- Lock retry replacement semantics ([#1379](https://github.com/NousResearch/hermes-agent/pull/1379))
|
||||
- Improve error logging in session search tool — by @aydnOktay ([#1533](https://github.com/NousResearch/hermes-agent/pull/1533))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Comprehensive SOUL.md guide ([#1315](https://github.com/NousResearch/hermes-agent/pull/1315))
|
||||
- Voice mode documentation ([#1316](https://github.com/NousResearch/hermes-agent/pull/1316), [#1362](https://github.com/NousResearch/hermes-agent/pull/1362))
|
||||
- Provider contribution guide ([#1361](https://github.com/NousResearch/hermes-agent/pull/1361))
|
||||
- ACP and internal systems implementation guides ([#1259](https://github.com/NousResearch/hermes-agent/pull/1259))
|
||||
- Expand Docusaurus coverage across CLI, tools, skills, and skins ([#1232](https://github.com/NousResearch/hermes-agent/pull/1232))
|
||||
- Terminal backend and Windows troubleshooting ([#1297](https://github.com/NousResearch/hermes-agent/pull/1297))
|
||||
- Skills hub reference section ([#1317](https://github.com/NousResearch/hermes-agent/pull/1317))
|
||||
- Checkpoint, /rollback, and git worktrees guide ([#1493](https://github.com/NousResearch/hermes-agent/pull/1493), [#1524](https://github.com/NousResearch/hermes-agent/pull/1524))
|
||||
- CLI status bar and /usage reference ([#1523](https://github.com/NousResearch/hermes-agent/pull/1523))
|
||||
- Fallback providers + /background command docs ([#1430](https://github.com/NousResearch/hermes-agent/pull/1430))
|
||||
- Gateway service scopes docs ([#1378](https://github.com/NousResearch/hermes-agent/pull/1378))
|
||||
- Slack thread reply behavior docs ([#1407](https://github.com/NousResearch/hermes-agent/pull/1407))
|
||||
- Redesigned landing page with Nous blue palette — by @austinpickett ([#974](https://github.com/NousResearch/hermes-agent/pull/974))
|
||||
- Fix several documentation typos — by @JackTheGit ([#953](https://github.com/NousResearch/hermes-agent/pull/953))
|
||||
- Stabilize website diagrams ([#1405](https://github.com/NousResearch/hermes-agent/pull/1405))
|
||||
- CLI vs messaging quick reference in README ([#1491](https://github.com/NousResearch/hermes-agent/pull/1491))
|
||||
- Add search to Docusaurus ([#1053](https://github.com/NousResearch/hermes-agent/pull/1053))
|
||||
- Home Assistant integration docs ([#1170](https://github.com/NousResearch/hermes-agent/pull/1170))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** — 220+ PRs spanning every area of the codebase
|
||||
|
||||
### Top Community Contributors
|
||||
|
||||
- **@0xbyt4** (4 PRs) — Anthropic adapter fixes (max_tokens, fallback crash, 429/529 retry), Slack file upload thread context, setup NameError fix
|
||||
- **@erosika** (1 PR) — Honcho memory integration: async writes, memory modes, session title integration
|
||||
- **@SHL0MS** (2 PRs) — ASCII video skill design patterns and refactoring
|
||||
- **@alt-glitch** (2 PRs) — Persistent shell mode for local/SSH backends, setuptools packaging fix
|
||||
- **@arceus77-7** (2 PRs) — 1Password skill, fix skills list mislabeling
|
||||
- **@kshitijk4poor** (1 PR) — OpenClaw migration during setup wizard
|
||||
- **@ASRagab** (1 PR) — Fix adaptive thinking for Claude 4.6 models
|
||||
- **@eren-karakus0** (1 PR) — Strip Hermes provider env vars from subprocess environment
|
||||
- **@mr-emmett-one** (1 PR) — Fix DeepSeek V3 parser multi-tool call support
|
||||
- **@jplew** (1 PR) — Gateway restart on retryable startup failures
|
||||
- **@brandtcormorant** (1 PR) — Fix Anthropic cache control for empty text blocks
|
||||
- **@aydnOktay** (1 PR) — Improve error logging in session search tool
|
||||
- **@austinpickett** (1 PR) — Landing page redesign with Nous blue palette
|
||||
- **@JackTheGit** (1 PR) — Documentation typo fixes
|
||||
|
||||
### All Contributors
|
||||
|
||||
@0xbyt4, @alt-glitch, @arceus77-7, @ASRagab, @austinpickett, @aydnOktay, @brandtcormorant, @eren-karakus0, @erosika, @JackTheGit, @jplew, @kshitijk4poor, @mr-emmett-one, @SHL0MS, @teknium1
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.3.12...v2026.3.17](https://github.com/NousResearch/hermes-agent/compare/v2026.3.12...v2026.3.17)
|
||||
@@ -57,6 +57,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"minimax": "MiniMax-M2.5-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.5-highspeed",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"ai-gateway": "google/gemini-3-flash",
|
||||
}
|
||||
|
||||
# OpenRouter app attribution headers
|
||||
|
||||
@@ -40,6 +40,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"anthropic/claude-opus-4.6": 200000,
|
||||
"anthropic/claude-sonnet-4": 200000,
|
||||
"anthropic/claude-sonnet-4-20250514": 200000,
|
||||
"anthropic/claude-sonnet-4.5": 200000,
|
||||
"anthropic/claude-sonnet-4.6": 200000,
|
||||
"anthropic/claude-haiku-4.5": 200000,
|
||||
# Bare Anthropic model IDs (for native API provider)
|
||||
"claude-opus-4-6": 200000,
|
||||
@@ -50,11 +52,18 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"claude-opus-4-20250514": 200000,
|
||||
"claude-sonnet-4-20250514": 200000,
|
||||
"claude-haiku-4-5-20251001": 200000,
|
||||
"openai/gpt-5": 128000,
|
||||
"openai/gpt-4.1": 1047576,
|
||||
"openai/gpt-4.1-mini": 1047576,
|
||||
"openai/gpt-4o": 128000,
|
||||
"openai/gpt-4-turbo": 128000,
|
||||
"openai/gpt-4o-mini": 128000,
|
||||
"google/gemini-3-pro-preview": 1048576,
|
||||
"google/gemini-3-flash": 1048576,
|
||||
"google/gemini-2.5-flash": 1048576,
|
||||
"google/gemini-2.0-flash": 1048576,
|
||||
"google/gemini-2.5-pro": 1048576,
|
||||
"deepseek/deepseek-v3.2": 65536,
|
||||
"meta-llama/llama-3.3-70b-instruct": 131072,
|
||||
"deepseek/deepseek-chat-v3": 65536,
|
||||
"qwen/qwen-2.5-72b-instruct": 32768,
|
||||
|
||||
@@ -468,7 +468,7 @@ from hermes_cli.banner import (
|
||||
VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
|
||||
build_welcome_banner,
|
||||
)
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter, SlashCommandAutoSuggest
|
||||
from hermes_cli import callbacks as _callbacks
|
||||
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
|
||||
|
||||
@@ -3618,18 +3618,18 @@ class HermesCLI:
|
||||
full_name = matches[0]
|
||||
if full_name == typed_base:
|
||||
# Already an exact token — no expansion possible; fall through
|
||||
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
|
||||
else:
|
||||
remainder = cmd_original.strip()[len(typed_base):]
|
||||
full_cmd = full_name + remainder
|
||||
return self.process_command(full_cmd)
|
||||
elif len(matches) > 1:
|
||||
self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]")
|
||||
self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]")
|
||||
_cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
|
||||
else:
|
||||
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||
_cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -5746,6 +5746,34 @@ class HermesCLI:
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@kb.add('tab', eager=True)
|
||||
def handle_tab(event):
|
||||
"""Tab: accept completion and re-trigger if we just completed a provider.
|
||||
|
||||
After accepting a provider like 'anthropic:', the completion menu
|
||||
closes and complete_while_typing doesn't fire (no keystroke).
|
||||
This binding re-triggers completions so stage-2 models appear
|
||||
immediately.
|
||||
"""
|
||||
buf = event.current_buffer
|
||||
if buf.complete_state:
|
||||
completion = buf.complete_state.current_completion
|
||||
if completion is None:
|
||||
# Menu open but nothing selected — select first then grab it
|
||||
buf.go_to_completion(0)
|
||||
completion = buf.complete_state and buf.complete_state.current_completion
|
||||
if completion is None:
|
||||
return
|
||||
# Accept the selected completion
|
||||
buf.apply_completion(completion)
|
||||
# If text now looks like "/model provider:", re-trigger completions
|
||||
text = buf.document.text_before_cursor
|
||||
if text.startswith("/model ") and text.endswith(":"):
|
||||
buf.start_completion()
|
||||
else:
|
||||
# No menu open — start completions from scratch
|
||||
buf.start_completion()
|
||||
|
||||
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
|
||||
|
||||
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
|
||||
@@ -6012,6 +6040,39 @@ class HermesCLI:
|
||||
return cli_ref._get_tui_prompt_fragments()
|
||||
|
||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
|
||||
def _get_model_completer_info() -> dict:
|
||||
"""Return provider/model info for /model autocomplete."""
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
|
||||
provider_model_ids,
|
||||
)
|
||||
current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
|
||||
current = normalize_provider(current)
|
||||
|
||||
# Provider map: id -> label (only providers with known models)
|
||||
providers = {}
|
||||
for pid, plabel in _PROVIDER_LABELS.items():
|
||||
providers[pid] = plabel
|
||||
|
||||
def models_for(provider_name: str) -> list[str]:
|
||||
norm = normalize_provider(provider_name)
|
||||
return provider_model_ids(norm)
|
||||
|
||||
return {
|
||||
"current_provider": current,
|
||||
"providers": providers,
|
||||
"models_for": models_for,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
_completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: _skill_commands,
|
||||
model_completer_provider=_get_model_completer_info,
|
||||
)
|
||||
input_area = TextArea(
|
||||
height=Dimension(min=1, max=8, preferred=1),
|
||||
prompt=get_prompt,
|
||||
@@ -6020,8 +6081,12 @@ class HermesCLI:
|
||||
wrap_lines=True,
|
||||
read_only=Condition(lambda: bool(cli_ref._command_running)),
|
||||
history=FileHistory(str(self._history_file)),
|
||||
completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
|
||||
completer=_completer,
|
||||
complete_while_typing=True,
|
||||
auto_suggest=SlashCommandAutoSuggest(
|
||||
history_suggest=AutoSuggestFromHistory(),
|
||||
completer=_completer,
|
||||
),
|
||||
)
|
||||
|
||||
# Dynamic height: accounts for both explicit newlines AND visual
|
||||
|
||||
+107
-3
@@ -727,7 +727,75 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
|
||||
return media, cleaned
|
||||
|
||||
|
||||
@staticmethod
|
||||
def extract_local_files(content: str) -> Tuple[List[str], str]:
|
||||
"""
|
||||
Detect bare local file paths in response text for native media delivery.
|
||||
|
||||
Matches absolute paths (/...) and tilde paths (~/) ending in common
|
||||
image or video extensions. Validates each candidate with
|
||||
``os.path.isfile()`` to avoid false positives from URLs or
|
||||
non-existent paths.
|
||||
|
||||
Paths inside fenced code blocks (``` ... ```) and inline code
|
||||
(`...`) are ignored so that code samples are never mutilated.
|
||||
|
||||
Returns:
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp',
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
)
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
# and relative paths (./foo.png)
|
||||
# (?:~/|/) anchors to absolute or home-relative paths
|
||||
path_re = re.compile(
|
||||
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Build spans covered by fenced code blocks and inline code
|
||||
code_spans: list = []
|
||||
for m in re.finditer(r'```[^\n]*\n.*?```', content, re.DOTALL):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
for m in re.finditer(r'`[^`\n]+`', content):
|
||||
code_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_code(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in code_spans)
|
||||
|
||||
found: list = [] # (raw_match_text, expanded_path)
|
||||
for match in path_re.finditer(content):
|
||||
if _in_code(match.start()):
|
||||
continue
|
||||
raw = match.group(0)
|
||||
expanded = os.path.expanduser(raw)
|
||||
if os.path.isfile(expanded):
|
||||
found.append((raw, expanded))
|
||||
|
||||
# Deduplicate by expanded path, preserving discovery order
|
||||
seen: set = set()
|
||||
unique: list = []
|
||||
for raw, expanded in found:
|
||||
if expanded not in seen:
|
||||
seen.add(expanded)
|
||||
unique.append((raw, expanded))
|
||||
|
||||
paths = [expanded for _, expanded in unique]
|
||||
|
||||
cleaned = content
|
||||
if unique:
|
||||
for raw, _exp in unique:
|
||||
cleaned = cleaned.replace(raw, '')
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
|
||||
return paths, cleaned
|
||||
|
||||
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None:
|
||||
"""
|
||||
Continuously send typing indicator until cancelled.
|
||||
@@ -840,8 +908,17 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax)
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Skipped when the chat has voice mode disabled (/voice off)
|
||||
@@ -935,7 +1012,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Send extracted media files — route by file type
|
||||
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.3gp'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
@@ -972,7 +1049,34 @@ class BasePlatformAdapter(ABC):
|
||||
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
|
||||
except Exception as media_err:
|
||||
print(f"[{self.name}] Error sending media: {media_err}")
|
||||
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
pending_event = self._pending_messages.pop(session_key)
|
||||
|
||||
+2
-2
@@ -195,8 +195,8 @@ def write_runtime_status(
|
||||
payload = _read_json_file(path) or _build_runtime_status_record()
|
||||
payload.setdefault("platforms", {})
|
||||
payload.setdefault("kind", _GATEWAY_KIND)
|
||||
payload.setdefault("pid", os.getpid())
|
||||
payload.setdefault("start_time", _get_process_start_time(os.getpid()))
|
||||
payload["pid"] = os.getpid()
|
||||
payload["start_time"] = _get_process_start_time(os.getpid())
|
||||
payload["updated_at"] = _utc_now_iso()
|
||||
|
||||
if gateway_state is not None:
|
||||
|
||||
@@ -11,5 +11,5 @@ Provides subcommands for:
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__release_date__ = "2026.3.12"
|
||||
__version__ = "0.3.0"
|
||||
__release_date__ = "2026.3.17"
|
||||
|
||||
@@ -155,6 +155,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("DEEPSEEK_API_KEY",),
|
||||
base_url_env_var="DEEPSEEK_BASE_URL",
|
||||
),
|
||||
"ai-gateway": ProviderConfig(
|
||||
id="ai-gateway",
|
||||
name="AI Gateway",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://ai-gateway.vercel.sh/v1",
|
||||
api_key_env_vars=("AI_GATEWAY_API_KEY",),
|
||||
base_url_env_var="AI_GATEWAY_BASE_URL",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -532,6 +540,7 @@ def resolve_provider(
|
||||
"kimi": "kimi-coding", "moonshot": "kimi-coding",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||||
}
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
+201
-6
@@ -11,11 +11,13 @@ To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
|
||||
@@ -32,6 +34,7 @@ class CommandDef:
|
||||
category: str # "Session", "Configuration", etc.
|
||||
aliases: tuple[str, ...] = () # alternative names: ("bg",)
|
||||
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
|
||||
subcommands: tuple[str, ...] = () # tab-completable subcommands
|
||||
cli_only: bool = False # only available in CLI
|
||||
gateway_only: bool = False # only available in gateway/messaging
|
||||
|
||||
@@ -75,17 +78,18 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("provider", "Show available providers and current provider",
|
||||
"Configuration"),
|
||||
CommandDef("prompt", "View/set custom system prompt", "Configuration",
|
||||
cli_only=True, args_hint="[text]"),
|
||||
cli_only=True, args_hint="[text]", subcommands=("clear",)),
|
||||
CommandDef("personality", "Set a predefined personality", "Configuration",
|
||||
args_hint="[name]"),
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]"),
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]"),
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
|
||||
# Tools & Skills
|
||||
CommandDef("tools", "List available tools", "Tools & Skills",
|
||||
@@ -93,9 +97,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
|
||||
cli_only=True),
|
||||
CommandDef("skills", "Search, install, inspect, or manage skills",
|
||||
"Tools & Skills", cli_only=True),
|
||||
"Tools & Skills", cli_only=True,
|
||||
subcommands=("search", "browse", "inspect", "install")),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]"),
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
@@ -169,6 +175,26 @@ for _cmd in COMMAND_REGISTRY:
|
||||
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
|
||||
|
||||
|
||||
# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
|
||||
SUBCOMMANDS: dict[str, list[str]] = {}
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
if _cmd.subcommands:
|
||||
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
|
||||
|
||||
# Also extract subcommands hinted in args_hint via pipe-separated patterns
|
||||
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
|
||||
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
|
||||
# Use the `subcommands` field on CommandDef for intentional tab-completable args.
|
||||
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
key = f"/{_cmd.name}"
|
||||
if key in SUBCOMMANDS or not _cmd.args_hint:
|
||||
continue
|
||||
m = _PIPE_SUBS_RE.search(_cmd.args_hint)
|
||||
if m:
|
||||
SUBCOMMANDS[key] = m.group(0).split("|")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -237,13 +263,34 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SlashCommandCompleter(Completer):
|
||||
"""Autocomplete for built-in slash commands and optional skill commands."""
|
||||
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
||||
model_completer_provider: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._skill_commands_provider = skill_commands_provider
|
||||
# model_completer_provider returns {"current_provider": str,
|
||||
# "providers": {id: label, ...}, "models_for": callable(provider) -> list[str]}
|
||||
self._model_completer_provider = model_completer_provider
|
||||
self._model_info_cache: dict[str, Any] | None = None
|
||||
self._model_info_cache_time: float = 0
|
||||
|
||||
def _get_model_info(self) -> dict[str, Any]:
|
||||
"""Get cached model/provider info for /model autocomplete."""
|
||||
import time
|
||||
now = time.monotonic()
|
||||
if self._model_info_cache is not None and now - self._model_info_cache_time < 60:
|
||||
return self._model_info_cache
|
||||
if self._model_completer_provider is None:
|
||||
return {}
|
||||
try:
|
||||
self._model_info_cache = self._model_completer_provider() or {}
|
||||
self._model_info_cache_time = now
|
||||
except Exception:
|
||||
self._model_info_cache = self._model_info_cache or {}
|
||||
return self._model_info_cache
|
||||
|
||||
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
||||
if self._skill_commands_provider is None:
|
||||
@@ -348,6 +395,70 @@ class SlashCommandCompleter(Completer):
|
||||
yield from self._path_completions(path_word)
|
||||
return
|
||||
|
||||
# Check if we're completing a subcommand (base command already typed)
|
||||
parts = text.split(maxsplit=1)
|
||||
base_cmd = parts[0].lower()
|
||||
if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage completion:
|
||||
# Stage 1: provider names (with : suffix)
|
||||
# Stage 2: after "provider:", list that provider's models
|
||||
if base_cmd == "/model" and " " not in sub_text:
|
||||
info = self._get_model_info()
|
||||
if info:
|
||||
current_prov = info.get("current_provider", "")
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: "anthropic:cl" → models for anthropic
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
prov_models = models_for(prov_part)
|
||||
except Exception:
|
||||
prov_models = []
|
||||
for mid in prov_models:
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
full = f"{prov_part}:{mid}"
|
||||
yield Completion(
|
||||
full,
|
||||
start_position=-len(sub_text),
|
||||
display=mid,
|
||||
)
|
||||
else:
|
||||
# Stage 1: providers sorted: non-current first, current last
|
||||
for pid, plabel in sorted(
|
||||
providers.items(),
|
||||
key=lambda kv: (kv[0] == current_prov, kv[0]),
|
||||
):
|
||||
display_name = f"{pid}:"
|
||||
if display_name.lower().startswith(sub_lower):
|
||||
meta = f"({plabel})" if plabel != pid else ""
|
||||
if pid == current_prov:
|
||||
meta = f"(current — {plabel})" if plabel != pid else "(current)"
|
||||
yield Completion(
|
||||
display_name,
|
||||
start_position=-len(sub_text),
|
||||
display=display_name,
|
||||
display_meta=meta,
|
||||
)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
yield Completion(
|
||||
sub,
|
||||
start_position=-len(sub_text),
|
||||
display=sub,
|
||||
)
|
||||
return
|
||||
|
||||
word = text[1:]
|
||||
|
||||
for cmd, desc in COMMANDS.items():
|
||||
@@ -373,6 +484,90 @@ class SlashCommandCompleter(Completer):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline auto-suggest (ghost text) for slash commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SlashCommandAutoSuggest(AutoSuggest):
|
||||
"""Inline ghost-text suggestions for slash commands and their subcommands.
|
||||
|
||||
Shows the rest of a command or subcommand in dim text as you type.
|
||||
Falls back to history-based suggestions for non-slash input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_suggest: AutoSuggest | None = None,
|
||||
completer: SlashCommandCompleter | None = None,
|
||||
) -> None:
|
||||
self._history = history_suggest
|
||||
self._completer = completer # Reuse its model cache
|
||||
|
||||
def get_suggestion(self, buffer, document):
|
||||
text = document.text_before_cursor
|
||||
|
||||
# Only suggest for slash commands
|
||||
if not text.startswith("/"):
|
||||
# Fall back to history for regular text
|
||||
if self._history:
|
||||
return self._history.get_suggestion(buffer, document)
|
||||
return None
|
||||
|
||||
parts = text.split(maxsplit=1)
|
||||
base_cmd = parts[0].lower()
|
||||
|
||||
if len(parts) == 1 and not text.endswith(" "):
|
||||
# Still typing the command name: /upd → suggest "ate"
|
||||
word = text[1:].lower()
|
||||
for cmd in COMMANDS:
|
||||
cmd_name = cmd[1:] # strip leading /
|
||||
if cmd_name.startswith(word) and cmd_name != word:
|
||||
return Suggestion(cmd_name[len(word):])
|
||||
return None
|
||||
|
||||
# Command is complete — suggest subcommands or model names
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage ghost text
|
||||
if base_cmd == "/model" and " " not in sub_text and self._completer:
|
||||
info = self._completer._get_model_info()
|
||||
if info:
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
current_prov = info.get("current_provider", "")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: after provider:, suggest model
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
for mid in models_for(prov_part):
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
return Suggestion(mid[len(model_part):])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Stage 1: suggest provider name with :
|
||||
for pid in sorted(providers, key=lambda p: (p == current_prov, p)):
|
||||
candidate = f"{pid}:"
|
||||
if candidate.lower().startswith(sub_lower) and candidate.lower() != sub_lower:
|
||||
return Suggestion(candidate[len(sub_text):])
|
||||
|
||||
# Static subcommands
|
||||
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
||||
if " " not in sub_text:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
return Suggestion(sub[len(sub_text):])
|
||||
|
||||
# Fall back to history
|
||||
if self._history:
|
||||
return self._history.get_suggestion(buffer, document)
|
||||
return None
|
||||
|
||||
|
||||
def _file_size_label(path: str) -> str:
|
||||
"""Return a compact human-readable file size, or '' on error."""
|
||||
try:
|
||||
|
||||
+142
-3
@@ -25,6 +25,18 @@ from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
# (managed by setup/provider flows directly).
|
||||
_EXTRA_ENV_KEYS = frozenset({
|
||||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"AUXILIARY_VISION_MODEL",
|
||||
"DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
|
||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
})
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -337,7 +349,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 8,
|
||||
"_config_version": 9,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -507,6 +519,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"BROWSER_USE_API_KEY": {
|
||||
"description": "Browser Use API key for cloud browser (optional — local browser works without this)",
|
||||
"prompt": "Browser Use API key",
|
||||
"url": "https://browser-use.com/",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"FAL_KEY": {
|
||||
"description": "FAL API key for image generation",
|
||||
"prompt": "FAL API key",
|
||||
@@ -765,7 +785,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
|
||||
"""
|
||||
results = {"env_added": [], "config_added": [], "warnings": []}
|
||||
|
||||
|
||||
# ── Always: sanitize .env (split concatenated keys) ──
|
||||
try:
|
||||
fixes = sanitize_env_file()
|
||||
if fixes and not quiet:
|
||||
print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
|
||||
except Exception:
|
||||
pass # best-effort; don't block migration on sanitize failure
|
||||
|
||||
# Check config version
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
@@ -808,6 +836,18 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
tz_display = config["timezone"] or "(server-local)"
|
||||
print(f" ✓ Added timezone to config.yaml: {tz_display}")
|
||||
|
||||
# ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
|
||||
# The new Anthropic auth flow no longer uses this env var.
|
||||
if current_ver < 9:
|
||||
try:
|
||||
old_token = get_env_value("ANTHROPIC_TOKEN")
|
||||
if old_token:
|
||||
save_env_value("ANTHROPIC_TOKEN", "")
|
||||
if not quiet:
|
||||
print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -1121,6 +1161,102 @@ def load_env() -> Dict[str, str]:
|
||||
return env_vars
|
||||
|
||||
|
||||
def _sanitize_env_lines(lines: list) -> list:
|
||||
"""Fix corrupted .env lines before writing.
|
||||
|
||||
Handles two known corruption patterns:
|
||||
1. Concatenated KEY=VALUE pairs on a single line (missing newline between
|
||||
entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
|
||||
2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
|
||||
|
||||
Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
|
||||
split on real Hermes env var names, avoiding false positives from values
|
||||
that happen to contain uppercase text with ``=``.
|
||||
"""
|
||||
# Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
|
||||
# Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
|
||||
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
|
||||
|
||||
sanitized: list[str] = []
|
||||
for line in lines:
|
||||
raw = line.rstrip("\r\n")
|
||||
stripped = raw.strip()
|
||||
|
||||
# Preserve blank lines and comments
|
||||
if not stripped or stripped.startswith("#"):
|
||||
sanitized.append(raw + "\n")
|
||||
continue
|
||||
|
||||
# Detect concatenated KEY=VALUE pairs on one line.
|
||||
# Search for known KEY= patterns at any position in the line.
|
||||
split_positions = []
|
||||
for key_name in known_keys:
|
||||
needle = key_name + "="
|
||||
idx = stripped.find(needle)
|
||||
while idx >= 0:
|
||||
split_positions.append(idx)
|
||||
idx = stripped.find(needle, idx + len(needle))
|
||||
|
||||
if len(split_positions) > 1:
|
||||
split_positions.sort()
|
||||
# Deduplicate (shouldn't happen, but be safe)
|
||||
split_positions = sorted(set(split_positions))
|
||||
for i, pos in enumerate(split_positions):
|
||||
end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
|
||||
part = stripped[pos:end].strip()
|
||||
if part:
|
||||
sanitized.append(part + "\n")
|
||||
else:
|
||||
sanitized.append(stripped + "\n")
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_env_file() -> int:
|
||||
"""Read, sanitize, and rewrite ~/.hermes/.env in place.
|
||||
|
||||
Returns the number of lines that were fixed (concatenation splits +
|
||||
placeholder removals). Returns 0 when no changes are needed.
|
||||
"""
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
return 0
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
original_lines = f.readlines()
|
||||
|
||||
sanitized = _sanitize_env_lines(original_lines)
|
||||
|
||||
if sanitized == original_lines:
|
||||
return 0
|
||||
|
||||
# Count fixes: difference in line count (from splits) + removed lines
|
||||
fixes = abs(len(sanitized) - len(original_lines))
|
||||
if fixes == 0:
|
||||
# Lines changed content (e.g. *** removal) even if count is same
|
||||
fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
|
||||
fixes += abs(len(sanitized) - len(original_lines))
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", **write_kw) as f:
|
||||
f.writelines(sanitized)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
return fixes
|
||||
|
||||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
@@ -1138,6 +1274,8 @@ def save_env_value(key: str, value: str):
|
||||
if env_path.exists():
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
# Sanitize on every read: split concatenated keys, drop stale placeholders
|
||||
lines = _sanitize_env_lines(lines)
|
||||
|
||||
# Find and update or append
|
||||
found = False
|
||||
@@ -1258,6 +1396,7 @@ def show_config():
|
||||
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
|
||||
("FIRECRAWL_API_KEY", "Firecrawl"),
|
||||
("BROWSERBASE_API_KEY", "Browserbase"),
|
||||
("BROWSER_USE_API_KEY", "Browser Use"),
|
||||
("FAL_KEY", "FAL"),
|
||||
]
|
||||
|
||||
@@ -1404,7 +1543,7 @@ def set_config_value(key: str, value: str):
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID',
|
||||
'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
|
||||
|
||||
@@ -570,6 +570,7 @@ def run_doctor(args):
|
||||
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811
|
||||
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
|
||||
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
]
|
||||
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
|
||||
_key = ""
|
||||
|
||||
+46
-3
@@ -562,6 +562,12 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
|
||||
scope_flag = " --system" if system else ""
|
||||
|
||||
if unit_path.exists() and not force:
|
||||
if not systemd_unit_is_current(system=system):
|
||||
print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service definition updated")
|
||||
return
|
||||
print(f"Service already installed at: {unit_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
@@ -787,6 +793,11 @@ def launchd_install(force: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
if plist_path.exists() and not force:
|
||||
if not launchd_plist_is_current():
|
||||
print(f"↻ Repairing outdated launchd service at: {plist_path}")
|
||||
refresh_launchd_plist_if_needed()
|
||||
print("✓ Service definition updated")
|
||||
return
|
||||
print(f"Service already installed at: {plist_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
@@ -816,7 +827,15 @@ def launchd_uninstall():
|
||||
|
||||
def launchd_start():
|
||||
refresh_launchd_plist_if_needed()
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
plist_path = get_launchd_plist_path()
|
||||
try:
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3 or not plist_path.exists():
|
||||
raise
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
@@ -824,22 +843,36 @@ def launchd_stop():
|
||||
print("✓ Service stopped")
|
||||
|
||||
def launchd_restart():
|
||||
refresh_launchd_plist_if_needed()
|
||||
launchd_stop()
|
||||
try:
|
||||
launchd_stop()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3:
|
||||
raise
|
||||
print("↻ launchd job was unloaded; skipping stop")
|
||||
launchd_start()
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
print(f"Launchd plist: {plist_path}")
|
||||
if launchd_plist_is_current():
|
||||
print("✓ Service definition matches the current Hermes install")
|
||||
else:
|
||||
print("⚠ Service definition is stale relative to the current Hermes install")
|
||||
print(" Run: hermes gateway start")
|
||||
|
||||
if result.returncode == 0:
|
||||
print("✓ Gateway service is loaded")
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("✗ Gateway service is not loaded")
|
||||
print(" Service definition exists locally but launchd has not loaded it.")
|
||||
print(" Run: hermes gateway start")
|
||||
|
||||
if deep:
|
||||
log_file = get_hermes_home() / "logs" / "gateway.log"
|
||||
@@ -1555,14 +1588,17 @@ def gateway_command(args):
|
||||
# Try service first, fall back to killing and restarting
|
||||
service_available = False
|
||||
system = getattr(args, 'system', False)
|
||||
service_configured = False
|
||||
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
service_configured = True
|
||||
try:
|
||||
systemd_restart(system=system)
|
||||
service_available = True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
service_configured = True
|
||||
try:
|
||||
launchd_restart()
|
||||
service_available = True
|
||||
@@ -1586,6 +1622,13 @@ def gateway_command(args):
|
||||
print(" hermes gateway restart")
|
||||
return
|
||||
|
||||
if service_configured:
|
||||
print()
|
||||
print("✗ Gateway service restart failed.")
|
||||
print(" The service definition exists, but the service manager did not recover it.")
|
||||
print(" Fix the service, then retry: hermes gateway start")
|
||||
sys.exit(1)
|
||||
|
||||
# Manual restart: kill existing processes
|
||||
killed = kill_gateway_processes()
|
||||
if killed:
|
||||
|
||||
+17
-2
@@ -768,6 +768,7 @@ def cmd_model(args):
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
active_label = provider_labels.get(active, active)
|
||||
@@ -787,6 +788,7 @@ def cmd_model(args):
|
||||
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
|
||||
("minimax", "MiniMax (global direct API)"),
|
||||
("minimax-cn", "MiniMax China (domestic direct API)"),
|
||||
("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
|
||||
]
|
||||
|
||||
# Add user-defined custom providers from config.yaml
|
||||
@@ -855,7 +857,7 @@ def cmd_model(args):
|
||||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider in ("zai", "minimax", "minimax-cn"):
|
||||
elif selected_provider in ("zai", "minimax", "minimax-cn", "ai-gateway"):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
|
||||
@@ -2122,7 +2124,17 @@ def _restore_stashed_changes(
|
||||
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
|
||||
return True
|
||||
|
||||
|
||||
def _invalidate_update_cache():
|
||||
"""Delete the update-check cache so ``hermes --version`` doesn't
|
||||
report a stale "commits behind" count after a successful update."""
|
||||
try:
|
||||
cache_file = Path(os.getenv(
|
||||
"HERMES_HOME", Path.home() / ".hermes"
|
||||
)) / ".update_check"
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
@@ -2195,6 +2207,7 @@ def cmd_update(args):
|
||||
commit_count = int(result.stdout.strip())
|
||||
|
||||
if commit_count == 0:
|
||||
_invalidate_update_cache()
|
||||
print("✓ Already up to date!")
|
||||
return
|
||||
|
||||
@@ -2215,6 +2228,8 @@ def cmd_update(args):
|
||||
prompt_user=prompt_for_restore,
|
||||
)
|
||||
|
||||
_invalidate_update_cache()
|
||||
|
||||
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
|
||||
print("→ Updating Python dependencies...")
|
||||
uv_bin = shutil.which("uv")
|
||||
|
||||
+52
-1
@@ -8,6 +8,7 @@ Add, remove, or reorder entries here — both `hermes setup` and
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from difflib import get_close_matches
|
||||
@@ -82,6 +83,20 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
"ai-gateway": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5",
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-4.1-mini",
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3-flash",
|
||||
"google/gemini-2.5-pro",
|
||||
"google/gemini-2.5-flash",
|
||||
"deepseek/deepseek-v3.2",
|
||||
],
|
||||
}
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
@@ -94,6 +109,7 @@ _PROVIDER_LABELS = {
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"anthropic": "Anthropic",
|
||||
"deepseek": "DeepSeek",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
|
||||
@@ -109,6 +125,9 @@ _PROVIDER_ALIASES = {
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
"deep-seek": "deepseek",
|
||||
"aigateway": "ai-gateway",
|
||||
"vercel": "ai-gateway",
|
||||
"vercel-ai-gateway": "ai-gateway",
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +161,8 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
# Canonical providers in display order
|
||||
_PROVIDER_ORDER = [
|
||||
"openrouter", "nous", "openai-codex",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
|
||||
"ai-gateway", "deepseek",
|
||||
]
|
||||
# Build reverse alias map
|
||||
aliases_for: dict[str, list[str]] = {}
|
||||
@@ -372,6 +392,10 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||
live = _fetch_anthropic_models()
|
||||
if live:
|
||||
return live
|
||||
if normalized == "ai-gateway":
|
||||
live = _fetch_ai_gateway_models()
|
||||
if live:
|
||||
return live
|
||||
return list(_PROVIDER_MODELS.get(normalized, []))
|
||||
|
||||
|
||||
@@ -475,6 +499,33 @@ def probe_api_models(
|
||||
}
|
||||
|
||||
|
||||
def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
"""Fetch available language models with tool-use from AI Gateway."""
|
||||
api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return None
|
||||
base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip()
|
||||
if not base_url:
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
base_url = AI_GATEWAY_BASE_URL
|
||||
|
||||
url = base_url.rstrip("/") + "/models"
|
||||
headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
return [
|
||||
m["id"]
|
||||
for m in data.get("data", [])
|
||||
if m.get("id")
|
||||
and m.get("type") == "language"
|
||||
and "tool-use" in (m.get("tags") or [])
|
||||
]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_api_models(
|
||||
api_key: Optional[str],
|
||||
base_url: Optional[str],
|
||||
|
||||
+37
-2
@@ -59,6 +59,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
||||
"minimax-cn": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
||||
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
|
||||
}
|
||||
|
||||
|
||||
@@ -724,6 +725,7 @@ def setup_model_provider(config: dict):
|
||||
"MiniMax (global endpoint)",
|
||||
"MiniMax China (mainland China endpoint)",
|
||||
"Anthropic (Claude models — API key or Claude Code subscription)",
|
||||
"AI Gateway (Vercel — 200+ models, pay-per-use)",
|
||||
]
|
||||
if keep_label:
|
||||
provider_choices.append(keep_label)
|
||||
@@ -1232,7 +1234,39 @@ def setup_model_provider(config: dict):
|
||||
_set_model_provider(config, "anthropic")
|
||||
selected_base_url = ""
|
||||
|
||||
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
|
||||
elif provider_idx == 9: # AI Gateway
|
||||
selected_provider = "ai-gateway"
|
||||
print()
|
||||
print_header("AI Gateway API Key")
|
||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
||||
print_info(f"Provider: {pconfig.name}")
|
||||
print_info("Get your API key at: https://vercel.com/docs/ai-gateway")
|
||||
print()
|
||||
|
||||
existing_key = get_env_value("AI_GATEWAY_API_KEY")
|
||||
if existing_key:
|
||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
||||
if prompt_yes_no("Update API key?", False):
|
||||
api_key = prompt(" AI Gateway API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("AI_GATEWAY_API_KEY", api_key)
|
||||
print_success("AI Gateway API key updated")
|
||||
else:
|
||||
api_key = prompt(" AI Gateway API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("AI_GATEWAY_API_KEY", api_key)
|
||||
print_success("AI Gateway API key saved")
|
||||
else:
|
||||
print_warning("Skipped - agent won't work without an API key")
|
||||
|
||||
# Clear custom endpoint vars if switching
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6")
|
||||
_set_model_provider(config, "ai-gateway", pconfig.inference_base_url)
|
||||
|
||||
# else: provider_idx == 10 (Keep current) — only shown when a provider already exists
|
||||
# Normalize "keep current" to an explicit provider so downstream logic
|
||||
# doesn't fall back to the generic OpenRouter/static-model path.
|
||||
if selected_provider is None:
|
||||
@@ -1269,6 +1303,7 @@ def setup_model_provider(config: dict):
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax CN",
|
||||
"anthropic": "Anthropic",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"custom": "your custom endpoint",
|
||||
}
|
||||
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
|
||||
@@ -1402,7 +1437,7 @@ def setup_model_provider(config: dict):
|
||||
_set_default_model(config, custom)
|
||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
_set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn"):
|
||||
elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "ai-gateway"):
|
||||
_setup_provider_model_selection(
|
||||
config, selected_provider, current_model,
|
||||
prompt_choice, prompt,
|
||||
|
||||
+56
-21
@@ -190,6 +190,7 @@ TOOL_CATEGORIES = {
|
||||
"name": "Local Browser",
|
||||
"tag": "Free headless Chromium (no API key needed)",
|
||||
"env_vars": [],
|
||||
"browser_provider": None,
|
||||
"post_setup": "browserbase", # Same npm install for agent-browser
|
||||
},
|
||||
{
|
||||
@@ -199,6 +200,16 @@ TOOL_CATEGORIES = {
|
||||
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
|
||||
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
||||
],
|
||||
"browser_provider": "browserbase",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Browser Use",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
|
||||
],
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
],
|
||||
@@ -575,10 +586,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
if _is_provider_active(p, config):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = " [active]" if config.get("tts", {}).get("provider", "edge") == p.get("tts_provider", "") else ""
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
@@ -587,15 +598,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
provider_choices.append("Skip — keep defaults / configure later")
|
||||
|
||||
# Detect current provider as default
|
||||
default_idx = 0
|
||||
for i, p in enumerate(providers):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
default_idx = i
|
||||
break
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
default_idx = i
|
||||
break
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
|
||||
provider_idx = _prompt_choice(f" {title}:", provider_choices, default_idx)
|
||||
|
||||
@@ -607,6 +610,28 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
_configure_provider(providers[provider_idx], config)
|
||||
|
||||
|
||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
"""Check if a provider entry matches the currently active config."""
|
||||
if provider.get("tts_provider"):
|
||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
return provider["browser_provider"] == current
|
||||
return False
|
||||
|
||||
|
||||
def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||
"""Return the index of the currently active provider, or 0."""
|
||||
for i, p in enumerate(providers):
|
||||
if _is_provider_active(p, config):
|
||||
return i
|
||||
# Fallback: env vars present → likely configured
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
def _configure_provider(provider: dict, config: dict):
|
||||
"""Configure a single provider - prompt for API keys and set config."""
|
||||
env_vars = provider.get("env_vars", [])
|
||||
@@ -615,6 +640,15 @@ def _configure_provider(provider: dict, config: dict):
|
||||
if provider.get("tts_provider"):
|
||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||
|
||||
# Set browser cloud provider in config if applicable
|
||||
if "browser_provider" in provider:
|
||||
bp = provider["browser_provider"]
|
||||
if bp:
|
||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
else:
|
||||
config.get("browser", {}).pop("cloud_provider", None)
|
||||
|
||||
if not env_vars:
|
||||
_print_success(f" {provider['name']} - no configuration needed!")
|
||||
return
|
||||
@@ -767,7 +801,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
env_vars = p.get("env_vars", [])
|
||||
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
if _is_provider_active(p, config):
|
||||
configured = " [active]"
|
||||
elif not env_vars:
|
||||
configured = ""
|
||||
@@ -775,15 +809,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{tag}{configured}")
|
||||
|
||||
default_idx = 0
|
||||
for i, p in enumerate(providers):
|
||||
if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
|
||||
default_idx = i
|
||||
break
|
||||
env_vars = p.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(v["key"]) for v in env_vars):
|
||||
default_idx = i
|
||||
break
|
||||
default_idx = _detect_active_provider_index(providers, config)
|
||||
|
||||
provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx)
|
||||
_reconfigure_provider(providers[provider_idx], config)
|
||||
@@ -797,6 +823,15 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||
_print_success(f" TTS provider set to: {provider['tts_provider']}")
|
||||
|
||||
if "browser_provider" in provider:
|
||||
bp = provider["browser_provider"]
|
||||
if bp:
|
||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
else:
|
||||
config.get("browser", {}).pop("cloud_provider", None)
|
||||
_print_success(f" Browser set to local mode")
|
||||
|
||||
if not env_vars:
|
||||
_print_success(f" {provider['name']} - no configuration needed!")
|
||||
return
|
||||
|
||||
@@ -8,5 +8,9 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
|
||||
|
||||
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
||||
AI_GATEWAY_MODELS_URL = f"{AI_GATEWAY_BASE_URL}/models"
|
||||
AI_GATEWAY_CHAT_URL = f"{AI_GATEWAY_BASE_URL}/chat/completions"
|
||||
|
||||
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
NOUS_API_CHAT_URL = f"{NOUS_API_BASE_URL}/chat/completions"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+18
-1
@@ -3523,6 +3523,8 @@ class AIAgent:
|
||||
base_url = (self.base_url or "").lower()
|
||||
if "nousresearch" in base_url:
|
||||
return True
|
||||
if "ai-gateway.vercel.sh" in base_url:
|
||||
return True
|
||||
if "openrouter" not in base_url:
|
||||
return False
|
||||
if "api.mistral.ai" in base_url:
|
||||
@@ -5256,6 +5258,15 @@ class AIAgent:
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
if self.api_mode in ("codex_responses", "anthropic_messages"):
|
||||
prompt_tokens = getattr(response.usage, 'input_tokens', 0) or 0
|
||||
if self.api_mode == "anthropic_messages":
|
||||
# Anthropic splits input into cache_read + cache_creation
|
||||
# + non-cached input_tokens. Without adding the cached
|
||||
# portions, the context bar shows only the tiny non-cached
|
||||
# portion (e.g. 3 tokens) instead of the real total (~18K).
|
||||
# Other providers (OpenAI/Codex) already include cached
|
||||
# tokens in their input_tokens/prompt_tokens field.
|
||||
prompt_tokens += getattr(response.usage, 'cache_read_input_tokens', 0) or 0
|
||||
prompt_tokens += getattr(response.usage, 'cache_creation_input_tokens', 0) or 0
|
||||
completion_tokens = getattr(response.usage, 'output_tokens', 0) or 0
|
||||
total_tokens = (
|
||||
getattr(response.usage, 'total_tokens', None)
|
||||
@@ -5574,7 +5585,13 @@ class AIAgent:
|
||||
# are programming bugs, not transient failures.
|
||||
_RETRYABLE_STATUS_CODES = {413, 429, 529}
|
||||
is_local_validation_error = isinstance(api_error, (ValueError, TypeError))
|
||||
is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES
|
||||
# Detect generic 400s from Anthropic OAuth (transient server-side failures).
|
||||
# Real invalid_request_error responses include a descriptive message;
|
||||
# transient ones contain only "Error" or are empty. (ref: issue #1608)
|
||||
_err_body = getattr(api_error, "body", None) or {}
|
||||
_err_message = (_err_body.get("error", {}).get("message", "") if isinstance(_err_body, dict) else "")
|
||||
_is_generic_400 = (status_code == 400 and _err_message.strip().lower() in ("error", ""))
|
||||
is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES and not _is_generic_400
|
||||
is_client_error = (is_local_validation_error or is_client_status_error or any(phrase in error_msg for phrase in [
|
||||
'error code: 401', 'error code: 403',
|
||||
'error code: 404', 'error code: 422',
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for extract_local_files() — auto-detection of bare local file paths
|
||||
in model response text for native media delivery.
|
||||
|
||||
Covers: path matching, code-block exclusion, URL rejection, tilde expansion,
|
||||
deduplication, text cleanup, and extension routing.
|
||||
|
||||
Based on PR #1636 by sudoingX (salvaged + hardened).
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract(content: str, existing_files: set[str] | None = None):
|
||||
"""
|
||||
Run extract_local_files with os.path.isfile mocked to return True
|
||||
for any path in *existing_files* (expanded form). If *existing_files*
|
||||
is None every path passes.
|
||||
"""
|
||||
existing = existing_files
|
||||
|
||||
def fake_isfile(p):
|
||||
if existing is None:
|
||||
return True
|
||||
return p in existing
|
||||
|
||||
def fake_expanduser(p):
|
||||
if p.startswith("~/"):
|
||||
return "/home/user" + p[1:]
|
||||
return p
|
||||
|
||||
with patch("os.path.isfile", side_effect=fake_isfile), \
|
||||
patch("os.path.expanduser", side_effect=fake_expanduser):
|
||||
return BasePlatformAdapter.extract_local_files(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBasicDetection:
|
||||
|
||||
def test_absolute_path_image(self):
|
||||
paths, cleaned = _extract("Here is the screenshot /root/screenshots/game.png enjoy")
|
||||
assert paths == ["/root/screenshots/game.png"]
|
||||
assert "/root/screenshots/game.png" not in cleaned
|
||||
assert "Here is the screenshot" in cleaned
|
||||
|
||||
def test_tilde_path_image(self):
|
||||
paths, cleaned = _extract("Check out ~/photos/cat.jpg for the cat")
|
||||
assert paths == ["/home/user/photos/cat.jpg"]
|
||||
assert "~/photos/cat.jpg" not in cleaned
|
||||
|
||||
def test_video_extensions(self):
|
||||
for ext in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
|
||||
text = f"Video at /tmp/clip{ext} here"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed for {ext}"
|
||||
assert paths[0] == f"/tmp/clip{ext}"
|
||||
|
||||
def test_image_extensions(self):
|
||||
for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
|
||||
text = f"Image at /tmp/pic{ext} here"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed for {ext}"
|
||||
assert paths[0] == f"/tmp/pic{ext}"
|
||||
|
||||
def test_case_insensitive_extension(self):
|
||||
paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now")
|
||||
assert len(paths) == 2
|
||||
|
||||
def test_multiple_paths(self):
|
||||
text = "First /tmp/a.png then /tmp/b.jpg and /tmp/c.mp4 done"
|
||||
paths, cleaned = _extract(text)
|
||||
assert len(paths) == 3
|
||||
assert "/tmp/a.png" in paths
|
||||
assert "/tmp/b.jpg" in paths
|
||||
assert "/tmp/c.mp4" in paths
|
||||
for p in paths:
|
||||
assert p not in cleaned
|
||||
|
||||
def test_path_at_line_start(self):
|
||||
paths, _ = _extract("/var/data/image.png")
|
||||
assert paths == ["/var/data/image.png"]
|
||||
|
||||
def test_path_at_end_of_line(self):
|
||||
paths, _ = _extract("saved to /var/data/image.png")
|
||||
assert paths == ["/var/data/image.png"]
|
||||
|
||||
def test_path_with_dots_in_directory(self):
|
||||
paths, _ = _extract("See /opt/my.app/assets/logo.png here")
|
||||
assert paths == ["/opt/my.app/assets/logo.png"]
|
||||
|
||||
def test_path_with_hyphens(self):
|
||||
paths, _ = _extract("File at /tmp/my-screenshot-2024.png done")
|
||||
assert paths == ["/tmp/my-screenshot-2024.png"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-existent files are skipped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsfileGuard:
|
||||
|
||||
def test_nonexistent_path_skipped(self):
|
||||
"""Paths that don't exist on disk are not extracted."""
|
||||
paths, cleaned = _extract(
|
||||
"See /tmp/nope.png here",
|
||||
existing_files=set(), # nothing exists
|
||||
)
|
||||
assert paths == []
|
||||
assert "/tmp/nope.png" in cleaned # not stripped
|
||||
|
||||
def test_only_existing_paths_extracted(self):
|
||||
"""Mix of existing and non-existing — only existing are returned."""
|
||||
paths, cleaned = _extract(
|
||||
"A /tmp/real.png and /tmp/fake.jpg end",
|
||||
existing_files={"/tmp/real.png"},
|
||||
)
|
||||
assert paths == ["/tmp/real.png"]
|
||||
assert "/tmp/real.png" not in cleaned
|
||||
assert "/tmp/fake.jpg" in cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL false-positive prevention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestURLRejection:
|
||||
|
||||
def test_https_url_not_matched(self):
|
||||
"""Paths embedded in HTTP URLs must not be extracted."""
|
||||
paths, cleaned = _extract("Visit https://example.com/images/photo.png for details")
|
||||
# The regex lookbehind should prevent matching the URL's path segment
|
||||
# Even if it did match, isfile would be False for /images/photo.png
|
||||
# (we mock isfile to True-for-all here, so the lookbehind is the guard)
|
||||
assert paths == []
|
||||
assert "https://example.com/images/photo.png" in cleaned
|
||||
|
||||
def test_http_url_not_matched(self):
|
||||
paths, _ = _extract("See http://cdn.example.com/assets/banner.jpg here")
|
||||
assert paths == []
|
||||
|
||||
def test_file_url_not_matched(self):
|
||||
paths, _ = _extract("Open file:///home/user/doc.png in browser")
|
||||
# file:// has :// before /home so lookbehind blocks it
|
||||
assert paths == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code block exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodeBlockExclusion:
|
||||
|
||||
def test_fenced_code_block_skipped(self):
|
||||
text = "Here's how:\n```python\nimg = open('/tmp/image.png')\n```\nDone."
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert "/tmp/image.png" in cleaned # not stripped
|
||||
|
||||
def test_inline_code_skipped(self):
|
||||
text = "Use the path `/tmp/image.png` in your config"
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert "`/tmp/image.png`" in cleaned
|
||||
|
||||
def test_path_outside_code_block_still_matched(self):
|
||||
text = (
|
||||
"```\ncode: /tmp/inside.png\n```\n"
|
||||
"But this one is real: /tmp/outside.png"
|
||||
)
|
||||
paths, _ = _extract(text, existing_files={"/tmp/outside.png"})
|
||||
assert paths == ["/tmp/outside.png"]
|
||||
|
||||
def test_mixed_inline_code_and_bare_path(self):
|
||||
text = "Config uses `/etc/app/bg.png` but output is /tmp/result.jpg"
|
||||
paths, cleaned = _extract(text, existing_files={"/tmp/result.jpg"})
|
||||
assert paths == ["/tmp/result.jpg"]
|
||||
assert "`/etc/app/bg.png`" in cleaned
|
||||
assert "/tmp/result.jpg" not in cleaned
|
||||
|
||||
def test_multiline_fenced_block(self):
|
||||
text = (
|
||||
"```bash\n"
|
||||
"cp /source/a.png /dest/b.png\n"
|
||||
"mv /source/c.mp4 /dest/d.mp4\n"
|
||||
"```\n"
|
||||
"Files are ready."
|
||||
)
|
||||
paths, _ = _extract(text)
|
||||
assert paths == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeduplication:
|
||||
|
||||
def test_duplicate_paths_deduplicated(self):
|
||||
text = "See /tmp/img.png and also /tmp/img.png again"
|
||||
paths, _ = _extract(text)
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_tilde_and_expanded_same_file(self):
|
||||
"""~/photos/a.png and /home/user/photos/a.png are the same file."""
|
||||
text = "See ~/photos/a.png and /home/user/photos/a.png here"
|
||||
paths, _ = _extract(text, existing_files={"/home/user/photos/a.png"})
|
||||
assert len(paths) == 1
|
||||
assert paths[0] == "/home/user/photos/a.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTextCleanup:
|
||||
|
||||
def test_path_removed_from_text(self):
|
||||
paths, cleaned = _extract("Before /tmp/x.png after")
|
||||
assert "Before" in cleaned
|
||||
assert "after" in cleaned
|
||||
assert "/tmp/x.png" not in cleaned
|
||||
|
||||
def test_excessive_blank_lines_collapsed(self):
|
||||
text = "Before\n\n\n/tmp/x.png\n\n\nAfter"
|
||||
_, cleaned = _extract(text)
|
||||
assert "\n\n\n" not in cleaned
|
||||
|
||||
def test_no_paths_text_unchanged(self):
|
||||
text = "This is a normal response with no file paths."
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == []
|
||||
assert cleaned == text
|
||||
|
||||
def test_tilde_form_cleaned_from_text(self):
|
||||
"""The raw ~/... form should be removed, not the expanded /home/user/... form."""
|
||||
text = "Output saved to ~/result.png for review"
|
||||
paths, cleaned = _extract(text)
|
||||
assert paths == ["/home/user/result.png"]
|
||||
assert "~/result.png" not in cleaned
|
||||
|
||||
def test_only_path_in_text(self):
|
||||
"""If the response is just a path, cleaned text is empty."""
|
||||
paths, cleaned = _extract("/tmp/screenshot.png")
|
||||
assert paths == ["/tmp/screenshot.png"]
|
||||
assert cleaned == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
def test_empty_string(self):
|
||||
paths, cleaned = _extract("")
|
||||
assert paths == []
|
||||
assert cleaned == ""
|
||||
|
||||
def test_no_media_extensions(self):
|
||||
"""Non-media extensions should not be matched."""
|
||||
paths, _ = _extract("See /tmp/data.csv and /tmp/script.py and /tmp/notes.txt")
|
||||
assert paths == []
|
||||
|
||||
def test_path_with_spaces_not_matched(self):
|
||||
"""Paths with spaces are intentionally not matched (avoids false positives)."""
|
||||
paths, _ = _extract("File at /tmp/my file.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_windows_path_not_matched(self):
|
||||
"""Windows-style paths should not match."""
|
||||
paths, _ = _extract("See C:\\Users\\test\\image.png")
|
||||
assert paths == []
|
||||
|
||||
def test_relative_path_not_matched(self):
|
||||
"""Relative paths like ./image.png should not match."""
|
||||
paths, _ = _extract("File at ./screenshots/image.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_bare_filename_not_matched(self):
|
||||
"""Just 'image.png' without a path should not match."""
|
||||
paths, _ = _extract("Open image.png to see")
|
||||
assert paths == []
|
||||
|
||||
def test_path_followed_by_punctuation(self):
|
||||
"""Path followed by comma, period, paren should still match."""
|
||||
for suffix in [",", ".", ")", ":", ";"]:
|
||||
text = f"See /tmp/img.png{suffix} details"
|
||||
paths, _ = _extract(text)
|
||||
assert len(paths) == 1, f"Failed with suffix '{suffix}'"
|
||||
|
||||
def test_path_in_parentheses(self):
|
||||
paths, _ = _extract("(see /tmp/img.png)")
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_path_in_quotes(self):
|
||||
paths, _ = _extract('The file is "/tmp/img.png" right here')
|
||||
assert paths == ["/tmp/img.png"]
|
||||
|
||||
def test_deep_nested_path(self):
|
||||
paths, _ = _extract("At /a/b/c/d/e/f/g/h/image.png end")
|
||||
assert paths == ["/a/b/c/d/e/f/g/h/image.png"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -44,6 +44,26 @@ class TestGatewayPidState:
|
||||
|
||||
|
||||
class TestGatewayRuntimeStatus:
|
||||
def test_write_runtime_status_overwrites_stale_pid_on_restart(self, tmp_path, monkeypatch):
|
||||
"""Regression: setdefault() preserved stale PID from previous process (#1631)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
# Simulate a previous gateway run that left a state file with a stale PID
|
||||
state_path = tmp_path / "gateway_state.json"
|
||||
state_path.write_text(json.dumps({
|
||||
"pid": 99999,
|
||||
"start_time": 1000.0,
|
||||
"kind": "hermes-gateway",
|
||||
"platforms": {},
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
}))
|
||||
|
||||
status.write_runtime_status(gateway_state="running")
|
||||
|
||||
payload = status.read_runtime_status()
|
||||
assert payload["pid"] == os.getpid(), "PID should be overwritten, not preserved via setdefault"
|
||||
assert payload["start_time"] != 1000.0, "start_time should be overwritten on restart"
|
||||
|
||||
def test_write_runtime_status_records_platform_failure(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from hermes_cli.commands import (
|
||||
COMMANDS_BY_CATEGORY,
|
||||
CommandDef,
|
||||
GATEWAY_KNOWN_COMMANDS,
|
||||
SUBCOMMANDS,
|
||||
SlashCommandAutoSuggest,
|
||||
SlashCommandCompleter,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
@@ -323,3 +325,182 @@ class TestSlashCommandCompleter:
|
||||
completions = _completions(completer, "/no-desc")
|
||||
assert len(completions) == 1
|
||||
assert "Skill command" in completions[0].display_meta_text
|
||||
|
||||
|
||||
# ── SUBCOMMANDS extraction ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubcommands:
|
||||
def test_explicit_subcommands_extracted(self):
|
||||
"""Commands with explicit subcommands on CommandDef are extracted."""
|
||||
assert "/prompt" in SUBCOMMANDS
|
||||
assert "clear" in SUBCOMMANDS["/prompt"]
|
||||
|
||||
def test_reasoning_has_subcommands(self):
|
||||
assert "/reasoning" in SUBCOMMANDS
|
||||
subs = SUBCOMMANDS["/reasoning"]
|
||||
assert "high" in subs
|
||||
assert "show" in subs
|
||||
assert "hide" in subs
|
||||
|
||||
def test_voice_has_subcommands(self):
|
||||
assert "/voice" in SUBCOMMANDS
|
||||
assert "on" in SUBCOMMANDS["/voice"]
|
||||
assert "off" in SUBCOMMANDS["/voice"]
|
||||
|
||||
def test_cron_has_subcommands(self):
|
||||
assert "/cron" in SUBCOMMANDS
|
||||
assert "list" in SUBCOMMANDS["/cron"]
|
||||
assert "add" in SUBCOMMANDS["/cron"]
|
||||
|
||||
def test_commands_without_subcommands_not_in_dict(self):
|
||||
"""Plain commands should not appear in SUBCOMMANDS."""
|
||||
assert "/help" not in SUBCOMMANDS
|
||||
assert "/quit" not in SUBCOMMANDS
|
||||
assert "/clear" not in SUBCOMMANDS
|
||||
|
||||
|
||||
# ── Subcommand tab completion ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubcommandCompletion:
|
||||
def test_subcommand_completion_after_space(self):
|
||||
"""Typing '/reasoning ' then Tab should show subcommands."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "high" in texts
|
||||
assert "show" in texts
|
||||
|
||||
def test_subcommand_prefix_filters(self):
|
||||
"""Typing '/reasoning sh' should only show 'show'."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"show"}
|
||||
|
||||
def test_subcommand_exact_match_suppressed(self):
|
||||
"""Typing the full subcommand shouldn't re-suggest it."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning show")
|
||||
texts = {c.text for c in completions}
|
||||
assert "show" not in texts
|
||||
|
||||
def test_no_subcommands_for_plain_command(self):
|
||||
"""Commands without subcommands yield nothing after space."""
|
||||
completions = _completions(SlashCommandCompleter(), "/help ")
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Two-stage /model completion ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _model_completer() -> SlashCommandCompleter:
|
||||
"""Build a completer with mock model/provider info."""
|
||||
return SlashCommandCompleter(
|
||||
model_completer_provider=lambda: {
|
||||
"current_provider": "openrouter",
|
||||
"providers": {
|
||||
"anthropic": "Anthropic",
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Research",
|
||||
},
|
||||
"models_for": lambda p: {
|
||||
"anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250414"],
|
||||
"openrouter": ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
|
||||
"nous": ["hermes-3-llama-3.1-405b"],
|
||||
}.get(p, []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestModelCompletion:
|
||||
def test_stage1_shows_providers(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:" in texts
|
||||
assert "openrouter:" in texts
|
||||
assert "nous:" in texts
|
||||
|
||||
def test_stage1_current_provider_last(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = [c.text for c in completions]
|
||||
assert texts[-1] == "openrouter:"
|
||||
|
||||
def test_stage1_current_provider_labeled(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
for c in completions:
|
||||
if c.text == "openrouter:":
|
||||
assert "current" in c.display_meta_text.lower()
|
||||
break
|
||||
else:
|
||||
raise AssertionError("openrouter: not found in completions")
|
||||
|
||||
def test_stage1_prefix_filters(self):
|
||||
completions = _completions(_model_completer(), "/model an")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"anthropic:"}
|
||||
|
||||
def test_stage2_shows_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" in texts
|
||||
|
||||
def test_stage2_prefix_filters_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:claude-s")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" not in texts
|
||||
|
||||
def test_stage2_no_model_provider_returns_empty(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/model ")
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||
|
||||
|
||||
def _suggestion(text: str, completer=None) -> str | None:
|
||||
"""Get ghost text suggestion for given input."""
|
||||
suggest = SlashCommandAutoSuggest(completer=completer)
|
||||
doc = Document(text=text)
|
||||
|
||||
class FakeBuffer:
|
||||
pass
|
||||
|
||||
result = suggest.get_suggestion(FakeBuffer(), doc)
|
||||
return result.text if result else None
|
||||
|
||||
|
||||
class TestGhostText:
|
||||
def test_command_name_suggestion(self):
|
||||
"""/he → 'lp'"""
|
||||
assert _suggestion("/he") == "lp"
|
||||
|
||||
def test_command_name_suggestion_reasoning(self):
|
||||
"""/rea → 'soning'"""
|
||||
assert _suggestion("/rea") == "soning"
|
||||
|
||||
def test_no_suggestion_for_complete_command(self):
|
||||
assert _suggestion("/help") is None
|
||||
|
||||
def test_subcommand_suggestion(self):
|
||||
"""/reasoning h → 'igh'"""
|
||||
assert _suggestion("/reasoning h") == "igh"
|
||||
|
||||
def test_subcommand_suggestion_show(self):
|
||||
"""/reasoning sh → 'ow'"""
|
||||
assert _suggestion("/reasoning sh") == "ow"
|
||||
|
||||
def test_no_suggestion_for_non_slash(self):
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
def test_model_stage1_ghost_text(self):
|
||||
"""/model a → 'nthropic:'"""
|
||||
completer = _model_completer()
|
||||
assert _suggestion("/model a", completer=completer) == "nthropic:"
|
||||
|
||||
def test_model_stage2_ghost_text(self):
|
||||
"""/model anthropic:cl → rest of first matching model"""
|
||||
completer = _model_completer()
|
||||
s = _suggestion("/model anthropic:cl", completer=completer)
|
||||
assert s is not None
|
||||
assert s.startswith("aude-")
|
||||
|
||||
@@ -12,9 +12,12 @@ from hermes_cli.config import (
|
||||
ensure_hermes_home,
|
||||
load_config,
|
||||
load_env,
|
||||
migrate_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
save_env_value_secure,
|
||||
sanitize_env_file,
|
||||
_sanitize_env_lines,
|
||||
)
|
||||
|
||||
|
||||
@@ -203,3 +206,142 @@ class TestSaveConfigAtomicity:
|
||||
raw = yaml.safe_load(f)
|
||||
assert raw["model"] == "test/atomic-model"
|
||||
assert raw["agent"]["max_turns"] == 77
|
||||
|
||||
|
||||
class TestSanitizeEnvLines:
|
||||
"""Tests for .env file corruption repair."""
|
||||
|
||||
def test_splits_concatenated_keys(self):
|
||||
"""Two KEY=VALUE pairs jammed on one line get split."""
|
||||
lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == [
|
||||
"ANTHROPIC_API_KEY=sk-ant-xxx\n",
|
||||
"OPENAI_BASE_URL=https://api.openai.com/v1\n",
|
||||
]
|
||||
|
||||
def test_preserves_clean_file(self):
|
||||
"""A well-formed .env file passes through unchanged (modulo trailing newlines)."""
|
||||
lines = [
|
||||
"OPENROUTER_API_KEY=sk-or-xxx\n",
|
||||
"FIRECRAWL_API_KEY=fc-xxx\n",
|
||||
"# a comment\n",
|
||||
"\n",
|
||||
]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_preserves_comments_and_blanks(self):
|
||||
lines = ["# comment\n", "\n", "KEY=val\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_adds_missing_trailing_newline(self):
|
||||
"""Lines missing trailing newline get one added."""
|
||||
lines = ["FOO_BAR=baz"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == ["FOO_BAR=baz\n"]
|
||||
|
||||
def test_three_concatenated_keys(self):
|
||||
"""Three known keys on one line all get separated."""
|
||||
lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == [
|
||||
"FAL_KEY=111\n",
|
||||
"FIRECRAWL_API_KEY=222\n",
|
||||
"GITHUB_TOKEN=333\n",
|
||||
]
|
||||
|
||||
def test_value_with_equals_sign_not_split(self):
|
||||
"""A value containing '=' shouldn't be falsely split (lowercase in value)."""
|
||||
lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert result == lines
|
||||
|
||||
def test_unknown_keys_not_split(self):
|
||||
"""Unknown key names on one line are NOT split (avoids false positives)."""
|
||||
lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
# Unknown keys stay on one line — no false split
|
||||
assert len(result) == 1
|
||||
|
||||
def test_value_ending_with_digits_still_splits(self):
|
||||
"""Concatenation is detected even when value ends with digits."""
|
||||
lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
|
||||
result = _sanitize_env_lines(lines)
|
||||
assert len(result) == 2
|
||||
assert result[0].startswith("OPENROUTER_API_KEY=")
|
||||
assert result[1].startswith("OPENAI_BASE_URL=")
|
||||
|
||||
def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
|
||||
"""save_env_value sanitizes corrupted lines when writing a new key."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
|
||||
"FAL_KEY=existing\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
save_env_value("MESSAGING_CWD", "/tmp")
|
||||
|
||||
content = env_file.read_text()
|
||||
lines = content.strip().split("\n")
|
||||
|
||||
# Corrupted line should be split, new key added
|
||||
assert "ANTHROPIC_API_KEY=sk-ant" in lines
|
||||
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
|
||||
assert "MESSAGING_CWD=/tmp" in lines
|
||||
|
||||
def test_sanitize_env_file_returns_fix_count(self, tmp_path):
|
||||
"""sanitize_env_file reports how many entries were fixed."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"FAL_KEY=good\n"
|
||||
"OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
fixes = sanitize_env_file()
|
||||
assert fixes > 0
|
||||
|
||||
# Verify file is now clean
|
||||
content = env_file.read_text()
|
||||
assert "OPENROUTER_API_KEY=val\n" in content
|
||||
assert "FIRECRAWL_API_KEY=val2\n" in content
|
||||
|
||||
def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
|
||||
"""No changes when file is already clean."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
fixes = sanitize_env_file()
|
||||
assert fixes == 0
|
||||
|
||||
|
||||
class TestAnthropicTokenMigration:
|
||||
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
|
||||
|
||||
def _write_config_version(self, tmp_path, version):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
import yaml
|
||||
config_path.write_text(yaml.safe_dump({"_config_version": version}))
|
||||
|
||||
def test_clears_token_on_upgrade_to_v9(self, tmp_path):
|
||||
"""ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
|
||||
self._write_config_version(tmp_path, 8)
|
||||
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
|
||||
with patch.dict(os.environ, {
|
||||
"HERMES_HOME": str(tmp_path),
|
||||
"ANTHROPIC_TOKEN": "old-token",
|
||||
}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
assert load_env().get("ANTHROPIC_TOKEN") == ""
|
||||
|
||||
def test_skips_on_version_9_or_later(self, tmp_path):
|
||||
"""Already at v9 — ANTHROPIC_TOKEN is not touched."""
|
||||
self._write_config_version(tmp_path, 9)
|
||||
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
|
||||
with patch.dict(os.environ, {
|
||||
"HERMES_HOME": str(tmp_path),
|
||||
"ANTHROPIC_TOKEN": "current-token",
|
||||
}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
|
||||
|
||||
@@ -7,6 +7,29 @@ import hermes_cli.gateway as gateway_cli
|
||||
|
||||
|
||||
class TestSystemdServiceRefresh:
|
||||
def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.systemd_install()
|
||||
|
||||
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
|
||||
assert calls[:2] == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
["systemctl", "--user", "enable", gateway_cli.get_service_name()],
|
||||
]
|
||||
|
||||
def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
@@ -96,6 +119,71 @@ class TestGatewayStopCleanup:
|
||||
assert kill_calls == [False]
|
||||
|
||||
|
||||
class TestLaunchdServiceRecovery:
|
||||
def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_install()
|
||||
|
||||
assert "--replace" in plist_path.read_text(encoding="utf-8")
|
||||
assert calls[:2] == [
|
||||
["launchctl", "unload", str(plist_path)],
|
||||
["launchctl", "load", str(plist_path)],
|
||||
]
|
||||
|
||||
def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1:
|
||||
raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_start()
|
||||
|
||||
assert calls == [
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
["launchctl", "load", str(plist_path)],
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
]
|
||||
|
||||
def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(returncode=113, stdout="", stderr="Could not find service"),
|
||||
)
|
||||
|
||||
gateway_cli.launchd_status()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert str(plist_path) in output
|
||||
assert "stale" in output.lower()
|
||||
assert "not loaded" in output.lower()
|
||||
|
||||
|
||||
class TestGatewayServiceDetection:
|
||||
def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch):
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
@@ -158,6 +246,34 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(False, False)]
|
||||
|
||||
def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("plist\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"launchd_restart",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
gateway_cli.subprocess.CalledProcessError(5, ["launchctl", "start", "ai.hermes.gateway"])
|
||||
),
|
||||
)
|
||||
|
||||
run_calls = []
|
||||
monkeypatch.setattr(gateway_cli, "run_gateway", lambda verbose=False, replace=False: run_calls.append((verbose, replace)))
|
||||
monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda force=False: 0)
|
||||
|
||||
try:
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="restart", system=False))
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
else:
|
||||
raise AssertionError("Expected gateway_command to exit when service restart fails")
|
||||
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
class TestEnsureUserSystemdEnv:
|
||||
"""Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection."""
|
||||
|
||||
@@ -144,9 +144,11 @@ class TestIsClaudeCodeTokenValid:
|
||||
|
||||
|
||||
class TestResolveAnthropicToken:
|
||||
def test_prefers_oauth_token_over_api_key(self, monkeypatch):
|
||||
def test_prefers_oauth_token_over_api_key(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path):
|
||||
@@ -174,9 +176,11 @@ class TestResolveAnthropicToken:
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-api03-mykey"
|
||||
|
||||
def test_falls_back_to_token(self, monkeypatch):
|
||||
def test_falls_back_to_token(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_returns_none_with_no_creds(self, monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax)."""
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -37,6 +37,7 @@ class TestProviderRegistry:
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "AI Gateway", "api_key"),
|
||||
])
|
||||
def test_provider_registered(self, provider_id, name, auth_type):
|
||||
assert provider_id in PROVIDER_REGISTRY
|
||||
@@ -65,11 +66,17 @@ class TestProviderRegistry:
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
|
||||
|
||||
def test_ai_gateway_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
||||
assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
|
||||
|
||||
def test_base_urls(self):
|
||||
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
|
||||
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1"
|
||||
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1"
|
||||
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
||||
|
||||
def test_oauth_providers_unchanged(self):
|
||||
"""Ensure we didn't break the existing OAuth providers."""
|
||||
@@ -87,6 +94,7 @@ PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"OPENAI_BASE_URL",
|
||||
)
|
||||
|
||||
@@ -112,6 +120,9 @@ class TestResolveProvider:
|
||||
def test_explicit_minimax_cn(self):
|
||||
assert resolve_provider("minimax-cn") == "minimax-cn"
|
||||
|
||||
def test_explicit_ai_gateway(self):
|
||||
assert resolve_provider("ai-gateway") == "ai-gateway"
|
||||
|
||||
def test_alias_glm(self):
|
||||
assert resolve_provider("glm") == "zai"
|
||||
|
||||
@@ -130,6 +141,12 @@ class TestResolveProvider:
|
||||
def test_alias_minimax_underscore(self):
|
||||
assert resolve_provider("minimax_cn") == "minimax-cn"
|
||||
|
||||
def test_alias_aigateway(self):
|
||||
assert resolve_provider("aigateway") == "ai-gateway"
|
||||
|
||||
def test_alias_vercel(self):
|
||||
assert resolve_provider("vercel") == "ai-gateway"
|
||||
|
||||
def test_alias_case_insensitive(self):
|
||||
assert resolve_provider("GLM") == "zai"
|
||||
assert resolve_provider("Z-AI") == "zai"
|
||||
@@ -163,6 +180,10 @@ class TestResolveProvider:
|
||||
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
|
||||
assert resolve_provider("auto") == "minimax-cn"
|
||||
|
||||
def test_auto_detects_ai_gateway_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
|
||||
assert resolve_provider("auto") == "ai-gateway"
|
||||
|
||||
def test_openrouter_takes_priority_over_glm(self, monkeypatch):
|
||||
"""OpenRouter API key should win over GLM in auto-detection."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
@@ -248,6 +269,13 @@ class TestResolveApiKeyProviderCredentials:
|
||||
assert creds["api_key"] == "mmcn-secret-key"
|
||||
assert creds["base_url"] == "https://api.minimaxi.com/v1"
|
||||
|
||||
def test_resolve_ai_gateway_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("ai-gateway")
|
||||
assert creds["provider"] == "ai-gateway"
|
||||
assert creds["api_key"] == "gw-secret-key"
|
||||
assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
||||
|
||||
def test_resolve_with_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4")
|
||||
@@ -309,6 +337,15 @@ class TestRuntimeProviderResolution:
|
||||
assert result["provider"] == "minimax"
|
||||
assert result["api_key"] == "mm-key"
|
||||
|
||||
def test_runtime_ai_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="ai-gateway")
|
||||
assert result["provider"] == "ai-gateway"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gw-key"
|
||||
assert "ai-gateway.vercel.sh" in result["base_url"]
|
||||
|
||||
def test_runtime_auto_detects_api_key_provider(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
@@ -72,15 +72,17 @@ class TestSlashCommandPrefixMatching:
|
||||
def test_ambiguous_prefix_shows_suggestions(self):
|
||||
"""/re matches multiple commands — should show ambiguous message."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.process_command("/re")
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/re")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Ambiguous" in printed or "Did you mean" in printed
|
||||
|
||||
def test_unknown_command_shows_error(self):
|
||||
"""/xyz should show unknown command error."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.process_command("/xyz")
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/xyz")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Unknown command" in printed
|
||||
|
||||
def test_exact_command_still_works(self):
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for context token tracking in run_agent.py's usage extraction.
|
||||
|
||||
The context counter (status bar) must show the TOTAL prompt tokens including
|
||||
Anthropic's cached portions. This is an integration test for the token
|
||||
extraction in run_conversation(), not the ContextCompressor itself (which
|
||||
is tested in tests/agent/test_context_compressor.py).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
||||
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
||||
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
||||
|
||||
import run_agent
|
||||
|
||||
|
||||
def _patch_bootstrap(monkeypatch):
|
||||
monkeypatch.setattr(run_agent, "get_tool_definitions", lambda **kwargs: [{
|
||||
"type": "function",
|
||||
"function": {"name": "t", "description": "t", "parameters": {"type": "object", "properties": {}}},
|
||||
}])
|
||||
monkeypatch.setattr(run_agent, "check_toolset_requirements", lambda: {})
|
||||
|
||||
|
||||
class _FakeAnthropicClient:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _make_agent(monkeypatch, api_mode, provider, response_fn):
|
||||
_patch_bootstrap(monkeypatch)
|
||||
if api_mode == "anthropic_messages":
|
||||
monkeypatch.setattr("agent.anthropic_adapter.build_anthropic_client", lambda k, b=None: _FakeAnthropicClient())
|
||||
|
||||
class _A(run_agent.AIAgent):
|
||||
def __init__(self, *a, **kw):
|
||||
kw.update(skip_context_files=True, skip_memory=True, max_iterations=4)
|
||||
super().__init__(*a, **kw)
|
||||
self._cleanup_task_resources = self._persist_session = lambda *a, **k: None
|
||||
self._save_trajectory = self._save_session_log = lambda *a, **k: None
|
||||
|
||||
def run_conversation(self, msg, conversation_history=None, task_id=None):
|
||||
self._interruptible_api_call = lambda kw: response_fn()
|
||||
return super().run_conversation(msg, conversation_history=conversation_history, task_id=task_id)
|
||||
|
||||
return _A(model="test-model", api_key="test-key", provider=provider, api_mode=api_mode)
|
||||
|
||||
|
||||
def _anthropic_resp(input_tok, output_tok, cache_read=0, cache_creation=0):
|
||||
usage_fields = {"input_tokens": input_tok, "output_tokens": output_tok}
|
||||
if cache_read:
|
||||
usage_fields["cache_read_input_tokens"] = cache_read
|
||||
if cache_creation:
|
||||
usage_fields["cache_creation_input_tokens"] = cache_creation
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
stop_reason="end_turn",
|
||||
usage=SimpleNamespace(**usage_fields),
|
||||
model="claude-sonnet-4-6",
|
||||
)
|
||||
|
||||
|
||||
# -- Anthropic: cached tokens must be included --
|
||||
|
||||
def test_anthropic_cache_read_and_creation_added(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(3, 10, cache_read=15000, cache_creation=2000))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 17003 # 3+15000+2000
|
||||
assert agent.session_prompt_tokens == 17003
|
||||
|
||||
|
||||
def test_anthropic_no_cache_fields(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(500, 20))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 500
|
||||
|
||||
|
||||
def test_anthropic_cache_read_only(monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
|
||||
lambda: _anthropic_resp(5, 15, cache_read=17666, cache_creation=15))
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 17686 # 5+17666+15
|
||||
|
||||
|
||||
# -- OpenAI: prompt_tokens already total --
|
||||
|
||||
def test_openai_prompt_tokens_unchanged(monkeypatch):
|
||||
resp = lambda: SimpleNamespace(
|
||||
choices=[SimpleNamespace(index=0, message=SimpleNamespace(
|
||||
role="assistant", content="ok", tool_calls=None, reasoning_content=None,
|
||||
), finish_reason="stop")],
|
||||
usage=SimpleNamespace(prompt_tokens=5000, completion_tokens=100, total_tokens=5100),
|
||||
model="gpt-4o",
|
||||
)
|
||||
agent = _make_agent(monkeypatch, "chat_completions", "openrouter", resp)
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 5000
|
||||
|
||||
|
||||
# -- Codex: no cache fields, getattr returns 0 --
|
||||
|
||||
def test_codex_no_cache_fields(monkeypatch):
|
||||
resp = lambda: SimpleNamespace(
|
||||
output=[SimpleNamespace(type="message", content=[SimpleNamespace(type="output_text", text="ok")])],
|
||||
usage=SimpleNamespace(input_tokens=3000, output_tokens=50, total_tokens=3050),
|
||||
status="completed", model="gpt-5-codex",
|
||||
)
|
||||
agent = _make_agent(monkeypatch, "codex_responses", "openai-codex", resp)
|
||||
agent.run_conversation("hi")
|
||||
assert agent.context_compressor.last_prompt_tokens == 3000
|
||||
@@ -137,6 +137,40 @@ class TestBuildApiKwargsOpenRouter:
|
||||
assert "codex_reasoning_items" in messages[1]
|
||||
|
||||
|
||||
class TestBuildApiKwargsAIGateway:
|
||||
def test_uses_chat_completions_format(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "messages" in kwargs
|
||||
assert "model" in kwargs
|
||||
assert kwargs["messages"][-1]["content"] == "hi"
|
||||
|
||||
def test_no_responses_api_fields(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "input" not in kwargs
|
||||
assert "instructions" not in kwargs
|
||||
assert "store" not in kwargs
|
||||
|
||||
def test_includes_reasoning_in_extra_body(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
extra = kwargs.get("extra_body", {})
|
||||
assert "reasoning" in extra
|
||||
assert extra["reasoning"]["enabled"] is True
|
||||
|
||||
def test_includes_tools(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "tools" in kwargs
|
||||
tool_names = [t["function"]["name"] for t in kwargs["tools"]]
|
||||
assert "web_search" in tool_names
|
||||
|
||||
|
||||
class TestBuildApiKwargsNousPortal:
|
||||
def test_includes_nous_product_tags(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
|
||||
|
||||
@@ -72,10 +72,11 @@ class TestCLIQuickCommands:
|
||||
|
||||
def test_unknown_command_still_shows_error(self):
|
||||
cli = self._make_cli({})
|
||||
cli.process_command("/nonexistent")
|
||||
cli.console.print.assert_called()
|
||||
args = cli.console.print.call_args_list[0][0][0]
|
||||
assert "unknown command" in args.lower()
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli.process_command("/nonexistent")
|
||||
mock_cprint.assert_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "unknown command" in printed.lower()
|
||||
|
||||
def test_timeout_shows_error(self):
|
||||
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
|
||||
|
||||
@@ -26,6 +26,20 @@ def test_resolve_runtime_provider_codex(monkeypatch):
|
||||
assert resolved["requested_provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_ai_gateway(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-ai-gw-key")
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="ai-gateway")
|
||||
|
||||
assert resolved["provider"] == "ai-gateway"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
assert resolved["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
||||
assert resolved["api_key"] == "test-ai-gw-key"
|
||||
assert resolved["requested_provider"] == "ai-gateway"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
|
||||
@@ -62,22 +62,44 @@ class TestScanCronPrompt:
|
||||
|
||||
|
||||
class TestCronjobRequirements:
|
||||
def test_requires_crontab_binary_even_in_interactive_mode(self, monkeypatch):
|
||||
def test_requires_no_crontab_binary(self, monkeypatch):
|
||||
"""Cron is internal (JSON-based scheduler), no system crontab needed."""
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||||
# Even with no crontab in PATH, the cronjob tool should be available
|
||||
# because hermes uses an internal scheduler, not system crontab.
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
assert check_cronjob_requirements() is False
|
||||
|
||||
def test_accepts_interactive_mode_when_crontab_exists(self, monkeypatch):
|
||||
def test_accepts_interactive_mode(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/crontab")
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_accepts_gateway_session(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_accepts_exec_ask(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.setenv("HERMES_EXEC_ASK", "1")
|
||||
|
||||
assert check_cronjob_requirements() is True
|
||||
|
||||
def test_rejects_when_no_session_env(self, monkeypatch):
|
||||
"""Without any session env vars, cronjob tool should not be available."""
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
|
||||
assert check_cronjob_requirements() is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# schedule_cronjob
|
||||
|
||||
@@ -25,8 +25,11 @@ def _make_config():
|
||||
|
||||
|
||||
def _install_telegram_mock(monkeypatch, bot):
|
||||
telegram_mod = SimpleNamespace(Bot=lambda token: bot)
|
||||
parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
|
||||
constants_mod = SimpleNamespace(ParseMode=parse_mode)
|
||||
telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod)
|
||||
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
||||
|
||||
|
||||
class TestSendMessageTool:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Cloud browser provider abstraction.
|
||||
|
||||
Import the ABC so callers can do::
|
||||
|
||||
from tools.browser_providers import CloudBrowserProvider
|
||||
"""
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
__all__ = ["CloudBrowserProvider"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Abstract base class for cloud browser providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class CloudBrowserProvider(ABC):
|
||||
"""Interface for cloud browser backends (Browserbase, Steel, etc.).
|
||||
|
||||
Implementations live in sibling modules and are registered in
|
||||
``browser_tool._PROVIDER_REGISTRY``. The user selects a provider via
|
||||
``hermes setup`` / ``hermes tools``; the choice is persisted as
|
||||
``config["browser"]["cloud_provider"]``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str:
|
||||
"""Short, human-readable name shown in logs and diagnostics."""
|
||||
|
||||
@abstractmethod
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when all required env vars / credentials are present.
|
||||
|
||||
Called at tool-registration time (``check_browser_requirements``) to
|
||||
gate availability. Must be cheap — no network calls.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
"""Create a cloud browser session and return session metadata.
|
||||
|
||||
Must return a dict with at least::
|
||||
|
||||
{
|
||||
"session_name": str, # unique name for agent-browser --session
|
||||
"bb_session_id": str, # provider session ID (for close/cleanup)
|
||||
"cdp_url": str, # CDP websocket URL
|
||||
"features": dict, # feature flags that were enabled
|
||||
}
|
||||
|
||||
``bb_session_id`` is a legacy key name kept for backward compat with
|
||||
the rest of browser_tool.py — it holds the provider's session ID
|
||||
regardless of which provider is in use.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
"""Release / terminate a cloud session by its provider session ID.
|
||||
|
||||
Returns True on success, False on failure. Should not raise.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
"""Best-effort session teardown during process exit.
|
||||
|
||||
Called from atexit / signal handlers. Must tolerate missing
|
||||
credentials, network errors, etc. — log and move on.
|
||||
"""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Browser Use cloud browser provider."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.browser-use.com/api/v2"
|
||||
|
||||
|
||||
class BrowserUseProvider(CloudBrowserProvider):
|
||||
"""Browser Use (https://browser-use.com) cloud browser backend."""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "Browser Use"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(os.environ.get("BROWSER_USE_API_KEY"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"BROWSER_USE_API_KEY environment variable is required. "
|
||||
"Get your key at https://browser-use.com"
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": api_key,
|
||||
}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
response = requests.post(
|
||||
f"{_BASE_URL}/browsers",
|
||||
headers=self._headers(),
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browser Use session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info("Created Browser Use session %s", session_name)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["cdpUrl"],
|
||||
"features": {"browser_use": True},
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
response = requests.patch(
|
||||
f"{_BASE_URL}/browsers/{session_id}",
|
||||
headers=self._headers(),
|
||||
json={"action": "stop"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed Browser Use session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Browser Use session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if not api_key:
|
||||
logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
|
||||
return
|
||||
try:
|
||||
requests.patch(
|
||||
f"{_BASE_URL}/browsers/{session_id}",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": api_key,
|
||||
},
|
||||
json={"action": "stop"},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Emergency cleanup failed for Browser Use session %s: %s", session_id, e)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Browserbase cloud browser provider."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserbaseProvider(CloudBrowserProvider):
|
||||
"""Browserbase (https://browserbase.com) cloud browser backend."""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "Browserbase"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
os.environ.get("BROWSERBASE_API_KEY")
|
||||
and os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config(self) -> Dict[str, str]:
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
raise ValueError(
|
||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment "
|
||||
"variables are required. Get your credentials at "
|
||||
"https://browserbase.com"
|
||||
)
|
||||
return {"api_key": api_key, "project_id": project_id}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
|
||||
# Optional env-var knobs
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
features_enabled = {
|
||||
"basic_stealth": True,
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
session_config: Dict[str, object] = {"projectId": config["project_id"]}
|
||||
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
|
||||
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {"advancedStealth": True}
|
||||
|
||||
# --- Create session via API ---
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 — paid features unavailable
|
||||
if response.status_code == 402:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
"keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning(
|
||||
"Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browserbase session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info("Created Browserbase session %s with features: %s", session_name, feature_str)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning("Cannot close Browserbase session %s — missing credentials", session_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed Browserbase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": project_id,
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Emergency cleanup failed for Browserbase session %s: %s", session_id, e)
|
||||
+119
-281
@@ -65,6 +65,9 @@ import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
from agent.auxiliary_client import call_llm
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,16 +111,43 @@ def _get_cdp_override() -> str:
|
||||
return os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
|
||||
|
||||
def _is_local_mode() -> bool:
|
||||
"""Return True when no Browserbase credentials are configured.
|
||||
# ============================================================================
|
||||
# Cloud Provider Registry
|
||||
# ============================================================================
|
||||
|
||||
In local mode the browser tools launch a headless Chromium instance via
|
||||
``agent-browser --session`` instead of connecting to a remote Browserbase
|
||||
session via ``--cdp``.
|
||||
_PROVIDER_REGISTRY: Dict[str, type] = {
|
||||
"browserbase": BrowserbaseProvider,
|
||||
"browser-use": BrowserUseProvider,
|
||||
}
|
||||
|
||||
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
|
||||
_cloud_provider_resolved = False
|
||||
|
||||
|
||||
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the configured cloud browser provider, or None for local mode.
|
||||
|
||||
Reads ``config["browser"]["cloud_provider"]`` once and caches the result
|
||||
for the process lifetime. If unset → local mode (None).
|
||||
"""
|
||||
if _get_cdp_override():
|
||||
return False # CDP override takes priority
|
||||
return not (os.environ.get("BROWSERBASE_API_KEY") and os.environ.get("BROWSERBASE_PROJECT_ID"))
|
||||
global _cached_cloud_provider, _cloud_provider_resolved
|
||||
if _cloud_provider_resolved:
|
||||
return _cached_cloud_provider
|
||||
|
||||
_cloud_provider_resolved = True
|
||||
try:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
provider_key = cfg.get("browser", {}).get("cloud_provider")
|
||||
if provider_key and provider_key in _PROVIDER_REGISTRY:
|
||||
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read cloud_provider from config: %s", e)
|
||||
return _cached_cloud_provider
|
||||
|
||||
|
||||
def _socket_safe_tmpdir() -> str:
|
||||
@@ -452,161 +482,6 @@ BROWSER_TOOL_SCHEMAS = [
|
||||
# Utility Functions
|
||||
# ============================================================================
|
||||
|
||||
def _create_browserbase_session(task_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Create a Browserbase session with stealth features.
|
||||
|
||||
Browserbase Stealth Modes:
|
||||
- Basic Stealth: ALWAYS enabled automatically. Generates random fingerprints,
|
||||
viewports, and solves visual CAPTCHAs. No configuration needed.
|
||||
- Advanced Stealth: Uses custom Chromium build for better bot detection avoidance.
|
||||
Requires Scale Plan. Enable via BROWSERBASE_ADVANCED_STEALTH=true.
|
||||
|
||||
Proxies are enabled by default to route traffic through residential IPs,
|
||||
which significantly improves CAPTCHA solving rates. Can be disabled via
|
||||
BROWSERBASE_PROXIES=false if needed.
|
||||
|
||||
Args:
|
||||
task_id: Unique identifier for the task
|
||||
|
||||
Returns:
|
||||
Dict with session_name, bb_session_id, cdp_url, and feature flags
|
||||
"""
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
config = _get_browserbase_config()
|
||||
|
||||
# Check for optional settings from environment
|
||||
# Proxies: enabled by default for better CAPTCHA solving
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
# Advanced Stealth: requires Scale Plan, disabled by default
|
||||
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
# keepAlive: enabled by default (requires paid plan) - allows reconnection after disconnects
|
||||
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
# Custom session timeout in milliseconds (optional) - extends session beyond project default
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
# Track which features are actually enabled for logging/debugging
|
||||
features_enabled = {
|
||||
"basic_stealth": True, # Always on
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
# Build session configuration
|
||||
# Note: Basic stealth mode is ALWAYS active - no configuration needed
|
||||
session_config = {
|
||||
"projectId": config["project_id"],
|
||||
}
|
||||
|
||||
# Enable keepAlive for session reconnection (default: true, requires paid plan)
|
||||
# Allows reconnecting to the same session after network hiccups
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
# Add custom timeout if specified (in milliseconds)
|
||||
# This extends session duration beyond project's default timeout
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
|
||||
|
||||
# Enable proxies for better CAPTCHA solving (default: true)
|
||||
# Routes traffic through residential IPs for more reliable access
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
# Add advanced stealth if enabled (requires Scale Plan)
|
||||
# Uses custom Chromium build to avoid bot detection altogether
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {
|
||||
"advancedStealth": True,
|
||||
}
|
||||
|
||||
# Create session via Browserbase API
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Track if we fell back from paid features
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 Payment Required - likely paid features not available
|
||||
# Try to identify which feature caused the issue and retry without it
|
||||
if response.status_code == 402:
|
||||
# First try without keepAlive (most likely culprit for paid plan requirement)
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning("keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations.")
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# If still 402, try without proxies too
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning("Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective.")
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
},
|
||||
json=session_config,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"Failed to create Browserbase session: {response.status_code} {response.text}")
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Update features based on what actually succeeded
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
|
||||
# Log session info for debugging
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info("Created session %s with features: %s", session_name, feature_str)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
|
||||
def _create_local_session(task_id: str) -> Dict[str, str]:
|
||||
import uuid
|
||||
session_name = f"h_{uuid.uuid4().hex[:10]}"
|
||||
@@ -667,10 +542,12 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
cdp_override = _get_cdp_override()
|
||||
if cdp_override:
|
||||
session_info = _create_cdp_session(task_id, cdp_override)
|
||||
elif _is_local_mode():
|
||||
session_info = _create_local_session(task_id)
|
||||
else:
|
||||
session_info = _create_browserbase_session(task_id)
|
||||
provider = _get_cloud_provider()
|
||||
if provider is None:
|
||||
session_info = _create_local_session(task_id)
|
||||
else:
|
||||
session_info = provider.create_session(task_id)
|
||||
|
||||
with _cleanup_lock:
|
||||
_active_sessions[task_id] = session_info
|
||||
@@ -692,31 +569,6 @@ def _get_session_name(task_id: Optional[str] = None) -> str:
|
||||
return session_info["session_name"]
|
||||
|
||||
|
||||
def _get_browserbase_config() -> Dict[str, str]:
|
||||
"""
|
||||
Get Browserbase configuration from environment.
|
||||
|
||||
Returns:
|
||||
Dict with api_key and project_id
|
||||
|
||||
Raises:
|
||||
ValueError: If required env vars are not set
|
||||
"""
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
|
||||
if not api_key or not project_id:
|
||||
raise ValueError(
|
||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables are required. "
|
||||
"Get your credentials at https://browserbase.com"
|
||||
)
|
||||
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"project_id": project_id
|
||||
}
|
||||
|
||||
|
||||
def _find_agent_browser() -> str:
|
||||
"""
|
||||
Find the agent-browser CLI executable.
|
||||
@@ -859,27 +711,62 @@ def _run_browser_command(
|
||||
browser_env["PATH"] = ":".join(path_parts)
|
||||
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_parts,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=browser_env,
|
||||
)
|
||||
|
||||
# Use temp files for stdout/stderr instead of pipes.
|
||||
# agent-browser starts a background daemon that inherits file
|
||||
# descriptors. With capture_output=True (pipes), the daemon keeps
|
||||
# the pipe fds open after the CLI exits, so communicate() never
|
||||
# sees EOF and blocks until the timeout fires.
|
||||
stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
|
||||
stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
|
||||
stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
stderr_fd = os.open(stderr_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd_parts,
|
||||
stdout=stdout_fd,
|
||||
stderr=stderr_fd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env=browser_env,
|
||||
)
|
||||
finally:
|
||||
os.close(stdout_fd)
|
||||
os.close(stderr_fd)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
|
||||
command, timeout, task_id, task_socket_dir)
|
||||
return {"success": False, "error": f"Command timed out after {timeout} seconds"}
|
||||
|
||||
with open(stdout_path, "r") as f:
|
||||
stdout = f.read()
|
||||
with open(stderr_path, "r") as f:
|
||||
stderr = f.read()
|
||||
returncode = proc.returncode
|
||||
|
||||
# Clean up temp files (best-effort)
|
||||
for p in (stdout_path, stderr_path):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Log stderr for diagnostics — use warning level on failure so it's visible
|
||||
if result.stderr and result.stderr.strip():
|
||||
level = logging.WARNING if result.returncode != 0 else logging.DEBUG
|
||||
logger.log(level, "browser '%s' stderr: %s", command, result.stderr.strip()[:500])
|
||||
if stderr and stderr.strip():
|
||||
level = logging.WARNING if returncode != 0 else logging.DEBUG
|
||||
logger.log(level, "browser '%s' stderr: %s", command, stderr.strip()[:500])
|
||||
|
||||
# Log empty output as warning — common sign of broken agent-browser
|
||||
if not result.stdout.strip() and result.returncode == 0:
|
||||
if not stdout.strip() and returncode == 0:
|
||||
logger.warning("browser '%s' returned empty stdout with rc=0. "
|
||||
"cmd=%s stderr=%s",
|
||||
command, " ".join(cmd_parts[:4]) + "...",
|
||||
(result.stderr or "")[:200])
|
||||
(stderr or "")[:200])
|
||||
|
||||
stdout_text = result.stdout.strip()
|
||||
stdout_text = stdout.strip()
|
||||
|
||||
if stdout_text:
|
||||
try:
|
||||
@@ -890,15 +777,15 @@ def _run_browser_command(
|
||||
if not snap_data.get("snapshot") and not snap_data.get("refs"):
|
||||
logger.warning("snapshot returned empty content. "
|
||||
"Possible stale daemon or CDP connection issue. "
|
||||
"returncode=%s", result.returncode)
|
||||
"returncode=%s", returncode)
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
raw = stdout_text[:2000]
|
||||
logger.warning("browser '%s' returned non-JSON output (rc=%s): %s",
|
||||
command, result.returncode, raw[:500])
|
||||
command, returncode, raw[:500])
|
||||
|
||||
if command == "screenshot":
|
||||
stderr_text = (result.stderr or "").strip()
|
||||
stderr_text = (stderr or "").strip()
|
||||
combined_text = "\n".join(
|
||||
part for part in [stdout_text, stderr_text] if part
|
||||
)
|
||||
@@ -923,17 +810,13 @@ def _run_browser_command(
|
||||
}
|
||||
|
||||
# Check for errors
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() if result.stderr else f"Command failed with code {result.returncode}"
|
||||
logger.warning("browser '%s' failed (rc=%s): %s", command, result.returncode, error_msg[:300])
|
||||
if returncode != 0:
|
||||
error_msg = stderr.strip() if stderr else f"Command failed with code {returncode}"
|
||||
logger.warning("browser '%s' failed (rc=%s): %s", command, returncode, error_msg[:300])
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
|
||||
command, timeout, task_id, task_socket_dir)
|
||||
return {"success": False, "error": f"Command timed out after {timeout} seconds"}
|
||||
except Exception as e:
|
||||
logger.warning("browser '%s' exception: %s", command, e, exc_info=True)
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1509,7 +1392,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
|
||||
if not result.get("success"):
|
||||
error_detail = result.get("error", "Unknown error")
|
||||
mode = "local" if _is_local_mode() else "cloud"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Failed to take screenshot ({mode} mode): {error_detail}"
|
||||
@@ -1521,7 +1405,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
|
||||
# Check if screenshot file was created
|
||||
if not screenshot_path.exists():
|
||||
mode = "local" if _is_local_mode() else "cloud"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
@@ -1639,48 +1524,6 @@ def _cleanup_old_recordings(max_age_hours=72):
|
||||
# Cleanup and Management Functions
|
||||
# ============================================================================
|
||||
|
||||
def _close_browserbase_session(session_id: str, api_key: str, project_id: str) -> bool:
|
||||
"""
|
||||
Close a Browserbase session immediately via the API.
|
||||
|
||||
Uses POST /v1/sessions/{id} with status=REQUEST_RELEASE to immediately
|
||||
terminate the session without waiting for keepAlive timeout.
|
||||
|
||||
Args:
|
||||
session_id: The Browserbase session ID
|
||||
api_key: Browserbase API key
|
||||
project_id: Browserbase project ID
|
||||
|
||||
Returns:
|
||||
True if session was successfully closed, False otherwise
|
||||
"""
|
||||
try:
|
||||
# POST to update session status to REQUEST_RELEASE
|
||||
response = requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": api_key,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"projectId": project_id,
|
||||
"status": "REQUEST_RELEASE"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed BrowserBase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning("Failed to close session %s: HTTP %s - %s", session_id, response.status_code, response.text[:200])
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Exception closing session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clean up browser session for a task.
|
||||
@@ -1721,15 +1564,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
_active_sessions.pop(task_id, None)
|
||||
_session_last_activity.pop(task_id, None)
|
||||
|
||||
# Cloud mode: close the Browserbase session via API
|
||||
if bb_session_id and not _is_local_mode():
|
||||
try:
|
||||
config = _get_browserbase_config()
|
||||
success = _close_browserbase_session(bb_session_id, config["api_key"], config["project_id"])
|
||||
if not success:
|
||||
logger.warning("Could not close BrowserBase session %s", bb_session_id)
|
||||
except Exception as e:
|
||||
logger.error("Exception during BrowserBase session close: %s", e)
|
||||
# Cloud mode: close the cloud browser session via provider API
|
||||
if bb_session_id:
|
||||
provider = _get_cloud_provider()
|
||||
if provider is not None:
|
||||
try:
|
||||
provider.close_session(bb_session_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not close cloud browser session: %s", e)
|
||||
|
||||
# Kill the daemon process and clean up socket directory
|
||||
session_name = session_info.get("session_name", "")
|
||||
@@ -1798,12 +1640,10 @@ def check_browser_requirements() -> bool:
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
# In cloud mode, also require Browserbase credentials
|
||||
if not _is_local_mode():
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
return False
|
||||
# In cloud mode, also require provider credentials
|
||||
provider = _get_cloud_provider()
|
||||
if provider is not None and not provider.is_configured():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1819,7 +1659,8 @@ if __name__ == "__main__":
|
||||
print("🌐 Browser Tool Module")
|
||||
print("=" * 40)
|
||||
|
||||
mode = "local" if _is_local_mode() else "cloud (Browserbase)"
|
||||
_cp = _get_cloud_provider()
|
||||
mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
|
||||
print(f" Mode: {mode}")
|
||||
|
||||
# Check requirements
|
||||
@@ -1832,12 +1673,9 @@ if __name__ == "__main__":
|
||||
except FileNotFoundError:
|
||||
print(" - agent-browser CLI not found")
|
||||
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
|
||||
if not _is_local_mode():
|
||||
if not os.environ.get("BROWSERBASE_API_KEY"):
|
||||
print(" - BROWSERBASE_API_KEY not set (required for cloud mode)")
|
||||
if not os.environ.get("BROWSERBASE_PROJECT_ID"):
|
||||
print(" - BROWSERBASE_PROJECT_ID not set (required for cloud mode)")
|
||||
print(" Tip: unset BROWSERBASE_API_KEY to use free local mode instead")
|
||||
if _cp is not None and not _cp.is_configured():
|
||||
print(f" - {_cp.provider_name()} credentials not configured")
|
||||
print(" Tip: remove cloud_provider from config to use free local mode instead")
|
||||
|
||||
print("\n📋 Available Browser Tools:")
|
||||
for schema in BROWSER_TOOL_SCHEMAS:
|
||||
|
||||
@@ -8,7 +8,6 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -414,13 +413,10 @@ def check_cronjob_requirements() -> bool:
|
||||
"""
|
||||
Check if cronjob tools can be used.
|
||||
|
||||
Requires 'crontab' executable to be present in the system PATH.
|
||||
Available in interactive CLI mode and gateway/messaging platforms.
|
||||
The cron system is internal (JSON file-based scheduler ticked by the gateway),
|
||||
so no external crontab executable is required.
|
||||
"""
|
||||
# Ensure the system can actually install and manage cron entries.
|
||||
if not shutil.which("crontab"):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
os.getenv("HERMES_INTERACTIVE")
|
||||
or os.getenv("HERMES_GATEWAY_SESSION")
|
||||
|
||||
@@ -308,9 +308,23 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
|
||||
|
||||
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None):
|
||||
"""Send via Telegram Bot API (one-shot, no polling needed)."""
|
||||
"""Send via Telegram Bot API (one-shot, no polling needed).
|
||||
|
||||
Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
|
||||
so that bold, links, and headers render correctly.
|
||||
"""
|
||||
try:
|
||||
from telegram import Bot
|
||||
from telegram.constants import ParseMode
|
||||
|
||||
# Reuse the gateway adapter's format_message for markdown→MarkdownV2
|
||||
try:
|
||||
from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2
|
||||
_adapter = TelegramAdapter.__new__(TelegramAdapter)
|
||||
formatted = _adapter.format_message(message)
|
||||
except Exception:
|
||||
# Fallback: send as-is if formatting unavailable
|
||||
formatted = message
|
||||
|
||||
bot = Bot(token=token)
|
||||
int_chat_id = int(chat_id)
|
||||
@@ -322,10 +336,27 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
last_msg = None
|
||||
warnings = []
|
||||
|
||||
if message.strip():
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=message, **thread_kwargs
|
||||
)
|
||||
if formatted.strip():
|
||||
try:
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=formatted,
|
||||
parse_mode=ParseMode.MARKDOWN_V2, **thread_kwargs
|
||||
)
|
||||
except Exception as md_error:
|
||||
# MarkdownV2 failed, fall back to plain text
|
||||
if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
|
||||
logger.warning("MarkdownV2 parse failed in _send_telegram, falling back to plain text: %s", md_error)
|
||||
try:
|
||||
from gateway.platforms.telegram import _strip_mdv2
|
||||
plain = _strip_mdv2(formatted)
|
||||
except Exception:
|
||||
plain = message
|
||||
last_msg = await bot.send_message(
|
||||
chat_id=int_chat_id, text=plain,
|
||||
parse_mode=None, **thread_kwargs
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
|
||||
@@ -37,6 +37,7 @@ That ordering matters because Hermes treats the saved model/provider choice as t
|
||||
|
||||
Current provider families include:
|
||||
|
||||
- AI Gateway (Vercel)
|
||||
- OpenRouter
|
||||
- Nous Portal
|
||||
- OpenAI Codex
|
||||
@@ -68,11 +69,21 @@ This resolver is the main reason Hermes can share auth/runtime logic between:
|
||||
- ACP editor sessions
|
||||
- auxiliary model tasks
|
||||
|
||||
## OpenRouter vs custom OpenAI-compatible base URLs
|
||||
## AI Gateway
|
||||
|
||||
Hermes contains logic to avoid leaking the wrong API key to a custom endpoint when both `OPENROUTER_API_KEY` and `OPENAI_API_KEY` exist.
|
||||
Set `AI_GATEWAY_API_KEY` in `~/.hermes/.env` and run with `--provider ai-gateway`. Hermes fetches available models from the gateway's `/models` endpoint, filtering to language models with tool-use support.
|
||||
|
||||
It also distinguishes between:
|
||||
## OpenRouter, AI Gateway, and custom OpenAI-compatible base URLs
|
||||
|
||||
Hermes contains logic to avoid leaking the wrong API key to a custom endpoint when multiple provider keys exist (e.g. `OPENROUTER_API_KEY`, `AI_GATEWAY_API_KEY`, and `OPENAI_API_KEY`).
|
||||
|
||||
Each provider's API key is scoped to its own base URL:
|
||||
|
||||
- `OPENROUTER_API_KEY` is only sent to `openrouter.ai` endpoints
|
||||
- `AI_GATEWAY_API_KEY` is only sent to `ai-gateway.vercel.sh` endpoints
|
||||
- `OPENAI_API_KEY` is used for custom endpoints and as a fallback
|
||||
|
||||
Hermes also distinguishes between:
|
||||
|
||||
- a real custom endpoint selected by the user
|
||||
- the OpenRouter fallback path used when no custom endpoint is configured
|
||||
@@ -80,7 +91,7 @@ It also distinguishes between:
|
||||
That distinction is especially important for:
|
||||
|
||||
- local model servers
|
||||
- non-OpenRouter OpenAI-compatible APIs
|
||||
- non-OpenRouter/non-AI Gateway OpenAI-compatible APIs
|
||||
- switching providers without re-running setup
|
||||
- config-saved custom endpoints that should keep working even when `OPENAI_BASE_URL` is not exported in the current shell
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
|----------|-------------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) |
|
||||
| `OPENROUTER_BASE_URL` | Override the OpenRouter-compatible base URL |
|
||||
| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key ([ai-gateway.vercel.sh](https://ai-gateway.vercel.sh)) |
|
||||
| `AI_GATEWAY_BASE_URL` | Override AI Gateway base URL (default: `https://ai-gateway.vercel.sh/v1`) |
|
||||
| `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) |
|
||||
| `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) |
|
||||
| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) |
|
||||
|
||||
@@ -65,6 +65,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
||||
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
||||
| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) |
|
||||
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
||||
| **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) |
|
||||
| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) |
|
||||
| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) |
|
||||
| **MiniMax** | `MINIMAX_API_KEY` in `~/.hermes/.env` (provider: `minimax`) |
|
||||
|
||||
@@ -34,6 +34,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
|
||||
|
||||
| Provider | Value | Requirements |
|
||||
|----------|-------|-------------|
|
||||
| AI Gateway | `ai-gateway` | `AI_GATEWAY_API_KEY` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
|
||||
| Nous Portal | `nous` | `hermes login` (OAuth) |
|
||||
| OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) |
|
||||
|
||||
Reference in New Issue
Block a user