From 8db5c465158ff503d782f2fe446fb9e2edf34fcd Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Thu, 20 Aug 2026 22:13:54 +0700 Subject: [PATCH] test(ci): add import invariants, repair DeepSeek adapter, guard headless runs deepseek_adapter.py had never been importable: it referenced ProviderAdapter and ProfileConfig, neither of which exists in src/, and profile.extra, which RouterProfileConfig does not define. Rewritten against BaseProviderAdapter with the three missing abstract methods and registered as "deepseek". Adds tests/test_import_invariants.py, which walks every shipped module and fails on broken internal references while skipping absent optional GUI extras. It also asserts that test modules importing customtkinter call pytest.importorskip, since a missing guard aborts collection of the whole session and takes the release gate with it. Adds a headless CI job that uninstalls customtkinter and runs the suite, so that invariant is enforced rather than remembered. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 26 ++++ .../router/adapters/__init__.py | 10 +- .../router/adapters/deepseek_adapter.py | 132 ++++++++++++++---- tests/test_import_invariants.py | 96 +++++++++++++ 4 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 tests/test_import_invariants.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef568e0..de24955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,29 @@ jobs: - name: Run Automated Release Gate run: | 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 diff --git a/src/antigravity_provider/router/adapters/__init__.py b/src/antigravity_provider/router/adapters/__init__.py index c9b86a7..fa1ebb2 100644 --- a/src/antigravity_provider/router/adapters/__init__.py +++ b/src/antigravity_provider/router/adapters/__init__.py @@ -1,13 +1,14 @@ """Adapter registry for router provider backends.""" from __future__ import annotations -from typing import Dict -from .base_adapter import BaseProviderAdapter + from .antigravity_adapter import AntigravityAdapter -from .codex_adapter import CodexAdapter -from .opencode_adapter import OpenCodeGoAdapter +from .base_adapter import BaseProviderAdapter from .claude_adapter import ClaudeAdapter +from .codex_adapter import CodexAdapter +from .deepseek_adapter import DeepSeekResponsesAdapter from .grok_adapter import GrokAdapter +from .opencode_adapter import OpenCodeGoAdapter _ADAPTERS: dict[str, BaseProviderAdapter] = { "antigravity": AntigravityAdapter(), @@ -22,6 +23,7 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = { "grok": GrokAdapter(), "xai": GrokAdapter(), "xai-oauth": GrokAdapter(), + "deepseek": DeepSeekResponsesAdapter(), } diff --git a/src/antigravity_provider/router/adapters/deepseek_adapter.py b/src/antigravity_provider/router/adapters/deepseek_adapter.py index 7669ef0..dcacf6a 100644 --- a/src/antigravity_provider/router/adapters/deepseek_adapter.py +++ b/src/antigravity_provider/router/adapters/deepseek_adapter.py @@ -1,61 +1,133 @@ -"""DeepSeek Responses API Adapter.""" +"""DeepSeek OpenAI-compatible provider adapter.""" from __future__ import annotations import json import logging -from typing import Any, Dict, List, Optional -import urllib.request +import os +import re import urllib.error +import urllib.request +from typing import Any -from antigravity_provider.router.adapters import ProviderAdapter -from antigravity_provider.router.router_config import ProfileConfig +from ..router_config import RouterProfileConfig +from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification 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]: - api_key = profile.extra.get("api_key", "") - base_url = profile.extra.get("base_url", "https://api.deepseek.com/v1").rstrip("/") +class DeepSeekResponsesAdapter(BaseProviderAdapter): + """Adapter for DeepSeek's OpenAI-compatible chat completions API.""" + 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: - 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") - messages = request.get("messages", []) - temperature = request.get("temperature", 0.7) + base_url = ( + profile.custom_base_url + 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, - "messages": messages, - "temperature": temperature, + "messages": request.get("messages", []), + "temperature": request.get("temperature", 0.7), } - - if "tools" in request: + if request.get("tools"): payload["tools"] = request["tools"] + if "tool_choice" in request: + payload["tool_choice"] = request["tool_choice"] if "response_format" in request: payload["response_format"] = request["response_format"] - req_bytes = json.dumps(payload).encode("utf-8") req = urllib.request.Request( f"{base_url}/chat/completions", - data=req_bytes, + data=json.dumps(payload).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", + "User-Agent": "hermes-router/1.0", }, method="POST", ) try: - with urllib.request.urlopen(req, timeout=45) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data - except urllib.error.HTTPError as e: - err_body = e.read().decode("utf-8", errors="ignore") - logger.error(f"DeepSeek HTTP error {e.code}: {err_body}") - raise RuntimeError(f"DeepSeek API error ({e.code}): {err_body}") - except Exception as e: - raise RuntimeError(f"DeepSeek connection error: {e}") + with urllib.request.urlopen(req, timeout=120) as resp: + return json.loads(resp.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as http_err: + raw_err = http_err.read().decode("utf-8", errors="replace") + try: + err_msg = json.loads(raw_err).get("error", {}).get("message", raw_err) + except Exception: + err_msg = raw_err + 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) diff --git a/tests/test_import_invariants.py b/tests/test_import_invariants.py new file mode 100644 index 0000000..1f80236 --- /dev/null +++ b/tests/test_import_invariants.py @@ -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" + )