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 <noreply@anthropic.com>
This commit is contained in:
parent
0c511cd3b6
commit
8db5c46515
4 changed files with 230 additions and 34 deletions
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
96
tests/test_import_invariants.py
Normal file
96
tests/test_import_invariants.py
Normal 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"
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue