mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
2620 lines
107 KiB
Python
2620 lines
107 KiB
Python
"""
|
|
Tests for the OpenAI-compatible API server gateway adapter.
|
|
|
|
Tests cover:
|
|
- Chat Completions endpoint (request parsing, response format)
|
|
- Responses API endpoint (request parsing, response format)
|
|
- previous_response_id chaining (store/retrieve)
|
|
- Auth (valid key, invalid key, no key configured)
|
|
- /v1/models endpoint
|
|
- /health endpoint
|
|
- System prompt extraction
|
|
- Error handling (invalid JSON, missing fields)
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import time
|
|
import types
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
from gateway.platforms.api_server import (
|
|
APIServerAdapter,
|
|
ResponseStore,
|
|
_IdempotencyCache,
|
|
_derive_chat_session_id,
|
|
_hermes_version,
|
|
_redact_api_error_text,
|
|
_request_agent_overrides,
|
|
check_api_server_requirements,
|
|
cors_middleware,
|
|
security_headers_middleware,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_api_server_requirements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckRequirements:
|
|
|
|
@patch("gateway.platforms.api_server.AIOHTTP_AVAILABLE", False)
|
|
def test_returns_false_without_aiohttp(self):
|
|
assert check_api_server_requirements() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _redact_api_error_text — guards every outward error site (envelopes, SSE
|
|
# error events, cron-endpoint 500 bodies) that routes raw exception text to
|
|
# authenticated HTTP clients. #37733
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRedactApiErrorText:
|
|
def test_masks_secret_value_but_preserves_structure(self):
|
|
secret = "sk-api-server-leak-1234567890"
|
|
out = _redact_api_error_text(Exception(f"auth failed OPENAI_API_KEY={secret}"))
|
|
assert secret not in out
|
|
assert "OPENAI_API_KEY=" in out
|
|
|
|
def test_redacts_regardless_of_global_redaction_setting(self):
|
|
# force=True must mask even when global redaction is disabled.
|
|
secret = "sk-forced-redaction-0987654321"
|
|
with patch("agent.redact._REDACT_ENABLED", False):
|
|
out = _redact_api_error_text(Exception(f"boom AWS_SECRET_ACCESS_KEY={secret}"))
|
|
assert secret not in out
|
|
|
|
def test_limit_truncates_after_redaction(self):
|
|
assert len(_redact_api_error_text("x" * 500, limit=50)) == 50
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ResponseStore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResponseStore:
|
|
def test_put_and_get(self):
|
|
store = ResponseStore(max_size=10)
|
|
store.put("resp_1", {"output": "hello"})
|
|
assert store.get("resp_1") == {"output": "hello"}
|
|
|
|
def test_get_missing_returns_none(self):
|
|
store = ResponseStore(max_size=10)
|
|
assert store.get("resp_missing") is None
|
|
|
|
def test_lru_eviction(self):
|
|
store = ResponseStore(max_size=3)
|
|
store.put("resp_1", {"output": "one"})
|
|
store.put("resp_2", {"output": "two"})
|
|
store.put("resp_3", {"output": "three"})
|
|
# Adding a 4th should evict resp_1
|
|
store.put("resp_4", {"output": "four"})
|
|
assert store.get("resp_1") is None
|
|
assert store.get("resp_2") is not None
|
|
assert len(store) == 3
|
|
|
|
|
|
def test_delete_clears_conversation_mapping(self):
|
|
"""Deleting a response also removes conversation mappings that reference it."""
|
|
store = ResponseStore(max_size=10)
|
|
store.put("resp_1", {"output": "hello"})
|
|
store.set_conversation("chat-a", "resp_1")
|
|
assert store.get_conversation("chat-a") == "resp_1"
|
|
store.delete("resp_1")
|
|
assert store.get_conversation("chat-a") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _IdempotencyCache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIdempotencyCache:
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_same_key_and_fingerprint_runs_once(self):
|
|
cache = _IdempotencyCache()
|
|
gate = asyncio.Event()
|
|
started = asyncio.Event()
|
|
calls = 0
|
|
|
|
async def compute():
|
|
nonlocal calls
|
|
calls += 1
|
|
started.set()
|
|
await gate.wait()
|
|
return ("response", {"total_tokens": 1})
|
|
|
|
first = asyncio.create_task(cache.get_or_set("idem-key", "fp-1", compute))
|
|
second = asyncio.create_task(cache.get_or_set("idem-key", "fp-1", compute))
|
|
|
|
await started.wait()
|
|
assert calls == 1
|
|
|
|
gate.set()
|
|
first_result, second_result = await asyncio.gather(first, second)
|
|
|
|
assert first_result == second_result == ("response", {"total_tokens": 1})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter initialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdapterInit:
|
|
def test_default_config(self):
|
|
config = PlatformConfig(enabled=True)
|
|
adapter = APIServerAdapter(config)
|
|
assert adapter._host == "127.0.0.1"
|
|
assert adapter._port == 8642
|
|
assert adapter._api_key == ""
|
|
assert adapter.platform == Platform.API_SERVER
|
|
|
|
def test_custom_config_from_extra(self):
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"host": "0.0.0.0",
|
|
"port": 9999,
|
|
"key": "sk-test",
|
|
"cors_origins": ["http://localhost:3000"],
|
|
},
|
|
)
|
|
adapter = APIServerAdapter(config)
|
|
assert adapter._host == "0.0.0.0"
|
|
assert adapter._port == 9999
|
|
assert adapter._api_key == "sk-test"
|
|
assert adapter._cors_origins == ("http://localhost:3000",)
|
|
|
|
|
|
def test_create_agent_forwards_runtime_config(self, monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
|
|
monkeypatch.setattr(
|
|
"gateway.run._resolve_runtime_agent_kwargs",
|
|
lambda: {
|
|
"provider": "openai-codex",
|
|
"base_url": "https://example.test/v1",
|
|
"api_mode": "codex_responses",
|
|
},
|
|
)
|
|
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5.5")
|
|
monkeypatch.setattr(
|
|
"gateway.run._load_gateway_config",
|
|
lambda: {
|
|
"agent": {"reasoning_effort": "xhigh"},
|
|
"checkpoints": {
|
|
"enabled": True,
|
|
"max_snapshots": 7,
|
|
"max_total_size_mb": 321,
|
|
"max_file_size_mb": 4,
|
|
},
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.run.GatewayRunner._load_reasoning_config",
|
|
staticmethod(lambda: {"enabled": True, "effort": "xhigh"}),
|
|
)
|
|
monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None))
|
|
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
|
|
|
|
adapter = APIServerAdapter(PlatformConfig(enabled=True))
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
|
|
agent = adapter._create_agent(session_id="api-session")
|
|
|
|
assert isinstance(agent, FakeAgent)
|
|
assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"}
|
|
assert captured["checkpoints_enabled"] is True
|
|
assert captured["checkpoint_max_snapshots"] == 7
|
|
assert captured["checkpoint_max_total_size_mb"] == 321
|
|
assert captured["checkpoint_max_file_size_mb"] == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth checking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuth:
|
|
def test_no_key_configured_allows_all(self):
|
|
config = PlatformConfig(enabled=True)
|
|
adapter = APIServerAdapter(config)
|
|
mock_request = MagicMock()
|
|
mock_request.headers = {}
|
|
assert adapter._check_auth(mock_request) is None
|
|
|
|
|
|
def test_non_ascii_bearer_token_returns_401_not_500(self):
|
|
"""A non-ASCII byte in the bearer token must be rejected with 401, not
|
|
crash the handler: hmac.compare_digest raises TypeError on a str with
|
|
non-ASCII characters, and the token is raw client input."""
|
|
config = PlatformConfig(enabled=True, extra={"key": "sk-test123"})
|
|
adapter = APIServerAdapter(config)
|
|
mock_request = MagicMock()
|
|
mock_request.headers = {"Authorization": "Bearer ské-not-the-key"}
|
|
result = adapter._check_auth(mock_request) # must not raise
|
|
assert result is not None
|
|
assert result.status == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Concurrency cap (gateway.api_server.max_concurrent_runs) — #7483
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConcurrencyCap:
|
|
|
|
def test_resolve_reads_config_value(self):
|
|
cfg = {"gateway": {"api_server": {"max_concurrent_runs": 3}}}
|
|
with patch("hermes_cli.config.load_config", return_value=cfg):
|
|
assert APIServerAdapter._resolve_max_concurrent_runs() == 3
|
|
|
|
|
|
def test_under_cap_returns_none(self):
|
|
adapter = _make_adapter()
|
|
adapter._max_concurrent_runs = 5
|
|
adapter._inflight_agent_runs = 2
|
|
assert adapter._concurrency_limited_response() is None
|
|
|
|
def test_at_cap_returns_429_with_retry_after(self):
|
|
adapter = _make_adapter()
|
|
adapter._max_concurrent_runs = 3
|
|
adapter._inflight_agent_runs = 3
|
|
resp = adapter._concurrency_limited_response()
|
|
assert resp is not None
|
|
assert resp.status == 429
|
|
assert resp.headers.get("Retry-After")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for HTTP tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_adapter(api_key: str = "", cors_origins=None) -> APIServerAdapter:
|
|
"""Create an adapter with optional API key."""
|
|
extra = {}
|
|
if api_key:
|
|
extra["key"] = api_key
|
|
if cors_origins is not None:
|
|
extra["cors_origins"] = cors_origins
|
|
config = PlatformConfig(enabled=True, extra=extra)
|
|
return APIServerAdapter(config)
|
|
|
|
|
|
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
|
"""Create the aiohttp app from the adapter (without starting the full server)."""
|
|
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
|
|
app = web.Application(middlewares=mws)
|
|
app["api_server_adapter"] = adapter
|
|
app.router.add_get("/health", adapter._handle_health)
|
|
app.router.add_get("/health/detailed", adapter._handle_health_detailed)
|
|
app.router.add_get("/v1/health", adapter._handle_health)
|
|
app.router.add_get("/v1/models", adapter._handle_models)
|
|
app.router.add_get("/api/model/options", adapter._handle_model_options)
|
|
app.router.add_get("/v1/capabilities", adapter._handle_capabilities)
|
|
app.router.add_get("/v1/skills", adapter._handle_skills)
|
|
app.router.add_get("/v1/toolsets", adapter._handle_toolsets)
|
|
app.router.add_post("/api/sessions/{session_id}/chat", adapter._handle_session_chat)
|
|
app.router.add_post("/api/sessions/{session_id}/chat/stream", adapter._handle_session_chat_stream)
|
|
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
|
app.router.add_post("/v1/responses", adapter._handle_responses)
|
|
app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response)
|
|
app.router.add_delete("/v1/responses/{response_id}", adapter._handle_delete_response)
|
|
app.router.add_post(
|
|
"/api/platforms/{platform}/events",
|
|
adapter._handle_platform_event_callback,
|
|
)
|
|
return app
|
|
|
|
|
|
class _FakeGoogleChatAdapter:
|
|
def __init__(self, *, verify_ok: bool = True, verify_code: str = ""):
|
|
self.verify_ok = verify_ok
|
|
self.verify_code = verify_code
|
|
self.dispatched = []
|
|
|
|
def verify_http_event_request(self, auth_header: str):
|
|
self.auth_header = auth_header
|
|
return self.verify_ok, self.verify_code
|
|
|
|
async def dispatch_http_event(self, payload):
|
|
self.dispatched.append(payload)
|
|
return {"ok": True}
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
return _make_adapter()
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_adapter():
|
|
return _make_adapter(api_key="sk-secret")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAgentExecution:
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_uses_session_id_as_task_id(self, adapter):
|
|
mock_agent = MagicMock()
|
|
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
|
mock_agent.session_prompt_tokens = 1
|
|
mock_agent.session_completion_tokens = 2
|
|
mock_agent.session_total_tokens = 3
|
|
|
|
model_options = {"reasoning": {"enabled": False}, "fast": False}
|
|
with patch.object(adapter, "_create_agent", return_value=mock_agent) as mock_create_agent:
|
|
result, usage = await adapter._run_agent(
|
|
user_message="hello",
|
|
conversation_history=[],
|
|
session_id="session-123",
|
|
requested_model="MiniMax-M3",
|
|
requested_provider="minimax",
|
|
model_options=model_options,
|
|
)
|
|
|
|
# _run_agent annotates result with the effective agent.session_id
|
|
# when it's a real string, so the response-header writer can track
|
|
# compression-triggered session rotations (#16938). The mock agent
|
|
# here doesn't set an explicit session_id string so the guard skips
|
|
# the annotation — header will fall back to the provided session_id.
|
|
assert result["final_response"] == "ok"
|
|
assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
|
|
create_kwargs = mock_create_agent.call_args.kwargs
|
|
assert create_kwargs["requested_model"] == "MiniMax-M3"
|
|
assert create_kwargs["requested_provider"] == "minimax"
|
|
assert create_kwargs["model_options"] == model_options
|
|
mock_agent.run_conversation.assert_called_once_with(
|
|
user_message="hello",
|
|
conversation_history=[],
|
|
task_id="session-123",
|
|
)
|
|
|
|
|
|
class TestRunEventCallback:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_events_redact_secrets_and_carry_child_session(self, adapter):
|
|
"""Free-text fields (goal/summary/output_tail/preview) must pass the
|
|
forced secret redaction before hitting the public /v1/runs stream,
|
|
and child_session_id must survive the allowlist so clients can
|
|
correlate the child's session."""
|
|
run_id = "run_subagent_redact"
|
|
loop = asyncio.get_running_loop()
|
|
queue = asyncio.Queue()
|
|
adapter._run_streams[run_id] = queue
|
|
adapter._run_statuses.pop(run_id, None)
|
|
|
|
callback = adapter._make_run_event_callback(run_id, loop)
|
|
secret = "sk-proj-abcdef1234567890abcdef1234567890abcdef12"
|
|
callback(
|
|
"subagent.complete",
|
|
preview=f"leaked {secret}",
|
|
goal=f"use key {secret} to fetch data",
|
|
subagent_id="deleg_999",
|
|
child_session_id="child-sess-42",
|
|
status="completed",
|
|
summary=f"exported OPENAI_API_KEY={secret} then ran",
|
|
output_tail=f"env shows {secret}",
|
|
)
|
|
|
|
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
|
assert event["child_session_id"] == "child-sess-42"
|
|
for field in ("preview", "goal", "summary", "output_tail"):
|
|
assert secret not in event[field], field
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /health endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHealthEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_security_headers_present(self, adapter):
|
|
"""Responses should include basic security headers."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/health")
|
|
assert resp.status == 200
|
|
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; frame-ancestors 'none'"
|
|
assert resp.headers.get("Permissions-Policy") == "camera=(), microphone=(), geolocation=()"
|
|
assert resp.headers.get("Strict-Transport-Security") == "max-age=31536000; includeSubDomains"
|
|
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
|
assert resp.headers.get("X-Frame-Options") == "DENY"
|
|
assert resp.headers.get("X-XSS-Protection") == "0"
|
|
assert resp.headers.get("Referrer-Policy") == "no-referrer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_reports_version(self, adapter):
|
|
"""GET /health must expose a non-empty version so orchestrators (e.g.
|
|
AgentOS) can read the gateway version without scraping. Regression
|
|
guard for the missing-version gap."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/health")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert "version" in data
|
|
assert isinstance(data["version"], str)
|
|
assert data["version"] != ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /health/detailed endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHealthDetailedEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_health_detailed_returns_ok(self, adapter):
|
|
"""GET /health/detailed returns status, platform, and runtime fields."""
|
|
app = _create_app(adapter)
|
|
with patch("gateway.status.read_runtime_status", return_value={
|
|
"gateway_state": "running",
|
|
"platforms": {"telegram": {"state": "connected"}},
|
|
"active_agents": 2,
|
|
"exit_reason": None,
|
|
"updated_at": "2026-04-14T00:00:00Z",
|
|
}), patch("gateway.run._resolve_gateway_model", return_value="test/model"):
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/health/detailed")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["status"] == "ok"
|
|
assert data["platform"] == "hermes-agent"
|
|
assert data["gateway_state"] == "running"
|
|
assert data["platforms"] == {"telegram": {"state": "connected"}}
|
|
assert data["active_agents"] == 2
|
|
# Derived busy/drainable: this endpoint is served BY the live
|
|
# gateway, so running + 2 agents ⇒ busy and drainable.
|
|
assert data["gateway_busy"] is True
|
|
assert data["gateway_drainable"] is True
|
|
assert isinstance(data["pid"], int)
|
|
assert "updated_at" in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_health_does_not_run_readiness_probes(self, adapter):
|
|
app = _create_app(adapter)
|
|
with patch("gateway.platforms.api_server.collect_runtime_readiness") as probe:
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/health")
|
|
assert resp.status == 200
|
|
assert (await resp.json())["status"] == "ok"
|
|
probe.assert_not_called()
|
|
|
|
|
|
def test_readiness_work_counts_include_stopping_runs(self, adapter):
|
|
"""Regression: _handle_stop_run() sets status="stopping" and holds it
|
|
there — cooperatively, with no hard timeout — until the agent notices
|
|
the interrupt and the task actually exits. A run in that window is
|
|
still doing real executor-thread work and must count as active,
|
|
the same as "running"; excluding it undercounts active_api_runs for
|
|
the whole (now-unbounded) cooperative-stop duration."""
|
|
adapter._run_statuses = {
|
|
"queued": {"status": "queued"},
|
|
"running": {"status": "running"},
|
|
"approval": {"status": "waiting_for_approval"},
|
|
"stopping": {"status": "stopping"},
|
|
"done": {"status": "completed"},
|
|
"cancelled": {"status": "cancelled"},
|
|
}
|
|
|
|
with patch("tools.process_registry.process_registry.completion_queue.qsize", return_value=0), \
|
|
patch("tools.async_delegation.active_count", return_value=0):
|
|
assert adapter._readiness_work_counts() == (4, 0, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/models endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModelsEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_models_returns_hermes_agent(self, adapter):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/models")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["object"] == "list"
|
|
assert len(data["data"]) == 1
|
|
assert data["data"][0]["id"] == "hermes-agent"
|
|
assert data["data"][0]["owned_by"] == "hermes"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_models_returns_profile_name(self):
|
|
"""When running under a named profile, /v1/models advertises the profile name."""
|
|
with patch("gateway.platforms.api_server.APIServerAdapter._resolve_model_name", return_value="lucas"):
|
|
adapter = _make_adapter()
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/models")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["data"][0]["id"] == "lucas"
|
|
assert data["data"][0]["root"] == "lucas"
|
|
|
|
|
|
def test_resolve_model_name_default_profile(self):
|
|
"""Default profile falls back to 'hermes-agent'."""
|
|
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
|
|
assert APIServerAdapter._resolve_model_name("") == "hermes-agent"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_model_options_returns_shared_inventory(self, adapter, monkeypatch):
|
|
"""GET /api/model/options builds the shared picker payload off-loop."""
|
|
from hermes_cli import inventory
|
|
|
|
ctx = object()
|
|
payload = {
|
|
"providers": [{"slug": "nous", "name": "Nous Portal", "models": ["gpt-5.5"]}],
|
|
"model": "gpt-5.5",
|
|
"provider": "nous",
|
|
}
|
|
seen = {"thread_calls": 0}
|
|
|
|
monkeypatch.setattr(inventory, "load_picker_context", lambda: ctx)
|
|
|
|
def fake_build_model_options_payload(received_ctx, **kwargs):
|
|
seen["ctx"] = received_ctx
|
|
seen["kwargs"] = kwargs
|
|
return payload
|
|
|
|
async def fake_to_thread(func, *args, **kwargs):
|
|
seen["thread_calls"] += 1
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
inventory,
|
|
"build_model_options_payload",
|
|
fake_build_model_options_payload,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.api_server.asyncio.to_thread",
|
|
fake_to_thread,
|
|
)
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/api/model/options?refresh=true")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
|
|
assert data == payload
|
|
assert seen["thread_calls"] == 1
|
|
assert seen["ctx"] is ctx
|
|
assert seen["kwargs"] == {
|
|
"include_unconfigured": True,
|
|
"refresh": True,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/capabilities endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCapabilitiesEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_capabilities_advertises_plugin_safe_contract(self, adapter):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/capabilities")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["object"] == "hermes.api_server.capabilities"
|
|
assert data["platform"] == "hermes-agent"
|
|
assert data["model"] == "hermes-agent"
|
|
assert data["auth"]["type"] == "bearer"
|
|
assert data["auth"]["required"] is False
|
|
assert data["runtime"]["mode"] == "server_agent"
|
|
assert data["runtime"]["tool_execution"] == "server"
|
|
assert data["runtime"]["split_runtime"] is False
|
|
assert "API-server host" in data["runtime"]["description"]
|
|
assert data["features"]["chat_completions"] is True
|
|
assert data["features"]["run_status"] is True
|
|
assert data["features"]["run_events_sse"] is True
|
|
assert data["features"]["model_options"] is True
|
|
assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id"
|
|
assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}"
|
|
assert data["endpoints"]["model_options"] == {"method": "GET", "path": "/api/model/options"}
|
|
assert data["endpoints"]["skills"] == {"method": "GET", "path": "/v1/skills"}
|
|
assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/skills and /v1/toolsets endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillsEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_skills_returns_list_envelope(self, adapter):
|
|
fake_skills = [
|
|
{"name": "github", "description": "GitHub workflow skill", "category": "github"},
|
|
{"name": "ascii-art", "description": "ASCII art generation", "category": "creative"},
|
|
]
|
|
with patch(
|
|
"tools.skills_tool._find_all_skills",
|
|
return_value=list(fake_skills),
|
|
):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/skills")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["object"] == "list"
|
|
names = sorted(s["name"] for s in data["data"])
|
|
assert names == ["ascii-art", "github"]
|
|
for entry in data["data"]:
|
|
assert set(entry.keys()) >= {"name", "description", "category"}
|
|
|
|
|
|
class TestToolsetsEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_toolsets_returns_resolved_tools(self, adapter):
|
|
fake_toolsets = [
|
|
("default", "Default Tools", "Core tools"),
|
|
("web", "Web Tools", "Search and extract"),
|
|
]
|
|
with patch(
|
|
"hermes_cli.tools_config._get_effective_configurable_toolsets",
|
|
return_value=fake_toolsets,
|
|
), patch(
|
|
"hermes_cli.tools_config._get_platform_tools",
|
|
return_value={"default"},
|
|
), patch(
|
|
"hermes_cli.tools_config._toolset_has_keys",
|
|
return_value=True,
|
|
), patch(
|
|
"toolsets.resolve_toolset",
|
|
side_effect=lambda name: {
|
|
"default": ["terminal", "read_file"],
|
|
"web": ["web_search"],
|
|
}[name],
|
|
):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/toolsets")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["object"] == "list"
|
|
assert data["platform"] == "api_server"
|
|
by_name = {ts["name"]: ts for ts in data["data"]}
|
|
assert by_name["default"]["enabled"] is True
|
|
assert by_name["default"]["tools"] == ["read_file", "terminal"]
|
|
assert by_name["web"]["enabled"] is False
|
|
assert by_name["web"]["tools"] == ["web_search"]
|
|
assert by_name["default"]["configured"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/chat/completions endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestChatCompletionsEndpoint:
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_json_returns_400(self, adapter):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
data="not json",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert resp.status == 400
|
|
data = await resp.json()
|
|
assert "Invalid JSON" in data["error"]["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_messages_returns_400(self, adapter):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post("/v1/chat/completions", json={"model": "test"})
|
|
assert resp.status == 400
|
|
data = await resp.json()
|
|
assert "messages" in data["error"]["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completions_stream_passes_request_model_provider_options(self, adapter):
|
|
app = _create_app(adapter)
|
|
model_options = {"reasoning": {"enabled": False}, "reasoning_effort": "none", "fast": False}
|
|
|
|
async def _mock_run_agent(**kwargs):
|
|
cb = kwargs.get("stream_delta_callback")
|
|
if cb:
|
|
cb("ok")
|
|
return (
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent) as mock_run:
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "MiniMax-M3",
|
|
"provider": "minimax",
|
|
"model_options": model_options,
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.text()
|
|
|
|
assert "data: " in body
|
|
kwargs = mock_run.call_args.kwargs
|
|
assert kwargs["requested_model"] == "MiniMax-M3"
|
|
assert kwargs["requested_provider"] == "minimax"
|
|
assert kwargs["model_options"] == model_options
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_chat_stream_passes_request_model_provider_options(self, adapter):
|
|
app = _create_app(adapter)
|
|
model_options = {"reasoning_effort": "medium", "service_tier": "priority"}
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with (
|
|
patch.object(adapter, "_get_existing_session_or_404", return_value=({"id": "s1"}, None)),
|
|
patch.object(adapter, "_conversation_history_for_session", return_value=[]),
|
|
patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run,
|
|
):
|
|
mock_run.return_value = (
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
resp = await cli.post(
|
|
"/api/sessions/s1/chat/stream",
|
|
json={
|
|
"message": "hi",
|
|
"model": "MiniMax-M3",
|
|
"provider": "minimax",
|
|
"model_options": model_options,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.text()
|
|
|
|
assert "event: run.completed" in body
|
|
kwargs = mock_run.call_args.kwargs
|
|
assert kwargs["requested_model"] == "MiniMax-M3"
|
|
assert kwargs["requested_provider"] == "minimax"
|
|
assert kwargs["model_options"] == model_options
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter):
|
|
"""Regression guard for #24451: completion callback must signal SSE EOS."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
class _FakeTask:
|
|
def __init__(self):
|
|
self.callbacks = []
|
|
|
|
def add_done_callback(self, cb):
|
|
self.callbacks.append(cb)
|
|
|
|
fake_task = _FakeTask()
|
|
|
|
def _fake_ensure_future(coro):
|
|
# We short-circuit task scheduling in this unit test.
|
|
coro.close()
|
|
return fake_task
|
|
|
|
with (
|
|
patch.object(
|
|
adapter,
|
|
"_run_agent",
|
|
new=AsyncMock(
|
|
return_value=(
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
),
|
|
),
|
|
patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future),
|
|
patch.object(adapter, "_write_sse_chat_completion", new_callable=AsyncMock) as mock_write_sse,
|
|
):
|
|
mock_write_sse.return_value = web.Response(status=200, text="ok")
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert len(fake_task.callbacks) == 1
|
|
stream_q = mock_write_sse.call_args.args[4]
|
|
assert stream_q.empty()
|
|
fake_task.callbacks[0](fake_task)
|
|
assert stream_q.get_nowait() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_includes_tool_progress(self, adapter):
|
|
"""tool_start_callback fires → progress appears as custom SSE event, not in delta.content."""
|
|
import asyncio
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
async def _mock_run_agent(**kwargs):
|
|
cb = kwargs.get("stream_delta_callback")
|
|
ts_cb = kwargs.get("tool_start_callback")
|
|
# Simulate the structured tool start the gateway now consumes.
|
|
if ts_cb:
|
|
ts_cb("call_terminal_1", "terminal", {"command": "ls -la"})
|
|
if cb:
|
|
await asyncio.sleep(0.05)
|
|
cb("Here are the files.")
|
|
return (
|
|
{"final_response": "Here are the files.", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
|
|
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test",
|
|
"messages": [{"role": "user", "content": "list files"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.text()
|
|
assert "[DONE]" in body
|
|
# Tool progress must appear as a custom SSE event, not in
|
|
# delta.content — prevents model from learning to imitate
|
|
# markers instead of calling tools (#6972).
|
|
assert "event: hermes.tool.progress" in body
|
|
assert '"tool": "terminal"' in body
|
|
# ``label`` is now derived by ``build_tool_preview`` from the
|
|
# tool args rather than passed by the caller, so we assert
|
|
# only that *some* label exists rather than a literal value.
|
|
assert '"label":' in body
|
|
# The progress marker must NOT appear inside any
|
|
# chat.completion.chunk delta.content field.
|
|
import json as _json
|
|
for line in body.splitlines():
|
|
if line.startswith("data: ") and line.strip() != "data: [DONE]":
|
|
try:
|
|
chunk = _json.loads(line[len("data: "):])
|
|
except _json.JSONDecodeError:
|
|
continue
|
|
if chunk.get("object") == "chat.completion.chunk":
|
|
for choice in chunk.get("choices", []):
|
|
content = choice.get("delta", {}).get("content", "")
|
|
# Tool emoji markers must never leak into content
|
|
assert "ls -la" not in content or content == "Here are the files."
|
|
# Final content must also be present
|
|
assert "Here are the files." in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_emits_tool_lifecycle_with_call_id(self, adapter):
|
|
"""Regression for #16588.
|
|
|
|
``/v1/chat/completions`` streaming previously emitted only a
|
|
``tool.started``-style ``hermes.tool.progress`` event; clients
|
|
rendering tool lifecycle UI had no way to mark a tool as finished
|
|
because no matching ``status: completed`` event was emitted, and
|
|
no ``toolCallId`` was carried for correlation.
|
|
|
|
The fix adds ``tool_start_callback`` / ``tool_complete_callback``
|
|
to the chat completions agent invocation and writes both halves
|
|
of the lifecycle pair on the same ``event: hermes.tool.progress``
|
|
SSE line, with stable ``toolCallId`` and ``status``.
|
|
"""
|
|
import asyncio
|
|
import json as _json
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
async def _mock_run_agent(**kwargs):
|
|
cb = kwargs.get("stream_delta_callback")
|
|
ts_cb = kwargs.get("tool_start_callback")
|
|
tc_cb = kwargs.get("tool_complete_callback")
|
|
# The structured callbacks own the chat-completions SSE
|
|
# channel now; ``tool_progress_callback`` is intentionally
|
|
# not wired so each tool start emits exactly one event.
|
|
if ts_cb:
|
|
ts_cb("call_terminal_1", "terminal", {"command": "ls -la"})
|
|
if tc_cb:
|
|
tc_cb("call_terminal_1", "terminal", {"command": "ls -la"}, "ok")
|
|
if cb:
|
|
await asyncio.sleep(0.05)
|
|
cb("done.")
|
|
return (
|
|
{"final_response": "done.", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
|
|
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test",
|
|
"messages": [{"role": "user", "content": "list"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.text()
|
|
|
|
# Walk the SSE body and collect *(status, toolCallId)* pairs
|
|
# per event so the assertions verify per-event correlation —
|
|
# an event missing ``toolCallId`` would not pass even if a
|
|
# different event happens to carry the right id.
|
|
pairs: list[tuple[str | None, str | None]] = []
|
|
lines = body.splitlines()
|
|
for i, line in enumerate(lines):
|
|
if line.strip() != "event: hermes.tool.progress":
|
|
continue
|
|
for follow in lines[i + 1: i + 4]:
|
|
if follow.startswith("data: "):
|
|
try:
|
|
payload = _json.loads(follow[len("data: "):])
|
|
except _json.JSONDecodeError:
|
|
break
|
|
pairs.append((payload.get("status"), payload.get("toolCallId")))
|
|
break
|
|
|
|
# Each tool start must emit exactly one event (no duplicate
|
|
# legacy + new emit), and each lifecycle pair must carry the
|
|
# same toolCallId on every event — not just somewhere in the
|
|
# aggregate.
|
|
assert len(pairs) == 2, f"expected 2 events (running+completed), got {pairs}"
|
|
assert pairs[0] == ("running", "call_terminal_1"), pairs
|
|
assert pairs[1] == ("completed", "call_terminal_1"), pairs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_tool_lifecycle_skips_internal_and_orphan_completes(self, adapter):
|
|
"""Internal tools (``_thinking``-style) and ``completed`` events
|
|
without a prior matching ``running`` must produce no lifecycle
|
|
events on the wire — otherwise clients would see orphaned
|
|
``status: completed`` updates they cannot correlate."""
|
|
import asyncio
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
async def _mock_run_agent(**kwargs):
|
|
cb = kwargs.get("stream_delta_callback")
|
|
ts_cb = kwargs.get("tool_start_callback")
|
|
tc_cb = kwargs.get("tool_complete_callback")
|
|
# Internal tool — must be filtered.
|
|
if ts_cb:
|
|
ts_cb("call_internal_1", "_thinking", {})
|
|
if tc_cb:
|
|
tc_cb("call_internal_1", "_thinking", {}, "")
|
|
# Completion without start — orphan, must be dropped.
|
|
if tc_cb:
|
|
tc_cb("call_orphan_1", "web_search", {}, "ok")
|
|
if cb:
|
|
await asyncio.sleep(0.05)
|
|
cb("ok.")
|
|
return (
|
|
{"final_response": "ok.", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
|
|
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "test",
|
|
"messages": [{"role": "user", "content": "ok"}],
|
|
"stream": True,
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.text()
|
|
|
|
# Neither the internal call_id nor the orphan call_id should
|
|
# surface as a lifecycle payload on the wire.
|
|
assert "call_internal_1" not in body
|
|
assert "call_orphan_1" not in body
|
|
assert '"status": "running"' not in body
|
|
assert '"status": "completed"' not in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _derive_chat_session_id unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeriveChatSessionId:
|
|
def test_deterministic(self):
|
|
"""Same inputs always produce the same session ID."""
|
|
a = _derive_chat_session_id("sys", "hello")
|
|
b = _derive_chat_session_id("sys", "hello")
|
|
assert a == b
|
|
|
|
|
|
def test_different_system_prompt(self):
|
|
a = _derive_chat_session_id("You are a pirate.", "Hello")
|
|
b = _derive_chat_session_id("You are a robot.", "Hello")
|
|
assert a != b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/responses endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResponsesEndpoint:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_response_with_string_input(self, adapter):
|
|
"""String input is wrapped in a user message."""
|
|
mock_result = {
|
|
"final_response": "Paris is the capital of France.",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "What is the capital of France?",
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["object"] == "response"
|
|
assert data["id"].startswith("resp_")
|
|
assert data["status"] == "completed"
|
|
assert len(data["output"]) == 1
|
|
assert data["output"][0]["type"] == "message"
|
|
assert data["output"][0]["content"][0]["type"] == "output_text"
|
|
assert data["output"][0]["content"][0]["text"] == "Paris is the capital of France."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_response_id_stores_compressed_transcript_directly(self, adapter):
|
|
"""After compression, stored history is the compressed transcript, not prior + compressed."""
|
|
prior_history = [
|
|
{"role": "user", "content": "What is 1+1?"},
|
|
{"role": "assistant", "content": "2"},
|
|
] * 10 # 20 messages — enough to simulate a long conversation
|
|
adapter._response_store.put(
|
|
"resp_prev",
|
|
{
|
|
"response": {"id": "resp_prev", "status": "completed"},
|
|
"conversation_history": list(prior_history),
|
|
"session_id": "api-test-session",
|
|
},
|
|
)
|
|
|
|
compressed_history = [
|
|
# Compressed transcript starts with summary, NOT with prior[0]
|
|
{"role": "user", "content": "[Compressed summary of earlier conversation]"},
|
|
{"role": "user", "content": "Now add 1 more"},
|
|
{"role": "assistant", "content": "3"},
|
|
]
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{
|
|
"final_response": "3",
|
|
"messages": list(compressed_history),
|
|
"_compressed": True,
|
|
"api_calls": 1,
|
|
},
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
|
)
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "Now add 1 more",
|
|
"previous_response_id": "resp_prev",
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
|
|
stored = adapter._response_store.get(data["id"])
|
|
stored_history = stored["conversation_history"]
|
|
# Must NOT contain the original prior_history messages
|
|
for msg in prior_history:
|
|
assert msg not in stored_history, (
|
|
f"Prior history message leaked into stored compressed transcript: {msg}"
|
|
)
|
|
# Must contain the compressed transcript
|
|
assert stored_history == compressed_history
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_response_id_outputs_only_current_turn_items(self, adapter):
|
|
"""Response output must not replay previous tool artifacts."""
|
|
prior_history = [
|
|
{"role": "user", "content": "Read old file"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_old",
|
|
"function": {
|
|
"name": "read_file",
|
|
"arguments": '{"path":"old.txt"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_old",
|
|
"content": '{"content":"old"}',
|
|
},
|
|
{"role": "assistant", "content": "old"},
|
|
]
|
|
adapter._response_store.put(
|
|
"resp_prev",
|
|
{
|
|
"response": {"id": "resp_prev", "status": "completed"},
|
|
"conversation_history": list(prior_history),
|
|
"session_id": "api-test-session",
|
|
},
|
|
)
|
|
full_agent_transcript = prior_history + [
|
|
{"role": "user", "content": "Read new file"},
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_new",
|
|
"function": {
|
|
"name": "read_file",
|
|
"arguments": '{"path":"new.txt"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_new",
|
|
"content": '{"content":"new"}',
|
|
},
|
|
{"role": "assistant", "content": "new"},
|
|
]
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{
|
|
"final_response": "new",
|
|
"messages": list(full_agent_transcript),
|
|
"api_calls": 1,
|
|
},
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
|
)
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "Read new file",
|
|
"previous_response_id": "resp_prev",
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
|
|
output_json = json.dumps(data["output"])
|
|
assert "call_new" in output_json
|
|
assert "call_old" not in output_json
|
|
assert "old.txt" not in output_json
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_previous_response_id_returns_404(self, adapter):
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "follow up",
|
|
"previous_response_id": "resp_nonexistent",
|
|
},
|
|
)
|
|
assert resp.status == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_string_false_does_not_store(self, adapter):
|
|
"""Quoted false must preserve ephemeral store=false semantics."""
|
|
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
mock_result,
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
|
)
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "Hello",
|
|
"store": "false",
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert adapter._response_store.get(data["id"]) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_instructions_inherited_from_previous(self, adapter):
|
|
"""If no instructions provided, carry forward from previous response."""
|
|
mock_result = {"final_response": "Ahoy!", "messages": [], "api_calls": 1}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
# First request with instructions
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp1 = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "Hello",
|
|
"instructions": "Be a pirate",
|
|
},
|
|
)
|
|
|
|
data1 = await resp1.json()
|
|
resp_id = data1["id"]
|
|
|
|
# Second request without instructions
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp2 = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "Tell me more",
|
|
"previous_response_id": resp_id,
|
|
},
|
|
)
|
|
|
|
assert resp2.status == 200
|
|
call_kwargs = mock_run.call_args.kwargs
|
|
assert call_kwargs["ephemeral_system_prompt"] == "Be a pirate"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_result_error_fallback_is_redacted(self, adapter):
|
|
raw_secret = "sk-responses-leak-1234567890"
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{
|
|
"final_response": "",
|
|
"error": f"provider auth failed OPENAI_API_KEY={raw_secret}",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
},
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
|
)
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "Hello"},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
body = json.dumps(data)
|
|
assert raw_secret not in body
|
|
assert "OPENAI_API_KEY=" in body
|
|
assert data["output"][0]["content"][0]["text"] != f"provider auth failed OPENAI_API_KEY={raw_secret}"
|
|
|
|
|
|
class TestResponsesStreaming:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter):
|
|
"""Regression guard for #24451 on /v1/responses streaming path."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
class _FakeTask:
|
|
def __init__(self):
|
|
self.callbacks = []
|
|
|
|
def add_done_callback(self, cb):
|
|
self.callbacks.append(cb)
|
|
|
|
fake_task = _FakeTask()
|
|
|
|
def _fake_ensure_future(coro):
|
|
# We short-circuit task scheduling in this unit test.
|
|
coro.close()
|
|
return fake_task
|
|
|
|
with (
|
|
patch.object(
|
|
adapter,
|
|
"_run_agent",
|
|
new=AsyncMock(
|
|
return_value=(
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
),
|
|
),
|
|
patch("gateway.platforms.api_server.asyncio.ensure_future", side_effect=_fake_ensure_future),
|
|
patch.object(adapter, "_write_sse_responses", new_callable=AsyncMock) as mock_write_sse,
|
|
):
|
|
mock_write_sse.return_value = web.Response(status=200, text="ok")
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi", "stream": True},
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert len(fake_task.callbacks) == 1
|
|
stream_q = mock_write_sse.call_args.kwargs["stream_q"]
|
|
assert stream_q.empty()
|
|
fake_task.callbacks[0](fake_task)
|
|
assert stream_q.get_nowait() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_cancelled_persists_incomplete_snapshot(self, adapter):
|
|
"""Server-side asyncio.CancelledError (shutdown, request timeout) must
|
|
still leave an ``incomplete`` snapshot in ResponseStore so
|
|
GET /v1/responses/{id} and previous_response_id chaining keep
|
|
working. Regression for PR #15171 follow-up.
|
|
|
|
Calls _write_sse_responses directly so the test can await the
|
|
handler to completion (TestClient disconnection races the server
|
|
handler, which makes end-to-end assertion on the final stored
|
|
snapshot flaky).
|
|
"""
|
|
# Build a minimal fake request + stream queue the writer understands.
|
|
fake_request = MagicMock()
|
|
fake_request.headers = {}
|
|
|
|
written_payloads: list = []
|
|
|
|
class _FakeStreamResponse:
|
|
async def prepare(self, req):
|
|
pass
|
|
|
|
async def write(self, payload):
|
|
written_payloads.append(payload)
|
|
|
|
# Patch web.StreamResponse for the duration of the writer call.
|
|
import gateway.platforms.api_server as api_mod
|
|
import queue as _q
|
|
|
|
stream_q: _q.Queue = _q.Queue()
|
|
|
|
async def _agent_coro():
|
|
# Feed one partial delta into the stream queue...
|
|
stream_q.put("partial output")
|
|
# ...then give the drain loop a moment to pick it up before
|
|
# raising CancelledError to simulate a server-side cancel.
|
|
await asyncio.sleep(0.01)
|
|
raise asyncio.CancelledError()
|
|
|
|
agent_task = asyncio.ensure_future(_agent_coro())
|
|
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
|
|
with patch.object(api_mod.web, "StreamResponse", return_value=_FakeStreamResponse()):
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await adapter._write_sse_responses(
|
|
request=fake_request,
|
|
response_id=response_id,
|
|
model="hermes-agent",
|
|
created_at=int(time.time()),
|
|
stream_q=stream_q,
|
|
agent_task=agent_task,
|
|
agent_ref=[None],
|
|
conversation_history=[],
|
|
user_message="will be cancelled",
|
|
instructions=None,
|
|
conversation=None,
|
|
store=True,
|
|
session_id=None,
|
|
)
|
|
|
|
# The in_progress snapshot was persisted on response.created,
|
|
# and the CancelledError handler must have updated it to
|
|
# ``incomplete`` with the partial text it saw.
|
|
stored = adapter._response_store.get(response_id)
|
|
assert stored is not None, "snapshot must be retrievable after cancellation"
|
|
assert stored["response"]["status"] == "incomplete"
|
|
# Partial text captured before cancel should be preserved.
|
|
output_text = "".join(
|
|
part.get("text", "")
|
|
for item in stored["response"].get("output", [])
|
|
if item.get("type") == "message"
|
|
for part in item.get("content", [])
|
|
)
|
|
assert "partial output" in output_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_client_disconnect_persists_incomplete_snapshot(self, adapter):
|
|
"""Client disconnect (ConnectionResetError) during streaming must
|
|
persist an ``incomplete`` snapshot in ResponseStore. Regression
|
|
for PR #15171."""
|
|
fake_request = MagicMock()
|
|
fake_request.headers = {}
|
|
|
|
write_call_count = {"n": 0}
|
|
|
|
class _DisconnectingStreamResponse:
|
|
async def prepare(self, req):
|
|
pass
|
|
|
|
async def write(self, payload):
|
|
# First two writes succeed (prepare + response.created).
|
|
# On the third write (a text delta), the "client"
|
|
# disconnects — simulate with ConnectionResetError.
|
|
write_call_count["n"] += 1
|
|
if write_call_count["n"] >= 3:
|
|
raise ConnectionResetError("simulated client disconnect")
|
|
|
|
import gateway.platforms.api_server as api_mod
|
|
import queue as _q
|
|
|
|
stream_q: _q.Queue = _q.Queue()
|
|
stream_q.put("some streamed text")
|
|
stream_q.put(None) # EOS sentinel
|
|
|
|
async def _agent_coro():
|
|
await asyncio.sleep(0.01)
|
|
return ({"final_response": "", "messages": [], "api_calls": 0},
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
|
|
agent_task = asyncio.ensure_future(_agent_coro())
|
|
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
|
|
with patch.object(api_mod.web, "StreamResponse", return_value=_DisconnectingStreamResponse()):
|
|
await adapter._write_sse_responses(
|
|
request=fake_request,
|
|
response_id=response_id,
|
|
model="hermes-agent",
|
|
created_at=int(time.time()),
|
|
stream_q=stream_q,
|
|
agent_task=agent_task,
|
|
agent_ref=[None],
|
|
conversation_history=[],
|
|
user_message="will disconnect",
|
|
instructions=None,
|
|
conversation=None,
|
|
store=True,
|
|
session_id=None,
|
|
)
|
|
|
|
stored = adapter._response_store.get(response_id)
|
|
assert stored is not None, "snapshot must survive client disconnect"
|
|
assert stored["response"]["status"] == "incomplete"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth on endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEndpointAuth:
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completions_requires_auth(self, auth_adapter):
|
|
app = _create_app(auth_adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={"model": "test", "messages": [{"role": "user", "content": "hi"}]},
|
|
)
|
|
assert resp.status == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigIntegration:
|
|
def test_platform_enum_has_api_server(self):
|
|
assert Platform.API_SERVER.value == "api_server"
|
|
|
|
|
|
def test_env_override_cors_origins(self, monkeypatch):
|
|
monkeypatch.setenv("API_SERVER_ENABLED", "true")
|
|
monkeypatch.setenv("API_SERVER_KEY", "opensslrandhex32strongkey")
|
|
monkeypatch.setenv(
|
|
"API_SERVER_CORS_ORIGINS",
|
|
"http://localhost:3000, http://127.0.0.1:3000",
|
|
)
|
|
from gateway.config import load_gateway_config
|
|
config = load_gateway_config()
|
|
assert config.platforms[Platform.API_SERVER].extra.get("cors_origins") == [
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
]
|
|
|
|
def test_api_server_in_connected_platforms(self):
|
|
config = GatewayConfig()
|
|
config.platforms[Platform.API_SERVER] = PlatformConfig(
|
|
enabled=True, extra={"key": "opensslrandhex32strongkey"}
|
|
)
|
|
connected = config.get_connected_platforms()
|
|
assert Platform.API_SERVER in connected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multiple system messages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMultipleSystemMessages:
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_system_messages_concatenated(self, adapter):
|
|
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"messages": [
|
|
{"role": "system", "content": "You are helpful."},
|
|
{"role": "system", "content": "Be concise."},
|
|
{"role": "user", "content": "Hello"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
call_kwargs = mock_run.call_args.kwargs
|
|
prompt = call_kwargs["ephemeral_system_prompt"]
|
|
assert "You are helpful." in prompt
|
|
assert "Be concise." in prompt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send() method (not used but required by base)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendMethod:
|
|
@pytest.mark.asyncio
|
|
async def test_send_returns_not_supported(self):
|
|
config = PlatformConfig(enabled=True)
|
|
adapter = APIServerAdapter(config)
|
|
result = await adapter.send("chat1", "hello")
|
|
assert result.success is False
|
|
assert "HTTP request/response" in result.error
|
|
|
|
|
|
class TestPlatformEventCallbackEndpoint:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_invalid_google_chat_auth(self, adapter):
|
|
app = _create_app(adapter)
|
|
app["platform_event_adapters"] = {
|
|
"google_chat": _FakeGoogleChatAdapter(
|
|
verify_ok=False,
|
|
verify_code="invalid_google_bearer",
|
|
)
|
|
}
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/api/platforms/google_chat/events",
|
|
headers={"Authorization": "Bearer bad"},
|
|
json={"type": "MESSAGE"},
|
|
)
|
|
body = await resp.json()
|
|
|
|
assert resp.status == 401
|
|
assert body["error"]["code"] == "invalid_google_bearer"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/responses/{response_id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetResponse:
|
|
@pytest.mark.asyncio
|
|
async def test_get_stored_response(self, adapter):
|
|
"""GET returns a previously stored response."""
|
|
mock_result = {"final_response": "Hello!", "messages": [], "api_calls": 1}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
# Create a response first
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "Hi"},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
response_id = data["id"]
|
|
|
|
# Now GET it
|
|
resp2 = await cli.get(f"/v1/responses/{response_id}")
|
|
assert resp2.status == 200
|
|
data2 = await resp2.json()
|
|
assert data2["id"] == response_id
|
|
assert data2["object"] == "response"
|
|
assert data2["status"] == "completed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /v1/responses/{response_id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteResponse:
|
|
@pytest.mark.asyncio
|
|
async def test_delete_stored_response(self, adapter):
|
|
"""DELETE removes a stored response and returns confirmation."""
|
|
mock_result = {"final_response": "Hello!", "messages": [], "api_calls": 1}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "Hi"},
|
|
)
|
|
|
|
data = await resp.json()
|
|
response_id = data["id"]
|
|
|
|
# Delete it
|
|
resp2 = await cli.delete(f"/v1/responses/{response_id}")
|
|
assert resp2.status == 200
|
|
data2 = await resp2.json()
|
|
assert data2["id"] == response_id
|
|
assert data2["object"] == "response"
|
|
assert data2["deleted"] is True
|
|
|
|
# Verify it's gone
|
|
resp3 = await cli.get(f"/v1/responses/{response_id}")
|
|
assert resp3.status == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool calls in output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestToolCallsInOutput:
|
|
@pytest.mark.asyncio
|
|
async def test_tool_calls_in_output(self, adapter):
|
|
"""When agent returns tool calls, they appear as function_call items."""
|
|
mock_result = {
|
|
"final_response": "The result is 42.",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_abc123",
|
|
"function": {
|
|
"name": "calculator",
|
|
"arguments": '{"expression": "6*7"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_abc123",
|
|
"content": "42",
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": "The result is 42.",
|
|
},
|
|
],
|
|
"api_calls": 2,
|
|
}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "What is 6*7?"},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
output = data["output"]
|
|
|
|
# Should have: function_call, function_call_output, message
|
|
assert len(output) == 3
|
|
assert output[0]["type"] == "function_call"
|
|
assert output[0]["name"] == "calculator"
|
|
assert output[0]["arguments"] == '{"expression": "6*7"}'
|
|
assert output[0]["call_id"] == "call_abc123"
|
|
assert output[1]["type"] == "function_call_output"
|
|
assert output[1]["call_id"] == "call_abc123"
|
|
assert output[1]["output"] == "42"
|
|
assert output[2]["type"] == "message"
|
|
assert output[2]["content"][0]["text"] == "The result is 42."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage / token counting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUsageCounting:
|
|
@pytest.mark.asyncio
|
|
async def test_responses_usage(self, adapter):
|
|
"""Responses API returns real token counts."""
|
|
mock_result = {"final_response": "Done", "messages": [], "api_calls": 1}
|
|
usage = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, usage)
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "Hi"},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["usage"]["input_tokens"] == 100
|
|
assert data["usage"]["output_tokens"] == 50
|
|
assert data["usage"]["total_tokens"] == 150
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Truncation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTruncation:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncation_auto_preserves_non_leading_compaction_summary(self, adapter):
|
|
"""A summary sitting after a retained system head must survive too.
|
|
|
|
The gateway /compress path can force a user-leading layout that
|
|
leaves the compaction summary after a kept system message, so the
|
|
preservation predicate must not assume the summary is at index 0.
|
|
"""
|
|
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
|
|
|
system_head = {"role": "system", "content": "You are a helpful agent."}
|
|
summary = {
|
|
"role": "user",
|
|
"content": "[CONTEXT COMPACTION — REFERENCE ONLY]\nEarlier work.",
|
|
"_compressed_summary": True,
|
|
}
|
|
long_history = [system_head, summary] + [
|
|
{"role": "user", "content": f"msg {i}"}
|
|
for i in range(148)
|
|
]
|
|
adapter._response_store.put("resp_summary_mid", {
|
|
"response": {"id": "resp_summary_mid", "object": "response"},
|
|
"conversation_history": long_history,
|
|
"instructions": None,
|
|
})
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "follow up",
|
|
"previous_response_id": "resp_summary_mid",
|
|
"truncation": "auto",
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
history = mock_run.call_args.kwargs["conversation_history"]
|
|
assert len(history) == 100
|
|
assert history[0] == summary
|
|
assert history[1]["content"] == "msg 49"
|
|
assert history[-1]["content"] == "msg 147"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response-side truncation / failure handling (issue #22496)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestChatCompletionsAgentIncomplete:
|
|
"""When the agent run yields a partial / failed result, the API server
|
|
must NOT pretend it succeeded. Either signal truncation via
|
|
finish_reason='length' (with the partial text), or 502 with an OpenAI
|
|
error envelope (no usable text). Issue #22496."""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hard_failure_redacts_secret_like_error_text(self, adapter):
|
|
raw_secret = "sk-api-server-leak-1234567890"
|
|
mock_result = {
|
|
"final_response": "",
|
|
"completed": False,
|
|
"partial": False,
|
|
"failed": True,
|
|
"error": f"provider auth failed OPENAI_API_KEY={raw_secret}",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
|
|
assert resp.status == 502
|
|
data = await resp.json()
|
|
body = json.dumps(data)
|
|
assert raw_secret not in body
|
|
assert raw_secret not in resp.headers.get("X-Hermes-Error", "")
|
|
assert "OPENAI_API_KEY=" in body
|
|
assert data["error"]["hermes"]["failed"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CORS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCORS:
|
|
def test_origin_allowed_for_non_browser_client(self, adapter):
|
|
assert adapter._origin_allowed("") is True
|
|
|
|
|
|
def test_origin_allowed_for_allowlist_match(self):
|
|
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
|
|
assert adapter._origin_allowed("http://localhost:3000") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_browser_origin_rejected_by_default(self, adapter):
|
|
"""Browser-originated requests are rejected unless explicitly allowed."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/health", headers={"Origin": "http://evil.example"})
|
|
assert resp.status == 403
|
|
assert resp.headers.get("Access-Control-Allow-Origin") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cors_allows_idempotency_key_header(self):
|
|
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.options(
|
|
"/v1/chat/completions",
|
|
headers={
|
|
"Origin": "http://localhost:3000",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "Idempotency-Key",
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
assert "Idempotency-Key" in resp.headers.get("Access-Control-Allow-Headers", "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cors_options_preflight_allowed_for_configured_origin(self):
|
|
"""Configured origins can complete browser preflight."""
|
|
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.options(
|
|
"/v1/chat/completions",
|
|
headers={
|
|
"Origin": "http://localhost:3000",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "Authorization, Content-Type",
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000"
|
|
assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conversation parameter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConversationParameter:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_separate_conversations_are_isolated(self, adapter):
|
|
"""Different conversation names have independent histories."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{"final_response": "Response A", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
# Conversation A
|
|
await cli.post("/v1/responses", json={"input": "conv-a msg", "conversation": "conv-a"})
|
|
# Conversation B
|
|
mock_run.return_value = (
|
|
{"final_response": "Response B", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
await cli.post("/v1/responses", json={"input": "conv-b msg", "conversation": "conv-b"})
|
|
|
|
# They should have different response IDs in the mapping
|
|
assert adapter._response_store.get_conversation("conv-a") != adapter._response_store.get_conversation("conv-b")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_reuse_after_eviction_no_404(self, adapter):
|
|
"""After eviction clears a conversation mapping, reusing that name starts fresh (no 404)."""
|
|
adapter._response_store = ResponseStore(max_size=1)
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{"final_response": "First", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
# Create conversation -> resp stored
|
|
resp1 = await cli.post("/v1/responses", json={
|
|
"input": "hello",
|
|
"conversation": "my-chat",
|
|
})
|
|
assert resp1.status == 200
|
|
|
|
# Evict by adding another response
|
|
mock_run.return_value = (
|
|
{"final_response": "Other", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
await cli.post("/v1/responses", json={"input": "other"})
|
|
|
|
# Conversation mapping should have been cleaned by eviction
|
|
assert adapter._response_store.get_conversation("my-chat") is None
|
|
|
|
# Reuse conversation name — should start fresh, not 404
|
|
mock_run.return_value = (
|
|
{"final_response": "Restarted", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
)
|
|
resp3 = await cli.post("/v1/responses", json={
|
|
"input": "hello again",
|
|
"conversation": "my-chat",
|
|
})
|
|
assert resp3.status == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# X-Hermes-Session-Id header (session continuity)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionIdHeader:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_traversal_session_id_header_rejected(self, auth_adapter):
|
|
"""Security (#5958): a path-traversal X-Hermes-Session-Id must be
|
|
rejected with 400 so it can't reach the filesystem artifact paths
|
|
(session snapshot / request dump) and escape the sessions dir."""
|
|
app = _create_app(auth_adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
for bad in ("../../../../etc/pwned", "/abs/path", "..\\win"):
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
headers={"X-Hermes-Session-Id": bad, "Authorization": "Bearer sk-secret"},
|
|
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
|
)
|
|
assert resp.status == 400, f"{bad!r} should be rejected"
|
|
# The agent is never invoked for a rejected ID.
|
|
assert mock_run.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provided_session_id_loads_history_from_db(self, auth_adapter):
|
|
"""When X-Hermes-Session-Id is provided, history comes from SessionDB not request body."""
|
|
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
|
db_history = [
|
|
{"role": "user", "content": "stored message 1"},
|
|
{"role": "assistant", "content": "stored reply 1"},
|
|
]
|
|
mock_db = MagicMock()
|
|
mock_db.get_messages_as_conversation.return_value = db_history
|
|
auth_adapter._session_db = mock_db
|
|
app = _create_app(auth_adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
headers={"X-Hermes-Session-Id": "existing-session", "Authorization": "Bearer sk-secret"},
|
|
# Request body has different history — should be ignored
|
|
json={
|
|
"model": "hermes-agent",
|
|
"messages": [
|
|
{"role": "user", "content": "old msg from client"},
|
|
{"role": "assistant", "content": "old reply from client"},
|
|
{"role": "user", "content": "new question"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert resp.status == 200
|
|
call_kwargs = mock_run.call_args.kwargs
|
|
# History must come from DB, not from the request body
|
|
assert call_kwargs["conversation_history"] == db_history
|
|
assert call_kwargs["user_message"] == "new question"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# X-Hermes-Session-Key header (long-term memory scoping)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionKeyHeader:
|
|
"""The session key is a stable per-channel identifier that scopes
|
|
long-term memory (e.g. Honcho) independently of the transcript-scoped
|
|
session_id. A third-party Web UI passes one stable key per assistant
|
|
channel and rotates session_id on /new, matching the native
|
|
gateway's session_key / session_id split.
|
|
"""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_key_threads_into_create_agent(self, auth_adapter):
|
|
"""End-to-end: verify AIAgent(gateway_session_key=...) receives the key via _create_agent."""
|
|
captured_kwargs = {}
|
|
|
|
def _fake_create_agent(**kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
mock_agent = MagicMock()
|
|
mock_agent.run_conversation.return_value = {"final_response": "ok", "messages": []}
|
|
mock_agent.session_prompt_tokens = 0
|
|
mock_agent.session_completion_tokens = 0
|
|
mock_agent.session_total_tokens = 0
|
|
return mock_agent
|
|
|
|
app = _create_app(auth_adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(auth_adapter, "_create_agent", side_effect=_fake_create_agent):
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
headers={
|
|
"X-Hermes-Session-Key": "agent:main:webui:dm:user-7",
|
|
"Authorization": "Bearer sk-secret",
|
|
},
|
|
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "hi"}]},
|
|
)
|
|
assert resp.status == 200
|
|
# _create_agent must be called with gateway_session_key threaded through
|
|
assert captured_kwargs.get("gateway_session_key") == "agent:main:webui:dm:user-7"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_endpoint_accepts_session_key(self, auth_adapter):
|
|
"""Responses API honors the same X-Hermes-Session-Key contract."""
|
|
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
|
app = _create_app(auth_adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
headers={
|
|
"X-Hermes-Session-Key": "webui:chan-1",
|
|
"Authorization": "Bearer sk-secret",
|
|
},
|
|
json={"model": "hermes-agent", "input": "hello", "store": False},
|
|
)
|
|
assert resp.status == 200
|
|
assert resp.headers.get("X-Hermes-Session-Key") == "webui:chan-1"
|
|
call_kwargs = mock_run.call_args.kwargs
|
|
assert call_kwargs["gateway_session_key"] == "webui:chan-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capabilities_advertises_session_key_header(self, adapter):
|
|
"""GET /v1/capabilities should advertise the new header so clients can feature-detect."""
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/capabilities")
|
|
assert resp.status == 200
|
|
data = await resp.json()
|
|
assert data["features"]["session_key_header"] == "X-Hermes-Session-Key"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-client model routing (model_routes)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_routing_adapter(routes) -> APIServerAdapter:
|
|
"""Create an adapter with model_routes configured."""
|
|
config = PlatformConfig(enabled=True, extra={"model_routes": routes})
|
|
return APIServerAdapter(config)
|
|
|
|
|
|
def _patch_create_agent_runtime(monkeypatch, captured: dict, fake_agent_cls):
|
|
"""Stub out every external dependency of _create_agent."""
|
|
monkeypatch.setattr("run_agent.AIAgent", fake_agent_cls)
|
|
monkeypatch.setattr(
|
|
"gateway.run._resolve_runtime_agent_kwargs",
|
|
lambda: {
|
|
"provider": "openrouter",
|
|
"api_key": "sk-global",
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "global/model")
|
|
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
|
|
monkeypatch.setattr(
|
|
"gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda model="": {})
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)
|
|
)
|
|
monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 90)
|
|
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
|
|
|
|
|
|
class TestModelRoutesParsing:
|
|
def test_valid_routes_are_parsed(self):
|
|
routes = {"minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"}}
|
|
adapter = _make_routing_adapter(routes)
|
|
assert adapter._model_routes == routes
|
|
|
|
|
|
def test_route_without_model_is_dropped(self):
|
|
adapter = _make_routing_adapter({"bad": {"provider": "openrouter"}})
|
|
assert adapter._model_routes == {}
|
|
|
|
|
|
class TestModelRoutesModelsEndpoint:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_models_endpoint_route_alias_fields_and_no_secrets(self):
|
|
routes = {"my-alias": {"model": "openai/gpt-5", "api_key": "sk-route-secret"}}
|
|
adapter = _make_routing_adapter(routes)
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.get("/v1/models")
|
|
data = await resp.json()
|
|
alias_entry = next(m for m in data["data"] if m["id"] == "my-alias")
|
|
assert alias_entry["root"] == "openai/gpt-5"
|
|
assert alias_entry["parent"] == adapter._model_name
|
|
# per-route api_key must never leak through the discovery endpoint
|
|
assert "sk-route-secret" not in json.dumps(data)
|
|
|
|
|
|
class TestModelRoutesHandlers:
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completions_passes_route_to_run_agent(self):
|
|
routes = {"minimax-m2": {"model": "minimax/minimax-m1", "provider": "openrouter"}}
|
|
adapter = _make_routing_adapter(routes)
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{"final_response": "hi", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
|
|
)
|
|
resp = await cli.post("/v1/chat/completions", json={
|
|
"model": "minimax-m2",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
})
|
|
assert resp.status == 200
|
|
kwargs = mock_run.call_args.kwargs
|
|
assert kwargs.get("route") == {
|
|
"model": "minimax/minimax-m1", "provider": "openrouter",
|
|
}
|
|
|
|
|
|
class TestModelRoutesAgentCreation:
|
|
|
|
def test_route_provider_resolves_provider_credentials(self, monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
|
|
monkeypatch.setattr(
|
|
"gateway.run._resolve_runtime_agent_kwargs_for_provider",
|
|
lambda provider: {
|
|
"provider": provider,
|
|
"api_key": f"sk-{provider}",
|
|
"base_url": f"https://{provider}.example/v1",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
adapter = _make_routing_adapter(
|
|
{"alias": {"model": "other/model", "provider": "otherprov"}}
|
|
)
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
monkeypatch.setattr(adapter, "_session_model_override_for", lambda *_: None)
|
|
|
|
adapter._create_agent(session_id="s1", route=adapter._resolve_route("alias"))
|
|
|
|
assert captured["model"] == "other/model"
|
|
assert captured["provider"] == "otherprov"
|
|
assert captured["api_key"] == "sk-otherprov"
|
|
|
|
|
|
def test_session_model_override_beats_route(self, monkeypatch):
|
|
"""A user-issued /model on the session must win over static route config."""
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
|
|
adapter = _make_routing_adapter({"alias": {"model": "route/model", "api_key": "sk-route"}})
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
monkeypatch.setattr(
|
|
adapter,
|
|
"_session_model_override_for",
|
|
lambda key: {
|
|
"model": "session/override-model",
|
|
"provider": "sessionprov",
|
|
"api_key": "sk-session",
|
|
"base_url": "https://session.example/v1",
|
|
"api_mode": "responses",
|
|
"credential_pool": "pool-session",
|
|
},
|
|
)
|
|
|
|
adapter._create_agent(session_id="s1", route=adapter._resolve_route("alias"))
|
|
|
|
assert captured["model"] == "session/override-model"
|
|
assert captured["provider"] == "sessionprov"
|
|
assert captured["api_key"] == "sk-session"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event-loop offloading for synchronous SessionDB calls (P1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionDbOffEventLoop:
|
|
"""Regression: synchronous SessionDB calls in the OpenAI-compatible API
|
|
server must run OFF the aiohttp event loop. A blocking SQLite read/write on
|
|
the loop freezes every in-flight request under load (same class of bug as
|
|
gateway build_channel_directory, #60794 / #60810), so each call is wrapped
|
|
in asyncio.to_thread.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_existing_session_or_404_offloads(self, auth_adapter):
|
|
import threading
|
|
|
|
captured = {}
|
|
|
|
class FakeDB:
|
|
def get_session(self, session_id):
|
|
captured["thread"] = threading.current_thread()
|
|
return {"id": session_id, "source": "api_server"}
|
|
|
|
auth_adapter._session_db = FakeDB()
|
|
session, err = await auth_adapter._get_existing_session_or_404("sess-x")
|
|
assert err is None
|
|
assert session["id"] == "sess-x"
|
|
# The blocking DB call must NOT execute on the event-loop thread.
|
|
assert captured["thread"] is not None
|
|
assert captured["thread"] != threading.current_thread()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _api_key_passes_startup_guard — fail-closed on an unverifiable key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestApiKeyStartupGuardFailsClosed:
|
|
"""The guard is the only thing between a guessable key and an endpoint the
|
|
code itself describes as ``terminal-capable agent work`` where "a guessable
|
|
key is remote code execution".
|
|
|
|
So "the strength check could not be run" must never resolve to "start
|
|
anyway" — the same posture ``tools/credential_files.py`` takes when its
|
|
deny-list cannot be consulted.
|
|
"""
|
|
|
|
class _Stub:
|
|
name = "api_server"
|
|
_host = "0.0.0.0"
|
|
|
|
def __init__(self, key):
|
|
self._api_key = key
|
|
|
|
@staticmethod
|
|
def _guard(key):
|
|
return APIServerAdapter._api_key_passes_startup_guard(
|
|
TestApiKeyStartupGuardFailsClosed._Stub(key)
|
|
)
|
|
|
|
@staticmethod
|
|
def _blocking_auth_import():
|
|
real_import = __import__
|
|
|
|
def _blocked(name, *args, **kwargs):
|
|
if name == "hermes_cli.auth":
|
|
raise ImportError("simulated: hermes_cli.auth unavailable")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
return patch("builtins.__import__", _blocked)
|
|
|
|
def test_weak_key_refused_when_check_is_unavailable(self):
|
|
"""The bug: an unimportable auth module silently dropped the check and
|
|
the server started on a 4-character key."""
|
|
with self._blocking_auth_import():
|
|
assert self._guard("test") is False
|
|
|
|
def test_strong_key_also_refused_when_check_is_unavailable(self):
|
|
"""Fail-closed: we cannot verify the key, so we do not expose the
|
|
endpoint — the log tells the operator to repair the install."""
|
|
with self._blocking_auth_import():
|
|
assert self._guard("a" * 40) is False
|
|
|
|
|
|
class TestKeyRejectionSetsNonRetryableFatalError:
|
|
"""Each startup-guard rejection must set a non-retryable fatal error so
|
|
the reconnect watcher drops the platform from the retry queue instead of
|
|
looping indefinitely.
|
|
|
|
Previously connect() returned bare ``False``, which gateway.run treated
|
|
as retryable — re-queueing every backoff interval forever and
|
|
re-instantiating the adapter (with its ResponseStore sqlite connection)
|
|
each retry (#38803: ~501 leaked connections / 1002 fds over 2.5 days,
|
|
ending in EMFILE for the whole gateway). Mirrors the port-conflict
|
|
precedent (test_port_conflict_sets_non_retryable_fatal_error, #65665).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_adapter(key, monkeypatch):
|
|
monkeypatch.delenv("API_SERVER_KEY", raising=False)
|
|
return APIServerAdapter(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={"host": "127.0.0.1", "port": 0, "key": key},
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
async def _assert_key_rejection_is_fatal(adapter):
|
|
try:
|
|
assert await adapter.connect() is False
|
|
assert adapter.has_fatal_error is True
|
|
assert adapter.fatal_error_retryable is False
|
|
assert adapter.fatal_error_code == "api_server_key_invalid"
|
|
assert "API_SERVER_KEY" in (adapter.fatal_error_message or "")
|
|
finally:
|
|
await adapter.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_key_sets_non_retryable_fatal_error(self, monkeypatch):
|
|
adapter = self._make_adapter("", monkeypatch)
|
|
await self._assert_key_rejection_is_fatal(adapter)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bare-model opt-in gate (direct_model_requests) for _request_agent_overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDirectModelRequestsGate:
|
|
"""Bare ``model`` (no ``provider``) is opt-in on OpenAI-compatible
|
|
endpoints so generic clients hardcoding "gpt-4o" keep falling back to
|
|
the gateway default (idea credit: PR #22825 by @mssteuer)."""
|
|
|
|
def test_bare_model_dropped_when_disallowed(self):
|
|
overrides = _request_agent_overrides(
|
|
{"model": "openai/gpt-5"}, allow_bare_model=False
|
|
)
|
|
assert "requested_model" not in overrides
|
|
|
|
|
|
def test_adapter_flag_opt_in(self):
|
|
adapter = APIServerAdapter(
|
|
PlatformConfig(enabled=True, extra={"direct_model_requests": True})
|
|
)
|
|
assert adapter._direct_model_requests is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_completions_bare_model_honored_when_enabled(self):
|
|
adapter = APIServerAdapter(
|
|
PlatformConfig(enabled=True, extra={"direct_model_requests": True})
|
|
)
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as cli:
|
|
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = (
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
)
|
|
resp = await cli.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": "openai/gpt-5",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
assert mock_run.call_args.kwargs.get("requested_model") == "openai/gpt-5"
|
|
|
|
|
|
class TestRouteWithoutModelKeepsDefault:
|
|
"""A model_routes alias whose route has no ``model`` key must keep the
|
|
global default model — the alias string itself is never a model name."""
|
|
|
|
def test_alias_never_leaks_as_model(self, monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
|
|
adapter = _make_routing_adapter(
|
|
{"alias": {"model": "", "api_key": "sk-route"}}
|
|
)
|
|
# _parse_model_routes drops routes without model; simulate a
|
|
# credentials-only route surviving via direct dict (defensive path).
|
|
adapter._model_routes = {"alias": {"api_key": "sk-route"}}
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
monkeypatch.setattr(adapter, "_session_model_override_for", lambda *_: None)
|
|
|
|
adapter._create_agent(
|
|
session_id="s1",
|
|
route=adapter._resolve_route("alias"),
|
|
requested_model="alias",
|
|
)
|
|
|
|
assert captured["model"] == "global/model"
|
|
assert captured["api_key"] == "sk-route"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty-model recovery + provider-auth error typing in _create_agent
|
|
# (salvaged from PR #57947 by @FvanW)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateAgentModelRecovery:
|
|
def test_create_agent_defaults_to_provider_catalog_model_when_empty(self, monkeypatch):
|
|
"""api_server.py had no equivalent of run.py's provider-catalog
|
|
default when model resolves empty but a provider did resolve (e.g.
|
|
`hermes auth add openai-codex` without `hermes model`) —
|
|
AIAgent(model="") 400s every call."""
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
|
|
monkeypatch.setattr(
|
|
"gateway.run._resolve_runtime_agent_kwargs",
|
|
lambda: {"provider": "openai-codex", "base_url": "https://example.test/v1",
|
|
"api_mode": "codex_responses"},
|
|
)
|
|
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "")
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.get_default_model_for_provider",
|
|
lambda provider: "gpt-5.5-codex" if provider == "openai-codex" else None,
|
|
)
|
|
|
|
adapter = APIServerAdapter(PlatformConfig(enabled=True))
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
|
|
agent = adapter._create_agent(session_id="api-session")
|
|
|
|
assert isinstance(agent, FakeAgent)
|
|
assert captured["model"] == "gpt-5.5-codex"
|
|
|
|
def test_create_agent_recovers_last_known_good_model_when_empty(self, monkeypatch):
|
|
"""Last-known-good recovery (#35314): a transient config-cache miss
|
|
producing an empty model would build AIAgent(model="") and fail every
|
|
call until manual retry, instead of reusing the model that just
|
|
worked."""
|
|
captured = []
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.append(dict(kwargs))
|
|
|
|
_patch_create_agent_runtime(monkeypatch, {}, FakeAgent)
|
|
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
|
|
|
|
adapter = APIServerAdapter(PlatformConfig(enabled=True))
|
|
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
|
|
|
|
# Turn 1: model resolves fine — populates the last-known-good cache
|
|
# (keyed on gateway_session_key).
|
|
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "minimax/minimax-m3")
|
|
adapter._create_agent(session_id="api-session", gateway_session_key="stable-chan-1")
|
|
assert captured[0]["model"] == "minimax/minimax-m3"
|
|
assert adapter._last_resolved_model["stable-chan-1"] == "minimax/minimax-m3"
|
|
|
|
# Turn 2: transient empty resolution, no provider catalog default —
|
|
# must recover the model from turn 1, not build model="".
|
|
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "")
|
|
monkeypatch.setattr(
|
|
"gateway.run._resolve_runtime_agent_kwargs",
|
|
lambda: {"provider": None, "base_url": None, "api_mode": None},
|
|
)
|
|
adapter._create_agent(session_id="another-session", gateway_session_key="stable-chan-1")
|
|
assert captured[1]["model"] == "minimax/minimax-m3"
|
|
|
|
|