mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-06 07:51:53 +00:00
remove Vercel AI Gateway and Vercel Sandbox (#33067)
* remove Vercel AI Gateway provider and Vercel Sandbox terminal backend Both Vercel-hosted integrations are removed end-to-end. Users on the AI Gateway should switch to OpenRouter or one of the other aggregators (Nous Portal, Kilo Code). Users on the Vercel Sandbox backend should switch to Docker, Modal, Daytona, or SSH. What's removed: - `plugins/model-providers/ai-gateway/` provider plugin - `hermes_cli/vercel_auth.py` Vercel-Sandbox auth helper - `tools/environments/vercel_sandbox.py` terminal backend - `ai-gateway` provider wiring across auth, doctor, setup, models, config, status, providers, main, web_server, model_normalize, dump - `vercel_sandbox` backend wiring across terminal_tool, file_tools, code_execution_tool, file_operations, approval, skills_tool, environments/local, credential_files, lazy_deps, prompt_builder, cli, gateway/run - `AI_GATEWAY_BASE_URL` constant, `_AI_GATEWAY_HEADERS` auxiliary-client header set, run_agent base-URL header/reasoning special-cases - `[vercel]` pyproject extra and `vercel`/`vercel-workers` from uv.lock - env vars: `AI_GATEWAY_API_KEY`, `AI_GATEWAY_BASE_URL`, `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, `VERCEL_OIDC_TOKEN`, `TERMINAL_VERCEL_RUNTIME` - Tests: deletes test_ai_gateway_models.py and test_vercel_sandbox_environment.py; scrubs references across 23 surviving test files (no entire tests deleted unless they were dedicated to AI Gateway / Sandbox) - Docs: provider tables, env-var reference, setup guides, security notes, tool config, terminal-backend tables — English plus zh-Hans i18n parity - `hermes-agent` skill: provider table entry and remote-backend list What stays (intentional): - `popular-web-designs/templates/vercel.md` — CSS design reference, unrelated to Vercel-the-AI-product - `x-vercel-id` in `stream_diag.py` headers — generic Vercel CDN response header, useful diag signal on any Vercel-hosted endpoint - `vercel-labs/agent-browser` URL in browser config — lightpanda browser project, different OSS effort - `userStories.json` historical contributor entry mentioning Vercel Sandbox — archive, not active docs Validation: - 1153 tests in the 22 targeted files pass (`scripts/run_tests.sh`) - Full repo `py_compile` clean - Live import of every touched module + invariant check (no `ai-gateway` in `PROVIDER_REGISTRY`, no `_AI_GATEWAY_HEADERS`, no `vercel_sandbox` in `_REMOTE_TERMINAL_BACKENDS`) * test: convert profile-count check from change-detector to invariant The hardcoded "== 34" assertion broke when ai-gateway was removed. Per AGENTS.md change-detector-test guidance, assert the relationship (registry count >= number of plugin dirs) instead of a literal count. Counts shift when providers are added/removed; that's expected.
This commit is contained in:
parent
cb38ce28cb
commit
febc4cfec0
95 changed files with 111 additions and 3088 deletions
|
|
@ -94,7 +94,6 @@ class TestProviderMapping:
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -942,7 +942,7 @@ 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"):
|
||||
for backend in ("docker", "singularity", "modal", "daytona", "ssh"):
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def test_normalize_usage_openai_subtracts_cached_prompt_tokens():
|
|||
|
||||
|
||||
def test_normalize_usage_openai_reads_top_level_anthropic_cache_fields():
|
||||
"""Some OpenAI-compatible proxies (OpenRouter, Vercel AI Gateway, Cline) expose
|
||||
"""Some OpenAI-compatible proxies (OpenRouter, 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -544,30 +544,6 @@ class TestRootLevelProviderOverride:
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ _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",
|
||||
|
|
@ -158,7 +157,6 @@ _CREDENTIAL_NAMES = frozenset({
|
|||
"OLLAMA_BASE_URL",
|
||||
"GROQ_BASE_URL",
|
||||
"XAI_BASE_URL",
|
||||
"AI_GATEWAY_BASE_URL",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
})
|
||||
|
||||
|
|
@ -217,7 +215,6 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
|||
"HERMES_TENANT",
|
||||
"TERMINAL_CWD",
|
||||
"TERMINAL_ENV",
|
||||
"TERMINAL_VERCEL_RUNTIME",
|
||||
"TERMINAL_CONTAINER_CPU",
|
||||
"TERMINAL_CONTAINER_DISK",
|
||||
"TERMINAL_CONTAINER_MEMORY",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
|||
"backend": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
"container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
|
|
@ -245,24 +244,3 @@ class TestTildeExpansion:
|
|||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == os.path.expanduser("~/nested")
|
||||
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax)."""
|
||||
|
||||
import os
|
||||
|
||||
|
|
@ -40,7 +40,6 @@ 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"),
|
||||
])
|
||||
|
|
@ -97,11 +96,6 @@ class TestProviderRegistry:
|
|||
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
|
||||
|
||||
def test_ai_gateway_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
||||
assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
|
||||
|
||||
def test_kilocode_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["kilocode"]
|
||||
assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",)
|
||||
|
|
@ -125,7 +119,6 @@ class TestProviderRegistry:
|
|||
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"
|
||||
|
|
@ -149,7 +142,6 @@ PROVIDER_ENV_VARS = (
|
|||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||
"GMI_API_KEY", "GMI_BASE_URL",
|
||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
|
|
@ -184,9 +176,6 @@ class TestResolveProvider:
|
|||
def test_explicit_minimax_cn(self):
|
||||
assert resolve_provider("minimax-cn") == "minimax-cn"
|
||||
|
||||
def test_explicit_ai_gateway(self):
|
||||
assert resolve_provider("ai-gateway") == "ai-gateway"
|
||||
|
||||
def test_explicit_gmi(self):
|
||||
assert resolve_provider("gmi") == "gmi"
|
||||
|
||||
|
|
@ -211,12 +200,6 @@ class TestResolveProvider:
|
|||
def test_alias_minimax_underscore(self):
|
||||
assert resolve_provider("minimax_cn") == "minimax-cn"
|
||||
|
||||
def test_alias_aigateway(self):
|
||||
assert resolve_provider("aigateway") == "ai-gateway"
|
||||
|
||||
def test_alias_vercel(self):
|
||||
assert resolve_provider("vercel") == "ai-gateway"
|
||||
|
||||
def test_alias_gmi_cloud(self):
|
||||
assert resolve_provider("gmi-cloud") == "gmi"
|
||||
|
||||
|
|
@ -291,10 +274,6 @@ class TestResolveProvider:
|
|||
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
|
||||
assert resolve_provider("auto") == "minimax-cn"
|
||||
|
||||
def test_auto_detects_ai_gateway_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
|
||||
assert resolve_provider("auto") == "ai-gateway"
|
||||
|
||||
def test_auto_detects_gmi_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GMI_API_KEY", "test-gmi-key")
|
||||
assert resolve_provider("auto") == "gmi"
|
||||
|
|
@ -535,13 +514,6 @@ class TestResolveApiKeyProviderCredentials:
|
|||
assert creds["api_key"] == "mmcn-secret-key"
|
||||
assert creds["base_url"] == "https://api.minimaxi.com/anthropic"
|
||||
|
||||
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")
|
||||
|
|
@ -641,15 +613,6 @@ class TestRuntimeProviderResolution:
|
|||
assert result["provider"] == "minimax"
|
||||
assert result["api_key"] == "mm-key"
|
||||
|
||||
def test_runtime_ai_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="ai-gateway")
|
||||
assert result["provider"] == "ai-gateway"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gw-key"
|
||||
assert "ai-gateway.vercel.sh" in result["base_url"]
|
||||
|
||||
def test_runtime_kilocode(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
|
|
|||
|
|
@ -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", "AI_GATEWAY_API_KEY",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -253,38 +253,6 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
|
|||
assert issues == []
|
||||
|
||||
|
||||
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) ──
|
||||
|
||||
|
||||
|
|
@ -522,7 +490,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
|
|||
@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"),
|
||||
|
|
@ -566,7 +533,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
|||
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"}:
|
||||
if provider in {"opencode-zen", "kilocode"}:
|
||||
assert (
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
|
||||
not in out
|
||||
|
|
|
|||
|
|
@ -183,7 +183,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -226,20 +226,6 @@ 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(
|
||||
|
|
@ -351,36 +337,6 @@ def test_resolve_runtime_provider_lmstudio_saved_base_url_wins_over_env(monkeypa
|
|||
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: {})
|
||||
|
|
|
|||
|
|
@ -125,13 +125,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -30,17 +30,6 @@ 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: (
|
||||
|
|
@ -494,85 +483,6 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
|
|||
assert config["terminal"]["modal_mode"] == "direct"
|
||||
|
||||
|
||||
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 = {}
|
||||
|
|
|
|||
|
|
@ -83,37 +83,6 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -19,7 +19,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", "AI_GATEWAY_API_KEY",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_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",
|
||||
|
|
|
|||
|
|
@ -377,12 +377,6 @@ class TestBuildSchemaFromConfig:
|
|||
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
|
||||
|
||||
def test_empty_prefix_produces_correct_keys(self):
|
||||
from hermes_cli.web_server import _build_schema_from_config
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class TestXiaomiAutoDetection:
|
|||
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",
|
||||
"MINIMAX_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"):
|
||||
|
|
|
|||
|
|
@ -46,14 +46,26 @@ def test_bundled_plugins_discovered():
|
|||
assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml"
|
||||
|
||||
|
||||
def test_all_34_profiles_register():
|
||||
"""After discovery, the registry must contain exactly 34 distinct profiles."""
|
||||
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.
|
||||
"""
|
||||
_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)
|
||||
assert len(names) == 34, f"Expected 34 profiles, got {len(names)}: {names}"
|
||||
# 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}"
|
||||
)
|
||||
|
||||
# Spot-check representative providers from different categories
|
||||
for required in (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
"""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.
|
||||
"""
|
||||
"""Attribution default_headers applied per provider via base-URL detection."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -28,26 +24,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -313,40 +313,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def test_lazy_installable_extras_excluded_from_all():
|
|||
"fal",
|
||||
"edge-tts", "tts-premium",
|
||||
"voice", # faster-whisper / sounddevice / numpy
|
||||
"modal", "daytona", "vercel",
|
||||
"modal", "daytona",
|
||||
"messaging", "slack", "matrix", "dingtalk", "feishu",
|
||||
"honcho", "hindsight",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,10 +73,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -241,7 +241,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", "vercel_sandbox"):
|
||||
for env in ("docker", "singularity", "modal", "daytona"):
|
||||
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)
|
||||
|
|
@ -372,7 +372,7 @@ def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):
|
|||
|
||||
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", "vercel_sandbox"):
|
||||
for env in ("docker", "singularity", "modal", "daytona"):
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -132,10 +132,6 @@ 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)
|
||||
|
||||
|
|
@ -291,10 +287,6 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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
|
||||
|
|
@ -102,26 +101,6 @@ class TestCwdHandling:
|
|||
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 ~."""
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ class TestSkillViewPrerequisites:
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
["ssh", "daytona", "docker", "singularity", "modal", "vercel_sandbox"],
|
||||
["ssh", "daytona", "docker", "singularity", "modal"],
|
||||
)
|
||||
def test_remote_backend_becomes_available_after_local_secret_capture(
|
||||
self, tmp_path, monkeypatch, backend
|
||||
|
|
|
|||
|
|
@ -21,13 +21,8 @@ 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",
|
||||
]
|
||||
|
|
@ -191,126 +186,3 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey
|
|||
"paid Nous subscription is required" 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,68 +64,3 @@ class TestTerminalRequirements:
|
|||
|
||||
assert "terminal" in names
|
||||
assert "execute_code" in 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
|
||||
|
|
|
|||
|
|
@ -1,606 +0,0 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue