From ad12df6ba488129c07c2b58d9ad30dcaba440ab4 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 29 Jul 2026 18:16:17 -0700
Subject: [PATCH] Revert "remove Vercel AI Gateway and Vercel Sandbox (#33067)"
This reverts commit febc4cfec0a79b175a430304765473c97e10622f.
---
README.md | 2 +-
agent/agent_init.py | 4 +-
agent/auxiliary_client.py | 13 +-
agent/model_metadata.py | 4 +-
agent/models_dev.py | 1 +
agent/prompt_builder.py | 5 +-
agent/usage_pricing.py | 4 +-
cli-config.yaml.example | 1 +
cli.py | 3 +-
gateway/run.py | 1 +
hermes_cli/auth.py | 9 +
hermes_cli/config.py | 4 +
hermes_cli/doctor.py | 68 +-
hermes_cli/dump.py | 1 +
hermes_cli/main.py | 3 +
hermes_cli/model_normalize.py | 1 +
hermes_cli/model_setup_flows.py | 54 ++
hermes_cli/models.py | 219 +++++-
hermes_cli/providers.py | 9 +
hermes_cli/setup.py | 140 +++-
hermes_cli/status.py | 18 +
hermes_cli/vercel_auth.py | 70 ++
hermes_cli/web_server.py | 7 +-
hermes_constants.py | 2 +
.../model-providers/ai-gateway/__init__.py | 43 ++
.../model-providers/ai-gateway/plugin.yaml | 5 +
pyproject.toml | 3 +-
run_agent.py | 5 +
setup-hermes.sh | 2 +-
.../references/providers-and-models.md | 2 +-
tests/agent/test_models_dev.py | 12 +
tests/agent/test_prompt_builder.py | 8 +
tests/agent/test_usage_pricing.py | 2 +-
tests/cli/test_cli_init.py | 60 ++
tests/conftest.py | 3 +
tests/gateway/test_config_cwd_bridge.py | 20 +
tests/hermes_cli/test_ai_gateway_models.py | 161 +++++
tests/hermes_cli/test_api_key_providers.py | 270 +++++++-
tests/hermes_cli/test_arcee_provider.py | 2 +-
tests/hermes_cli/test_doctor.py | 408 +++++++++++
tests/hermes_cli/test_gmi_provider.py | 1 +
.../test_runtime_provider_resolution.py | 281 ++++++++
tests/hermes_cli/test_set_config_value.py | 7 +
tests/hermes_cli/test_setup.py | 104 +++
tests/hermes_cli/test_status.py | 69 ++
.../test_tencent_tokenhub_provider.py | 2 +-
tests/hermes_cli/test_web_server.py | 14 +
tests/hermes_cli/test_xiaomi_provider.py | 16 +
tests/providers/test_plugin_discovery.py | 18 +-
.../test_provider_attribution_headers.py | 42 +-
tests/run_agent/test_provider_parity.py | 34 +
tests/test_project_metadata.py | 2 +-
tests/tools/test_command_guards.py | 4 +
tests/tools/test_hardline_blocklist.py | 4 +-
tests/tools/test_local_env_blocklist.py | 8 +
tests/tools/test_modal_sandbox_fixes.py | 90 +++
tests/tools/test_skills_tool.py | 71 ++
tests/tools/test_terminal_requirements.py | 128 ++++
.../tools/test_terminal_tool_requirements.py | 64 ++
.../tools/test_vercel_sandbox_environment.py | 606 ++++++++++++++++
tools/approval.py | 2 +-
tools/code_execution_tool.py | 18 +-
tools/credential_files.py | 2 +-
tools/environments/__init__.py | 4 +-
tools/environments/local.py | 4 +
tools/environments/vercel_sandbox.py | 654 ++++++++++++++++++
tools/file_operations.py | 2 +-
tools/file_tools.py | 3 +-
tools/lazy_deps.py | 1 +
tools/skills_tool.py | 2 +-
tools/terminal_tool.py | 121 +++-
website/docs/developer-guide/architecture.md | 2 +-
.../docs/developer-guide/provider-runtime.md | 12 +-
website/docs/developer-guide/tools-runtime.md | 1 +
website/docs/getting-started/quickstart.md | 1 +
website/docs/integrations/providers.md | 3 +-
website/docs/reference/cli-commands.md | 2 +-
.../docs/reference/environment-variables.md | 9 +-
website/docs/user-guide/configuration.md | 52 +-
.../user-guide/features/fallback-providers.md | 1 +
website/docs/user-guide/features/tools.md | 33 +-
website/docs/user-guide/security.md | 5 +-
.../current/developer-guide/architecture.md | 2 +-
.../developer-guide/provider-runtime.md | 12 +-
.../current/developer-guide/tools-runtime.md | 1 +
.../current/getting-started/quickstart.md | 1 +
.../current/integrations/providers.md | 1 +
.../current/reference/cli-commands.md | 2 +-
.../reference/environment-variables.md | 9 +-
.../current/user-guide/configuration.md | 52 +-
.../user-guide/features/fallback-providers.md | 1 +
.../current/user-guide/features/tools.md | 33 +-
.../current/user-guide/security.md | 5 +-
.../autonomous-ai-agents-hermes-agent.md | 3 +-
94 files changed, 4168 insertions(+), 102 deletions(-)
create mode 100644 hermes_cli/vercel_auth.py
create mode 100644 plugins/model-providers/ai-gateway/__init__.py
create mode 100644 plugins/model-providers/ai-gateway/plugin.yaml
create mode 100644 tests/hermes_cli/test_ai_gateway_models.py
create mode 100644 tests/tools/test_vercel_sandbox_environment.py
create mode 100644 tools/environments/vercel_sandbox.py
diff --git a/README.md b/README.md
index d78d6d57f04..c0511226674 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
-| Runs anywhere, not just your laptop | Six terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
+| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, trajectory compression for training the next generation of tool-calling models. |
diff --git a/agent/agent_init.py b/agent/agent_init.py
index 0b2ced0d327..407f9a6c7b4 100644
--- a/agent/agent_init.py
+++ b/agent/agent_init.py
@@ -1161,8 +1161,8 @@ def init_agent(
client_kwargs["default_headers"] = hermes_xai_default_headers()
elif "default_headers" not in client_kwargs:
# Fall back to profile.default_headers for providers that
- # declare custom headers (e.g. Kimi User-Agent on non-kimi.com
- # endpoints).
+ # declare custom headers (e.g. Vercel AI Gateway attribution,
+ # Kimi User-Agent on non-kimi.com endpoints).
try:
from providers import get_provider_profile as _gpf
_ph = _gpf(agent.provider)
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 2dc845b69c1..acd22d88485 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -543,6 +543,7 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"kimi-coding-cn": "kimi-k2-turbo-preview",
"gmi": "google/gemini-3.1-flash-lite-preview",
"anthropic": "claude-haiku-4-5-20251001",
+ "ai-gateway": "google/gemini-3-flash",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
"kilocode": "google/gemini-3.6-flash",
@@ -729,6 +730,15 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict:
return {}
+# Vercel AI Gateway app attribution headers. HTTP-Referer maps to
+# referrerUrl and X-Title maps to appName in the gateway's analytics.
+from hermes_cli import __version__ as _HERMES_VERSION
+
+_AI_GATEWAY_HEADERS = {
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
+ "X-Title": "Hermes Agent",
+ "User-Agent": f"HermesAgent/{_HERMES_VERSION}",
+}
# Nous Portal extra_body for product attribution.
# Callers should pass this as extra_body in chat.completions.create()
@@ -5725,7 +5735,8 @@ def resolve_provider_client(
else:
# Fall back to profile.default_headers for providers that declare
# client-level attribution headers on their profile (e.g. GMI
- # User-Agent for traffic identification).
+ # User-Agent for traffic identification, Vercel AI Gateway
+ # Referer/Title for analytics).
try:
from providers import get_provider_profile as _gpf_main
_ph_main = _gpf_main(provider)
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 99f18d8851a..cec891dbb78 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -72,7 +72,7 @@ def _resolve_requests_verify() -> bool | str:
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
- "opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
+ "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
"arcee",
@@ -84,7 +84,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"ollama",
- "stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
+ "stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
"arcee-ai", "arceeai",
diff --git a/agent/models_dev.py b/agent/models_dev.py
index aed49b0d1d5..71af78625a4 100644
--- a/agent/models_dev.py
+++ b/agent/models_dev.py
@@ -168,6 +168,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"alibaba": "alibaba",
"qwen-oauth": "alibaba",
"copilot": "github-copilot",
+ "ai-gateway": "vercel",
"opencode-zen": "opencode",
"opencode-go": "opencode-go",
"kilocode": "kilo",
diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index e6f1f96adf8..f0ba1a0a6b8 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -981,7 +981,7 @@ WSL_ENVIRONMENT_HINT = (
# misleading — the agent should only see the machine it can actually touch.
_REMOTE_TERMINAL_BACKENDS = frozenset({
"docker", "singularity", "modal", "daytona", "ssh",
- "managed_modal",
+ "vercel_sandbox", "managed_modal",
})
@@ -995,6 +995,7 @@ _BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = {
"modal": "a Modal sandbox (Linux)",
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
+ "vercel_sandbox": "a Vercel sandbox (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}
@@ -1159,7 +1160,7 @@ def build_environment_hints() -> str:
and a Windows-only note that `terminal` shells out to bash, not
PowerShell).
- For **remote / sandbox** terminal backends (docker, singularity,
- modal, daytona, ssh): host info is **suppressed**
+ modal, daytona, ssh, vercel_sandbox): host info is **suppressed**
because the agent's tools can't touch the host — only the backend
matters. A live probe inside the backend reports its OS, user, $HOME,
and cwd. Falls back to a static summary if the probe fails.
diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py
index b7982e46ab0..04d34d13372 100644
--- a/agent/usage_pricing.py
+++ b/agent/usage_pricing.py
@@ -1244,8 +1244,8 @@ def normalize_usage(
output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
details = getattr(response_usage, "prompt_tokens_details", None)
# Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style
- # top-level fields that some OpenAI-compatible proxies (OpenRouter, Cline)
- # expose when routing Claude models — without this
+ # top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel
+ # AI Gateway, Cline) expose when routing Claude models — without this
# fallback, cache writes are undercounted as 0 and cache reads can be
# missed when the proxy only surfaces them at the top level.
# Port of cline/cline#10266.
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index df89eba21ee..95915405bea 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -46,6 +46,7 @@ model:
# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings)
# "deepinfra" - DeepInfra (requires: DEEPINFRA_API_KEY)
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
+ # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
# "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID)
# "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1)
#
diff --git a/cli.py b/cli.py
index 16c6fd5545c..bb91aa4fc37 100644
--- a/cli.py
+++ b/cli.py
@@ -664,12 +664,13 @@ def load_cli_config() -> Dict[str, Any]:
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ "vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
# SSH config
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
- # Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
+ # Container resource config (docker, singularity, modal, daytona, vercel_sandbox -- ignored for local/ssh)
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
diff --git a/gateway/run.py b/gateway/run.py
index 115cdd8d375..d7f44c6a4b9 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -1914,6 +1914,7 @@ if _config_path.exists():
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ "vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 1807d7cf8ec..0d08007ef33 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -365,6 +365,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("NVIDIA_API_KEY",),
base_url_env_var="NVIDIA_BASE_URL",
),
+ "ai-gateway": ProviderConfig(
+ id="ai-gateway",
+ name="Vercel 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",
+ ),
"opencode-zen": ProviderConfig(
id="opencode-zen",
name="OpenCode Zen",
@@ -1889,6 +1897,7 @@ def resolve_provider(
"github": "copilot", "github-copilot": "copilot",
"github-models": "copilot", "github-model": "copilot",
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
+ "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
"opencode": "opencode-zen", "zen": "opencode-zen",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 4782bcc0666..4970c4c7f80 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -3180,6 +3180,7 @@ TERMINAL_CONFIG_ENV_MAP = {
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ "vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
@@ -4314,6 +4315,9 @@ def show_config():
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
daytona_key = get_env_value('DAYTONA_API_KEY')
print(f" API key: {'configured' if daytona_key else '(not set)'}")
+ elif terminal.get('backend') == 'vercel_sandbox':
+ print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}")
+ print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index 8b07e7ab1af..ea04255983e 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -31,6 +31,7 @@ load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".en
from hermes_cli.colors import Colors, color
from hermes_cli.models import _HERMES_USER_AGENT
+from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import OPENROUTER_MODELS_URL
from utils import base_url_host_matches
@@ -56,6 +57,7 @@ _PROVIDER_ENV_HINTS = (
"DEEPSEEK_API_KEY",
"DASHSCOPE_API_KEY",
"HF_TOKEN",
+ "AI_GATEWAY_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
"XIAOMI_API_KEY",
@@ -604,6 +606,7 @@ def _build_apikey_providers_list() -> list:
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
+ ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
# OpenCode Go has no shared /models endpoint; skip the health check.
@@ -619,7 +622,7 @@ def _build_apikey_providers_list() -> list:
"Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
"Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
"Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
- "MiniMax (China)": "minimax-cn",
+ "MiniMax (China)": "minimax-cn", "Vercel AI Gateway": "ai-gateway",
"Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
"OpenCode Go": "opencode-go",
}
@@ -1064,6 +1067,7 @@ def run_doctor(args):
providers_accepting_vendor_slugs = {
"openrouter",
"auto",
+ "ai-gateway",
"kilocode",
"opencode-zen",
"huggingface",
@@ -1810,6 +1814,68 @@ def run_doctor(args):
issues,
)
+ # Vercel Sandbox (if using vercel_sandbox backend)
+ if terminal_env == "vercel_sandbox":
+ runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
+ from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
+ if runtime in _SUPPORTED_VERCEL_RUNTIMES:
+ check_ok("Vercel runtime", f"({runtime})")
+ else:
+ supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
+ _fail_and_issue(
+ "Vercel runtime unsupported",
+ f"({runtime}; use {supported})",
+ f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}",
+ issues,
+ )
+
+ disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
+ if disk in {"", "0", "51200"}:
+ check_ok("Vercel disk setting", "(uses platform default)")
+ else:
+ _fail_and_issue(
+ "Vercel custom disk unsupported",
+ "(reset terminal.container_disk to 51200)",
+ "Vercel Sandbox does not support custom container_disk; use the shared default 51200",
+ issues,
+ )
+
+ if importlib.util.find_spec("vercel") is not None:
+ check_ok("vercel SDK", "(installed)")
+ else:
+ _fail_and_issue(
+ "vercel SDK not installed",
+ "(pip install 'hermes-agent[vercel]')",
+ "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'",
+ issues,
+ )
+
+ auth_status = describe_vercel_auth()
+ if auth_status.ok:
+ check_ok("Vercel auth", f"({auth_status.label})")
+ elif auth_status.label.startswith("partial"):
+ _fail_and_issue(
+ "Vercel auth incomplete",
+ f"({auth_status.label})",
+ "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
+ issues,
+ )
+ else:
+ _fail_and_issue(
+ "Vercel auth not configured",
+ f"({auth_status.label})",
+ "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
+ issues,
+ )
+ for line in auth_status.detail_lines:
+ check_info(f"Vercel auth {line}")
+
+ persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
+ if persistent:
+ check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
+ else:
+ check_info("Vercel persistence: ephemeral filesystem")
+
# Node.js + agent-browser (for browser automation tools)
if _safe_which("node"):
check_ok("Node.js")
diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py
index e8ed12963e6..1f46cbf7230 100644
--- a/hermes_cli/dump.py
+++ b/hermes_cli/dump.py
@@ -379,6 +379,7 @@ def run_dump(args):
("DASHSCOPE_API_KEY", "dashscope"),
("HF_TOKEN", "huggingface"),
("NVIDIA_API_KEY", "nvidia"),
+ ("AI_GATEWAY_API_KEY", "ai_gateway"),
("OPENCODE_ZEN_API_KEY", "opencode_zen"),
("OPENCODE_GO_API_KEY", "opencode_go"),
("KILOCODE_API_KEY", "kilocode"),
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 30c3104316e..b3ba3b2b98d 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -794,6 +794,7 @@ from hermes_cli.model_setup_flows import (
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
+ _model_flow_ai_gateway,
)
logger = logging.getLogger(__name__)
@@ -3370,6 +3371,8 @@ def select_provider_and_model(args=None):
_model_flow_openrouter(config, current_model)
elif selected_provider == "moa":
_model_flow_moa(config, current_model)
+ elif selected_provider == "ai-gateway":
+ _model_flow_ai_gateway(config, current_model)
elif selected_provider == "nous":
_model_flow_nous(config, current_model, args=args)
elif selected_provider == "openai-codex":
diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py
index c2865b0364b..041aa47f0db 100644
--- a/hermes_cli/model_normalize.py
+++ b/hermes_cli/model_normalize.py
@@ -69,6 +69,7 @@ _VENDOR_PREFIXES: dict[str, str] = {
_AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
"openrouter",
"nous",
+ "ai-gateway",
"kilocode",
})
diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py
index 8cefca92469..cea14a263fb 100644
--- a/hermes_cli/model_setup_flows.py
+++ b/hermes_cli/model_setup_flows.py
@@ -255,6 +255,60 @@ def _print_moa_preset(name: str, preset: dict) -> None:
print(f" Aggregator: {agg.get('provider')}:{agg.get('model')}")
+def _model_flow_ai_gateway(config, current_model=""):
+ """Vercel AI Gateway provider: ensure API key, then pick model with pricing."""
+ from hermes_constants import AI_GATEWAY_BASE_URL
+ from hermes_cli.main import _prompt_api_key
+ from hermes_cli.auth import (
+ PROVIDER_REGISTRY,
+ _prompt_model_selection,
+ _save_model_choice,
+ deactivate_provider,
+ )
+ from hermes_cli.config import get_env_value
+
+ # Route through _prompt_api_key so users can replace a stale/broken key
+ # in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand.
+ pconfig = PROVIDER_REGISTRY["ai-gateway"]
+ existing_key = get_env_value("AI_GATEWAY_API_KEY") or ""
+ if not existing_key:
+ print(
+ "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway"
+ )
+ print("Add a payment method to get $5 in free credits.")
+ print()
+ _resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="ai-gateway")
+ if abort:
+ return
+
+ from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider
+
+ models_list = ai_gateway_model_ids(force_refresh=True)
+ pricing = get_pricing_for_provider("ai-gateway", force_refresh=True)
+
+ selected = _prompt_model_selection(
+ models_list, current_model=current_model, pricing=pricing
+ )
+ if selected:
+ _save_model_choice(selected)
+
+ from hermes_cli.config import load_config, save_config
+
+ cfg = load_config()
+ model = cfg.get("model")
+ if not isinstance(model, dict):
+ model = {"default": model} if model else {}
+ cfg["model"] = model
+ model["provider"] = "ai-gateway"
+ model["base_url"] = AI_GATEWAY_BASE_URL
+ model["api_mode"] = "chat_completions"
+ save_config(cfg)
+ deactivate_provider()
+ print(f"Default model set to: {selected} (via Vercel AI Gateway)")
+ else:
+ print("No change.")
+
+
def _model_flow_moa(config, current_model=""):
"""Mixture of Agents virtual provider: pick a preset, then persist it.
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index 7f996de8fe7..5ad6e926e78 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -98,6 +98,29 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
+# Fallback Vercel AI Gateway snapshot used when the live catalog is unavailable.
+# OSS / open-weight models prioritized first, then closed-source by family.
+# Slugs match Vercel's actual /v1/models catalog (e.g. alibaba/ for Qwen,
+# zai/ and xai/ without hyphens).
+VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [
+ ("moonshotai/kimi-k2.6", "recommended"),
+ ("alibaba/qwen3.6-plus", ""),
+ ("zai/glm-5.1", ""),
+ ("minimax/minimax-m2.7", ""),
+ ("anthropic/claude-sonnet-4.6", ""),
+ ("anthropic/claude-opus-4.7", ""),
+ ("anthropic/claude-opus-4.6", ""),
+ ("anthropic/claude-haiku-4.5", ""),
+ ("openai/gpt-5.4", ""),
+ ("openai/gpt-5.4-mini", ""),
+ ("openai/gpt-5.3-codex", ""),
+ ("google/gemini-3.1-pro-preview", ""),
+ ("google/gemini-3-flash", ""),
+ ("google/gemini-3.1-flash-lite-preview", ""),
+ ("xai/grok-4.20-reasoning", ""),
+]
+
+_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None
def _codex_curated_models() -> list[str]:
@@ -582,6 +605,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
],
}
+# Vercel AI Gateway: derive the bare-model-id catalog from the curated
+# ``VERCEL_AI_GATEWAY_MODELS`` snapshot so both the picker (tuples with descriptions)
+# and the static fallback catalog (bare ids) stay in sync from a single
+# source of truth.
+_PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS]
+
# ---------------------------------------------------------------------------
# Nous Portal free-model helper
# ---------------------------------------------------------------------------
@@ -1111,6 +1140,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"),
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"),
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"),
+ ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (Multi-model aggregator)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (Reuses local Qwen CLI login)"),
]
@@ -1284,6 +1314,9 @@ _PROVIDER_ALIASES = {
"zen": "opencode-zen",
"go": "opencode-go",
"opencode-go-sub": "opencode-go",
+ "aigateway": "ai-gateway",
+ "vercel": "ai-gateway",
+ "vercel-ai-gateway": "ai-gateway",
"kilo": "kilocode",
"kilo-code": "kilocode",
"kilo-gateway": "kilocode",
@@ -1553,6 +1586,95 @@ def get_curated_nous_model_ids() -> list[str]:
return list(_PROVIDER_MODELS.get("nous", []))
+def _ai_gateway_model_is_free(pricing: Any) -> bool:
+ """Return True if an AI Gateway model has $0 input AND output pricing."""
+ if not isinstance(pricing, dict):
+ return False
+ try:
+ return float(pricing.get("input", "0")) == 0 and float(pricing.get("output", "0")) == 0
+ except (TypeError, ValueError):
+ return False
+
+
+def fetch_ai_gateway_models(
+ timeout: float = 8.0,
+ *,
+ force_refresh: bool = False,
+) -> list[tuple[str, str]]:
+ """Return the curated AI Gateway picker list, refreshed from the live catalog when possible."""
+ global _ai_gateway_catalog_cache
+
+ if _ai_gateway_catalog_cache is not None and not force_refresh:
+ return list(_ai_gateway_catalog_cache)
+
+ from hermes_constants import AI_GATEWAY_BASE_URL
+
+ fallback = list(VERCEL_AI_GATEWAY_MODELS)
+ preferred_ids = [mid for mid, _ in fallback]
+
+ try:
+ req = urllib.request.Request(
+ f"{AI_GATEWAY_BASE_URL.rstrip('/')}/models",
+ headers={"Accept": "application/json"},
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ payload = json.loads(resp.read().decode())
+ except Exception:
+ return list(_ai_gateway_catalog_cache or fallback)
+
+ live_items = payload.get("data", [])
+ if not isinstance(live_items, list):
+ return list(_ai_gateway_catalog_cache or fallback)
+
+ live_by_id: dict[str, dict[str, Any]] = {}
+ for item in live_items:
+ if not isinstance(item, dict):
+ continue
+ mid = str(item.get("id") or "").strip()
+ if not mid:
+ continue
+ live_by_id[mid] = item
+
+ curated: list[tuple[str, str]] = []
+ for preferred_id in preferred_ids:
+ live_item = live_by_id.get(preferred_id)
+ if live_item is None:
+ continue
+ desc = "free" if _ai_gateway_model_is_free(live_item.get("pricing")) else ""
+ curated.append((preferred_id, desc))
+
+ if not curated:
+ return list(_ai_gateway_catalog_cache or fallback)
+
+ # If the live catalog offers a free Moonshot model, auto-promote it to
+ # position #1 as "recommended" — dynamic discovery without a PR.
+ free_moonshot = next(
+ (
+ mid
+ for mid, item in live_by_id.items()
+ if mid.startswith("moonshotai/")
+ and _ai_gateway_model_is_free(item.get("pricing"))
+ ),
+ None,
+ )
+ if free_moonshot:
+ curated = [(mid, desc) for mid, desc in curated if mid != free_moonshot]
+ curated.insert(0, (free_moonshot, "recommended"))
+ else:
+ first_id, _ = curated[0]
+ curated[0] = (first_id, "recommended")
+
+ _ai_gateway_catalog_cache = curated
+ return list(curated)
+
+
+def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]:
+ """Return just the AI Gateway model-id strings."""
+ return [mid for mid, _ in fetch_ai_gateway_models(force_refresh=force_refresh)]
+
+
+
+
# ---------------------------------------------------------------------------
# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models
# ---------------------------------------------------------------------------
@@ -1736,6 +1858,56 @@ def fetch_models_with_pricing(
return result
+def fetch_ai_gateway_pricing(
+ timeout: float = 8.0,
+ *,
+ force_refresh: bool = False,
+) -> dict[str, dict[str, str]]:
+ """Fetch Vercel AI Gateway /v1/models and return hermes-shaped pricing.
+
+ Vercel uses ``input`` / ``output`` field names; hermes's picker expects
+ ``prompt`` / ``completion``. This translates. Cache read/write field names
+ already match.
+ """
+ from hermes_constants import AI_GATEWAY_BASE_URL
+
+ cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
+ if not force_refresh and cache_key in _pricing_cache:
+ return _pricing_cache[cache_key]
+
+ try:
+ req = urllib.request.Request(
+ f"{cache_key}/models",
+ headers={"Accept": "application/json"},
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ payload = json.loads(resp.read().decode())
+ except Exception:
+ _pricing_cache[cache_key] = {}
+ return {}
+
+ result: dict[str, dict[str, str]] = {}
+ for item in payload.get("data", []):
+ if not isinstance(item, dict):
+ continue
+ mid = item.get("id")
+ pricing = item.get("pricing")
+ if not (mid and isinstance(pricing, dict)):
+ continue
+ entry: dict[str, str] = {
+ "prompt": str(pricing.get("input", "")),
+ "completion": str(pricing.get("output", "")),
+ }
+ if pricing.get("input_cache_read"):
+ entry["input_cache_read"] = str(pricing["input_cache_read"])
+ if pricing.get("input_cache_write"):
+ entry["input_cache_write"] = str(pricing["input_cache_write"])
+ result[mid] = entry
+
+ _pricing_cache[cache_key] = result
+ return result
+
+
def _resolve_openrouter_api_key() -> str:
"""Best-effort OpenRouter API key for pricing fetch."""
return os.getenv("OPENROUTER_API_KEY", "").strip()
@@ -1790,7 +1962,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
- """Return live pricing for providers that support it (openrouter, nous, novita)."""
+ """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita)."""
normalized = normalize_provider(provider)
if normalized == "openrouter":
return fetch_models_with_pricing(
@@ -1798,6 +1970,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d
base_url="https://openrouter.ai/api",
force_refresh=force_refresh,
)
+ if normalized == "ai-gateway":
+ return fetch_ai_gateway_pricing(force_refresh=force_refresh)
if normalized == "novita":
return _fetch_novita_pricing(force_refresh=force_refresh)
if normalized == "deepinfra":
@@ -1882,8 +2056,9 @@ def _fetch_novita_pricing(
0.0001 USD. Convert them to the per-token strings used by the shared
pricing formatter.
- Results are cached in ``_pricing_cache`` keyed on the resolved base URL —
- without this, every menu render or pricing lookup re-hits the network.
+ Results are cached in ``_pricing_cache`` keyed on the resolved base URL,
+ matching the pattern used by ``fetch_ai_gateway_pricing`` — without this,
+ every menu render or pricing lookup re-hits the network.
"""
api_key = os.getenv("NOVITA_API_KEY", "").strip()
if not api_key:
@@ -2105,7 +2280,7 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
_AGGREGATOR_PROVIDERS = frozenset(
- {"nous", "openrouter", "copilot", "kilocode"}
+ {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"}
)
# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
@@ -2515,7 +2690,7 @@ def _resolve_copilot_catalog_api_key() -> str:
# - "nous": curated list and Portal /models endpoint are the source of
# truth for the subscription tier.
# Also excluded: providers that already have dedicated live-endpoint
-# branches below (copilot, anthropic, ollama-cloud, custom,
+# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom,
# stepfun, openai-codex) — those paths handle freshness themselves.
_MODELS_DEV_PREFERRED: frozenset[str] = frozenset({
"opencode-go",
@@ -2690,6 +2865,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
merged_lower.add(m.lower())
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
+ if normalized == "ai-gateway":
+ live = _fetch_ai_gateway_models()
+ if live:
+ return live
if normalized == "deepinfra":
# DeepInfra's generic /models endpoint mixes chat, image, video,
# speech, and embedding models. The tagged catalog helper is the only
@@ -4266,6 +4445,36 @@ def _fetch_deepinfra_pricing(
return result
+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}",
+ "User-Agent": _HERMES_USER_AGENT,
+ }
+ 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],
diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py
index fed10a2a73f..17cae5d584f 100644
--- a/hermes_cli/providers.py
+++ b/hermes_cli/providers.py
@@ -142,6 +142,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
base_url_env_var="ALIBABA_CODING_PLAN_BASE_URL",
),
+ "vercel": HermesOverlay(
+ transport="openai_chat",
+ is_aggregator=True,
+ ),
"opencode": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
@@ -309,6 +313,11 @@ ALIASES: Dict[str, str] = {
"github": "github-copilot",
"github-copilot-acp": "copilot-acp",
+ # vercel (models.dev ID for AI Gateway)
+ "ai-gateway": "vercel",
+ "aigateway": "vercel",
+ "vercel-ai-gateway": "vercel",
+
# opencode (models.dev ID for OpenCode Zen)
"opencode-zen": "opencode",
"zen": "opencode",
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index c4b0c8baf62..fc5fa42f852 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -105,6 +105,7 @@ _DEFAULT_PROVIDER_MODELS = {
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
+ "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
"kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-5", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
"opencode-go": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
@@ -743,6 +744,102 @@ def _prompt_container_resources(config: dict):
pass
+def _prompt_vercel_sandbox_settings(config: dict):
+ """Prompt for Vercel Sandbox settings without exposing unsupported disk sizing."""
+ terminal = config.setdefault("terminal", {})
+
+ print()
+ print_info("Vercel Sandbox settings:")
+ print_info(" Filesystem persistence uses Vercel snapshots.")
+ print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.")
+
+ from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
+
+ current_runtime = terminal.get("vercel_runtime") or "node24"
+ supported_label = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
+ runtime = prompt(f" Runtime ({supported_label})", current_runtime).strip() or current_runtime
+ if runtime not in _SUPPORTED_VERCEL_RUNTIMES:
+ print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.")
+ runtime = current_runtime if current_runtime in _SUPPORTED_VERCEL_RUNTIMES else "node24"
+ terminal["vercel_runtime"] = runtime
+ save_env_value("TERMINAL_VERCEL_RUNTIME", runtime)
+
+ current_persist = terminal.get("container_persistent", True)
+ persist_label = "yes" if current_persist else "no"
+ terminal["container_persistent"] = prompt(
+ " Persist filesystem with snapshots? (yes/no)", persist_label
+ ).lower() in {"yes", "true", "y", "1"}
+
+ current_cpu = terminal.get("container_cpu", 1)
+ cpu_str = prompt(" CPU cores", str(current_cpu))
+ try:
+ terminal["container_cpu"] = float(cpu_str)
+ except ValueError:
+ pass
+
+ current_mem = terminal.get("container_memory", 5120)
+ mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
+ try:
+ terminal["container_memory"] = int(mem_str)
+ except ValueError:
+ pass
+
+ if terminal.get("container_disk", 51200) not in {0, 51200}:
+ print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.")
+ terminal["container_disk"] = 51200
+
+ print()
+ print_info("Vercel authentication:")
+ print_info(" Use a long-lived Vercel access token plus project/team IDs.")
+ linked_project = _read_nearest_vercel_project()
+ if linked_project:
+ print_info(" Found defaults in nearest .vercel/project.json.")
+
+ remove_env_value("VERCEL_OIDC_TOKEN")
+ token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True)
+ project = prompt(
+ " Vercel project ID",
+ get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""),
+ )
+ team = prompt(
+ " Vercel team ID",
+ get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""),
+ )
+ if token:
+ save_env_value("VERCEL_TOKEN", token)
+ if project:
+ save_env_value("VERCEL_PROJECT_ID", project)
+ if team:
+ save_env_value("VERCEL_TEAM_ID", team)
+
+
+def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]:
+ """Read project/team defaults from the nearest Vercel link file."""
+ current = (start or Path.cwd()).resolve()
+ if current.is_file():
+ current = current.parent
+
+ for directory in (current, *current.parents):
+ project_file = directory / ".vercel" / "project.json"
+ if not project_file.exists():
+ continue
+ try:
+ data = json.loads(project_file.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ return {
+ key: value
+ for key, value in {
+ "projectId": data.get("projectId"),
+ "orgId": data.get("orgId"),
+ }.items()
+ if isinstance(value, str) and value.strip()
+ }
+ return {}
+
+
# Tool categories and provider config are now in tools_config.py (shared
# between `hermes tools` and `hermes setup tools`).
@@ -800,6 +897,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
# on demand via `hermes auth add`, `hermes setup` vision, and
# `hermes setup tts`. This keeps both quick and full setup thin.
+
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
@@ -1221,11 +1319,12 @@ def setup_terminal_backend(config: dict):
"Modal - serverless cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
+ "Vercel Sandbox - cloud microVM with snapshot filesystem persistence",
]
- idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
- backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
+ idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"}
+ backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5}
- next_idx = 5
+ next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
@@ -1433,6 +1532,39 @@ def setup_terminal_backend(config: dict):
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
+ elif selected_backend == "vercel_sandbox":
+ print_success("Terminal backend: Vercel Sandbox")
+ print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.")
+ print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'")
+
+ try:
+ __import__("vercel")
+ except ImportError:
+ print_info("Installing vercel SDK...")
+ import subprocess
+
+ uv_bin = shutil.which("uv")
+ if uv_bin:
+ result = subprocess.run(
+ [uv_bin, "pip", "install", "--python", sys.executable, "vercel"],
+ capture_output=True,
+ text=True,
+ )
+ else:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "vercel"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ print_success("vercel SDK installed")
+ else:
+ print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'")
+ if result.stderr:
+ print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
+
+ _prompt_vercel_sandbox_settings(config)
+
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")
@@ -1486,6 +1618,8 @@ def setup_terminal_backend(config: dict):
save_env_value("TERMINAL_ENV", selected_backend)
if selected_backend == "modal":
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
+ if selected_backend == "vercel_sandbox":
+ save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24"))
save_config(config)
print()
print_success(f"Terminal backend set to: {selected_backend}")
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index de7921c20a5..ee470b1fc32 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -22,6 +22,7 @@ from hermes_cli.nous_account import (
)
from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.runtime_provider import resolve_requested_provider
+from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import OPENROUTER_MODELS_URL
from tools.tool_backend_helpers import managed_nous_tools_enabled
@@ -430,6 +431,23 @@ def show_status(args):
elif terminal_env == "daytona":
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
print(f" Daytona Image: {daytona_image}")
+ elif terminal_env == "vercel_sandbox":
+ runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24"
+ persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
+ if persist is None:
+ persist_enabled = bool(terminal_cfg.get("container_persistent", True))
+ else:
+ persist_enabled = persist.lower() in {"1", "true", "yes", "on"}
+ auth_status = describe_vercel_auth()
+ sdk_ok = importlib.util.find_spec("vercel") is not None
+ sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')"
+ print(f" Runtime: {runtime}")
+ print(f" SDK: {check_mark(sdk_ok)} {sdk_label}")
+ print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}")
+ for line in auth_status.detail_lines:
+ print(f" Auth detail: {line}")
+ print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
+ print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
diff --git a/hermes_cli/vercel_auth.py b/hermes_cli/vercel_auth.py
new file mode 100644
index 00000000000..4666d516e1e
--- /dev/null
+++ b/hermes_cli/vercel_auth.py
@@ -0,0 +1,70 @@
+"""Helpers for reporting Vercel Sandbox authentication state."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+
+
+_TOKEN_TUPLE_VARS = ("VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID")
+
+
+@dataclass(frozen=True)
+class VercelAuthStatus:
+ ok: bool
+ label: str
+ detail_lines: tuple[str, ...]
+
+
+def _present(name: str) -> bool:
+ return bool(os.getenv(name))
+
+
+def describe_vercel_auth() -> VercelAuthStatus:
+ """Return Vercel auth status without exposing secret values."""
+
+ has_oidc = _present("VERCEL_OIDC_TOKEN")
+ token_states = {name: _present(name) for name in _TOKEN_TUPLE_VARS}
+ present_token_vars = tuple(name for name, present in token_states.items() if present)
+ missing_token_vars = tuple(name for name, present in token_states.items() if not present)
+
+ if has_oidc:
+ details = [
+ "mode: OIDC",
+ "active env: VERCEL_OIDC_TOKEN",
+ "note: OIDC tokens are development-only; use access-token auth for deployments and long-running processes",
+ ]
+ if present_token_vars:
+ details.append(f"also present: {', '.join(present_token_vars)}")
+ return VercelAuthStatus(True, "OIDC token via VERCEL_OIDC_TOKEN", tuple(details))
+
+ if not missing_token_vars:
+ return VercelAuthStatus(
+ True,
+ "access token + project/team via VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
+ (
+ "mode: access token",
+ "active env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
+ ),
+ )
+
+ if present_token_vars:
+ return VercelAuthStatus(
+ False,
+ f"partial access-token auth (missing {', '.join(missing_token_vars)})",
+ (
+ "mode: incomplete access token",
+ f"present env: {', '.join(present_token_vars)}",
+ f"missing env: {', '.join(missing_token_vars)}",
+ "recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
+ ),
+ )
+
+ return VercelAuthStatus(
+ False,
+ "not configured",
+ (
+ "recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
+ "development-only alternative: set VERCEL_OIDC_TOKEN",
+ ),
+ )
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 82fd6c74f85..767882ba9d1 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -847,7 +847,12 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"terminal.backend": {
"type": "select",
"description": "Terminal execution backend",
- "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
+ "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
+ },
+ "terminal.vercel_runtime": {
+ "type": "select",
+ "description": "Vercel Sandbox runtime",
+ "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
},
"terminal.modal_mode": {
"type": "select",
diff --git a/hermes_constants.py b/hermes_constants.py
index e282dc2dcce..bd3db68f131 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -1238,3 +1238,5 @@ FINISH_REASON_LENGTH = "length"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
+
+AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
diff --git a/plugins/model-providers/ai-gateway/__init__.py b/plugins/model-providers/ai-gateway/__init__.py
new file mode 100644
index 00000000000..9d01ab98246
--- /dev/null
+++ b/plugins/model-providers/ai-gateway/__init__.py
@@ -0,0 +1,43 @@
+"""Vercel AI Gateway provider profile.
+
+AI Gateway routes to multiple backends. Hermes sends attribution
+headers and full reasoning config passthrough.
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class VercelAIGatewayProfile(ProviderProfile):
+ """Vercel AI Gateway — attribution headers + reasoning passthrough."""
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ supports_reasoning: bool = True,
+ **ctx: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ extra_body: dict[str, Any] = {}
+ if supports_reasoning and reasoning_config is not None:
+ extra_body["reasoning"] = dict(reasoning_config)
+ elif supports_reasoning:
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+ return extra_body, {}
+
+
+vercel = VercelAIGatewayProfile(
+ name="ai-gateway",
+ aliases=("vercel", "vercel-ai-gateway", "ai_gateway", "aigateway"),
+ env_vars=("AI_GATEWAY_API_KEY",),
+ base_url="https://ai-gateway.vercel.sh/v1",
+ default_headers={
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
+ "X-Title": "Hermes Agent",
+ },
+ default_aux_model="google/gemini-3-flash",
+)
+
+register_provider(vercel)
diff --git a/plugins/model-providers/ai-gateway/plugin.yaml b/plugins/model-providers/ai-gateway/plugin.yaml
new file mode 100644
index 00000000000..252ca42ed6c
--- /dev/null
+++ b/plugins/model-providers/ai-gateway/plugin.yaml
@@ -0,0 +1,5 @@
+name: ai-gateway-provider
+kind: model-provider
+version: 1.0.0
+description: Vercel AI Gateway
+author: Nous Research
diff --git a/pyproject.toml b/pyproject.toml
index fd47c33207b..408aec4edc1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -160,6 +160,7 @@ fal = ["fal-client==0.13.1"]
edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
+vercel = ["vercel==0.5.7"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82)
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
@@ -311,7 +312,7 @@ all = [
#
# Removed from [all] on 2026-05-12 (covered by lazy-install):
# anthropic, exa, firecrawl, parallel-web, fal, edge-tts,
- # modal, daytona, messaging (telegram/discord/slack),
+ # modal, daytona, vercel, messaging (telegram/discord/slack),
# matrix, slack, honcho, voice (faster-whisper),
# dingtalk, feishu, bedrock, tts-premium (elevenlabs)
#
diff --git a/run_agent.py b/run_agent.py
index 522579438d9..54cfb18e979 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -5233,12 +5233,15 @@ class AIAgent:
apply_user_headers: bool = True,
) -> None:
from agent.auxiliary_client import (
+ _AI_GATEWAY_HEADERS,
build_nvidia_nim_headers,
build_or_headers,
)
if base_url_host_matches(base_url, "openrouter.ai"):
self._client_kwargs["default_headers"] = build_or_headers()
+ elif base_url_host_matches(base_url, "ai-gateway.vercel.sh"):
+ self._client_kwargs["default_headers"] = dict(_AI_GATEWAY_HEADERS)
elif base_url_host_matches(base_url, "integrate.api.nvidia.com"):
self._client_kwargs["default_headers"] = build_nvidia_nim_headers(base_url)
elif base_url_host_matches(base_url, "api.routermint.com"):
@@ -6439,6 +6442,8 @@ class AIAgent:
"""
if base_url_host_matches(self._base_url_lower, "nousresearch.com"):
return True
+ if base_url_host_matches(self._base_url_lower, "ai-gateway.vercel.sh"):
+ return True
if (
base_url_host_matches(self._base_url_lower, "models.github.ai")
or base_url_host_matches(self._base_url_lower, "githubcopilot.com")
diff --git a/setup-hermes.sh b/setup-hermes.sh
index 42cf2b759a5..1706201055b 100755
--- a/setup-hermes.sh
+++ b/setup-hermes.sh
@@ -214,7 +214,7 @@ else
# if mistral can't resolve.
_BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
_ALL_EXTRAS=(
- modal daytona messaging matrix cron cli dev tts-premium slack
+ modal daytona vercel messaging matrix cron cli dev tts-premium slack
pty honcho mcp homeassistant sms acp voice dingtalk feishu google
bedrock web youtube
)
diff --git a/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md b/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md
index bd8d7ed6be8..4c9cc9ea136 100644
--- a/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md
+++ b/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md
@@ -25,7 +25,7 @@ Full docs: https://hermes-agent.nousresearch.com/docs/integrations/providers
| alibaba (+coding-plan) | API key | `DASHSCOPE_API_KEY` / `ALIBABA_CODING_PLAN_API_KEY` |
| xiaomi | API key | `XIAOMI_API_KEY` |
| huggingface | Token | `HF_TOKEN` |
-| fireworks / novita / nvidia / deepinfra / gmi / arcee / stepfun / upstage / kilocode / opencode-zen / opencode-go / ollama-cloud | API key | `_API_KEY` |
+| fireworks / novita / nvidia / deepinfra / gmi / arcee / stepfun / upstage / kilocode / ai-gateway / opencode-zen / opencode-go / ollama-cloud | API key | `_API_KEY` |
| bedrock / vertex / azure-foundry | Cloud SDK / key | AWS SDK creds / Vertex ADC / `AZURE_FOUNDRY_API_KEY` |
| custom | Config | `model.base_url` + `model.api_key` in config.yaml |
diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py
index 315a2728d94..2e87c79a629 100644
--- a/tests/agent/test_models_dev.py
+++ b/tests/agent/test_models_dev.py
@@ -88,6 +88,18 @@ SAMPLE_REGISTRY = {
class TestProviderMapping:
+ def test_all_mapped_providers_are_strings(self):
+ for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
+ assert isinstance(hermes_id, str)
+ assert isinstance(mdev_id, str)
+
+ def test_known_providers_mapped(self):
+ assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
+ assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
+ assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
+ assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
+ assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel"
+
def test_xai_oauth_uses_xai_catalog(self):
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py
index 19505a887ba..28def42c050 100644
--- a/tests/agent/test_prompt_builder.py
+++ b/tests/agent/test_prompt_builder.py
@@ -781,6 +781,14 @@ class TestEnvironmentHints:
+ def test_remote_backend_list_covers_known_sandboxes(self):
+ """Regression guard: if someone adds a remote backend, they must list it here."""
+ import agent.prompt_builder as _pb
+ for backend in ("docker", "singularity", "modal", "daytona", "ssh", "vercel_sandbox"):
+ assert backend in _pb._REMOTE_TERMINAL_BACKENDS, (
+ f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host "
+ f"info is suppressed in the system prompt"
+ )
# =========================================================================
diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py
index 54702452ac4..3d5228d19ae 100644
--- a/tests/agent/test_usage_pricing.py
+++ b/tests/agent/test_usage_pricing.py
@@ -40,7 +40,7 @@ def test_normalize_usage_reads_deepseek_native_cache_hit_tokens():
def test_normalize_usage_openai_reads_top_level_anthropic_cache_fields():
- """Some OpenAI-compatible proxies (OpenRouter, Cline) expose
+ """Some OpenAI-compatible proxies (OpenRouter, Vercel AI Gateway, Cline) expose
Anthropic-style cache token counts at the top level of the usage object when
routing Claude models, instead of nesting them in prompt_tokens_details.
diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py
index 57d62c53379..6767101e165 100644
--- a/tests/cli/test_cli_init.py
+++ b/tests/cli/test_cli_init.py
@@ -399,6 +399,66 @@ class TestRootLevelProviderOverride:
assert cfg["model"]["provider"] == "opencode-go"
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(yaml.safe_dump({
+ "base_url": "https://example.com/v1",
+ "model": {
+ "default": "google/gemini-3-flash-preview",
+ },
+ }))
+
+ import cli
+ monkeypatch.setattr(cli, "_hermes_home", hermes_home)
+ cfg = cli.load_cli_config()
+
+ assert cfg["model"]["base_url"] == "https://example.com/v1"
+
+ def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch):
+ """Classic CLI must expose terminal.vercel_runtime to terminal_tool.py."""
+ import yaml
+
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("TERMINAL_VERCEL_RUNTIME", raising=False)
+
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(yaml.safe_dump({
+ "terminal": {
+ "backend": "vercel_sandbox",
+ "vercel_runtime": "python3.13",
+ },
+ }))
+
+ import cli
+ monkeypatch.setattr(cli, "_hermes_home", hermes_home)
+ cfg = cli.load_cli_config()
+
+ assert cfg["terminal"]["vercel_runtime"] == "python3.13"
+ assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
+
+ def test_normalize_root_model_keys_moves_to_model(self):
+ """_normalize_root_model_keys migrates root keys into model section."""
+ from hermes_cli.config import _normalize_root_model_keys
+
+ config = {
+ "provider": "opencode-go",
+ "base_url": "https://example.com/v1",
+ "model": {
+ "default": "some-model",
+ },
+ }
+ result = _normalize_root_model_keys(config)
+ # Root keys removed
+ assert "provider" not in result
+ assert "base_url" not in result
+ # Migrated into model section
+ assert result["model"]["provider"] == "opencode-go"
+ assert result["model"]["base_url"] == "https://example.com/v1"
def test_normalize_root_model_keys_does_not_override_existing(self):
"""Existing model.provider is never overridden by root-level key."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 79599cc542a..c36942512b5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -146,6 +146,7 @@ _CREDENTIAL_NAMES = frozenset({
"TOOL_GATEWAY_USER_TOKEN",
"TELEGRAM_WEBHOOK_SECRET",
"WEBHOOK_SECRET",
+ "AI_GATEWAY_API_KEY",
"VOICE_TOOLS_OPENAI_KEY",
"BROWSER_USE_API_KEY",
"CUSTOM_API_KEY",
@@ -156,6 +157,7 @@ _CREDENTIAL_NAMES = frozenset({
"OLLAMA_BASE_URL",
"GROQ_BASE_URL",
"XAI_BASE_URL",
+ "AI_GATEWAY_BASE_URL",
"ANTHROPIC_BASE_URL",
})
@@ -244,6 +246,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_DASHBOARD_PORTAL_URL",
"TERMINAL_CWD",
"TERMINAL_ENV",
+ "TERMINAL_VERCEL_RUNTIME",
"TERMINAL_CONTAINER_CPU",
"TERMINAL_CONTAINER_DISK",
"TERMINAL_CONTAINER_MEMORY",
diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py
index 2a9c2193c2f..f212b557e59 100644
--- a/tests/gateway/test_config_cwd_bridge.py
+++ b/tests/gateway/test_config_cwd_bridge.py
@@ -39,6 +39,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
+ "vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
@@ -214,3 +215,22 @@ class TestTildeExpansion:
assert result["TERMINAL_CWD"] == "~"
+class TestVercelTerminalBridge:
+ def test_vercel_terminal_settings_bridge(self):
+ cfg = {
+ "terminal": {
+ "backend": "vercel_sandbox",
+ "vercel_runtime": "python3.13",
+ "container_persistent": True,
+ "container_cpu": 2,
+ "container_memory": 4096,
+ "container_disk": 51200,
+ }
+ }
+ result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
+ assert result["TERMINAL_ENV"] == "vercel_sandbox"
+ assert result["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
+ assert result["TERMINAL_CONTAINER_PERSISTENT"] == "True"
+ assert result["TERMINAL_CONTAINER_CPU"] == "2"
+ assert result["TERMINAL_CONTAINER_MEMORY"] == "4096"
+ assert result["TERMINAL_CONTAINER_DISK"] == "51200"
diff --git a/tests/hermes_cli/test_ai_gateway_models.py b/tests/hermes_cli/test_ai_gateway_models.py
new file mode 100644
index 00000000000..ba608fd08ee
--- /dev/null
+++ b/tests/hermes_cli/test_ai_gateway_models.py
@@ -0,0 +1,161 @@
+"""AI Gateway model list and pricing translation.
+
+Vercel AI Gateway exposes ``/v1/models`` with a richer shape than OpenAI's
+spec (type, tags, pricing). The pricing object uses ``input`` / ``output``
+where hermes's shared picker expects ``prompt`` / ``completion``; these tests
+pin the translation and the curated-list filtering.
+"""
+import json
+from unittest.mock import patch, MagicMock
+
+from hermes_cli import models as models_module
+from hermes_cli.models import (
+ VERCEL_AI_GATEWAY_MODELS,
+ _ai_gateway_model_is_free,
+ fetch_ai_gateway_models,
+ fetch_ai_gateway_pricing,
+)
+
+
+def _mock_urlopen(payload):
+ """Build a urlopen() context manager mock returning the given payload."""
+ resp = MagicMock()
+ resp.read.return_value = json.dumps(payload).encode()
+ ctx = MagicMock()
+ ctx.__enter__.return_value = resp
+ ctx.__exit__.return_value = False
+ return ctx
+
+
+def _reset_caches():
+ models_module._ai_gateway_catalog_cache = None
+ models_module._pricing_cache.clear()
+
+
+def test_ai_gateway_pricing_translates_input_output_to_prompt_completion():
+ _reset_caches()
+ payload = {
+ "data": [
+ {
+ "id": "moonshotai/kimi-k2.5",
+ "type": "language",
+ "pricing": {
+ "input": "0.0000006",
+ "output": "0.0000025",
+ "input_cache_read": "0.00000015",
+ "input_cache_write": "0.0000006",
+ },
+ }
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_pricing(force_refresh=True)
+
+ entry = result["moonshotai/kimi-k2.5"]
+ assert entry["prompt"] == "0.0000006"
+ assert entry["completion"] == "0.0000025"
+ assert entry["input_cache_read"] == "0.00000015"
+ assert entry["input_cache_write"] == "0.0000006"
+
+
+def test_ai_gateway_pricing_returns_empty_on_fetch_failure():
+ _reset_caches()
+ with patch("urllib.request.urlopen", side_effect=OSError("network down")):
+ result = fetch_ai_gateway_pricing(force_refresh=True)
+ assert result == {}
+
+
+def test_ai_gateway_pricing_skips_entries_without_pricing_dict():
+ _reset_caches()
+ payload = {
+ "data": [
+ {"id": "x/y", "pricing": None},
+ {"id": "a/b", "pricing": {"input": "0", "output": "0"}},
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_pricing(force_refresh=True)
+ assert "x/y" not in result
+ assert result["a/b"] == {"prompt": "0", "completion": "0"}
+
+
+def test_ai_gateway_free_detector():
+ assert _ai_gateway_model_is_free({"input": "0", "output": "0"}) is True
+ assert _ai_gateway_model_is_free({"input": "0", "output": "0.01"}) is False
+ assert _ai_gateway_model_is_free({"input": "0.01", "output": "0"}) is False
+ assert _ai_gateway_model_is_free(None) is False
+ assert _ai_gateway_model_is_free({"input": "not a number"}) is False
+
+
+def test_fetch_ai_gateway_models_filters_against_live_catalog():
+ _reset_caches()
+ preferred = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS]
+ live_ids = preferred[:3] # only first three exist live
+ payload = {
+ "data": [
+ {"id": mid, "pricing": {"input": "0.001", "output": "0.002"}}
+ for mid in live_ids
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_models(force_refresh=True)
+
+ assert [mid for mid, _ in result] == live_ids
+ assert result[0][1] == "recommended"
+
+
+def test_fetch_ai_gateway_models_tags_free_models():
+ _reset_caches()
+ first_id = VERCEL_AI_GATEWAY_MODELS[0][0]
+ second_id = VERCEL_AI_GATEWAY_MODELS[1][0]
+ payload = {
+ "data": [
+ {"id": first_id, "pricing": {"input": "0.001", "output": "0.002"}},
+ {"id": second_id, "pricing": {"input": "0", "output": "0"}},
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_models(force_refresh=True)
+
+ by_id = dict(result)
+ assert by_id[first_id] == "recommended"
+ assert by_id[second_id] == "free"
+
+
+def test_free_moonshot_model_auto_promoted_to_top_even_if_not_curated():
+ _reset_caches()
+ first_curated = VERCEL_AI_GATEWAY_MODELS[0][0]
+ unlisted_free_moonshot = "moonshotai/kimi-coder-free-preview"
+ payload = {
+ "data": [
+ {"id": first_curated, "pricing": {"input": "0.001", "output": "0.002"}},
+ {"id": unlisted_free_moonshot, "pricing": {"input": "0", "output": "0"}},
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_models(force_refresh=True)
+
+ assert result[0] == (unlisted_free_moonshot, "recommended")
+ assert any(mid == first_curated for mid, _ in result)
+
+
+def test_paid_moonshot_does_not_get_auto_promoted():
+ _reset_caches()
+ first_curated = VERCEL_AI_GATEWAY_MODELS[0][0]
+ payload = {
+ "data": [
+ {"id": first_curated, "pricing": {"input": "0.001", "output": "0.002"}},
+ {"id": "moonshotai/some-paid-variant", "pricing": {"input": "0.001", "output": "0.002"}},
+ ]
+ }
+ with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
+ result = fetch_ai_gateway_models(force_refresh=True)
+
+ assert result[0][0] == first_curated
+
+
+def test_fetch_ai_gateway_models_falls_back_on_error():
+ _reset_caches()
+ with patch("urllib.request.urlopen", side_effect=OSError("network")):
+ result = fetch_ai_gateway_models(force_refresh=True)
+ assert result == list(VERCEL_AI_GATEWAY_MODELS)
diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py
index e1fed13363c..d22d81eec51 100644
--- a/tests/hermes_cli/test_api_key_providers.py
+++ b/tests/hermes_cli/test_api_key_providers.py
@@ -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 json
import os
@@ -40,6 +40,7 @@ class TestProviderRegistry:
("stepfun", "StepFun Step Plan", "api_key"),
("minimax", "MiniMax", "api_key"),
("minimax-cn", "MiniMax (China)", "api_key"),
+ ("ai-gateway", "Vercel AI Gateway", "api_key"),
("kilocode", "Kilo Code", "api_key"),
("gmi", "GMI Cloud", "api_key"),
])
@@ -53,6 +54,67 @@ class TestProviderRegistry:
+ def test_copilot_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["copilot"]
+ assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
+ assert pconfig.base_url_env_var == "COPILOT_API_BASE_URL"
+
+ def test_kimi_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["kimi-coding"]
+ # KIMI_API_KEY is the primary env var; KIMI_CODING_API_KEY is a
+ # secondary fallback for Kimi Code sk-kimi- keys so users don't
+ # have to overload the same variable.
+ assert "KIMI_API_KEY" in pconfig.api_key_env_vars
+ assert "KIMI_CODING_API_KEY" in pconfig.api_key_env_vars
+ assert pconfig.base_url_env_var == "KIMI_BASE_URL"
+
+ def test_minimax_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["minimax"]
+ assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",)
+ assert pconfig.base_url_env_var == "MINIMAX_BASE_URL"
+
+ def test_stepfun_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["stepfun"]
+ assert pconfig.api_key_env_vars == ("STEPFUN_API_KEY",)
+ assert pconfig.base_url_env_var == "STEPFUN_BASE_URL"
+
+ def test_minimax_cn_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["minimax-cn"]
+ 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_kilocode_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["kilocode"]
+ assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",)
+ assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
+
+ def test_gmi_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["gmi"]
+ assert pconfig.api_key_env_vars == ("GMI_API_KEY",)
+ assert pconfig.base_url_env_var == "GMI_BASE_URL"
+
+ def test_huggingface_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["huggingface"]
+ assert pconfig.api_key_env_vars == ("HF_TOKEN",)
+ assert pconfig.base_url_env_var == "HF_BASE_URL"
+
+ def test_base_urls(self):
+ assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
+ assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
+ 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["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL
+ assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
+ assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
+ assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
+ assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway"
+ assert PROVIDER_REGISTRY["gmi"].inference_base_url == "https://api.gmi-serving.com/v1"
+ assert PROVIDER_REGISTRY["huggingface"].inference_base_url == "https://router.huggingface.co/v1"
def test_oauth_providers_unchanged(self):
"""Ensure we didn't break the existing OAuth providers."""
@@ -107,9 +169,130 @@ class TestResolveProvider:
+ def test_explicit_ai_gateway(self):
+ assert resolve_provider("ai-gateway") == "ai-gateway"
+
+ def test_explicit_gmi(self):
+ assert resolve_provider("gmi") == "gmi"
+ def test_alias_zhipu(self):
+ assert resolve_provider("zhipu") == "zai"
+
+ def test_alias_kimi(self):
+ assert resolve_provider("kimi") == "kimi-coding"
+
+ def test_alias_moonshot(self):
+ assert resolve_provider("moonshot") == "kimi-coding"
+
+ def test_alias_step(self):
+ assert resolve_provider("step") == "stepfun"
+
+ 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_gmi_cloud(self):
+ assert resolve_provider("gmi-cloud") == "gmi"
+
+ def test_explicit_kilocode(self):
+ assert resolve_provider("kilocode") == "kilocode"
+
+ def test_alias_kilo(self):
+ assert resolve_provider("kilo") == "kilocode"
+
+ def test_alias_kilo_code(self):
+ assert resolve_provider("kilo-code") == "kilocode"
+
+ def test_alias_kilo_gateway(self):
+ assert resolve_provider("kilo-gateway") == "kilocode"
+
+ def test_alias_case_insensitive(self):
+ assert resolve_provider("GLM") == "zai"
+ assert resolve_provider("Z-AI") == "zai"
+ assert resolve_provider("Kimi") == "kimi-coding"
+
+ def test_alias_github_copilot(self):
+ assert resolve_provider("github-copilot") == "copilot"
+
+ def test_alias_github_models(self):
+ assert resolve_provider("github-models") == "copilot"
+
+ def test_alias_github_copilot_acp(self):
+ assert resolve_provider("github-copilot-acp") == "copilot-acp"
+ assert resolve_provider("copilot-acp-agent") == "copilot-acp"
+
+ def test_explicit_huggingface(self):
+ assert resolve_provider("huggingface") == "huggingface"
+
+ def test_alias_hf(self):
+ assert resolve_provider("hf") == "huggingface"
+
+ def test_alias_hugging_face(self):
+ assert resolve_provider("hugging-face") == "huggingface"
+
+ def test_alias_huggingface_hub(self):
+ assert resolve_provider("huggingface-hub") == "huggingface"
+
+ def test_unknown_provider_raises(self):
+ with pytest.raises(AuthError):
+ resolve_provider("nonexistent-provider-xyz")
+
+ def test_auto_detects_glm_key(self, monkeypatch):
+ monkeypatch.setenv("GLM_API_KEY", "test-glm-key")
+ assert resolve_provider("auto") == "zai"
+
+ def test_auto_detects_zai_key(self, monkeypatch):
+ monkeypatch.setenv("ZAI_API_KEY", "test-zai-key")
+ assert resolve_provider("auto") == "zai"
+
+ def test_auto_detects_z_ai_key(self, monkeypatch):
+ monkeypatch.setenv("Z_AI_API_KEY", "test-z-ai-key")
+ assert resolve_provider("auto") == "zai"
+
+ def test_auto_detects_kimi_key(self, monkeypatch):
+ monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key")
+ assert resolve_provider("auto") == "kimi-coding"
+
+ def test_auto_detects_stepfun_key(self, monkeypatch):
+ monkeypatch.setenv("STEPFUN_API_KEY", "test-stepfun-key")
+ assert resolve_provider("auto") == "stepfun"
+
+ def test_auto_detects_minimax_key(self, monkeypatch):
+ monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key")
+ assert resolve_provider("auto") == "minimax"
+
+ def test_auto_detects_minimax_cn_key(self, monkeypatch):
+ 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_auto_detects_gmi_key(self, monkeypatch):
+ monkeypatch.setenv("GMI_API_KEY", "test-gmi-key")
+ assert resolve_provider("auto") == "gmi"
+
+ def test_auto_detects_kilocode_key(self, monkeypatch):
+ monkeypatch.setenv("KILOCODE_API_KEY", "test-kilo-key")
+ assert resolve_provider("auto") == "kilocode"
+
+ def test_auto_detects_hf_token(self, monkeypatch):
+ monkeypatch.setenv("HF_TOKEN", "hf_test_token")
+ assert resolve_provider("auto") == "huggingface"
+
+ 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")
+ monkeypatch.setenv("GLM_API_KEY", "glm-key")
+ assert resolve_provider("auto") == "openrouter"
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
# AWS Bedrock auto-detection (via boto3's credential chain) runs at
@@ -195,6 +378,19 @@ class TestResolveApiKeyProviderCredentials:
+ 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_kilocode_with_key(self, monkeypatch):
+ monkeypatch.setenv("KILOCODE_API_KEY", "kilo-secret-key")
+ creds = resolve_api_key_provider_credentials("kilocode")
+ assert creds["provider"] == "kilocode"
+ assert creds["api_key"] == "kilo-secret-key"
+ assert creds["base_url"] == "https://api.kilo.ai/api/gateway"
@@ -217,6 +413,78 @@ class TestRuntimeProviderResolution:
+ def test_runtime_minimax(self, monkeypatch):
+ monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ result = resolve_runtime_provider(requested="minimax")
+ 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_kilocode(self, monkeypatch):
+ monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ result = resolve_runtime_provider(requested="kilocode")
+ assert result["provider"] == "kilocode"
+ assert result["api_mode"] == "chat_completions"
+ assert result["api_key"] == "kilo-key"
+ assert "kilo.ai" in result["base_url"]
+
+ def test_runtime_gmi(self, monkeypatch):
+ monkeypatch.setenv("GMI_API_KEY", "gmi-key")
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ result = resolve_runtime_provider(requested="gmi")
+ assert result["provider"] == "gmi"
+ assert result["api_mode"] == "chat_completions"
+ assert result["api_key"] == "gmi-key"
+ assert result["base_url"] == "https://api.gmi-serving.com/v1"
+
+ 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
+ result = resolve_runtime_provider(requested="auto")
+ assert result["provider"] == "kimi-coding"
+ assert result["api_key"] == "auto-kimi-key"
+
+ def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
+ monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ result = resolve_runtime_provider(requested="copilot")
+ assert result["provider"] == "copilot"
+ assert result["api_mode"] == "chat_completions"
+ assert result["api_key"] == "gho_cli_secret"
+ assert result["base_url"] == "https://api.githubcopilot.com"
+
+ def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
+ monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider._get_model_config",
+ lambda: {"provider": "copilot", "default": "gpt-5.4"},
+ )
+ monkeypatch.setattr(
+ "hermes_cli.models.fetch_github_model_catalog",
+ lambda api_key=None, timeout=5.0: [
+ {
+ "id": "gpt-5.4",
+ "supported_endpoints": ["/responses"],
+ "capabilities": {"type": "chat"},
+ }
+ ],
+ )
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ result = resolve_runtime_provider(requested="copilot")
+
+ assert result["provider"] == "copilot"
+ assert result["api_mode"] == "codex_responses"
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
diff --git a/tests/hermes_cli/test_arcee_provider.py b/tests/hermes_cli/test_arcee_provider.py
index 5f55448c3a5..5b58fc38a64 100644
--- a/tests/hermes_cli/test_arcee_provider.py
+++ b/tests/hermes_cli/test_arcee_provider.py
@@ -16,7 +16,7 @@ _OTHER_PROVIDER_KEYS = (
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
- "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
+ "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
)
diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py
index 681cee606e0..b03aa0757b7 100644
--- a/tests/hermes_cli/test_doctor.py
+++ b/tests/hermes_cli/test_doctor.py
@@ -186,6 +186,38 @@ class TestHonchoDoctorConfigDetection:
+def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
+ monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
+ monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
+ monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
+ monkeypatch.setenv("VERCEL_TEAM_ID", "team")
+ monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+
+ out = buf.getvalue()
+ assert "Vercel runtime" in out
+ assert "python3.13" in out
+ assert "Vercel custom disk unsupported" in out
+ assert "Vercel auth incomplete" in out
+ assert "VERCEL_PROJECT_ID" in out
+ assert "Vercel auth mode: incomplete access token" in out
+ assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
+ assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
+ assert "super-secret-value" not in out
+ assert "snapshot filesystem only" in out
+
+
# ── Memory provider section (doctor should only check the *active* provider) ──
@@ -300,6 +332,382 @@ def _run_doctor_with_managed_agent_browser(monkeypatch, tmp_path, runnable):
+def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text(
+ "model:\n"
+ " provider: custom\n"
+ " default: local-model\n"
+ " base_url: http://localhost:8000/v1\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ (tmp_path / "project").mkdir(exist_ok=True)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+
+ out = buf.getvalue()
+ assert "model.provider 'custom' is not a recognised provider" not in out
+
+
+def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text(
+ "model:\n"
+ " provider: openrouter\n"
+ " default: openai/gpt-4.1-mini\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ (tmp_path / "project").mkdir(exist_ok=True)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+
+ out = buf.getvalue()
+ assert "model.provider 'openrouter' is set but no API key is configured" in out
+ assert "No credentials found for provider 'openrouter'." in out
+
+
+@pytest.mark.parametrize(
+ ("provider", "default_model"),
+ [
+ ("ai-gateway", "anthropic/claude-sonnet-4.6"),
+ ("opencode-zen", "anthropic/claude-sonnet-4.6"),
+ ("kilocode", "anthropic/claude-sonnet-4.6"),
+ ("kimi-coding", "kimi-k2"),
+ ],
+)
+def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
+ monkeypatch, tmp_path, provider, default_model
+):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text(
+ "model:\n"
+ f" provider: {provider}\n"
+ f" default: {default_model}\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ (tmp_path / "project").mkdir(exist_ok=True)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+
+ out = buf.getvalue()
+ assert f"model.provider '{provider}' is not a recognised provider" not in out
+ assert f"model.provider '{provider}' is unknown" not in out
+ if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
+ assert (
+ f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
+ not in out
+ )
+
+
+
+
+def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
+ (home / "config.yaml").write_text(
+ "model:\n"
+ " provider: kimi-coding-cn\n"
+ " default: kimi-k2.6\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ (tmp_path / "project").mkdir(exist_ok=True)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+
+ out = buf.getvalue()
+ assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
+
+
+def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
+ project = tmp_path / "project"
+ project.mkdir(exist_ok=True)
+
+ monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
+ monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
+ TOOLSET_REQUIREMENTS={
+ "terminal": {"name": "terminal"},
+ "browser": {"name": "browser"},
+ },
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ import io, contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+ out = buf.getvalue()
+
+ assert "✓ browser" not in out
+ assert "browser" in out
+ assert "system dependency not met" in out
+ assert "agent-browser is not installed (expected in the tested Termux path)" in out
+ assert "npm install -g agent-browser && agent-browser install" in out
+
+
+def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
+ (home / ".env").write_text("KIMI_CN_API_KEY=sk-test\n", encoding="utf-8")
+ project = tmp_path / "project"
+ project.mkdir(exist_ok=True)
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test")
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except Exception:
+ pass
+
+ calls = []
+
+ def fake_get(url, headers=None, timeout=None):
+ calls.append((url, headers, timeout))
+ return types.SimpleNamespace(status_code=200)
+
+ import httpx
+ monkeypatch.setattr(httpx, "get", fake_get)
+
+ import io, contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+ out = buf.getvalue()
+
+ assert "API key or custom endpoint configured" in out
+ assert "Kimi / Moonshot (China)" in out
+ assert "str expected, not NoneType" not in out
+ assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls)
+
+
+def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(monkeypatch, tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
+ (home / ".env").write_text("DASHSCOPE_API_KEY=sk-test\n", encoding="utf-8")
+ project = tmp_path / "project"
+ project.mkdir(exist_ok=True)
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test")
+ monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except ImportError:
+ pass
+
+ calls = []
+
+ def fake_get(url, headers=None, timeout=None):
+ calls.append((url, headers, timeout))
+ status = 200 if "dashscope.aliyuncs.com" in url else 401
+ return types.SimpleNamespace(status_code=status)
+
+ import httpx
+ monkeypatch.setattr(httpx, "get", fake_get)
+
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+ out = buf.getvalue()
+
+ assert "Alibaba/DashScope" in out
+ assert "invalid API key" not in out
+ assert any(
+ url == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"
+ for url, _, _ in calls
+ )
+ assert any(
+ url == "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
+ for url, _, _ in calls
+ )
+
+
+@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
+def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
+ home = tmp_path / ".hermes"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
+ (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
+ project = tmp_path / "project"
+ project.mkdir(exist_ok=True)
+
+ monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
+ monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
+ monkeypatch.setattr(doctor_mod, "_DHH", str(home))
+ monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
+ if base_url:
+ monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
+ else:
+ monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
+
+ fake_model_tools = types.SimpleNamespace(
+ check_tool_availability=lambda *a, **kw: ([], []),
+ TOOLSET_REQUIREMENTS={},
+ )
+ monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
+
+ try:
+ from hermes_cli import auth as _auth_mod
+ monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
+ monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
+ except ImportError:
+ pass
+
+ calls = []
+
+ def fake_get(url, headers=None, timeout=None):
+ calls.append((url, headers, timeout))
+ return types.SimpleNamespace(status_code=200)
+
+ import httpx
+ monkeypatch.setattr(httpx, "get", fake_get)
+
+ import io, contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ doctor_mod.run_doctor(Namespace(fix=False))
+ out = buf.getvalue()
+
+ assert any(
+ "OpenCode Go" in line and "(key configured)" in line
+ for line in out.splitlines()
+ )
+ assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
+ assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)
+
class TestGitHubTokenCheck:
"""Tests for GitHub token / gh auth detection in doctor."""
diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py
index 7a81bb06612..b7afd65dac0 100644
--- a/tests/hermes_cli/test_gmi_provider.py
+++ b/tests/hermes_cli/test_gmi_provider.py
@@ -161,6 +161,7 @@ class TestGmiDoctor:
"DASHSCOPE_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
+ "AI_GATEWAY_API_KEY",
"KILOCODE_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py
index e0bf6f2b451..3c12ba8a66b 100644
--- a/tests/hermes_cli/test_runtime_provider_resolution.py
+++ b/tests/hermes_cli/test_runtime_provider_resolution.py
@@ -105,6 +105,287 @@ def test_qwen_oauth_auto_fallthrough_on_auth_failure(monkeypatch):
assert resolved["provider"] != "qwen-oauth"
+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_lmstudio_uses_token_when_present(monkeypatch):
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
+ monkeypatch.setattr(
+ rp,
+ "_get_model_config",
+ lambda: {
+ "provider": "lmstudio",
+ "base_url": "http://127.0.0.1:1234/v1",
+ "default": "publisher/model-a",
+ },
+ )
+ monkeypatch.setattr(
+ rp,
+ "load_pool",
+ lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
+ )
+ monkeypatch.setattr(
+ rp,
+ "resolve_api_key_provider_credentials",
+ lambda provider: {
+ "provider": "lmstudio",
+ "api_key": "lm-token",
+ "base_url": "http://127.0.0.1:1234/v1",
+ "source": "LM_API_KEY",
+ },
+ )
+
+ resolved = rp.resolve_runtime_provider(requested="lmstudio")
+
+ assert resolved["provider"] == "lmstudio"
+ assert resolved["api_key"] == "lm-token"
+ assert resolved["api_mode"] == "chat_completions"
+ assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
+
+
+def test_resolve_runtime_provider_lmstudio_honors_saved_base_url(monkeypatch):
+ """Pre-existing configs with `provider: lmstudio` + custom base_url must keep working.
+
+ Before this PR, `lmstudio` aliased to `custom`, so a user with a remote
+ LM Studio (e.g. lab box) could write `provider: "lmstudio"` plus
+ `base_url: "http://192.168.1.10:1234/v1"` and the custom path honored it.
+ Now that `lmstudio` is first-class with `inference_base_url=127.0.0.1`,
+ the saved `base_url` from `model_cfg` must still win — otherwise this
+ PR is a silent breaking change for those users.
+ """
+ monkeypatch.delenv("LM_API_KEY", raising=False)
+ monkeypatch.delenv("LM_BASE_URL", raising=False)
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
+ monkeypatch.setattr(
+ rp,
+ "_get_model_config",
+ lambda: {
+ "provider": "lmstudio",
+ "base_url": "http://192.168.1.10:1234/v1",
+ "default": "qwen/qwen3-coder-30b",
+ },
+ )
+ monkeypatch.setattr(
+ rp,
+ "load_pool",
+ lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
+ )
+ # Don't mock resolve_api_key_provider_credentials — exercise the real
+ # function so we test the end-to-end precedence between model_cfg and
+ # the pconfig default.
+
+ resolved = rp.resolve_runtime_provider(requested="lmstudio")
+
+ assert resolved["provider"] == "lmstudio"
+ assert resolved["api_mode"] == "chat_completions"
+ # The saved base_url must NOT be shadowed by the 127.0.0.1 default.
+ assert resolved["base_url"] == "http://192.168.1.10:1234/v1"
+ # No-auth LM Studio: missing LM_API_KEY substitutes the placeholder.
+ assert resolved["api_key"] == "dummy-lm-api-key"
+
+
+def test_resolve_runtime_provider_lmstudio_saved_base_url_wins_over_env(monkeypatch):
+ """Saved model.base_url takes precedence over LM_BASE_URL env var.
+
+ This matches the established contract for all api_key providers: the
+ explicit config value (model.base_url) wins over the env-derived
+ default. Users who saved a remote LM Studio URL must not have it
+ silently overridden by a stale shell variable.
+ """
+ monkeypatch.delenv("LM_API_KEY", raising=False)
+ monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
+ monkeypatch.setattr(
+ rp,
+ "_get_model_config",
+ lambda: {
+ "provider": "lmstudio",
+ "base_url": "http://192.168.1.10:1234/v1",
+ "default": "qwen/qwen3-coder-30b",
+ },
+ )
+ monkeypatch.setattr(
+ rp,
+ "load_pool",
+ lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
+ )
+
+ resolved = rp.resolve_runtime_provider(requested="lmstudio")
+
+ assert resolved["provider"] == "lmstudio"
+ assert resolved["api_mode"] == "chat_completions"
+ # Saved config base_url wins over env var (standard contract).
+ assert resolved["base_url"] == "http://192.168.1.10:1234/v1"
+ assert resolved["api_key"] == "dummy-lm-api-key"
+
+
+def test_resolve_runtime_provider_ai_gateway_explicit_override_skips_pool(monkeypatch):
+ def _unexpected_pool(provider):
+ raise AssertionError(f"load_pool should not be called for {provider}")
+
+ def _unexpected_provider_resolution(provider):
+ raise AssertionError(f"resolve_api_key_provider_credentials should not be called for {provider}")
+
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
+ monkeypatch.setattr(rp, "_get_model_config", lambda: {})
+ monkeypatch.setattr(rp, "load_pool", _unexpected_pool)
+ monkeypatch.setattr(
+ rp,
+ "resolve_api_key_provider_credentials",
+ _unexpected_provider_resolution,
+ )
+
+ resolved = rp.resolve_runtime_provider(
+ requested="ai-gateway",
+ explicit_api_key="ai-gateway-explicit-token",
+ explicit_base_url="https://proxy.example.com/v1/",
+ )
+
+ assert resolved["provider"] == "ai-gateway"
+ assert resolved["api_mode"] == "chat_completions"
+ assert resolved["api_key"] == "ai-gateway-explicit-token"
+ assert resolved["base_url"] == "https://proxy.example.com/v1"
+ assert resolved["source"] == "explicit"
+ assert resolved.get("credential_pool") is None
+
+
+def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
+ monkeypatch.setattr(rp, "_get_model_config", lambda: {})
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+
+ resolved = rp.resolve_runtime_provider(
+ requested="openrouter",
+ explicit_api_key="test-key",
+ explicit_base_url="https://example.com/v1/",
+ )
+
+ assert resolved["provider"] == "openrouter"
+ assert resolved["api_mode"] == "chat_completions"
+ assert resolved["api_key"] == "test-key"
+ assert resolved["base_url"] == "https://example.com/v1"
+ assert resolved["source"] == "explicit"
+
+
+def test_resolve_runtime_provider_auto_uses_openrouter_pool(monkeypatch):
+ class _Entry:
+ access_token = "pool-key"
+ source = "manual"
+ base_url = "https://openrouter.ai/api/v1"
+
+ class _Pool:
+ def has_credentials(self):
+ return True
+
+ def select(self):
+ return _Entry()
+
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
+ monkeypatch.setattr(rp, "_get_model_config", lambda: {})
+ monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+
+ resolved = rp.resolve_runtime_provider(requested="auto")
+
+ assert resolved["provider"] == "openrouter"
+ assert resolved["api_key"] == "pool-key"
+ assert resolved["base_url"] == "https://openrouter.ai/api/v1"
+ assert resolved["source"] == "manual"
+ assert resolved.get("credential_pool") is not None
+
+
+def test_resolve_runtime_provider_openrouter_explicit_api_key_skips_pool(monkeypatch):
+ class _Entry:
+ access_token = "pool-key"
+ source = "manual"
+ base_url = "https://openrouter.ai/api/v1"
+
+ class _Pool:
+ def has_credentials(self):
+ return True
+
+ def select(self):
+ return _Entry()
+
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
+ monkeypatch.setattr(rp, "_get_model_config", lambda: {})
+ monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+
+ resolved = rp.resolve_runtime_provider(
+ requested="openrouter",
+ explicit_api_key="explicit-key",
+ )
+
+ assert resolved["provider"] == "openrouter"
+ assert resolved["api_key"] == "explicit-key"
+ assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
+ assert resolved["source"] == "explicit"
+ assert resolved.get("credential_pool") is None
+
+
+def test_resolve_runtime_provider_openrouter_ignores_codex_config_base_url(monkeypatch):
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
+ monkeypatch.setattr(
+ rp,
+ "_get_model_config",
+ lambda: {
+ "provider": "openai-codex",
+ "base_url": "https://chatgpt.com/backend-api/codex",
+ },
+ )
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+
+ resolved = rp.resolve_runtime_provider(requested="openrouter")
+
+ assert resolved["provider"] == "openrouter"
+ assert resolved["base_url"] == rp.OPENROUTER_BASE_URL
+
+
+def test_resolve_runtime_provider_auto_uses_custom_config_base_url(monkeypatch):
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
+ monkeypatch.setattr(
+ rp,
+ "_get_model_config",
+ lambda: {
+ "provider": "auto",
+ "base_url": "https://custom.example/v1/",
+ },
+ )
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+
+ resolved = rp.resolve_runtime_provider(requested="auto")
+
+ assert resolved["provider"] == "openrouter"
+ assert resolved["base_url"] == "https://custom.example/v1"
+
+
def test_openrouter_key_takes_priority_over_openai_key(monkeypatch):
"""OPENROUTER_API_KEY should be used over OPENAI_API_KEY when both are set.
diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py
index 7ccdd3b451f..7b25a1ea0b0 100644
--- a/tests/hermes_cli/test_set_config_value.py
+++ b/tests/hermes_cli/test_set_config_value.py
@@ -113,6 +113,13 @@ class TestConfigYamlRouting:
or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content
)
+ def test_terminal_vercel_runtime_goes_to_config_and_env(self, _isolated_hermes_home):
+ set_config_value("terminal.vercel_runtime", "python3.13")
+ config = _read_config(_isolated_hermes_home)
+ env_content = _read_env(_isolated_hermes_home)
+ assert "vercel_runtime: python3.13" in config
+ assert "TERMINAL_VERCEL_RUNTIME=python3.13" in env_content
+
# ---------------------------------------------------------------------------
# Empty / falsy values — regression tests for #4277
diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py
index 99f5097e61d..2a3dcaef3ae 100644
--- a/tests/hermes_cli/test_setup.py
+++ b/tests/hermes_cli/test_setup.py
@@ -26,6 +26,17 @@ def _clear_provider_env(monkeypatch):
monkeypatch.delenv(key, raising=False)
+def _clear_vercel_env(monkeypatch):
+ for key in (
+ "TERMINAL_VERCEL_RUNTIME",
+ "VERCEL_OIDC_TOKEN",
+ "VERCEL_TOKEN",
+ "VERCEL_PROJECT_ID",
+ "VERCEL_TEAM_ID",
+ ):
+ monkeypatch.delenv(key, raising=False)
+
+
def _stub_tts(monkeypatch):
"""Stub out TTS prompts so setup_model_provider doesn't block."""
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: (
@@ -160,6 +171,99 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
# _setup_slack wizard migrated to the slack plugin's interactive_setup (#41112).
+def test_vercel_setup_configures_access_token_auth(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ _clear_vercel_env(monkeypatch)
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "old-oidc")
+ monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
+ config = load_config()
+
+ def fake_prompt_choice(question, choices, default=0):
+ if question == "Select terminal backend:":
+ return 5
+ raise AssertionError(f"Unexpected prompt_choice call: {question}")
+
+ prompt_values = iter(["python3.13", "yes", "2", "4096", "token", "project", "team"])
+
+ monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
+ monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
+
+ from hermes_cli.setup import setup_terminal_backend
+
+ setup_terminal_backend(config)
+
+ assert config["terminal"]["backend"] == "vercel_sandbox"
+ assert config["terminal"]["vercel_runtime"] == "python3.13"
+ assert config["terminal"]["container_disk"] == 51200
+ assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
+ assert "VERCEL_OIDC_TOKEN" not in os.environ
+ assert os.environ["VERCEL_TOKEN"] == "token"
+ assert os.environ["VERCEL_PROJECT_ID"] == "project"
+ assert os.environ["VERCEL_TEAM_ID"] == "team"
+
+
+def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ _clear_vercel_env(monkeypatch)
+ project_root = tmp_path / "project"
+ nested = project_root / "app" / "src"
+ nested.mkdir(parents=True)
+ vercel_dir = project_root / ".vercel"
+ vercel_dir.mkdir()
+ (vercel_dir / "project.json").write_text(
+ json.dumps({"projectId": "linked-project", "orgId": "linked-team"}),
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(nested)
+ monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
+ config = load_config()
+ config["terminal"]["container_disk"] = 999
+
+ def fake_prompt_choice(question, choices, default=0):
+ if question == "Select terminal backend:":
+ return 5
+ raise AssertionError(f"Unexpected prompt_choice call: {question}")
+
+ prompt_values = iter(["node24", "no", "1", "5120", "token", "", ""])
+ defaults = {}
+
+ def fake_prompt(message, default="", **kwargs):
+ defaults[message] = default
+ value = next(prompt_values)
+ return value or default
+
+ monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
+ monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
+
+ from hermes_cli.setup import setup_terminal_backend
+
+ setup_terminal_backend(config)
+
+ assert config["terminal"]["backend"] == "vercel_sandbox"
+ assert config["terminal"]["container_persistent"] is False
+ assert config["terminal"]["container_disk"] == 51200
+ assert "VERCEL_OIDC_TOKEN" not in os.environ
+ assert os.environ["VERCEL_TOKEN"] == "token"
+ assert os.environ["VERCEL_PROJECT_ID"] == "linked-project"
+ assert os.environ["VERCEL_TEAM_ID"] == "linked-team"
+ assert defaults[" Vercel project ID"] == "linked-project"
+ assert defaults[" Vercel team ID"] == "linked-team"
+
+
+def test_setup_slack_saves_home_channel(monkeypatch):
+ """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
+ saved = {}
+ prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"])
+
+ monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
+ monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
+ monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
+ monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
+ monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
+
+ setup_mod._setup_slack()
+
+ assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py
index ad306b29562..5aecd21225c 100644
--- a/tests/hermes_cli/test_status.py
+++ b/tests/hermes_cli/test_status.py
@@ -46,6 +46,75 @@ def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys,
assert "systemd (user)" not in output
+def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
+ from hermes_cli import status as status_mod
+ import hermes_cli.auth as auth_mod
+ import hermes_cli.gateway as gateway_mod
+
+ monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
+ monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
+ monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
+ monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
+ monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
+ monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
+ monkeypatch.setattr(
+ auth_mod,
+ "get_nous_auth_status",
+ lambda: {
+ "logged_in": False,
+ "portal_base_url": "https://portal.nousresearch.com",
+ "access_expires_at": "2026-04-20T01:00:51+00:00",
+ "agent_key_expires_at": "2026-04-20T04:54:24+00:00",
+ "has_refresh_token": True,
+ "error": "Refresh session has been revoked",
+ },
+ raising=False,
+ )
+ monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
+
+ status_mod.show_status(SimpleNamespace(all=False, deep=False))
+
+ output = capsys.readouterr().out
+ assert "Nous Portal ✗ not logged in (run: hermes auth add nous --type oauth)" in output
+ assert "Error: Refresh session has been revoked" in output
+ assert "Access exp:" in output
+ assert "Key exp:" in output
+
+
+def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_path):
+ from hermes_cli import status as status_mod
+ import hermes_cli.auth as auth_mod
+ import hermes_cli.gateway as gateway_mod
+
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
+ monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(status_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
+ monkeypatch.setattr(status_mod, "load_config", lambda: {"terminal": {"backend": "vercel_sandbox"}}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
+ monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
+
+ status_mod.show_status(SimpleNamespace(all=False, deep=False))
+
+ output = capsys.readouterr().out
+ assert "Backend: vercel_sandbox" in output
+ assert "Runtime: python3.13" in output
+ assert "Auth:" in output and "OIDC token via VERCEL_OIDC_TOKEN" in output
+ assert "Auth detail: mode: OIDC" in output
+ assert "Auth detail: active env: VERCEL_OIDC_TOKEN" in output
+ assert "oidc-token" not in output
+ assert "snapshot filesystem" in output
+ assert "live processes do not survive" in output
+
+
# ---------------------------------------------------------------------------
# Helpers shared by xAI OAuth status tests
# ---------------------------------------------------------------------------
diff --git a/tests/hermes_cli/test_tencent_tokenhub_provider.py b/tests/hermes_cli/test_tencent_tokenhub_provider.py
index b836fbdb69b..2e7ac767ed8 100644
--- a/tests/hermes_cli/test_tencent_tokenhub_provider.py
+++ b/tests/hermes_cli/test_tencent_tokenhub_provider.py
@@ -18,7 +18,7 @@ _OTHER_PROVIDER_KEYS = (
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
- "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
+ "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "OPENROUTER_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GITHUB_TOKEN", "ARCEEAI_API_KEY",
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index dc4bfe34503..436d00cde09 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1287,6 +1287,20 @@ class TestWebServerEndpoints:
class TestBuildSchemaFromConfig:
+ def test_overrides_applied(self):
+ from hermes_cli.web_server import CONFIG_SCHEMA
+ # terminal.backend should be a select with options
+ if "terminal.backend" in CONFIG_SCHEMA:
+ entry = CONFIG_SCHEMA["terminal.backend"]
+ assert entry["type"] == "select"
+ assert "options" in entry
+ assert "local" in entry["options"]
+ assert "vercel_sandbox" in entry["options"]
+ runtime_entry = CONFIG_SCHEMA["terminal.vercel_runtime"]
+ assert runtime_entry["type"] == "select"
+ assert "node24" in runtime_entry["options"]
+ assert "python3.13" in runtime_entry["options"]
+ assert len(runtime_entry["options"]) >= 3
diff --git a/tests/hermes_cli/test_xiaomi_provider.py b/tests/hermes_cli/test_xiaomi_provider.py
index aa49556549d..71f4bb515d7 100644
--- a/tests/hermes_cli/test_xiaomi_provider.py
+++ b/tests/hermes_cli/test_xiaomi_provider.py
@@ -56,6 +56,22 @@ class TestXiaomiAliases:
# =============================================================================
+class TestXiaomiAutoDetection:
+ """Setting XIAOMI_API_KEY should auto-detect the provider."""
+
+ def test_auto_detect(self, monkeypatch):
+ # Clear all other provider env vars
+ for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
+ "DEEPSEEK_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
+ "DASHSCOPE_API_KEY", "XAI_API_KEY", "KIMI_API_KEY",
+ "MINIMAX_API_KEY", "AI_GATEWAY_API_KEY", "KILOCODE_API_KEY",
+ "HF_TOKEN", "GLM_API_KEY", "COPILOT_GITHUB_TOKEN",
+ "GH_TOKEN", "GITHUB_TOKEN", "MINIMAX_CN_API_KEY",
+ "TOKENHUB_API_KEY", "ARCEEAI_API_KEY"):
+ monkeypatch.delenv(var, raising=False)
+ monkeypatch.setenv("XIAOMI_API_KEY", "sk-xiaomi-test-12345678")
+ provider = resolve_provider("auto")
+ assert provider == "xiaomi"
# =============================================================================
diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py
index 79169b1f72f..105189d6349 100644
--- a/tests/providers/test_plugin_discovery.py
+++ b/tests/providers/test_plugin_discovery.py
@@ -44,26 +44,14 @@ def test_bundled_plugins_discovered():
assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml"
-def test_all_profiles_register():
- """After discovery, the registry must contain every bundled provider directory.
-
- This is an invariant — the number of profiles matches the number of plugin
- directories, not a hardcoded count. Counts shift when providers are
- added/removed; that's expected and shouldn't break CI.
- """
+def test_all_34_profiles_register():
+ """After discovery, the registry must contain exactly 34 distinct profiles."""
_clear_provider_caches()
from providers import list_providers
- plugins_dir = REPO_ROOT / "plugins" / "model-providers"
- plugin_dir_count = sum(1 for c in plugins_dir.iterdir() if c.is_dir())
-
profiles = list_providers()
names = sorted(p.name for p in profiles)
- # Some plugin __init__.py files register multiple profiles, so the registry
- # count is >= the directory count (never less).
- assert len(names) >= plugin_dir_count, (
- f"Expected at least {plugin_dir_count} profiles (one per plugin dir), got {len(names)}: {names}"
- )
+ assert len(names) == 34, f"Expected 34 profiles, got {len(names)}: {names}"
# Spot-check representative providers from different categories
for required in (
diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py
index 95100d567b7..b7f3d493dd6 100644
--- a/tests/run_agent/test_provider_attribution_headers.py
+++ b/tests/run_agent/test_provider_attribution_headers.py
@@ -1,4 +1,8 @@
-"""Attribution default_headers applied per provider via base-URL detection."""
+"""Attribution default_headers applied per provider via base-URL detection.
+
+Mirrors the OpenRouter pattern for the Vercel AI Gateway so that
+referrerUrl / appName / User-Agent flow into gateway analytics.
+"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -24,6 +28,42 @@ def test_openrouter_base_url_applies_or_headers(mock_openai):
assert headers["X-Title"] == "Hermes Agent"
+@patch("run_agent.OpenAI")
+def test_ai_gateway_base_url_applies_attribution_headers(mock_openai):
+ mock_openai.return_value = MagicMock()
+ agent = AIAgent(
+ api_key="test-key",
+ base_url="https://openrouter.ai/api/v1",
+ model="test/model",
+ quiet_mode=True,
+ skip_context_files=True,
+ skip_memory=True,
+ )
+
+ agent._apply_client_headers_for_base_url("https://ai-gateway.vercel.sh/v1")
+
+ headers = agent._client_kwargs["default_headers"]
+ assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
+ assert headers["X-Title"] == "Hermes Agent"
+ assert headers["User-Agent"].startswith("HermesAgent/")
+
+
+@patch("run_agent.OpenAI")
+def test_routermint_base_url_applies_user_agent_header(mock_openai):
+ mock_openai.return_value = MagicMock()
+ agent = AIAgent(
+ api_key="test-key",
+ base_url="https://api.routermint.com/v1",
+ model="test/model",
+ quiet_mode=True,
+ skip_context_files=True,
+ skip_memory=True,
+ )
+
+ agent._apply_client_headers_for_base_url("https://api.routermint.com/v1")
+
+ headers = agent._client_kwargs["default_headers"]
+ assert headers["User-Agent"].startswith("HermesAgent/")
@patch("run_agent.OpenAI")
diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py
index 5f4d0ae1da5..105993c7c23 100644
--- a/tests/run_agent/test_provider_parity.py
+++ b/tests/run_agent/test_provider_parity.py
@@ -319,6 +319,40 @@ class TestBuildApiKwargsKimiNoTemperatureOverride:
assert "temperature" not in kwargs
+class TestBuildApiKwargsAIGateway:
+ def test_uses_chat_completions_format(self, monkeypatch):
+ agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1", model="gpt-4o")
+ 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", model="gpt-4o")
+ 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", model="gpt-4o")
+ 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", model="gpt-4o")
+ 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):
from agent.portal_tags import nous_portal_tags
diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py
index 4b37ab05994..2b2ae47a6fd 100644
--- a/tests/test_project_metadata.py
+++ b/tests/test_project_metadata.py
@@ -69,7 +69,7 @@ def test_lazy_installable_extras_excluded_from_all():
"fal",
"edge-tts", "tts-premium",
"voice", # faster-whisper / sounddevice / numpy
- "modal", "daytona",
+ "modal", "daytona", "vercel",
"messaging", "slack", "matrix", "dingtalk", "feishu",
"honcho", "hindsight",
"supermemory", "mem0",
diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py
index 4a644bcddc9..da385afcc3d 100644
--- a/tests/tools/test_command_guards.py
+++ b/tests/tools/test_command_guards.py
@@ -80,6 +80,10 @@ class TestContainerSkip:
result = check_all_command_guards("rm -rf /", "daytona")
assert result["approved"] is True
+ def test_vercel_sandbox_skips_both(self):
+ result = check_all_command_guards("rm -rf /", "vercel_sandbox")
+ assert result["approved"] is True
+
# ---------------------------------------------------------------------------
# tirith allow + safe command
diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py
index b7ee7ad127a..44e57b5d40b 100644
--- a/tests/tools/test_hardline_blocklist.py
+++ b/tests/tools/test_hardline_blocklist.py
@@ -506,7 +506,7 @@ def test_container_backends_still_bypass(clean_session):
Hardline only protects environments with real host impact (local, ssh).
"""
- for env in ("docker", "singularity", "modal", "daytona"):
+ for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
r1 = check_dangerous_command("rm -rf /", env)
assert r1["approved"] is True, f"container {env} should still bypass"
r2 = check_all_command_guards("rm -rf /", env)
@@ -598,7 +598,7 @@ def test_sudo_stdin_guard_detects_without_password():
def test_sudo_stdin_guard_container_bypass(clean_session):
"""Containerized backends still bypass — they can't touch the host."""
- for env in ("docker", "singularity", "modal", "daytona"):
+ for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
for cmd in _SUDO_STDIN_BLOCK:
result = check_all_command_guards(cmd, env)
assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}"
diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py
index 69b99cbd583..e2e98430a06 100644
--- a/tests/tools/test_local_env_blocklist.py
+++ b/tests/tools/test_local_env_blocklist.py
@@ -210,6 +210,10 @@ class TestProviderEnvBlocklist:
"MODAL_TOKEN_ID": "modal-id",
"MODAL_TOKEN_SECRET": "modal-secret",
"DAYTONA_API_KEY": "daytona-key",
+ "VERCEL_OIDC_TOKEN": "vercel-oidc-token",
+ "VERCEL_TOKEN": "vercel-token",
+ "VERCEL_PROJECT_ID": "vercel-project",
+ "VERCEL_TEAM_ID": "vercel-team",
}
result_env = _run_with_env(extra_os_env=leaked_vars)
@@ -459,6 +463,10 @@ class TestBlocklistCoverage:
"MODAL_TOKEN_ID",
"MODAL_TOKEN_SECRET",
"DAYTONA_API_KEY",
+ "VERCEL_OIDC_TOKEN",
+ "VERCEL_TOKEN",
+ "VERCEL_PROJECT_ID",
+ "VERCEL_TEAM_ID",
}
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py
index bb2bd533885..6238f4710e6 100644
--- a/tests/tools/test_modal_sandbox_fixes.py
+++ b/tests/tools/test_modal_sandbox_fixes.py
@@ -7,6 +7,7 @@ Covers the bugs discovered while setting up TBLite evaluation:
4. ensurepip fix in Modal image builder
5. No swe-rex dependency — uses native Modal SDK
6. /home/ added to host prefix check
+7. Vercel sandbox cwd normalization
"""
import os
@@ -84,6 +85,95 @@ class TestCwdHandling:
assert config["host_cwd"] is None
assert config["docker_mount_cwd_to_workspace"] is False
+ def test_users_path_maps_to_workspace_for_docker_when_enabled(self, monkeypatch):
+ """Docker should map the host cwd into /workspace only when explicitly enabled."""
+ monkeypatch.setenv("TERMINAL_ENV", "docker")
+ monkeypatch.setenv("TERMINAL_CWD", "/Users/someone/projects")
+ monkeypatch.setenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "true")
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/workspace"
+ assert config["host_cwd"] == "/Users/someone/projects"
+ assert config["docker_mount_cwd_to_workspace"] is True
+
+ def test_windows_path_replaced_for_modal(self, monkeypatch):
+ """TERMINAL_CWD=C:\\Users\\... should be replaced for modal."""
+ monkeypatch.setenv("TERMINAL_ENV", "modal")
+ monkeypatch.setenv("TERMINAL_CWD", "C:\\Users\\someone\\projects")
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/root"
+
+ def test_host_path_replaced_for_vercel_sandbox(self, monkeypatch):
+ """Host paths should be discarded for Vercel Sandbox."""
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_CWD", "/Users/someone/projects")
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/vercel/sandbox"
+
+ def test_relative_path_replaced_for_vercel_sandbox(self, monkeypatch):
+ """Relative cwd should not map into a remote Vercel sandbox."""
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_CWD", "src")
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/vercel/sandbox"
+
+ def test_default_cwd_is_workspace_root_for_vercel_sandbox(self, monkeypatch):
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.delenv("TERMINAL_CWD", raising=False)
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/vercel/sandbox"
+
+ @pytest.mark.parametrize("backend", ["modal", "docker", "singularity", "daytona"])
+ def test_default_cwd_is_root_for_container_backends(self, backend, monkeypatch):
+ """Container backends should default to /root, not ~."""
+ monkeypatch.setenv("TERMINAL_ENV", backend)
+ monkeypatch.delenv("TERMINAL_CWD", raising=False)
+ monkeypatch.delenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", raising=False)
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/root", (
+ f"Backend {backend}: expected /root default, got {config['cwd']}"
+ )
+
+ def test_docker_default_cwd_maps_current_directory_when_enabled(self, monkeypatch):
+ """Docker should use /workspace when cwd mounting is explicitly enabled."""
+ monkeypatch.setattr("tools.terminal_tool.os.getcwd", lambda: "/home/user/project")
+ monkeypatch.setenv("TERMINAL_ENV", "docker")
+ monkeypatch.setenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "true")
+ monkeypatch.delenv("TERMINAL_CWD", raising=False)
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == "/workspace"
+ assert config["host_cwd"] == "/home/user/project"
+
+ def test_local_backend_uses_getcwd(self, monkeypatch):
+ """Local backend should use os.getcwd(), not /root."""
+ monkeypatch.setenv("TERMINAL_ENV", "local")
+ monkeypatch.delenv("TERMINAL_CWD", raising=False)
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] == os.getcwd()
+
+ def test_create_environment_passes_docker_host_cwd_and_flag(self, monkeypatch):
+ """Docker host cwd and mount flag should reach DockerEnvironment."""
+ captured = {}
+ sentinel = object()
+
+ def _fake_docker_environment(**kwargs):
+ captured.update(kwargs)
+ return sentinel
+
+ monkeypatch.setattr(_tt_mod, "_DockerEnvironment", _fake_docker_environment)
+
+ env = _tt_mod._create_environment(
+ env_type="docker",
+ image="python:3.11",
+ cwd="/workspace",
+ timeout=60,
+ container_config={"docker_mount_cwd_to_workspace": True},
+ host_cwd="/home/user/project",
+ )
+
+ assert env is sentinel
+ assert captured["cwd"] == "/workspace"
+ assert captured["host_cwd"] == "/home/user/project"
+ assert captured["auto_mount_cwd"] is True
def test_ssh_preserves_home_paths(self, monkeypatch):
"""SSH backend should NOT replace /home/ paths (they're valid remotely)."""
diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py
index 24a5b63bef5..0f2a8ef0861 100644
--- a/tests/tools/test_skills_tool.py
+++ b/tests/tools/test_skills_tool.py
@@ -612,6 +612,77 @@ class TestSkillViewPrerequisites:
]
+ def test_remote_backend_treats_persisted_env_as_available(
+ self, tmp_path, monkeypatch
+ ):
+ monkeypatch.setenv("TERMINAL_ENV", "docker")
+
+ with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
+ _make_skill(
+ tmp_path,
+ "remote-ready",
+ frontmatter_extra="prerequisites:\n env_vars: [PERSISTED_REMOTE_KEY]\n",
+ )
+ from hermes_cli.config import save_env_value
+
+ save_env_value("PERSISTED_REMOTE_KEY", "persisted-value")
+ monkeypatch.delenv("PERSISTED_REMOTE_KEY", raising=False)
+ raw = skill_view("remote-ready")
+
+ result = json.loads(raw)
+ assert result["success"] is True
+ assert result["setup_needed"] is False
+ assert result["missing_required_environment_variables"] == []
+ assert result["readiness_status"] == "available"
+
+ def test_no_setup_metadata_when_no_required_envs(self, tmp_path):
+ with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
+ _make_skill(tmp_path, "plain-skill")
+ raw = skill_view("plain-skill")
+ result = json.loads(raw)
+ assert result["success"] is True
+ assert result["setup_needed"] is False
+ assert result["required_environment_variables"] == []
+
+ def test_skill_view_treats_backend_only_env_as_setup_needed(
+ self, tmp_path, monkeypatch
+ ):
+ monkeypatch.setenv("TERMINAL_ENV", "docker")
+
+ with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
+ _make_skill(
+ tmp_path,
+ "backend-ready",
+ frontmatter_extra="prerequisites:\n env_vars: [BACKEND_ONLY_KEY]\n",
+ )
+ raw = skill_view("backend-ready")
+ result = json.loads(raw)
+ assert result["success"] is True
+ assert result["setup_needed"] is True
+ assert result["missing_required_environment_variables"] == ["BACKEND_ONLY_KEY"]
+
+ def test_local_env_missing_keeps_setup_needed(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("TERMINAL_ENV", "local")
+ monkeypatch.delenv("SHELL_ONLY_KEY", raising=False)
+
+ with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
+ _make_skill(
+ tmp_path,
+ "shell-ready",
+ frontmatter_extra="prerequisites:\n env_vars: [SHELL_ONLY_KEY]\n",
+ )
+ raw = skill_view("shell-ready")
+
+ result = json.loads(raw)
+ assert result["success"] is True
+ assert result["setup_needed"] is True
+ assert result["missing_required_environment_variables"] == ["SHELL_ONLY_KEY"]
+ assert result["readiness_status"] == "setup_needed"
+
+ @pytest.mark.parametrize(
+ "backend",
+ ["ssh", "daytona", "docker", "singularity", "modal", "vercel_sandbox"],
+ )
def test_remote_backend_becomes_available_after_local_secret_capture(
self, tmp_path, monkeypatch
):
diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py
index ea4234ac50c..ea6d1887b6e 100644
--- a/tests/tools/test_terminal_requirements.py
+++ b/tests/tools/test_terminal_requirements.py
@@ -20,8 +20,13 @@ def _clear_terminal_env(monkeypatch):
"TERMINAL_SSH_PORT",
"TERMINAL_SSH_USER",
"TERMINAL_TIMEOUT",
+ "TERMINAL_VERCEL_RUNTIME",
"MODAL_TOKEN_ID",
"MODAL_TOKEN_SECRET",
+ "VERCEL_OIDC_TOKEN",
+ "VERCEL_TOKEN",
+ "VERCEL_PROJECT_ID",
+ "VERCEL_TEAM_ID",
"HOME",
"USERPROFILE",
]
@@ -76,3 +81,126 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey
"Nous Tool Gateway access is not currently available" in record.getMessage()
for record in caplog.records
)
+
+
+def test_vercel_backend_without_sdk_logs_specific_error(monkeypatch, caplog):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: None)
+
+ with caplog.at_level(logging.ERROR):
+ ok = terminal_tool_module.check_terminal_requirements()
+
+ assert ok is False
+ assert any(
+ "vercel is required for the Vercel Sandbox terminal backend" in record.getMessage()
+ for record in caplog.records
+ )
+
+
+def test_vercel_backend_without_auth_logs_specific_error(monkeypatch, caplog):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ with caplog.at_level(logging.ERROR):
+ ok = terminal_tool_module.check_terminal_requirements()
+
+ assert ok is False
+ assert any(
+ "no supported auth configuration was found" in record.getMessage()
+ for record in caplog.records
+ )
+
+
+def test_vercel_backend_accepts_oidc_auth(monkeypatch):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ assert terminal_tool_module.check_terminal_requirements() is True
+
+
+def test_vercel_backend_accepts_token_tuple_auth(monkeypatch):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("VERCEL_TOKEN", "token")
+ monkeypatch.setenv("VERCEL_PROJECT_ID", "project")
+ monkeypatch.setenv("VERCEL_TEAM_ID", "team")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ assert terminal_tool_module.check_terminal_requirements() is True
+
+
+@pytest.mark.parametrize("runtime", ["node24", "node22", "python3.13"])
+def test_vercel_backend_accepts_supported_runtimes(monkeypatch, runtime):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", runtime)
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ assert terminal_tool_module.check_terminal_requirements() is True
+
+
+def test_vercel_backend_accepts_blank_runtime(monkeypatch):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", " ")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ assert terminal_tool_module.check_terminal_requirements() is True
+
+
+def test_vercel_backend_rejects_unsupported_runtime(monkeypatch, caplog):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "node20")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ with caplog.at_level(logging.ERROR):
+ ok = terminal_tool_module.check_terminal_requirements()
+
+ assert ok is False
+ assert any(
+ "Vercel Sandbox runtime 'node20' is not supported" in record.getMessage()
+ and "node24, node22, python3.13" in record.getMessage()
+ for record in caplog.records
+ )
+
+
+def test_vercel_backend_rejects_nondefault_disk(monkeypatch, caplog):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "8192")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ with caplog.at_level(logging.ERROR):
+ ok = terminal_tool_module.check_terminal_requirements()
+
+ assert ok is False
+ assert any(
+ "does not support custom TERMINAL_CONTAINER_DISK=8192" in record.getMessage()
+ for record in caplog.records
+ )
+
+
+def test_vercel_backend_rejects_malformed_disk_without_raising(monkeypatch, caplog):
+ _clear_terminal_env(monkeypatch)
+ monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
+ monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "large")
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
+
+ with caplog.at_level(logging.ERROR):
+ ok = terminal_tool_module.check_terminal_requirements()
+
+ assert ok is False
+ assert any(
+ "Invalid value for TERMINAL_CONTAINER_DISK" in record.getMessage()
+ for record in caplog.records
+ )
diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py
index ac82adf2c3e..326a6f18c0a 100644
--- a/tests/tools/test_terminal_tool_requirements.py
+++ b/tests/tools/test_terminal_tool_requirements.py
@@ -177,3 +177,67 @@ class TestCheckFnTransientFailureSuppression:
assert {"read_file", "write_file", "patch", "search_files", "terminal"}.issubset(
child_names
)
+ def test_terminal_and_execute_code_tools_resolve_for_vercel_sandbox(self, monkeypatch):
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(
+ terminal_tool_module,
+ "_get_env_config",
+ lambda: {"env_type": "vercel_sandbox", "container_disk": 51200},
+ )
+ monkeypatch.setattr(
+ terminal_tool_module.importlib.util,
+ "find_spec",
+ lambda _name: object(),
+ )
+ tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
+ names = {tool["function"]["name"] for tool in tools}
+
+ assert "terminal" in names
+ assert "execute_code" in names
+
+ def test_terminal_and_execute_code_tools_hide_for_unsupported_vercel_runtime(self, monkeypatch):
+ monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
+ monkeypatch.setattr(
+ terminal_tool_module,
+ "_get_env_config",
+ lambda: {
+ "env_type": "vercel_sandbox",
+ "container_disk": 51200,
+ "vercel_runtime": "node20",
+ },
+ )
+ monkeypatch.setattr(
+ terminal_tool_module.importlib.util,
+ "find_spec",
+ lambda _name: object(),
+ )
+ tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
+ names = {tool["function"]["name"] for tool in tools}
+
+ assert "terminal" not in names
+ assert "execute_code" not in names
+
+ def test_terminal_and_execute_code_tools_hide_for_vercel_without_auth(self, monkeypatch):
+ monkeypatch.delenv("VERCEL_OIDC_TOKEN", raising=False)
+ monkeypatch.delenv("VERCEL_TOKEN", raising=False)
+ monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
+ monkeypatch.delenv("VERCEL_TEAM_ID", raising=False)
+ monkeypatch.setattr(
+ terminal_tool_module,
+ "_get_env_config",
+ lambda: {
+ "env_type": "vercel_sandbox",
+ "container_disk": 51200,
+ "vercel_runtime": "node22",
+ },
+ )
+ monkeypatch.setattr(
+ terminal_tool_module.importlib.util,
+ "find_spec",
+ lambda _name: object(),
+ )
+ tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
+ names = {tool["function"]["name"] for tool in tools}
+
+ assert "terminal" not in names
+ assert "execute_code" not in names
diff --git a/tests/tools/test_vercel_sandbox_environment.py b/tests/tools/test_vercel_sandbox_environment.py
new file mode 100644
index 00000000000..afeeb8cedf9
--- /dev/null
+++ b/tests/tools/test_vercel_sandbox_environment.py
@@ -0,0 +1,606 @@
+"""Unit tests for the Vercel Sandbox terminal backend."""
+
+from __future__ import annotations
+
+import importlib
+import io
+import re
+import sys
+import tarfile
+import threading
+import types
+from dataclasses import dataclass
+from enum import StrEnum
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+
+class _FakeRunResult:
+ def __init__(self, output: str | bytes = "", exit_code: int = 0):
+ self._output = output
+ self.exit_code = exit_code
+
+ def output(self) -> str | bytes:
+ return self._output
+
+
+class _FakeSandboxStatus(StrEnum):
+ PENDING = "pending"
+ RUNNING = "running"
+ STOPPING = "stopping"
+ STOPPED = "stopped"
+ FAILED = "failed"
+ ABORTED = "aborted"
+ SNAPSHOTTING = "snapshotting"
+
+
+@dataclass(frozen=True)
+class _FakeSnapshot:
+ snapshot_id: str
+
+
+class _FakeSandbox:
+ def __init__(
+ self,
+ *,
+ cwd: str = "/vercel/sandbox",
+ home: str = "/home/vercel",
+ status: _FakeSandboxStatus = _FakeSandboxStatus.RUNNING,
+ ):
+ self.sandbox = SimpleNamespace(cwd=cwd, id="sb-123")
+ self.status = status
+ self.home = home
+ self.closed = 0
+ self.client = SimpleNamespace(close=self._close)
+ self.run_command_calls: list[tuple[str, list[str], dict]] = []
+ self.run_command_side_effects: list[object] = []
+ self.write_files_calls: list[list[dict[str, object]]] = []
+ self.write_files_side_effects: list[object] = []
+ self.download_file_calls: list[tuple[str, Path]] = []
+ self.download_file_side_effects: list[object] = []
+ self.download_file_content = b""
+ self.stop_calls: list[tuple[tuple, dict]] = []
+ self.snapshot_calls: list[tuple[tuple, dict]] = []
+ self.snapshot_side_effects: list[object] = []
+ self.snapshot_id = "snap_default"
+ self.refresh_calls = 0
+ self.wait_for_status_calls: list[tuple[object, object, object]] = []
+ self.wait_for_status_side_effects: list[object] = []
+
+ def _close(self) -> None:
+ self.closed += 1
+
+ def refresh(self) -> None:
+ self.refresh_calls += 1
+
+ def wait_for_status(self, status: _FakeSandboxStatus | str, *, timeout, poll_interval) -> None:
+ self.wait_for_status_calls.append((status, timeout, poll_interval))
+ if self.wait_for_status_side_effects:
+ effect = self.wait_for_status_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if callable(effect):
+ effect(status, timeout, poll_interval)
+ return
+ self.status = _FakeSandboxStatus(status)
+
+ def run_command(self, cmd: str, args: list[str] | None = None, **kwargs):
+ args = list(args or [])
+ self.run_command_calls.append((cmd, args, kwargs))
+ if self.run_command_side_effects:
+ effect = self.run_command_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if callable(effect):
+ return effect(cmd, args, kwargs)
+ return effect
+ script = args[1] if len(args) > 1 else ""
+ if 'printf %s "$HOME"' in script:
+ return _FakeRunResult(self.home)
+ return _FakeRunResult("")
+
+ def write_files(self, files: list[dict[str, object]]) -> None:
+ self.write_files_calls.append(files)
+ if self.write_files_side_effects:
+ effect = self.write_files_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if callable(effect):
+ effect(files)
+
+ def download_file(self, remote_path: str, local_path) -> str:
+ destination = Path(local_path)
+ self.download_file_calls.append((remote_path, destination))
+ if self.download_file_side_effects:
+ effect = self.download_file_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if callable(effect):
+ return effect(remote_path, destination)
+ destination.write_bytes(self.download_file_content)
+ return str(destination.resolve())
+
+ def stop(self, *args, **kwargs) -> None:
+ self.stop_calls.append((args, kwargs))
+
+ def snapshot(self, *args, **kwargs):
+ self.snapshot_calls.append((args, kwargs))
+ if self.snapshot_side_effects:
+ effect = self.snapshot_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if callable(effect):
+ return effect(*args, **kwargs)
+ if isinstance(effect, str):
+ return _FakeSnapshot(effect)
+ return effect
+ return _FakeSnapshot(self.snapshot_id)
+
+
+@dataclass(frozen=True)
+class _FakeResources:
+ vcpus: float | None = None
+ memory: int | None = None
+
+
+@dataclass(frozen=True)
+class _FakeWriteFile:
+ path: str
+ content: bytes
+
+
+class _FakeSDK:
+ def __init__(self):
+ self.create_kwargs: list[dict[str, object]] = []
+ self.create_side_effects: list[object] = []
+ self.sandboxes: list[_FakeSandbox] = []
+
+ @property
+ def current(self) -> _FakeSandbox:
+ return self.sandboxes[-1]
+
+ def create(self, **kwargs):
+ self.create_kwargs.append(kwargs)
+ if self.create_side_effects:
+ effect = self.create_side_effects.pop(0)
+ if isinstance(effect, Exception):
+ raise effect
+ if isinstance(effect, _FakeSandbox):
+ self.sandboxes.append(effect)
+ return effect
+ sandbox = _FakeSandbox()
+ self.sandboxes.append(sandbox)
+ return sandbox
+
+
+def _cwd_result(body: str = "", *, cwd: str = "/vercel/sandbox", exit_code: int = 0):
+ def _result(_cmd: str, args: list[str], _kwargs: dict):
+ script = args[1] if len(args) > 1 else ""
+ match = re.search(r"__HERMES_CWD_[A-Za-z0-9]+__", script)
+ marker = match.group(0) if match else "__HERMES_CWD_MISSING__"
+ prefix = f"{body}\n\n" if body else "\n"
+ return _FakeRunResult(f"{prefix}{marker}{cwd}{marker}\n", exit_code)
+
+ return _result
+
+
+def _tar_bytes(entries: dict[str, bytes]) -> bytes:
+ buffer = io.BytesIO()
+ with tarfile.open(fileobj=buffer, mode="w") as tar:
+ for name, content in entries.items():
+ info = tarfile.TarInfo(name)
+ info.size = len(content)
+ tar.addfile(info, io.BytesIO(content))
+ return buffer.getvalue()
+
+
+@pytest.fixture()
+def vercel_sdk(monkeypatch):
+ fake_sdk = _FakeSDK()
+ sandbox_mod = types.ModuleType("vercel.sandbox")
+ sandbox_mod.Sandbox = types.SimpleNamespace(create=fake_sdk.create)
+ sandbox_mod.Resources = _FakeResources
+ sandbox_mod.WriteFile = _FakeWriteFile
+ sandbox_mod.SandboxStatus = _FakeSandboxStatus
+
+ vercel_mod = types.ModuleType("vercel")
+ vercel_mod.sandbox = sandbox_mod
+
+ monkeypatch.setitem(sys.modules, "vercel", vercel_mod)
+ monkeypatch.setitem(sys.modules, "vercel.sandbox", sandbox_mod)
+ return fake_sdk
+
+
+@pytest.fixture()
+def vercel_module(vercel_sdk, monkeypatch):
+ monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
+ monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
+ monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
+ monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
+
+ module = importlib.import_module("tools.environments.vercel_sandbox")
+ return importlib.reload(module)
+
+
+@pytest.fixture()
+def make_env(vercel_module, request):
+ envs = []
+
+ def _cleanup_envs():
+ for env in envs:
+ env._sync_manager = None
+ env.cleanup()
+
+ request.addfinalizer(_cleanup_envs)
+
+ def _factory(**kwargs):
+ kwargs.setdefault("runtime", "node22")
+ kwargs.setdefault("cwd", vercel_module.DEFAULT_VERCEL_CWD)
+ kwargs.setdefault("timeout", 30)
+ kwargs.setdefault("task_id", "task-123")
+ env = vercel_module.VercelSandboxEnvironment(**kwargs)
+ envs.append(env)
+ return env
+
+ return _factory
+
+
+class TestStartup:
+ def test_default_cwd_tracks_remote_workspace_root(self, make_env, vercel_sdk):
+ sandbox = _FakeSandbox(cwd="/workspace")
+ vercel_sdk.create_side_effects.append(sandbox)
+
+ env = make_env()
+
+ assert env.cwd == "/workspace"
+
+ def test_tilde_cwd_resolves_against_remote_home(self, make_env, vercel_sdk):
+ sandbox = _FakeSandbox(home="/home/custom")
+ vercel_sdk.create_side_effects.append(sandbox)
+
+ env = make_env(cwd="~")
+
+ assert env.cwd == "/home/custom"
+
+ def test_pending_sandbox_timeout_raises_descriptive_error(
+ self, make_env, vercel_sdk
+ ):
+ sandbox = _FakeSandbox(status=_FakeSandboxStatus.PENDING)
+ sandbox.wait_for_status_side_effects.append(TimeoutError("still pending"))
+ vercel_sdk.create_side_effects.append(sandbox)
+
+ with pytest.raises(RuntimeError, match="Sandbox did not reach running state"):
+ make_env()
+
+
+class TestFileSync:
+ def test_initial_sync_uploads_managed_files_under_remote_home(
+ self, make_env, vercel_sdk, monkeypatch, tmp_path
+ ):
+ src = tmp_path / "token.txt"
+ src.write_text("secret-token")
+ monkeypatch.setattr(
+ "tools.credential_files.get_credential_file_mounts",
+ lambda: [
+ {
+ "host_path": str(src),
+ "container_path": "/root/.hermes/credentials/token.txt",
+ }
+ ],
+ )
+ monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
+ monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
+
+ make_env()
+
+ uploaded = vercel_sdk.current.write_files_calls[0]
+ assert uploaded == [
+ {
+ "path": "/home/vercel/.hermes/credentials/token.txt",
+ "content": b"secret-token",
+ }
+ ]
+
+ def test_execute_resyncs_changed_managed_files(
+ self, make_env, vercel_sdk, monkeypatch, tmp_path
+ ):
+ src = tmp_path / "token.txt"
+ src.write_text("secret-token")
+ monkeypatch.setattr(
+ "tools.credential_files.get_credential_file_mounts",
+ lambda: [
+ {
+ "host_path": str(src),
+ "container_path": "/root/.hermes/credentials/token.txt",
+ }
+ ],
+ )
+ monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
+ monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
+
+ env = make_env()
+ src.write_text("updated-secret-token")
+ monkeypatch.setenv("HERMES_FORCE_FILE_SYNC", "1")
+ vercel_sdk.current.run_command_side_effects.append(_cwd_result("hello"))
+
+ result = env.execute("echo hello")
+
+ assert result == {"output": "hello\n", "returncode": 0}
+ assert vercel_sdk.current.write_files_calls[-1] == [
+ {
+ "path": "/home/vercel/.hermes/credentials/token.txt",
+ "content": b"updated-secret-token",
+ }
+ ]
+
+ def test_cleanup_syncs_back_snapshots_closes_and_is_idempotent(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ src = tmp_path / "token.txt"
+ src.write_text("host-token")
+ monkeypatch.setattr(
+ "tools.credential_files.get_credential_file_mounts",
+ lambda: [
+ {
+ "host_path": str(src),
+ "container_path": "/root/.hermes/credentials/token.txt",
+ }
+ ],
+ )
+ monkeypatch.setattr(
+ "tools.credential_files.iter_skills_files",
+ lambda **kwargs: [],
+ )
+ monkeypatch.setattr(
+ "tools.credential_files.iter_cache_files",
+ lambda **kwargs: [],
+ )
+ env = make_env()
+ sandbox = vercel_sdk.current
+ sandbox.snapshot_id = "snap_cleanup"
+ vercel_sdk.current.download_file_content = _tar_bytes(
+ {
+ "home/vercel/.hermes/credentials/token.txt": b"remote-token",
+ "home/vercel/.hermes/credentials/new.txt": b"new-remote",
+ "home/vercel/.hermes/unmapped/skip.txt": b"skip",
+ }
+ )
+
+ env.cleanup()
+ env.cleanup()
+
+ assert src.read_text() == "remote-token"
+ assert (tmp_path / "new.txt").read_text() == "new-remote"
+ assert not (tmp_path / "skip.txt").exists()
+ assert len(sandbox.snapshot_calls) == 1
+ assert len(sandbox.stop_calls) == 1 # always stop after snapshot to avoid resource leaks
+ assert sandbox.closed == 1
+ assert vercel_module._load_snapshots() == {"task-123": "snap_cleanup"}
+
+ def test_cleanup_sync_back_failure_from_download_does_not_block_snapshot(
+ self, make_env, vercel_sdk, monkeypatch, tmp_path
+ ):
+ src = tmp_path / "token.txt"
+ src.write_text("host-token")
+ monkeypatch.setattr(
+ "tools.credential_files.get_credential_file_mounts",
+ lambda: [
+ {
+ "host_path": str(src),
+ "container_path": "/root/.hermes/credentials/token.txt",
+ }
+ ],
+ )
+ monkeypatch.setattr(
+ "tools.credential_files.iter_skills_files",
+ lambda **kwargs: [],
+ )
+ monkeypatch.setattr(
+ "tools.credential_files.iter_cache_files",
+ lambda **kwargs: [],
+ )
+ env = make_env()
+ sandbox = vercel_sdk.current
+ sandbox.run_command_side_effects.extend(
+ [
+ _FakeRunResult("tar failed", exit_code=2),
+ _FakeRunResult(""),
+ _FakeRunResult("tar failed", exit_code=2),
+ _FakeRunResult(""),
+ _FakeRunResult("tar failed", exit_code=2),
+ _FakeRunResult(""),
+ ]
+ )
+ monkeypatch.setattr("tools.environments.file_sync.time.sleep", lambda _delay: None)
+
+ env.cleanup()
+
+ assert src.read_text() == "host-token"
+ assert len(sandbox.snapshot_calls) == 1
+ assert sandbox.closed == 1
+ assert len(sandbox.download_file_calls) == 0
+
+
+class TestExecute:
+
+ @pytest.mark.parametrize(
+ ("make_unhealthy", "label"),
+ [
+ (
+ lambda sandbox: setattr(
+ sandbox, "status", _FakeSandboxStatus.STOPPED
+ ),
+ "terminal state",
+ ),
+ (
+ lambda sandbox: setattr(
+ sandbox,
+ "refresh",
+ lambda: (_ for _ in ()).throw(RuntimeError("refresh failed")),
+ ),
+ "refresh failure",
+ ),
+ ],
+ ids=["terminal-state", "refresh-failure"],
+ )
+ def test_execute_recreates_unhealthy_sandbox_before_running_command(
+ self, make_env, vercel_sdk, make_unhealthy, label
+ ):
+ env = make_env()
+ original = vercel_sdk.current
+ make_unhealthy(original)
+
+ replacement = _FakeSandbox()
+ replacement.run_command_side_effects.extend(
+ [
+ _FakeRunResult(replacement.home),
+ _cwd_result("hello"),
+ ]
+ )
+ vercel_sdk.create_side_effects.append(replacement)
+
+ result = env.execute("echo hello")
+
+ assert result == {"output": "hello\n", "returncode": 0}, label
+ assert original.closed == 1
+ assert vercel_sdk.current is replacement
+
+ def test_run_bash_handle_uses_captured_sandbox_for_exec_and_cancel(
+ self, make_env
+ ):
+ env = make_env()
+ original = env._sandbox
+ assert original is not None
+ replacement = _FakeSandbox()
+ started = threading.Event()
+ release = threading.Event()
+
+ def blocking_command(_cmd: str, _args: list[str], _kwargs: dict):
+ started.set()
+ release.wait(timeout=5)
+ return _FakeRunResult("done")
+
+ original.run_command_side_effects.append(blocking_command)
+
+ handle = env._run_bash("echo done")
+ assert started.wait(timeout=1)
+
+ env._sandbox = replacement
+ handle.kill()
+ release.set()
+
+ assert handle.wait(timeout=2) == 0
+ assert len(original.stop_calls) == 1
+ assert replacement.stop_calls == []
+ cmd, args, kwargs = original.run_command_calls[-1]
+ assert cmd == "bash"
+ assert args == ["-c", "echo done"]
+ assert kwargs["cwd"] == "/vercel/sandbox"
+
+
+class TestSnapshotPersistence:
+ def test_create_restores_from_saved_snapshot(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ vercel_module._store_snapshot("task-123", "snap_saved")
+ restored = _FakeSandbox(cwd="/restored")
+ vercel_sdk.create_side_effects.append(restored)
+
+ env = make_env()
+
+ assert env.cwd == "/restored"
+ assert vercel_sdk.create_kwargs[0]["source"] == {
+ "type": "snapshot",
+ "snapshot_id": "snap_saved",
+ }
+ assert vercel_module._load_snapshots() == {"task-123": "snap_saved"}
+
+ def test_restore_failure_prunes_snapshot_and_falls_back_to_fresh_sandbox(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ vercel_module._store_snapshot("task-123", "snap_stale")
+ fresh = _FakeSandbox(cwd="/fresh")
+ vercel_sdk.create_side_effects.extend(
+ [RuntimeError("snapshot missing"), fresh]
+ )
+
+ env = make_env()
+
+ assert env.cwd == "/fresh"
+ assert vercel_sdk.create_kwargs[0]["source"] == {
+ "type": "snapshot",
+ "snapshot_id": "snap_stale",
+ }
+ assert "source" not in vercel_sdk.create_kwargs[1]
+ assert vercel_module._load_snapshots() == {}
+
+ def test_cleanup_stops_when_snapshot_fails_without_storing_metadata(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ env = make_env()
+ sandbox = vercel_sdk.current
+ sandbox.snapshot_side_effects.append(RuntimeError("snapshot failed"))
+
+ env.cleanup()
+
+ assert len(sandbox.snapshot_calls) == 1
+ assert len(sandbox.stop_calls) == 1
+ assert sandbox.closed == 1
+ assert vercel_module._load_snapshots() == {}
+
+ def test_non_persistent_cleanup_stops_without_snapshot(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ env = make_env(persistent_filesystem=False)
+ sandbox = vercel_sdk.current
+
+ env.cleanup()
+
+ assert sandbox.snapshot_calls == []
+ assert len(sandbox.stop_calls) == 1
+ assert sandbox.closed == 1
+ assert vercel_module._load_snapshots() == {}
+
+ def test_persistent_cleanup_without_task_id_stops_without_snapshot(
+ self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
+ ):
+ hermes_home = tmp_path / ".hermes"
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ env = make_env(task_id="")
+ sandbox = vercel_sdk.current
+
+ env.cleanup()
+
+ assert sandbox.snapshot_calls == []
+ assert len(sandbox.stop_calls) == 1
+ assert sandbox.closed == 1
+ assert vercel_module._load_snapshots() == {}
+
+
+class TestCleanup:
+ def test_cleanup_continues_when_sync_back_raises(self, make_env, vercel_sdk):
+ env = make_env()
+ sandbox = vercel_sdk.current
+
+ class FailingSyncManager:
+ def sync_back(self):
+ raise RuntimeError("download failed")
+
+ env._sync_manager = FailingSyncManager()
+
+ env.cleanup()
+
+ assert len(sandbox.snapshot_calls) == 1
+ assert sandbox.closed == 1
diff --git a/tools/approval.py b/tools/approval.py
index 6477075e778..5db5065f906 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -3056,7 +3056,7 @@ def _should_skip_container_guards(env_type: str, has_host_access: bool = False)
"""
if env_type == "docker":
return not has_host_access
- return env_type in ("singularity", "modal", "daytona")
+ return env_type in ("singularity", "modal", "daytona", "vercel_sandbox")
def check_dangerous_command(command: str, env_type: str,
diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py
index af9c41a015d..19a58bac8c7 100644
--- a/tools/code_execution_tool.py
+++ b/tools/code_execution_tool.py
@@ -288,6 +288,21 @@ def check_sandbox_requirements() -> bool:
"""Code execution sandbox requires a POSIX OS for Unix domain sockets."""
if not SANDBOX_AVAILABLE:
return False
+
+ try:
+ from tools.terminal_tool import (
+ _check_vercel_sandbox_requirements,
+ _get_env_config,
+ )
+
+ config = _get_env_config()
+ except Exception:
+ logger.debug("Could not resolve terminal config for execute_code availability", exc_info=True)
+ return False
+
+ if config.get("env_type") == "vercel_sandbox":
+ return _check_vercel_sandbox_requirements(config)
+
return True
@@ -752,12 +767,13 @@ def _get_or_create_env(task_id: str):
cwd = overrides.get("cwd") or config["cwd"]
container_config = None
- if env_type in {"docker", "singularity", "modal", "daytona"}:
+ if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
+ "vercel_runtime": config.get("vercel_runtime", ""),
"docker_volumes": config.get("docker_volumes", []),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_network": config.get("docker_network", True),
diff --git a/tools/credential_files.py b/tools/credential_files.py
index 583d9973b54..a86164ffc30 100644
--- a/tools/credential_files.py
+++ b/tools/credential_files.py
@@ -481,7 +481,7 @@ def to_agent_visible_cache_path(
translation (only Docker for now).
"""
# Only Docker backend requires translation at this time. Other backends
- # (Modal, Daytona) use different mount semantics and will be
+ # (Modal, Daytona, Vercel) use different mount semantics and will be
# addressed separately if needed. Backend is identified by TERMINAL_ENV
# (same env var tools/terminal_tool.py reads in _get_environment_config).
if os.environ.get("TERMINAL_ENV", "local") != "docker":
diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py
index 1eebcab42a0..0134dc16dcb 100644
--- a/tools/environments/__init__.py
+++ b/tools/environments/__init__.py
@@ -2,8 +2,8 @@
Each backend provides the same interface (BaseEnvironment ABC) for running
shell commands in a specific execution context: local, Docker, SSH,
-Singularity, Modal, or Daytona. (Modal additionally has direct and
-Nous-managed modes, selected via terminal.modal_mode.)
+Singularity, Modal, Daytona, or Vercel Sandbox. (Modal additionally has
+direct and Nous-managed modes, selected via terminal.modal_mode.)
The terminal_tool.py factory (_create_environment) selects the backend
based on the TERMINAL_ENV configuration.
diff --git a/tools/environments/local.py b/tools/environments/local.py
index 77cd33d5cf5..f3ad9d514b7 100644
--- a/tools/environments/local.py
+++ b/tools/environments/local.py
@@ -316,6 +316,10 @@ def _build_provider_env_blocklist() -> frozenset:
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_DELIVERY_KEY",
+ "VERCEL_OIDC_TOKEN",
+ "VERCEL_TOKEN",
+ "VERCEL_PROJECT_ID",
+ "VERCEL_TEAM_ID",
})
# CLAUDE_CODE_OAUTH_TOKEN is deliberately NOT stripped. It is set and
# owned by the user's Claude Code install (subscription OAuth), not a
diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py
new file mode 100644
index 00000000000..70edd54ad4a
--- /dev/null
+++ b/tools/environments/vercel_sandbox.py
@@ -0,0 +1,654 @@
+"""Vercel Sandbox execution environment.
+
+Uses the Vercel Python SDK to run commands in cloud sandboxes through Hermes'
+shared ``BaseEnvironment`` shell contract. When persistence is enabled, the
+backend stores task-scoped snapshot metadata under ``HERMES_HOME`` and restores
+new sandboxes from those snapshots on later task reuse.
+"""
+
+from __future__ import annotations
+
+from functools import cache
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+import math
+import os
+import shlex
+import threading
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+import httpx
+
+from hermes_constants import get_hermes_home
+from tools.environments.base import (
+ BaseEnvironment,
+ _ThreadedProcessHandle,
+ _load_json_store,
+ _save_json_store,
+)
+from tools.environments.file_sync import (
+ FileSyncManager,
+ iter_sync_files,
+ quoted_rm_command,
+)
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from vercel.sandbox import Resources, Sandbox, SandboxStatus, WriteFile
+
+DEFAULT_VERCEL_CWD = "/vercel/sandbox"
+_DEFAULT_CONTAINER_DISK_MB = 51200
+
+
+def _ensure_vercel_sdk() -> None:
+ """Lazy-install vercel SDK on demand. Idempotent."""
+ try:
+ from tools.lazy_deps import ensure as _lazy_ensure
+ _lazy_ensure("terminal.vercel", prompt=False)
+ except ImportError:
+ pass
+ except Exception as e:
+ raise ImportError(str(e))
+
+
+_CREATE_RETRY_ATTEMPTS = 3
+_WRITE_RETRY_ATTEMPTS = 3
+_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
+_RETRY_BACKOFF_STEP = timedelta(milliseconds=100)
+_MIN_SANDBOX_TIMEOUT = timedelta(minutes=5)
+_MIN_RUNNING_WAIT = timedelta(seconds=1)
+_RUNNING_WAIT_TIMEOUT = timedelta(seconds=30)
+_RUNNING_WAIT_POLL_INTERVAL = timedelta(milliseconds=250)
+_STOP_TIMEOUT = timedelta(seconds=15)
+_STOP_POLL_INTERVAL = timedelta(milliseconds=500)
+_SNAPSHOT_STORE_NAME = "vercel_sandbox_snapshots.json"
+
+
+def _exception_chain(exc: BaseException) -> list[BaseException]:
+ chain: list[BaseException] = []
+ current: BaseException | None = exc
+ seen: set[int] = set()
+ while current is not None and id(current) not in seen:
+ chain.append(current)
+ seen.add(id(current))
+ current = current.__cause__ or current.__context__
+ return chain
+
+
+def _extract_status_code(exc: BaseException) -> int | None:
+ response = getattr(exc, "response", None)
+ for value in (getattr(exc, "status_code", None), getattr(response, "status_code", None)):
+ if isinstance(value, int):
+ return value
+ return None
+
+
+def _is_transient_vercel_error(exc: BaseException) -> bool:
+ for error in _exception_chain(exc):
+ status_code = _extract_status_code(error)
+ if status_code in _TRANSIENT_STATUS_CODES:
+ return True
+ if isinstance(
+ error,
+ (httpx.NetworkError, httpx.ProtocolError, httpx.ReadError),
+ ):
+ return True
+ error_name = type(error).__name__.lower()
+ if "ratelimit" in error_name or "servererror" in error_name:
+ return True
+ return False
+
+
+def _retry_vercel_call(
+ label: str,
+ callback,
+ *,
+ attempts: int,
+):
+ backoff_seconds = _RETRY_BACKOFF_STEP.total_seconds()
+ for attempt in range(1, attempts + 1):
+ try:
+ return callback()
+ except Exception as exc:
+ if attempt >= attempts or not _is_transient_vercel_error(exc):
+ raise
+ logger.warning(
+ "Vercel: %s failed (%s); retrying %d/%d",
+ label,
+ exc,
+ attempt,
+ attempts,
+ )
+ time.sleep(backoff_seconds * attempt)
+
+
+def _coerce_text(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return str(value)
+
+
+def _extract_result_output(result: Any) -> str:
+ try:
+ return _coerce_text(result.output())
+ except (AttributeError, TypeError):
+ return _coerce_text(result)
+
+
+def _extract_result_returncode(result: Any) -> int:
+ try:
+ exit_code = result.exit_code
+ except AttributeError:
+ try:
+ exit_code = result.returncode
+ except AttributeError:
+ return 1
+ return exit_code if isinstance(exit_code, int) else 1
+
+
+def _snapshot_store_path() -> Path:
+ return get_hermes_home() / _SNAPSHOT_STORE_NAME
+
+
+def _load_snapshots() -> dict:
+ return _load_json_store(_snapshot_store_path())
+
+
+def _save_snapshots(data: dict) -> None:
+ _save_json_store(_snapshot_store_path(), data)
+
+
+def _get_snapshot_id(task_id: str) -> str | None:
+ if not task_id:
+ return None
+ snapshot_id = _load_snapshots().get(task_id)
+ return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None
+
+
+def _store_snapshot(task_id: str, snapshot_id: str) -> None:
+ if not task_id or not snapshot_id:
+ return
+ snapshots = _load_snapshots()
+ snapshots[task_id] = snapshot_id
+ _save_snapshots(snapshots)
+
+
+def _delete_snapshot(task_id: str, snapshot_id: str | None = None) -> None:
+ if not task_id:
+ return
+ snapshots = _load_snapshots()
+ existing = snapshots.get(task_id)
+ if existing is None:
+ return
+ if snapshot_id is not None and existing != snapshot_id:
+ return
+ snapshots.pop(task_id, None)
+ _save_snapshots(snapshots)
+
+
+def _extract_snapshot_id(snapshot: Any) -> str | None:
+ for attr in ("snapshot_id", "snapshotId", "id"):
+ value = getattr(snapshot, attr, None)
+ if isinstance(value, str) and value:
+ return value
+ if isinstance(snapshot, dict):
+ for key in ("snapshot_id", "snapshotId", "id"):
+ value = snapshot.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+@cache
+def _sandbox_status_type() -> type[SandboxStatus]:
+ _ensure_vercel_sdk()
+ from vercel.sandbox import SandboxStatus
+
+ return SandboxStatus
+
+
+@cache
+def _terminal_sandbox_states() -> frozenset[SandboxStatus]:
+ SandboxStatus = _sandbox_status_type()
+ return frozenset(
+ {
+ SandboxStatus.ABORTED,
+ SandboxStatus.FAILED,
+ SandboxStatus.STOPPED,
+ }
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class _SandboxCreateParams:
+ timeout: timedelta
+ runtime: str | None = None
+ resources: Resources | None = None
+
+
+class VercelSandboxEnvironment(BaseEnvironment):
+ """Vercel cloud sandbox backend."""
+
+ _stdin_mode = "heredoc"
+
+ def __init__(
+ self,
+ runtime: str | None = None,
+ cwd: str = DEFAULT_VERCEL_CWD,
+ timeout: int = 60,
+ cpu: float = 1,
+ memory: int = 5120,
+ disk: int = _DEFAULT_CONTAINER_DISK_MB,
+ persistent_filesystem: bool = True,
+ task_id: str = "default",
+ ):
+ requested_cwd = cwd
+ super().__init__(cwd=cwd, timeout=timeout)
+
+ self._runtime = runtime or None
+ self._persistent = persistent_filesystem
+ self._task_id = task_id
+ self._requested_cwd = requested_cwd
+ self._lock = threading.Lock()
+ self._sandbox: Sandbox | None = None
+ self._workspace_root = DEFAULT_VERCEL_CWD
+ self._remote_home = DEFAULT_VERCEL_CWD
+ self._sync_manager: FileSyncManager | None = None
+ self._create_params = self._build_create_params(cpu=cpu, memory=memory, disk=disk)
+
+ self._sandbox = self._create_sandbox()
+ self._configure_attached_sandbox(requested_cwd=requested_cwd)
+ self._sync_manager.sync(force=True)
+ self.init_session()
+
+ def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams:
+ if disk not in {0, _DEFAULT_CONTAINER_DISK_MB}:
+ raise ValueError(
+ "Vercel Sandbox does not support configurable container_disk. "
+ "Use the default shared setting."
+ )
+
+ _ensure_vercel_sdk()
+ from vercel.sandbox import Resources
+
+ sandbox_timeout = max(
+ timedelta(seconds=max(self.timeout, 0)),
+ _MIN_SANDBOX_TIMEOUT,
+ )
+ vcpus = math.floor(cpu) if cpu > 0 else None
+ memory_mb = memory if memory > 0 else None
+ resources = (
+ Resources(vcpus=vcpus, memory=memory_mb)
+ if vcpus is not None or memory_mb is not None
+ else None
+ )
+
+ return _SandboxCreateParams(
+ timeout=sandbox_timeout,
+ runtime=self._runtime,
+ resources=resources,
+ )
+
+ def _create_sandbox(self) -> Sandbox:
+ _ensure_vercel_sdk()
+ from vercel.sandbox import Sandbox
+
+ snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
+ if snapshot_id:
+ try:
+ return _retry_vercel_call(
+ "sandbox restore",
+ lambda: Sandbox.create(
+ timeout=self._create_params.timeout,
+ runtime=self._create_params.runtime,
+ resources=self._create_params.resources,
+ source={"type": "snapshot", "snapshot_id": snapshot_id},
+ ),
+ attempts=_CREATE_RETRY_ATTEMPTS,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Vercel: failed to restore snapshot %s for task %s; "
+ "falling back to a fresh sandbox: %s",
+ snapshot_id,
+ self._task_id,
+ exc,
+ )
+ _delete_snapshot(self._task_id, snapshot_id)
+
+ params = self._create_params
+ return _retry_vercel_call(
+ "sandbox create",
+ lambda: Sandbox.create(
+ timeout=params.timeout,
+ runtime=params.runtime,
+ resources=params.resources,
+ ),
+ attempts=_CREATE_RETRY_ATTEMPTS,
+ )
+
+ def _configure_attached_sandbox(self, *, requested_cwd: str) -> None:
+ self._wait_for_running()
+ self._workspace_root = self._detect_workspace_root()
+ self._remote_home = self._detect_remote_home()
+
+ if self._remote_home == "/":
+ container_base = "/.hermes"
+ else:
+ container_base = f"{self._remote_home.rstrip('/')}/.hermes"
+ self._sync_manager = FileSyncManager(
+ get_files_fn=lambda: iter_sync_files(container_base),
+ upload_fn=self._vercel_upload,
+ delete_fn=self._vercel_delete,
+ bulk_upload_fn=self._vercel_bulk_upload,
+ bulk_download_fn=self._vercel_bulk_download,
+ )
+
+ if requested_cwd == "~":
+ self.cwd = self._remote_home
+ elif requested_cwd in {"", DEFAULT_VERCEL_CWD}:
+ self.cwd = self._workspace_root
+ else:
+ self.cwd = requested_cwd
+
+ def _detect_workspace_root(self) -> str:
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ cwd = sandbox.sandbox.cwd
+ return cwd if cwd.startswith("/") else DEFAULT_VERCEL_CWD
+
+ def _detect_remote_home(self) -> str:
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ try:
+ result = sandbox.run_command(
+ "sh",
+ ["-lc", 'printf %s "$HOME"'],
+ cwd=self._workspace_root,
+ )
+ except Exception as exc:
+ logger.debug(
+ "Vercel: home detection failed for task %s: %s",
+ self._task_id,
+ exc,
+ )
+ return self._workspace_root
+
+ home = _extract_result_output(result).strip()
+ if home.startswith("/"):
+ return home
+ return self._workspace_root
+
+ def _wait_for_running(self, timeout: timedelta = _RUNNING_WAIT_TIMEOUT) -> None:
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ SandboxStatus = _sandbox_status_type()
+ status = sandbox.status
+ if status is None or status == SandboxStatus.RUNNING:
+ return
+ if status in _terminal_sandbox_states():
+ raise RuntimeError(f"Sandbox entered terminal state: {status}")
+
+ try:
+ sandbox.wait_for_status(
+ SandboxStatus.RUNNING,
+ timeout=max(timeout, _MIN_RUNNING_WAIT),
+ poll_interval=_RUNNING_WAIT_POLL_INTERVAL,
+ )
+ except TimeoutError as exc:
+ status = sandbox.status
+ if status in _terminal_sandbox_states():
+ raise RuntimeError(f"Sandbox entered terminal state: {status}") from exc
+ raise RuntimeError(
+ f"Sandbox did not reach running state (last status: {status})"
+ ) from exc
+
+ def _close_sandbox_client(self, sandbox: Sandbox | None) -> None:
+ if sandbox is None:
+ return
+ try:
+ sandbox.client.close()
+ except Exception:
+ pass
+
+ def _stop_sandbox(self, sandbox: Sandbox | None) -> None:
+ if sandbox is None:
+ return
+ try:
+ sandbox.stop(
+ blocking=True,
+ timeout=_STOP_TIMEOUT,
+ poll_interval=_STOP_POLL_INTERVAL,
+ )
+ except TypeError:
+ try:
+ sandbox.stop()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _snapshot_sandbox(self, sandbox: Sandbox) -> str | None:
+ if not self._persistent or not self._task_id:
+ return None
+ try:
+ snapshot = sandbox.snapshot()
+ except Exception as exc:
+ logger.warning(
+ "Vercel: filesystem snapshot failed for task %s: %s",
+ self._task_id,
+ exc,
+ )
+ return None
+
+ snapshot_id = _extract_snapshot_id(snapshot)
+ if not snapshot_id:
+ logger.warning(
+ "Vercel: filesystem snapshot for task %s did not return a snapshot id",
+ self._task_id,
+ )
+ return None
+
+ _store_snapshot(self._task_id, snapshot_id)
+ logger.info(
+ "Vercel: saved filesystem snapshot %s for task %s",
+ snapshot_id,
+ self._task_id,
+ )
+ return snapshot_id
+
+ def _ensure_sandbox_ready(self) -> None:
+ sandbox = self._sandbox
+ requested_cwd = self.cwd or self._requested_cwd or DEFAULT_VERCEL_CWD
+
+ if sandbox is None:
+ self._sandbox = self._create_sandbox()
+ self._configure_attached_sandbox(requested_cwd=requested_cwd)
+ return
+
+ try:
+ sandbox.refresh()
+ except Exception as exc:
+ logger.warning(
+ "Vercel: sandbox refresh failed for task %s: %s; recreating",
+ self._task_id,
+ exc,
+ )
+ self._close_sandbox_client(sandbox)
+ self._sandbox = self._create_sandbox()
+ self._configure_attached_sandbox(requested_cwd=requested_cwd)
+ return
+
+ status = sandbox.status
+ if status in _terminal_sandbox_states():
+ logger.warning(
+ "Vercel: sandbox entered state %s for task %s; recreating",
+ status,
+ self._task_id,
+ )
+ self._close_sandbox_client(sandbox)
+ self._sandbox = self._create_sandbox()
+ self._configure_attached_sandbox(requested_cwd=requested_cwd)
+ return
+
+ self._wait_for_running()
+
+ def _vercel_upload(self, host_path: str, remote_path: str) -> None:
+ self._vercel_bulk_upload([(host_path, remote_path)])
+
+ def _vercel_bulk_upload(self, files: list[tuple[str, str]]) -> None:
+ if not files:
+ return
+
+ payload: list[WriteFile] = [
+ {
+ "path": remote_path,
+ "content": Path(host_path).read_bytes(),
+ }
+ for host_path, remote_path in files
+ ]
+
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ _retry_vercel_call(
+ "write_files",
+ lambda: sandbox.write_files(payload),
+ attempts=_WRITE_RETRY_ATTEMPTS,
+ )
+
+ def _vercel_delete(self, remote_paths: list[str]) -> None:
+ if not remote_paths:
+ return
+
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ result = sandbox.run_command(
+ "bash",
+ ["-lc", quoted_rm_command(remote_paths)],
+ cwd=self._workspace_root,
+ )
+ if _extract_result_returncode(result) != 0:
+ raise RuntimeError(
+ f"Vercel delete failed: {_extract_result_output(result).strip()}"
+ )
+
+ def _vercel_bulk_download(self, dest_tar_path: Path) -> None:
+ remote_hermes = (
+ "/.hermes"
+ if self._remote_home == "/"
+ else f"{self._remote_home.rstrip('/')}/.hermes"
+ )
+ archive_member = remote_hermes.lstrip("/")
+ remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar"
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+
+ try:
+ result = sandbox.run_command(
+ "bash",
+ [
+ "-lc",
+ f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(archive_member)}",
+ ],
+ cwd=self._workspace_root,
+ )
+ if _extract_result_returncode(result) != 0:
+ raise RuntimeError(
+ f"Vercel bulk download failed: {_extract_result_output(result).strip()}"
+ )
+
+ sandbox.download_file(remote_tar, dest_tar_path)
+ finally:
+ try:
+ sandbox.run_command(
+ "bash",
+ ["-lc", f"rm -f {shlex.quote(remote_tar)}"],
+ cwd=self._workspace_root,
+ )
+ except Exception:
+ pass
+
+ def _before_execute(self) -> None:
+ with self._lock:
+ self._ensure_sandbox_ready()
+ if self._sync_manager is not None:
+ self._sync_manager.sync()
+
+ def _run_bash(
+ self,
+ cmd_string: str,
+ *,
+ login: bool = False,
+ timeout: int = 120,
+ stdin_data: str | None = None,
+ ):
+ """Run a bash command in the Vercel sandbox.
+
+ ``timeout`` is not forwarded to the Vercel SDK (which does not expose
+ a per-exec timeout parameter); the base class ``_wait_for_process``
+ enforces timeout by killing the sandbox via ``cancel_fn``.
+
+ ``stdin_data`` is intentionally discarded here because
+ ``_stdin_mode = "heredoc"`` causes the base class ``execute()`` to
+ embed any stdin payload into the command string before calling this
+ method.
+ """
+ del timeout
+ del stdin_data
+
+ sandbox = self._sandbox
+ if sandbox is None:
+ raise RuntimeError("Vercel sandbox is not attached")
+ workspace_root = self._workspace_root
+ lock = self._lock
+
+ def cancel() -> None:
+ with lock:
+ self._stop_sandbox(sandbox)
+
+ def exec_fn() -> tuple[str, int]:
+ result = sandbox.run_command(
+ "bash",
+ ["-lc" if login else "-c", cmd_string],
+ cwd=workspace_root,
+ )
+ return _extract_result_output(result), _extract_result_returncode(result)
+
+ return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
+
+ def cleanup(self):
+ with self._lock:
+ sandbox = self._sandbox
+ sync_manager = self._sync_manager
+ if sandbox is not None and sync_manager is not None:
+ try:
+ sync_manager.sync_back()
+ except Exception as exc:
+ logger.warning(
+ "Vercel: sync_back failed for task %s: %s",
+ self._task_id,
+ exc,
+ )
+ self._sandbox = None
+ self._sync_manager = None
+
+ if sandbox is None:
+ return
+
+ snapshot_id = self._snapshot_sandbox(sandbox)
+ # Always stop the sandbox during cleanup to avoid resource leaks,
+ # matching the Modal and Daytona patterns.
+ self._stop_sandbox(sandbox)
+ self._close_sandbox_client(sandbox)
diff --git a/tools/file_operations.py b/tools/file_operations.py
index 994723cf583..a4b1db8ef86 100644
--- a/tools/file_operations.py
+++ b/tools/file_operations.py
@@ -3,7 +3,7 @@
File Operations Module
Provides file manipulation capabilities (read, write, patch, search) that work
-across all terminal backends (local, docker, ssh, singularity, modal, daytona).
+across all terminal backends (local, docker, ssh, singularity, modal, daytona, vercel_sandbox).
The key insight is that all file operations can be expressed as shell commands,
so we wrap the terminal backend's execute() interface to provide a unified file API.
diff --git a/tools/file_tools.py b/tools/file_tools.py
index 8ea335d002d..4b41ac6aa2f 100644
--- a/tools/file_tools.py
+++ b/tools/file_tools.py
@@ -1042,12 +1042,13 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])
container_config = None
- if env_type in {"docker", "singularity", "modal", "daytona"}:
+ if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
+ "vercel_runtime": config.get("vercel_runtime", ""),
"docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
"docker_forward_env": config.get("docker_forward_env", []),
diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py
index 983af1ff5f1..15e89f92517 100644
--- a/tools/lazy_deps.py
+++ b/tools/lazy_deps.py
@@ -252,6 +252,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# ─── Terminal backends ─────────────────────────────────────────────────
"terminal.modal": ("modal==1.3.4",),
"terminal.daytona": ("daytona==0.155.0",),
+ "terminal.vercel": ("vercel==0.5.7",),
# ─── Skills ────────────────────────────────────────────────────────────
"skill.google_workspace": (
diff --git a/tools/skills_tool.py b/tools/skills_tool.py
index 9943db8160b..9566b3a3b7f 100644
--- a/tools/skills_tool.py
+++ b/tools/skills_tool.py
@@ -171,7 +171,7 @@ _PLATFORM_MAP = {
}
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_REMOTE_ENV_BACKENDS = frozenset(
- {"docker", "singularity", "modal", "ssh", "daytona"}
+ {"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox"}
)
_secret_capture_callback = None
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index e6f4bb05350..d08817c0c50 100644
--- a/tools/terminal_tool.py
+++ b/tools/terminal_tool.py
@@ -3,16 +3,18 @@
Terminal Tool Module
A terminal tool that executes commands in local, Docker, Modal, SSH,
-Singularity, and Daytona environments. Supports local execution,
-containerized backends, and cloud sandboxes, including managed Modal mode.
+Singularity, Daytona, and Vercel Sandbox environments. Supports local
+execution, containerized backends, and cloud sandboxes, including managed
+Modal mode.
-Supported environments:
+Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
+- "vercel_sandbox": Execute in Vercel Sandbox cloud sandboxes
Features:
-- Multiple execution backends (local, docker, modal)
+- Multiple execution backends (local, docker, modal, vercel_sandbox)
- Background task support
- VM/container lifecycle management
- Automatic cleanup after inactivity
@@ -119,6 +121,68 @@ DISK_USAGE_WARNING_THRESHOLD_GB = _safe_parse_import_env(
float,
"number",
)
+_VERCEL_SANDBOX_DEFAULT_CWD = "/vercel/sandbox"
+_SUPPORTED_VERCEL_RUNTIMES = ("node24", "node22", "python3.13")
+
+
+def _is_supported_vercel_runtime(runtime: str) -> bool:
+ return not runtime or runtime in _SUPPORTED_VERCEL_RUNTIMES
+
+
+def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool:
+ """Validate Vercel Sandbox terminal backend requirements."""
+ runtime = (config.get("vercel_runtime") or "").strip()
+ if not _is_supported_vercel_runtime(runtime):
+ supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
+ logger.error(
+ "Vercel Sandbox runtime %r is not supported. "
+ "Set TERMINAL_VERCEL_RUNTIME to one of: %s.",
+ runtime,
+ supported,
+ )
+ return False
+
+ disk = config.get("container_disk", 51200)
+ if disk not in {0, 51200}:
+ logger.error(
+ "Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. "
+ "Use the default shared setting (51200 MB).",
+ disk,
+ )
+ return False
+
+ if importlib.util.find_spec("vercel") is None:
+ logger.error(
+ "vercel is required for the Vercel Sandbox terminal backend: pip install vercel"
+ )
+ return False
+
+ has_oidc = bool(os.getenv("VERCEL_OIDC_TOKEN"))
+ has_token = bool(os.getenv("VERCEL_TOKEN"))
+ has_project = bool(os.getenv("VERCEL_PROJECT_ID"))
+ has_team = bool(os.getenv("VERCEL_TEAM_ID"))
+
+ if has_oidc:
+ return True
+
+ if has_token or has_project or has_team:
+ if has_token and has_project and has_team:
+ return True
+ logger.error(
+ "Vercel Sandbox backend selected with token auth, but "
+ "VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID must all "
+ "be set together. VERCEL_OIDC_TOKEN is supported for one-off "
+ "local development only."
+ )
+ return False
+
+ logger.error(
+ "Vercel Sandbox backend selected but no supported auth configuration "
+ "was found. Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID "
+ "for normal use. VERCEL_OIDC_TOKEN is supported for one-off local "
+ "development only."
+ )
+ return False
def _check_disk_usage_warning():
@@ -893,9 +957,10 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
should prepend sudo_stdin to their stdin_data and pass the merged bytes to
Popen's stdin pipe.
- Callers that cannot pipe subprocess stdin (modal, daytona) must embed
- the password in the command string themselves; see their execute()
- methods for how they handle the non-None sudo_stdin case.
+ Callers that cannot pipe subprocess stdin (modal, daytona,
+ vercel_sandbox) must embed the password in the command string
+ themselves; see their execute() methods for how they handle the
+ non-None sudo_stdin case.
If SUDO_PASSWORD is not set and an interactive UI is available
(HERMES_INTERACTIVE=1 or a registered sudo password callback):
@@ -1266,7 +1331,7 @@ def _safe_getcwd() -> str:
# cwd looks when it leaks toward a Linux container's ``-w`` flag.
_HOST_CWD_PREFIXES = ("/Users/", "/home/", "C:\\", "C:/")
-_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona"})
+_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"})
def _is_ssh_remote_tilde_cwd(backend: str, cwd: str) -> bool:
@@ -1383,12 +1448,14 @@ def _get_env_config() -> Dict[str, Any]:
docker_extra_args = []
# Default cwd: local uses the host's current directory, ssh uses the
- # remote home, and everything else starts in the backend's default
- # root-like cwd.
+ # remote home, Vercel uses its documented workspace root, and everything
+ # else starts in the backend's default root-like cwd.
if env_type == "local":
default_cwd = _safe_getcwd()
elif env_type == "ssh":
default_cwd = "~"
+ elif env_type == "vercel_sandbox":
+ default_cwd = _VERCEL_SANDBOX_DEFAULT_CWD
else:
default_cwd = "/root"
@@ -1425,6 +1492,7 @@ def _get_env_config() -> Dict[str, Any]:
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
+ "vercel_runtime": os.getenv("TERMINAL_VERCEL_RUNTIME", "").strip(),
"cwd": cwd,
"host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
@@ -1444,7 +1512,7 @@ def _get_env_config() -> Dict[str, Any]:
).lower() in {"true", "1", "yes"},
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
# Container resource config (applies to docker, singularity, modal,
- # daytona -- ignored for local/ssh)
+ # daytona, and vercel_sandbox -- ignored for local/ssh)
"container_cpu": container_cpu,
"container_memory": container_memory, # MB (default 5GB)
"container_disk": container_disk, # MB (default 50GB)
@@ -1492,8 +1560,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
Args:
env_type: One of "local", "docker", "singularity", "modal",
- "daytona", "ssh"
- image: Docker/Singularity/Modal image name (ignored for local/ssh)
+ "daytona", "vercel_sandbox", "ssh"
+ image: Docker/Singularity/Modal image name (ignored for local/ssh/vercel)
cwd: Working directory
timeout: Default command timeout
ssh_config: SSH connection config (for env_type="ssh")
@@ -1615,6 +1683,21 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
persistent_filesystem=persistent, task_id=task_id,
)
+ elif env_type == "vercel_sandbox":
+ from tools.environments.vercel_sandbox import (
+ VercelSandboxEnvironment as _VercelSandboxEnvironment,
+ )
+ return _VercelSandboxEnvironment(
+ runtime=cc.get("vercel_runtime") or None,
+ cwd=cwd,
+ timeout=timeout,
+ cpu=cpu,
+ memory=memory,
+ disk=disk,
+ persistent_filesystem=persistent,
+ task_id=task_id,
+ )
+
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
@@ -1630,7 +1713,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
else:
raise ValueError(
f"Unknown environment type: {env_type}. Use 'local', 'docker', "
- f"'singularity', 'modal', 'daytona', or 'ssh'"
+ f"'singularity', 'modal', 'daytona', 'vercel_sandbox', or 'ssh'"
)
@@ -2288,13 +2371,14 @@ def terminal_tool(
}
container_config = None
- if env_type in {"docker", "singularity", "modal", "daytona"}:
+ if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
"modal_mode": config.get("modal_mode", "auto"),
+ "vercel_runtime": config.get("vercel_runtime", ""),
"docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
"docker_forward_env": config.get("docker_forward_env", []),
@@ -3001,6 +3085,9 @@ def check_terminal_requirements() -> bool:
return True
+ elif env_type == "vercel_sandbox":
+ return _check_vercel_sandbox_requirements(config)
+
elif env_type == "daytona":
from daytona import Daytona # noqa: F401 — SDK presence check
return os.getenv("DAYTONA_API_KEY") is not None
@@ -3008,7 +3095,7 @@ def check_terminal_requirements() -> bool:
else:
logger.error(
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
- "modal, daytona, ssh.",
+ "modal, daytona, vercel_sandbox, ssh.",
env_type,
)
return False
@@ -3051,7 +3138,7 @@ if __name__ == "__main__":
print(
" TERMINAL_ENV: "
f"{os.getenv('TERMINAL_ENV', 'local')} "
- "(local/docker/singularity/modal/daytona/ssh)"
+ "(local/docker/singularity/modal/daytona/vercel_sandbox/ssh)"
)
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md
index a235f886d5b..6c1f6cafa41 100644
--- a/website/docs/developer-guide/architecture.md
+++ b/website/docs/developer-guide/architecture.md
@@ -213,7 +213,7 @@ A shared runtime resolver used by CLI, gateway, cron, ACP, and auxiliary calls.
### Tool System
-Central tool registry (`tools/registry.py`) with 70+ registered tools across ~28 toolsets. Each tool file self-registers at import time. The registry handles schema collection, dispatch, availability checking, and error wrapping. Terminal tools support 6 backends (local, Docker, SSH, Daytona, Modal, Singularity).
+Central tool registry (`tools/registry.py`) with 70+ registered tools across ~28 toolsets. Each tool file self-registers at import time. The registry handles schema collection, dispatch, availability checking, and error wrapping. Terminal tools support 7 backends (local, Docker, SSH, Daytona, Modal, Singularity, Vercel Sandbox).
→ [Tools Runtime](./tools-runtime.md)
diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md
index af00404ae1b..9cfd90097dd 100644
--- a/website/docs/developer-guide/provider-runtime.md
+++ b/website/docs/developer-guide/provider-runtime.md
@@ -42,6 +42,7 @@ That ordering matters because Hermes treats the saved model/provider choice as t
Current provider families include (see `plugins/model-providers/` for the complete bundled set):
+- AI Gateway (Vercel)
- OpenRouter
- Nous Portal
- OpenAI Codex
@@ -92,13 +93,18 @@ This resolver is the main reason Hermes can share auth/runtime logic between:
- ACP editor sessions
- auxiliary model tasks
-## OpenRouter and custom OpenAI-compatible base URLs
+## AI Gateway
-Hermes contains logic to avoid leaking the wrong API key to a custom endpoint when multiple provider keys exist (e.g. `OPENROUTER_API_KEY` and `OPENAI_API_KEY`).
+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.
+
+## 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:
@@ -109,7 +115,7 @@ Hermes 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
diff --git a/website/docs/developer-guide/tools-runtime.md b/website/docs/developer-guide/tools-runtime.md
index 2612ab2db1c..effd5f9b999 100644
--- a/website/docs/developer-guide/tools-runtime.md
+++ b/website/docs/developer-guide/tools-runtime.md
@@ -213,6 +213,7 @@ The terminal system supports multiple backends:
- singularity
- modal
- daytona
+- vercel_sandbox
It also supports:
diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md
index 9a9a206cdc6..58161582c4d 100644
--- a/website/docs/getting-started/quickstart.md
+++ b/website/docs/getting-started/quickstart.md
@@ -143,6 +143,7 @@ Good defaults:
| **NVIDIA NIM** | Nemotron models via build.nvidia.com or local NIM | Set `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) |
| **GitHub Copilot** | GitHub Copilot subscription (GPT-5.x, Claude, Gemini, etc.) | OAuth via `hermes model`, or `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` |
| **GitHub Copilot ACP** | Copilot ACP agent backend (spawns local `copilot` CLI) | `hermes model` (requires `copilot` CLI + `copilot login`) |
+| **Vercel AI Gateway** | Vercel AI Gateway routing | Set `AI_GATEWAY_API_KEY` |
| **Custom Endpoint** | VLLM, SGLang, Ollama, or any OpenAI-compatible API | Set base URL + API key |
For most first-time users: choose a provider, accept the defaults unless you know why you're changing them. The full provider catalog with env vars and setup steps lives on the [Providers](../integrations/providers.md) page.
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index 02f4ad8d593..169c6ff6248 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -22,6 +22,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
| **Fireworks AI** | `FIREWORKS_API_KEY` in `~/.hermes/.env` (provider: `fireworks`; aliases: `fireworks-ai`, `fw`) |
| **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) |
+| **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`) |
| **Kimi / Moonshot (China)** | `KIMI_CN_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding-cn`; aliases: `kimi-cn`, `moonshot-cn`) |
@@ -1497,7 +1498,7 @@ fallback_model:
When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session.
-Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
+Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
:::tip
Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/user-guide/features/fallback-providers).
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 8c1ceb11227..59d280e26c8 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -112,7 +112,7 @@ Common options:
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
| `-m`, `--model ` | Override the model for this run. |
| `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. |
-| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `upstage` (alias `solar`), `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
+| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `upstage` (alias `solar`), `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
| `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). |
| `-v`, `--verbose` | Verbose output. |
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 869c3065841..39f6a057ebf 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -19,6 +19,8 @@ Hermes reads environment variables from the process environment and, for user-ma
| `HERMES_OPENROUTER_CACHE_TTL` | Cache TTL in seconds (1-86400). Overrides `openrouter.response_cache_ttl` in config.yaml. |
| `NOUS_BASE_URL` | Override Nous Portal base URL (rarely needed; development/testing only) |
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference endpoint directly |
+| `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.) |
| `LM_API_KEY` | API key for LM Studio (`lmstudio` provider). Often a placeholder for local servers |
@@ -181,6 +183,10 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `BRV_API_KEY` | ByteRover API key (optional, for cloud sync — local-first by default) ([app.byterover.dev](https://app.byterover.dev)) |
| `SUPERMEMORY_API_KEY` | Semantic long-term memory with profile recall and session ingest ([supermemory.ai](https://supermemory.ai)) |
| `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) |
+| `VERCEL_TOKEN` | Vercel Sandbox access token ([vercel.com](https://vercel.com/)) |
+| `VERCEL_PROJECT_ID` | Vercel project ID (required with `VERCEL_TOKEN`) |
+| `VERCEL_TEAM_ID` | Vercel team ID (required with `VERCEL_TOKEN`) |
+| `VERCEL_OIDC_TOKEN` | Vercel short-lived OIDC token (development-only alternative) |
### Skill API Keys
@@ -224,7 +230,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway)
| Variable | Description |
|----------|-------------|
-| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` |
+| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona`, `vercel_sandbox` |
| `HERMES_DOCKER_BINARY` | Override the container binary Hermes shells out to (e.g. `podman`, `/usr/local/bin/docker`). When unset, Hermes auto-discovers `docker` or `podman` on `PATH`. Needed when both are installed and you want the non-default, or when the binary lives outside `PATH`. |
| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) |
| `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. |
@@ -235,6 +241,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway)
| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path |
| `TERMINAL_MODAL_IMAGE` | Modal container image |
| `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image |
+| `TERMINAL_VERCEL_RUNTIME` | Vercel Sandbox runtime (`node24`, `node22`, `python3.13`) |
| `TERMINAL_TIMEOUT` | Command timeout in seconds |
| `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |
| `TERMINAL_CWD` | Deprecated direct override for gateway/cron terminal sessions. Prefer `terminal.cwd` in `config.yaml`; CLI still uses the launch directory. |
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index 749eec22253..47d63fbe4c8 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -116,11 +116,11 @@ Before that stash step, Hermes also restores tracked `package-lock.json` diffs l
## Terminal Backend Configuration
-Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, or a Singularity/Apptainer container.
+Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, or a Singularity/Apptainer container.
```yaml
terminal:
- backend: local # local | docker | ssh | modal | daytona | singularity
+ backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity
cwd: "." # Gateway/cron working directory (CLI always uses launch dir)
timeout: 180 # Per-command timeout in seconds
home_mode: auto # auto | real | profile — subprocess HOME policy
@@ -130,7 +130,7 @@ terminal:
daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend
```
-For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
+For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
### Backend Overview
@@ -141,6 +141,7 @@ For cloud sandboxes such as Modal and Daytona, `container_persistent: true` mean
| **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware |
| **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals |
| **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments |
+| **vercel_sandbox** | Vercel Sandbox | Full (cloud microVM) | Cloud execution with snapshot-backed filesystem persistence |
| **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines |
### Local Backend
@@ -378,6 +379,49 @@ terminal:
**Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning.
+### Vercel Sandbox Backend
+
+Runs commands in a [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) cloud microVM. Hermes uses the normal terminal and file tool surfaces; there are no Vercel-specific model-facing tools.
+
+```yaml
+terminal:
+ backend: vercel_sandbox
+ vercel_runtime: node24 # node24 | node22 | python3.13
+ cwd: /vercel/sandbox # default workspace root
+ container_persistent: true # Snapshot/restore filesystem
+ container_disk: 51200 # Shared default only; custom disk is unsupported
+```
+
+**Required install:** Install the optional SDK extra:
+
+```bash
+pip install 'hermes-agent[vercel]'
+```
+
+**Required authentication:** Configure access-token auth with all three of `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID`. This is the supported setup for deployments and normal long-running Hermes processes on Render, Railway, Docker, and similar hosts.
+
+For one-off local development, Hermes also accepts short-lived Vercel OIDC tokens:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token )" hermes chat
+```
+
+From a linked Vercel project directory, you can omit the project name:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat
+```
+
+OIDC tokens are short-lived and should not be used as the documented deployment path.
+
+**Runtime:** `terminal.vercel_runtime` supports `node24`, `node22`, and `python3.13`. If unset, Hermes defaults to `node24`.
+
+**Persistence:** When `container_persistent: true`, Hermes snapshots the sandbox filesystem during cleanup and restores a later sandbox for the same task from that snapshot. Snapshot contents can include Hermes-synced credentials, skills, and cache files that were copied into the sandbox. This preserves filesystem state only; it does not preserve live sandbox identity, PID space, shell state, or running background processes.
+
+**Background commands:** `terminal(background=true)` uses Hermes' generic non-local background process flow. You can spawn, poll, wait, view logs, and kill processes through the normal process tool while the sandbox is alive. Hermes does not provide native Vercel detached-process recovery after cleanup or restart.
+
+**Disk sizing:** Vercel Sandbox does not currently support Hermes' `container_disk` resource knob. Leave `container_disk` unset or at the shared default `51200`; non-default values fail diagnostics and backend creation instead of being silently ignored.
+
### Singularity/Apptainer Backend
Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available.
@@ -1051,7 +1095,7 @@ auxiliary:
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
-Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `providers:` dict (e.g. `provider: "beans"`).
+Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `providers:` dict (e.g. `provider: "beans"`).
:::tip MiniMax OAuth
`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md).
diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md
index 2c0248b06fb..3d2dde7820b 100644
--- a/website/docs/user-guide/features/fallback-providers.md
+++ b/website/docs/user-guide/features/fallback-providers.md
@@ -47,6 +47,7 @@ Each entry requires both `provider` and `model`. Entries missing either field ar
| Provider | Value | Requirements |
|----------|-------|-------------|
+| AI Gateway | `ai-gateway` | `AI_GATEWAY_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Nous Portal | `nous` | `hermes setup --portal` (fresh) or `hermes auth add nous` (OAuth) |
| OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) |
diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md
index b5cb84a40bc..3fa4bdea199 100644
--- a/website/docs/user-guide/features/tools.md
+++ b/website/docs/user-guide/features/tools.md
@@ -65,13 +65,14 @@ The terminal tool can execute commands in different environments:
| `singularity` | HPC containers | Cluster computing, rootless |
| `modal` | Cloud execution | Serverless, scale |
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
+| `vercel_sandbox` | Vercel Sandbox cloud microVM | Cloud execution with snapshot-backed filesystem persistence |
### Configuration
```yaml
# In ~/.hermes/config.yaml
terminal:
- backend: local # or: docker, ssh, singularity, modal, daytona
+ backend: local # or: docker, ssh, singularity, modal, daytona, vercel_sandbox
cwd: "." # Working directory
timeout: 180 # Command timeout in seconds
```
@@ -122,13 +123,41 @@ modal setup
hermes config set terminal.backend modal
```
+### Vercel Sandbox
+
+```bash
+pip install 'hermes-agent[vercel]'
+hermes config set terminal.backend vercel_sandbox
+hermes config set terminal.vercel_runtime node24
+```
+
+Authenticate with all three of `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID`. This access-token setup is the supported path for deployments and normal long-running Hermes processes on Render, Railway, Docker, and similar hosts. Supported runtimes are `node24`, `node22`, and `python3.13`; Hermes defaults to `/vercel/sandbox` as the remote workspace root.
+
+For one-off local development, Hermes also accepts short-lived Vercel OIDC tokens:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token )" hermes chat
+```
+
+From a linked Vercel project directory:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat
+```
+
+With `container_persistent: true`, Hermes uses Vercel snapshots to preserve filesystem state across sandbox recreation for the same task. This can include Hermes-synced credentials, skills, and cache files inside the sandbox. Snapshots do not preserve live processes, PID space, or the same live sandbox identity.
+
+Background terminal commands use Hermes' generic non-local process flow: spawn, poll, wait, log, and kill work through the normal process tool while the sandbox is alive, but Hermes does not provide native Vercel detached-process recovery after cleanup or restart.
+
+Leave `container_disk` unset or at the shared default `51200`; custom disk sizing is unsupported for Vercel Sandbox and will fail diagnostics/backend creation.
+
### Container Resources
Configure CPU, memory, disk, and persistence for all container backends:
```yaml
terminal:
- backend: docker # or singularity, modal, daytona
+ backend: docker # or singularity, modal, daytona, vercel_sandbox
container_cpu: 1 # CPU cores (default: 1)
container_memory: 5120 # Memory in MB (default: 5GB)
container_disk: 51200 # Disk in MB (default: 50GB)
diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md
index 3e920b953a0..59fde9033a3 100644
--- a/website/docs/user-guide/security.md
+++ b/website/docs/user-guide/security.md
@@ -188,7 +188,7 @@ The following patterns trigger approval prompts (defined in `tools/approval.py`)
| `podman --remote`/`-r`/`--url`/`--connection`/`--identity`, `CONTAINER_HOST=` | Podman remote daemon redirect |
:::info
-**Container bypass**: When running in `docker`, `singularity`, `modal`, or `daytona` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host.
+**Container bypass**: When running in `docker`, `singularity`, `modal`, `daytona`, or `vercel_sandbox` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host.
:::
### Approval Flow (CLI)
@@ -483,7 +483,7 @@ terminal:
- **Ephemeral mode** (`container_persistent: false`): Uses tmpfs for workspace — everything is lost on cleanup
:::tip
-For production gateway deployments, use `docker`, `modal`, or `daytona` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely.
+For production gateway deployments, use `docker`, `modal`, `daytona`, or `vercel_sandbox` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely.
:::
:::warning
@@ -500,6 +500,7 @@ If you add names to `terminal.docker_forward_env`, those variables are intention
| **singularity** | Container | ❌ Skipped | HPC environments |
| **modal** | Cloud sandbox | ❌ Skipped | Scalable cloud isolation |
| **daytona** | Cloud sandbox | ❌ Skipped | Persistent cloud workspaces |
+| **vercel_sandbox** | Cloud microVM | ❌ Skipped | Cloud execution with snapshot persistence |
## Environment Variable Passthrough {#environment-variable-passthrough}
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md
index 5b3ba00aa3b..0c66f496fae 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/architecture.md
@@ -211,7 +211,7 @@ CLI、gateway、cron、ACP 及辅助调用共用的运行时解析器。将 `(pr
### 工具系统
-中央工具注册表(`tools/registry.py`),包含约 28 个 toolset 中的 70+ 个已注册工具。每个工具文件在导入时自行注册。注册表负责 schema 收集、分发、可用性检查和错误包装。终端工具支持 6 种后端(local、Docker、SSH、Daytona、Modal、Singularity)。
+中央工具注册表(`tools/registry.py`),包含约 28 个 toolset 中的 70+ 个已注册工具。每个工具文件在导入时自行注册。注册表负责 schema 收集、分发、可用性检查和错误包装。终端工具支持 7 种后端(local、Docker、SSH、Daytona、Modal、Singularity、Vercel Sandbox)。
→ [工具运行时](./tools-runtime.md)
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md
index 181c996c9e8..175244cd782 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md
@@ -42,6 +42,7 @@ Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景:
当前 provider 系列包括(完整内置集合见 `plugins/model-providers/`):
+- AI Gateway(Vercel)
- OpenRouter
- Nous Portal
- OpenAI Codex
@@ -92,13 +93,18 @@ Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景:
- ACP 编辑器会话
- 辅助模型任务
-## OpenRouter 与自定义 OpenAI 兼容 base URL
+## AI Gateway
-Hermes 包含相关逻辑,以避免在存在多个 provider 密钥时(例如同时存在 `OPENROUTER_API_KEY` 和 `OPENAI_API_KEY`)将错误的 API key 泄露给自定义端点。
+在 `~/.hermes/.env` 中设置 `AI_GATEWAY_API_KEY`,并使用 `--provider ai-gateway` 运行。Hermes 从 gateway 的 `/models` 端点获取可用模型,筛选出支持工具调用的语言模型。
+
+## OpenRouter、AI Gateway 与自定义 OpenAI 兼容 base URL
+
+Hermes 包含相关逻辑,以避免在存在多个 provider 密钥时(例如同时存在 `OPENROUTER_API_KEY`、`AI_GATEWAY_API_KEY` 和 `OPENAI_API_KEY`)将错误的 API key 泄露给自定义端点。
每个 provider 的 API key 仅作用于其自身的 base URL:
- `OPENROUTER_API_KEY` 仅发送至 `openrouter.ai` 端点
+- `AI_GATEWAY_API_KEY` 仅发送至 `ai-gateway.vercel.sh` 端点
- `OPENAI_API_KEY` 用于自定义端点及作为回退
Hermes 还区分以下两种情况:
@@ -109,7 +115,7 @@ Hermes 还区分以下两种情况:
这种区分对以下场景尤为重要:
- 本地模型服务器
-- 非 OpenRouter 的 OpenAI 兼容 API
+- 非 OpenRouter/非 AI Gateway 的 OpenAI 兼容 API
- 无需重新运行 setup 即可切换 provider
- 通过 config 保存的自定义端点,即使当前 shell 中未导出 `OPENAI_BASE_URL` 也应正常工作
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/tools-runtime.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/tools-runtime.md
index 631bc73374a..f167dc44860 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/tools-runtime.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/tools-runtime.md
@@ -213,6 +213,7 @@ registry.dispatch(name, args, **kwargs)
- singularity
- modal
- daytona
+- vercel_sandbox
还支持:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
index 161526af6d6..b7734879300 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
@@ -123,6 +123,7 @@ hermes setup --portal
| **NVIDIA NIM** | 通过 build.nvidia.com 或本地 NIM 使用 Nemotron 模型 | 设置 `NVIDIA_API_KEY`(可选:`NVIDIA_BASE_URL`) |
| **GitHub Copilot** | GitHub Copilot 订阅(GPT-5.x、Claude、Gemini 等) | 通过 `hermes model` 进行 OAuth,或设置 `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` |
| **GitHub Copilot ACP** | Copilot ACP agent 后端(在本地启动 `copilot` CLI) | `hermes model`(需要 `copilot` CLI + `copilot login`) |
+| **Vercel AI Gateway** | Vercel AI Gateway 路由 | 设置 `AI_GATEWAY_API_KEY` |
| **Custom Endpoint** | VLLM、SGLang、Ollama 或任何兼容 OpenAI 的 API | 设置 base URL + API key |
对于大多数初次使用的用户:选择一个 provider,接受默认值(除非你明确知道为何要修改)。完整的 provider 目录及环境变量和配置步骤请参阅 [Providers](../integrations/providers.md) 页面。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md
index 71a1f5c9b69..e64be9ca2b3 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md
@@ -21,6 +21,7 @@ sidebar_position: 1
| **Anthropic** | `hermes model`(Claude Max + 额外用量积分,通过 OAuth;也支持 Anthropic API key 或手动 setup-token——见下方说明) |
| **OpenRouter** | `~/.hermes/.env` 中的 `OPENROUTER_API_KEY` |
| **NovitaAI** | `~/.hermes/.env` 中的 `NOVITA_API_KEY`(provider: `novita`,200+ 模型,Model API、Agent Sandbox、GPU Cloud) |
+| **AI Gateway** | `~/.hermes/.env` 中的 `AI_GATEWAY_API_KEY`(provider: `ai-gateway`) |
| **z.ai / GLM** | `~/.hermes/.env` 中的 `GLM_API_KEY`(provider: `zai`) |
| **Kimi / Moonshot** | `~/.hermes/.env` 中的 `KIMI_API_KEY`(provider: `kimi-coding`) |
| **Kimi / Moonshot(中国)** | `~/.hermes/.env` 中的 `KIMI_CN_API_KEY`(provider: `kimi-coding-cn`;别名:`kimi-cn`、`moonshot-cn`) |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
index cd0353bb41a..7f51fabb3b3 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
@@ -95,7 +95,7 @@ hermes chat [options]
| `-q`, `--query "..."` | 单次非交互式 prompt。 |
| `-m`, `--model ` | 覆盖本次运行的模型。 |
| `-t`, `--toolsets ` | 启用逗号分隔的 toolset 集合。 |
-| `--provider ` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
+| `--provider ` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`ai-gateway`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
| `-s`, `--skills ` | 为会话预加载一个或多个 skill(可重复或逗号分隔)。 |
| `-v`, `--verbose` | 详细输出。 |
| `-Q`, `--quiet` | 程序化模式:抑制横幅/spinner/工具预览。 |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md
index 6060c497fbb..4b7f8fb1eb9 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md
@@ -18,6 +18,8 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
| `HERMES_OPENROUTER_CACHE_TTL` | 缓存 TTL(秒,1-86400)。覆盖 config.yaml 中的 `openrouter.response_cache_ttl`。 |
| `NOUS_BASE_URL` | 覆盖 Nous Portal base URL(极少使用;仅用于开发/测试) |
| `NOUS_INFERENCE_BASE_URL` | 直接覆盖 Nous 推理端点 |
+| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API 密钥([ai-gateway.vercel.sh](https://ai-gateway.vercel.sh)) |
+| `AI_GATEWAY_BASE_URL` | 覆盖 AI Gateway base URL(默认:`https://ai-gateway.vercel.sh/v1`) |
| `OPENAI_API_KEY` | 自定义 OpenAI 兼容端点的 API 密钥(与 `OPENAI_BASE_URL` 配合使用) |
| `OPENAI_BASE_URL` | 自定义端点的 base URL(VLLM、SGLang 等) |
| `COPILOT_GITHUB_TOKEN` | 用于 Copilot API 的 GitHub token——最高优先级(OAuth `gho_*` 或细粒度 PAT `github_pat_*`;经典 PAT `ghp_*` **不支持**) |
@@ -151,6 +153,10 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
| `HINDSIGHT_TIMEOUT` | Hindsight 内存提供商 API 调用超时(秒,默认:`60`)。如果 Hindsight 实例在 `/sync` 或 `on_session_switch` 期间响应缓慢并出现超时,请增大此值,并检查 `errors.log`。 |
| `SUPERMEMORY_API_KEY` | 支持 profile 召回和会话摄取的语义长期记忆([supermemory.ai](https://supermemory.ai)) |
| `DAYTONA_API_KEY` | Daytona 云沙箱([daytona.io](https://daytona.io/)) |
+| `VERCEL_TOKEN` | Vercel Sandbox 访问 token([vercel.com](https://vercel.com/)) |
+| `VERCEL_PROJECT_ID` | Vercel 项目 ID(与 `VERCEL_TOKEN` 配合使用) |
+| `VERCEL_TEAM_ID` | Vercel 团队 ID(与 `VERCEL_TOKEN` 配合使用) |
+| `VERCEL_OIDC_TOKEN` | Vercel 短期 OIDC token(仅用于开发的替代方案) |
### Langfuse 可观测性
@@ -183,7 +189,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
| 变量 | 描述 |
|----------|-------------|
-| `TERMINAL_ENV` | 后端:`local`、`docker`、`ssh`、`singularity`、`modal`、`daytona` |
+| `TERMINAL_ENV` | 后端:`local`、`docker`、`ssh`、`singularity`、`modal`、`daytona`、`vercel_sandbox` |
| `HERMES_DOCKER_BINARY` | 覆盖 Hermes 调用的容器二进制(例如 `podman`、`/usr/local/bin/docker`)。未设置时,Hermes 自动在 `PATH` 上发现 `docker` 或 `podman`。当两者都已安装且需要非默认选项,或二进制不在 `PATH` 中时使用。 |
| `TERMINAL_DOCKER_IMAGE` | Docker 镜像(默认:`nikolaik/python-nodejs:python3.11-nodejs20`) |
| `TERMINAL_DOCKER_FORWARD_ENV` | 显式转发到 Docker 终端会话的环境变量名 JSON 数组。注意:技能声明的 `required_environment_variables` 会自动转发——仅对未被任何技能声明的变量使用此项。 |
@@ -192,6 +198,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
| `TERMINAL_SINGULARITY_IMAGE` | Singularity 镜像或 `.sif` 路径 |
| `TERMINAL_MODAL_IMAGE` | Modal 容器镜像 |
| `TERMINAL_DAYTONA_IMAGE` | Daytona 沙箱镜像 |
+| `TERMINAL_VERCEL_RUNTIME` | Vercel Sandbox 运行时(`node24`、`node22`、`python3.13`) |
| `TERMINAL_TIMEOUT` | 命令超时(秒) |
| `TERMINAL_LIFETIME_SECONDS` | 终端会话最大生命周期(秒) |
| `TERMINAL_CWD` | 终端会话的工作目录(仅 gateway/cron;CLI 使用启动目录) |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
index b782f9f71bb..5537da40343 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
@@ -83,11 +83,11 @@ delegation:
## 终端后端配置
-Hermes 支持六种终端后端。每种后端决定 agent 的 shell 命令实际在哪里执行 —— 本地机器、Docker 容器、通过 SSH 的远程服务器、Modal 云沙箱(直接或通过 Nous 托管的 gateway)、Daytona 工作区,或 Singularity/Apptainer 容器。
+Hermes 支持七种终端后端。每种后端决定 agent 的 shell 命令实际在哪里执行 —— 本地机器、Docker 容器、通过 SSH 的远程服务器、Modal 云沙箱(直接或通过 Nous 托管的 gateway)、Daytona 工作区、Vercel Sandbox,或 Singularity/Apptainer 容器。
```yaml
terminal:
- backend: local # local | docker | ssh | modal | daytona | singularity
+ backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity
cwd: "." # Gateway/cron 工作目录(CLI 始终使用启动目录)
timeout: 180 # 每条命令的超时时间(秒)
env_passthrough: [] # 转发到沙箱执行的环境变量名(terminal + execute_code)
@@ -96,7 +96,7 @@ terminal:
daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Daytona 后端的容器镜像
```
-对于 Modal 和 Daytona 等云沙箱,`container_persistent: true` 表示 Hermes 将尝试在沙箱重建后保留文件系统状态。这并不保证相同的活跃沙箱、PID 空间或后台进程之后仍在运行。
+对于 Modal、Daytona 和 Vercel Sandbox 等云沙箱,`container_persistent: true` 表示 Hermes 将尝试在沙箱重建后保留文件系统状态。这并不保证相同的活跃沙箱、PID 空间或后台进程之后仍在运行。
### 后端概览
@@ -107,6 +107,7 @@ terminal:
| **ssh** | 通过 SSH 的远程服务器 | 网络边界 | 远程开发、强大硬件 |
| **modal** | Modal 云沙箱 | 完全(云 VM) | 临时云计算、评估 |
| **daytona** | Daytona 工作区 | 完全(云容器) | 托管云开发环境 |
+| **vercel_sandbox** | Vercel Sandbox | 完全(云 microVM) | 带快照文件系统持久化的云执行 |
| **singularity** | Singularity/Apptainer 容器 | 命名空间(--containall) | HPC 集群、共享机器 |
### Local 后端
@@ -231,6 +232,49 @@ terminal:
**磁盘限制:** Daytona 强制执行 10 GiB 最大值。超过此值的请求将被截断并发出警告。
+### Vercel Sandbox 后端
+
+在 [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) 云 microVM 中运行命令。Hermes 使用普通的终端和文件工具接口;没有 Vercel 特定的面向模型的工具。
+
+```yaml
+terminal:
+ backend: vercel_sandbox
+ vercel_runtime: node24 # node24 | node22 | python3.13
+ cwd: /vercel/sandbox # 默认工作区根目录
+ container_persistent: true # 快照/恢复文件系统
+ container_disk: 51200 # 仅共享默认值;不支持自定义磁盘
+```
+
+**必需安装:** 安装可选 SDK 扩展:
+
+```bash
+pip install 'hermes-agent[vercel]'
+```
+
+**必需认证:** 使用 `VERCEL_TOKEN`、`VERCEL_PROJECT_ID` 和 `VERCEL_TEAM_ID` 三者全部配置访问令牌认证。这是在 Render、Railway、Docker 及类似宿主上部署和正常长期运行 Hermes 进程的受支持设置。
+
+对于一次性本地开发,Hermes 也接受短期 Vercel OIDC token:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token )" hermes chat
+```
+
+在已链接的 Vercel 项目目录中,可以省略项目名称:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat
+```
+
+OIDC token 是短期的,不应作为文档化的部署路径使用。
+
+**运行时:** `terminal.vercel_runtime` 支持 `node24`、`node22` 和 `python3.13`。未设置时,Hermes 默认使用 `node24`。
+
+**持久化:** 当 `container_persistent: true` 时,Hermes 在清理期间对沙箱文件系统进行快照,并从该快照为同一任务恢复后续沙箱。快照内容可以包括复制到沙箱中的 Hermes 同步凭据、技能和缓存文件。这仅保留文件系统状态;不保留活跃沙箱身份、PID 空间、shell 状态或正在运行的后台进程。
+
+**后台命令:** `terminal(background=true)` 使用 Hermes 的通用非本地后台进程流程。您可以在沙箱存活期间通过普通进程工具生成、轮询、等待、查看日志和终止进程。Hermes 不提供清理或重启后的原生 Vercel 分离进程恢复。
+
+**磁盘大小:** Vercel Sandbox 目前不支持 Hermes 的 `container_disk` 资源旋钮。将 `container_disk` 保持未设置或使用共享默认值 `51200`;非默认值会导致诊断和后端创建失败,而不是被静默忽略。
+
### Singularity/Apptainer 后端
在 [Singularity/Apptainer](https://apptainer.org) 容器中运行命令。专为 Docker 不可用的 HPC 集群和共享机器设计。
@@ -767,7 +811,7 @@ Hermes 中的每个模型槽位 —— 辅助任务、压缩、回退 —— 使
当设置 `base_url` 时,Hermes 忽略 provider 并直接调用该端点(使用 `api_key` 或 `OPENAI_API_KEY` 进行认证)。当仅设置 `provider` 时,Hermes 使用该 provider 的内置认证和基础 URL。
-辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。
+辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`ai-gateway`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。
:::tip MiniMax OAuth
`minimax-oauth` 通过浏览器 OAuth 登录(无需 API 密钥)。运行 `hermes model` 并选择 **MiniMax (OAuth)** 进行认证。辅助任务自动使用 `MiniMax-M2.7-highspeed`。参阅 [MiniMax OAuth 指南](../guides/minimax-oauth.md)。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md
index 383be7370c3..ae106ffcd41 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md
@@ -47,6 +47,7 @@ fallback_model:
| 提供商 | 值 | 要求 |
|----------|-------|-------------|
+| AI Gateway | `ai-gateway` | `AI_GATEWAY_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Nous Portal | `nous` | `hermes setup --portal`(全新安装)或 `hermes auth add nous`(OAuth) |
| OpenAI Codex | `openai-codex` | `hermes model`(ChatGPT OAuth) |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md
index 6c90424e9c4..6646c13ebe3 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tools.md
@@ -65,13 +65,14 @@ hermes tools
| `singularity` | HPC 容器 | 集群计算、无 root 权限 |
| `modal` | 云端执行 | 无服务器、弹性扩展 |
| `daytona` | 云端沙箱工作区 | 持久化远程开发环境 |
+| `vercel_sandbox` | Vercel Sandbox 云微虚拟机 | 带快照文件系统持久化的云端执行 |
### 配置
```yaml
# 在 ~/.hermes/config.yaml 中
terminal:
- backend: local # 或:docker, ssh, singularity, modal, daytona
+ backend: local # 或:docker, ssh, singularity, modal, daytona, vercel_sandbox
cwd: "." # 工作目录
timeout: 180 # 命令超时时间(秒)
```
@@ -122,13 +123,41 @@ modal setup
hermes config set terminal.backend modal
```
+### Vercel Sandbox
+
+```bash
+pip install 'hermes-agent[vercel]'
+hermes config set terminal.backend vercel_sandbox
+hermes config set terminal.vercel_runtime node24
+```
+
+需同时配置 `VERCEL_TOKEN`、`VERCEL_PROJECT_ID` 和 `VERCEL_TEAM_ID` 三个凭据。此访问令牌配置方式是在 Render、Railway、Docker 及类似平台上进行部署和正常长期运行 Hermes 进程的推荐路径。支持的运行时为 `node24`、`node22` 和 `python3.13`;Hermes 默认使用 `/vercel/sandbox` 作为远程工作区根目录。
+
+对于本地一次性开发,Hermes 也接受短期 Vercel OIDC token:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token )" hermes chat
+```
+
+在已关联的 Vercel 项目目录中:
+
+```bash
+VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat
+```
+
+启用 `container_persistent: true` 后,Hermes 使用 Vercel 快照在同一任务的沙箱重建时保留文件系统状态,其中可包含沙箱内 Hermes 同步的凭据、技能和缓存文件。快照不保留活跃进程、PID 空间或相同的活跃沙箱标识。
+
+后台终端命令使用 Hermes 通用的非本地进程流程:在沙箱存活期间,spawn、poll、wait、log 和 kill 均通过标准 process 工具运行,但 Hermes 不提供清理或重启后的原生 Vercel 后台进程恢复能力。
+
+`container_disk` 保持未设置或使用共享默认值 `51200`;Vercel Sandbox 不支持自定义磁盘大小,设置后将导致诊断/后端创建失败。
+
### 容器资源
为所有容器后端配置 CPU、内存、磁盘和持久化:
```yaml
terminal:
- backend: docker # 或 singularity, modal, daytona
+ backend: docker # 或 singularity, modal, daytona, vercel_sandbox
container_cpu: 1 # CPU 核心数(默认:1)
container_memory: 5120 # 内存(MB,默认:5GB)
container_disk: 51200 # 磁盘(MB,默认:50GB)
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md
index 696e17e8c41..b5d7b36a792 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md
@@ -144,7 +144,7 @@ approvals:
| `gateway run` 配合 `&`/`disown`/`nohup`/`setsid` | 防止在服务管理器外启动 gateway |
:::info
-**容器绕过**:在 `docker`、`singularity`、`modal` 或 `daytona` 后端运行时,危险命令检查会被**跳过**,因为容器本身就是安全边界。容器内的破坏性命令不会危害宿主机。
+**容器绕过**:在 `docker`、`singularity`、`modal`、`daytona` 或 `vercel_sandbox` 后端运行时,危险命令检查会被**跳过**,因为容器本身就是安全边界。容器内的破坏性命令不会危害宿主机。
:::
### 审批流程(CLI)
@@ -340,7 +340,7 @@ terminal:
- **临时模式**(`container_persistent: false`):工作区使用 tmpfs——清理后所有内容丢失
:::tip
-对于生产 gateway 部署,使用 `docker`、`modal` 或 `daytona` 后端,将 Agent 命令与宿主机系统隔离。这样可以完全消除危险命令审批的需要。
+对于生产 gateway 部署,使用 `docker`、`modal`、`daytona` 或 `vercel_sandbox` 后端,将 Agent 命令与宿主机系统隔离。这样可以完全消除危险命令审批的需要。
:::
:::warning
@@ -357,6 +357,7 @@ terminal:
| **singularity** | 容器 | ❌ 跳过 | HPC 环境 |
| **modal** | 云沙箱 | ❌ 跳过 | 可扩展的云隔离 |
| **daytona** | 云沙箱 | ❌ 跳过 | 持久化云工作区 |
+| **vercel_sandbox** | 云微虚拟机 | ❌ 跳过 | 带快照持久化的云执行 |
## 环境变量透传 {#environment-variable-passthrough}
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
index c87ef643a7f..986eb015d48 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md
@@ -400,6 +400,7 @@ Profiles 使用 `~/.hermes/profiles//`,布局相同。
| Alibaba / DashScope | API key | `DASHSCOPE_API_KEY` |
| Xiaomi MiMo | API key | `XIAOMI_API_KEY` |
| Kilo Code | API key | `KILOCODE_API_KEY` |
+| AI Gateway (Vercel) | API key | `AI_GATEWAY_API_KEY` |
| OpenCode Zen | API key | `OPENCODE_ZEN_API_KEY` |
| OpenCode Go | API key | `OPENCODE_GO_API_KEY` |
| Qwen OAuth | OAuth | `hermes auth add qwen-oauth` |
@@ -920,7 +921,7 @@ monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic")
关于宿主 OS、用户 home、cwd、终端后端和 shell(Windows 上的 bash vs PowerShell)的事实性指导从 `agent/prompt_builder.py::build_environment_hints()` 输出。WSL 提示和每个后端的探测逻辑也在此处。约定:
- **本地终端后端** → 输出宿主信息(OS、`$HOME`、cwd)+ Windows 特有说明(hostname ≠ username,`terminal` 使用 bash 而非 PowerShell)。
-- **远程终端后端**(`_REMOTE_TERMINAL_BACKENDS` 中的任何内容:`docker, singularity, modal, daytona, ssh, managed_modal`)→ **完全抑制**宿主信息,仅描述后端。通过 `tools.environments.get_environment(...).execute(...)` 在后端内运行实时 `uname`/`whoami`/`pwd` 探测,每进程缓存在 `_BACKEND_PROBE_CACHE` 中,探测超时时使用静态回退。
+- **远程终端后端**(`_REMOTE_TERMINAL_BACKENDS` 中的任何内容:`docker, singularity, modal, daytona, ssh, vercel_sandbox, managed_modal`)→ **完全抑制**宿主信息,仅描述后端。通过 `tools.environments.get_environment(...).execute(...)` 在后端内运行实时 `uname`/`whoami`/`pwd` 探测,每进程缓存在 `_BACKEND_PROBE_CACHE` 中,探测超时时使用静态回退。
- **prompt 编写的关键事实:** 当 `TERMINAL_ENV != "local"` 时,*每个*文件工具(`read_file`、`write_file`、`patch`、`search_files`)都在后端容器内运行,而非宿主上。在这种情况下,系统 prompt 绝不能描述宿主——agent 无法访问它。
完整设计说明、确切输出字符串和测试陷阱:`references/prompt-builder-environment-hints.md`。