Merge branch 'chore/import-invariants-and-ci'

This commit is contained in:
Hermes Team 2026-08-20 22:22:44 +07:00
commit 51e5b67921
4 changed files with 230 additions and 34 deletions

View file

@ -37,3 +37,29 @@ jobs:
- name: Run Automated Release Gate - name: Run Automated Release Gate
run: | run: |
python scripts/release_gate.py python scripts/release_gate.py
headless:
name: Headless Run (no GUI dependencies)
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies without GUI extras
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip uninstall -y customtkinter
# A test module importing customtkinter at module scope aborts collection
# of the WHOLE session instead of skipping itself, which takes the release
# gate down with it. This job fails if that guard is ever dropped again.
- name: Suite must skip, not abort, without GUI dependencies
run: |
pytest -q

View file

@ -1,13 +1,14 @@
"""Adapter registry for router provider backends.""" """Adapter registry for router provider backends."""
from __future__ import annotations from __future__ import annotations
from typing import Dict
from .base_adapter import BaseProviderAdapter
from .antigravity_adapter import AntigravityAdapter from .antigravity_adapter import AntigravityAdapter
from .codex_adapter import CodexAdapter from .base_adapter import BaseProviderAdapter
from .opencode_adapter import OpenCodeGoAdapter
from .claude_adapter import ClaudeAdapter from .claude_adapter import ClaudeAdapter
from .codex_adapter import CodexAdapter
from .deepseek_adapter import DeepSeekResponsesAdapter
from .grok_adapter import GrokAdapter from .grok_adapter import GrokAdapter
from .opencode_adapter import OpenCodeGoAdapter
_ADAPTERS: dict[str, BaseProviderAdapter] = { _ADAPTERS: dict[str, BaseProviderAdapter] = {
"antigravity": AntigravityAdapter(), "antigravity": AntigravityAdapter(),
@ -22,6 +23,7 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = {
"grok": GrokAdapter(), "grok": GrokAdapter(),
"xai": GrokAdapter(), "xai": GrokAdapter(),
"xai-oauth": GrokAdapter(), "xai-oauth": GrokAdapter(),
"deepseek": DeepSeekResponsesAdapter(),
} }

View file

@ -1,61 +1,133 @@
"""DeepSeek Responses API Adapter.""" """DeepSeek OpenAI-compatible provider adapter."""
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Dict, List, Optional import os
import urllib.request import re
import urllib.error import urllib.error
import urllib.request
from typing import Any
from antigravity_provider.router.adapters import ProviderAdapter from ..router_config import RouterProfileConfig
from antigravity_provider.router.router_config import ProfileConfig from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification
logger = logging.getLogger("hermes.router.adapter.deepseek") logger = logging.getLogger("hermes.router.adapter.deepseek")
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
DEFAULT_DEEPSEEK_MODELS = ["deepseek-chat", "deepseek-reasoner"]
class DeepSeekResponsesAdapter(ProviderAdapter):
"""Adapter for DeepSeek OpenAI-compatible and Responses APIs."""
def invoke(self, profile: ProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]: class DeepSeekResponsesAdapter(BaseProviderAdapter):
api_key = profile.extra.get("api_key", "") """Adapter for DeepSeek's OpenAI-compatible chat completions API."""
base_url = profile.extra.get("base_url", "https://api.deepseek.com/v1").rstrip("/")
def _resolve_api_key(self, profile: RouterProfileConfig) -> str | None:
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key)
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"DEEPSEEK_API_KEY_{suffix}", "DEEPSEEK_API_KEY"):
value = os.environ.get(candidate, "").strip()
if value:
return value
return None
def invoke(self, profile: RouterProfileConfig, request: dict[str, Any]) -> dict[str, Any]:
api_key = self._resolve_api_key(profile)
if not api_key: if not api_key:
raise ValueError(f"DeepSeek profile '{profile.profile_id}' missing API key") raise RuntimeError(f"No API key found for DeepSeek profile '{profile.profile_id}'")
model = request.get("model", "deepseek-chat") base_url = (
messages = request.get("messages", []) profile.custom_base_url
temperature = request.get("temperature", 0.7) or os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_DEEPSEEK_BASE_URL)
).rstrip("/")
payload = { model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "deepseek-chat"
payload: dict[str, Any] = {
"model": model, "model": model,
"messages": messages, "messages": request.get("messages", []),
"temperature": temperature, "temperature": request.get("temperature", 0.7),
} }
if request.get("tools"):
if "tools" in request:
payload["tools"] = request["tools"] payload["tools"] = request["tools"]
if "tool_choice" in request:
payload["tool_choice"] = request["tool_choice"]
if "response_format" in request: if "response_format" in request:
payload["response_format"] = request["response_format"] payload["response_format"] = request["response_format"]
req_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
f"{base_url}/chat/completions", f"{base_url}/chat/completions",
data=req_bytes, data=json.dumps(payload).encode("utf-8"),
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"User-Agent": "hermes-router/1.0",
}, },
method="POST", method="POST",
) )
try: try:
with urllib.request.urlopen(req, timeout=45) as resp: with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8", errors="replace"))
return data except urllib.error.HTTPError as http_err:
except urllib.error.HTTPError as e: raw_err = http_err.read().decode("utf-8", errors="replace")
err_body = e.read().decode("utf-8", errors="ignore") try:
logger.error(f"DeepSeek HTTP error {e.code}: {err_body}") err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err)
raise RuntimeError(f"DeepSeek API error ({e.code}): {err_body}") except Exception:
except Exception as e: err_msg = raw_err
raise RuntimeError(f"DeepSeek connection error: {e}") raise RuntimeError(f"DeepSeek API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"DeepSeek Transport Error: {exc}") from exc
def health_check(self, profile: RouterProfileConfig) -> bool:
return self._resolve_api_key(profile) is not None
def discover_models(self, profile: RouterProfileConfig) -> list[str]:
return list(profile.preferred_models or DEFAULT_DEEPSEEK_MODELS)
def classify_error(
self,
exc: Exception,
response_data: dict[str, Any] | None = None,
) -> ErrorClassification:
err_msg = str(exc)
err_lower = err_msg.lower()
# Rate limits first: a 429 body routinely contains the word "limit",
# which would otherwise be swallowed by the quota branch below.
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=60,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota", "arrears")):
reset_sec = 1800
m_hr = re.search(r"(\d+)\s*(?:hours?|h\b)", err_lower)
m_min = re.search(r"(\d+)\s*(?:minutes?|m\b)", err_lower)
if m_hr:
reset_sec = int(m_hr.group(1)) * 3600
elif m_min:
reset_sec = int(m_min.group(1)) * 60
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=reset_sec,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "invalid api key", "authentication")):
return ErrorClassification(category=ErrorCategory.AUTH_REQUIRED, message=err_msg)
if any(k in err_lower for k in ("timeout", "502", "503", "504", "gateway", "econnreset")):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=5,
)
return ErrorClassification(category=ErrorCategory.FATAL, message=err_msg)

View file

@ -0,0 +1,96 @@
"""Import invariants for the whole package.
Guards against two classes of rot that unit tests do not catch:
1. Dead-on-arrival modules code that references project-internal names which
do not exist, so the module has never been imported by anything.
(Found in review: ``deepseek_adapter`` imported ``ProviderAdapter`` and
``ProfileConfig``, neither of which exists anywhere in ``src/``.)
2. Missing ``pytest.importorskip`` guards a test module that imports an
optional UI dependency at module scope aborts collection of the ENTIRE
session instead of skipping itself.
Missing *third-party* optional dependencies (customtkinter, PIL, ...) are
skipped, not failed: those are legitimately absent in headless environments.
Broken *internal* references always fail.
"""
from __future__ import annotations
import importlib
import pkgutil
from pathlib import Path
import pytest
TESTS_DIR = Path(__file__).resolve().parent
# ``antigravity_provider`` is a PEP 420 namespace package whose ``__path__`` also
# contains the *installed* plugin copy under %LOCALAPPDATA%. Walk only the
# repository sources, so the suite never grades a different deployed version.
PACKAGE_ROOT = TESTS_DIR.parent / "src" / "antigravity_provider"
# Third-party packages that may legitimately be absent (GUI / optional extras).
OPTIONAL_EXTERNAL_MODULES = {
"customtkinter",
"tkinter",
"PIL",
"psutil",
"fastapi",
"uvicorn",
"pydantic",
}
def _iter_module_names() -> list[str]:
names: list[str] = []
for mod in pkgutil.walk_packages([str(PACKAGE_ROOT)], prefix="antigravity_provider."):
names.append(mod.name)
return sorted(names)
def _missing_external(exc: ImportError) -> str | None:
"""Return the optional third-party module name if *exc* is caused by one."""
name = getattr(exc, "name", None) or ""
root = name.split(".")[0]
if root in OPTIONAL_EXTERNAL_MODULES:
return root
return None
@pytest.mark.unit
@pytest.mark.parametrize("module_name", _iter_module_names())
def test_module_is_importable(module_name: str) -> None:
"""Every shipped module must import, or fail only on an absent optional extra."""
try:
importlib.import_module(module_name)
except ImportError as exc:
external = _missing_external(exc)
if external:
pytest.skip(f"optional dependency '{external}' not installed")
pytest.fail(
f"{module_name} is not importable — broken internal reference: {exc}\n"
"The module ships in the package but has never been executed by anything."
)
@pytest.mark.unit
def test_gui_test_modules_guard_optional_ui_dependency() -> None:
"""Test modules touching customtkinter must call pytest.importorskip.
Without the guard a headless environment aborts collection of the whole
session ("Interrupted: 1 error during collection") instead of skipping the
affected module, which takes the release gate down with it.
"""
offenders: list[str] = []
for path in sorted(TESTS_DIR.glob("test_*.py")):
text = path.read_text(encoding="utf-8", errors="ignore")
if "customtkinter" not in text:
continue
if "importorskip" not in text:
offenders.append(path.name)
assert not offenders, (
"test modules import customtkinter without pytest.importorskip: "
+ ", ".join(offenders)
+ " — add pytest.importorskip('customtkinter') above the import"
)