1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей, 13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили. 2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и никогда не затрагиваются при сбросе или установке. 3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.). Пустые цепочки ролей являются нормальным рабочим состоянием. 4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново» в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>. 5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS как на пустой конфигурации, так и на заполненной. 6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
389 lines
16 KiB
Python
389 lines
16 KiB
Python
"""A32 Provider Adapters & Limits Export Test Suite.
|
|
|
|
Verifies:
|
|
1. Ollama adapter (local and remote endpoints, API key, discover_models via /models and /api/tags,
|
|
health_check, unlimited quota).
|
|
2. Claude adapter (health_check real probe, discover_models real probe with fallback).
|
|
3. OpenRouter adapter (HTTP-Referer, X-OpenRouter-Title headers, context_length and display_name discovery).
|
|
4. NVIDIA adapter (invoke, health_check, discover_models).
|
|
5. Quota & Limits export (/api/quotas/export endpoint and export_quotas action in JSON and CSV).
|
|
6. Multi-profile credential and token isolation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
|
|
from antigravity_provider.router.adapters import get_adapter
|
|
from antigravity_provider.router.adapters.claude_adapter import DEFAULT_CLAUDE_MODELS, ClaudeAdapter
|
|
from antigravity_provider.router.adapters.ollama_adapter import DEFAULT_OLLAMA_BASE_URL, OllamaAdapter
|
|
from antigravity_provider.router.adapters.openrouter_adapter import OpenRouterAdapter
|
|
from antigravity_provider.router.adapters.nvidia_adapter import NvidiaAdapter
|
|
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig
|
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
|
from antigravity_provider.router.action_handler import ActionExecutor, generate_quotas_export
|
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
|
from antigravity_provider.router.web.server import app
|
|
|
|
|
|
def _mock_urlopen(payload: dict, status: int = 200) -> MagicMock:
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
|
mock_response.status = status
|
|
mock_response.__enter__.return_value = mock_response
|
|
return mock_response
|
|
|
|
|
|
def _chat_response(content: str = "OK") -> dict:
|
|
return {
|
|
"id": "chatcmpl-test",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": content},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 1. Ollama Adapter Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_adapter_registered():
|
|
adapter = get_adapter("ollama")
|
|
assert adapter is not None
|
|
assert isinstance(adapter, OllamaAdapter)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_invoke_default_base_url():
|
|
adapter = OllamaAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="ollama-1",
|
|
provider="ollama",
|
|
preferred_models=["llama3:latest"],
|
|
auth_config={},
|
|
)
|
|
request = {
|
|
"model": "llama3:latest",
|
|
"messages": [{"role": "user", "content": "ping"}],
|
|
}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("pong"))) as mock_urlopen:
|
|
resp = adapter.invoke(profile, request)
|
|
|
|
assert resp["choices"][0]["message"]["content"] == "pong"
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/chat/completions"
|
|
assert req_arg.get_method() == "POST"
|
|
assert "Authorization" not in req_arg.headers
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_invoke_remote_custom_url_and_api_key():
|
|
adapter = OllamaAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="ollama-remote-1",
|
|
provider="ollama",
|
|
custom_base_url="https://remote-ollama.example.com:11434/v1",
|
|
preferred_models=["mistral:latest"],
|
|
auth_config={"api_key": "secret-bearer-token"},
|
|
)
|
|
request = {
|
|
"model": "mistral:latest",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("hello"))) as mock_urlopen:
|
|
resp = adapter.invoke(profile, request)
|
|
|
|
assert resp["choices"][0]["message"]["content"] == "hello"
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.get_full_url() == "https://remote-ollama.example.com:11434/v1/chat/completions"
|
|
assert req_arg.headers.get("Authorization") == "Bearer secret-bearer-token"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_discover_models_v1_models_endpoint():
|
|
adapter = OllamaAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="ollama-1",
|
|
provider="ollama",
|
|
auth_config={},
|
|
)
|
|
models_payload = {"data": [{"id": "qwen2.5-coder:7b"}, {"id": "llama3.1:8b"}]}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
|
|
models = adapter.discover_models(profile)
|
|
|
|
assert models == ["llama3.1:8b", "qwen2.5-coder:7b"]
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.get_full_url() == "http://127.0.0.1:11434/v1/models"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_discover_models_fallback_to_native_api_tags():
|
|
adapter = OllamaAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="ollama-1",
|
|
provider="ollama",
|
|
auth_config={},
|
|
)
|
|
native_tags_payload = {
|
|
"models": [
|
|
{"name": "deepseek-r1:14b", "model": "deepseek-r1:14b"},
|
|
{"name": "phi4:latest", "model": "phi4:latest"},
|
|
]
|
|
}
|
|
|
|
def _side_effect(req, timeout=5):
|
|
if req.get_full_url().endswith("/v1/models"):
|
|
raise OSError("404 Not Found")
|
|
if req.get_full_url().endswith("/api/tags"):
|
|
return _mock_urlopen(native_tags_payload)
|
|
raise OSError("Unknown url")
|
|
|
|
with patch("urllib.request.urlopen", side_effect=_side_effect):
|
|
models = adapter.discover_models(profile)
|
|
|
|
assert models == ["deepseek-r1:14b", "phi4:latest"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_health_check():
|
|
adapter = OllamaAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="ollama-1",
|
|
provider="ollama",
|
|
auth_config={},
|
|
)
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen({"data": []}, status=200)):
|
|
assert adapter.health_check(profile) is True
|
|
|
|
with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
|
|
assert adapter.health_check(profile) is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ollama_quota_unlimited():
|
|
service = AccountQuotaService.get()
|
|
snap = service.get_snapshot("ollama", "ollama-1")
|
|
assert snap is not None
|
|
assert snap.buckets[0].status == "unlimited"
|
|
assert snap.buckets[0].remaining_percent == 100.0
|
|
assert "Без ограничений" in snap.buckets[0].formatted_remaining()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 2. Claude Adapter Tests (Real Probes & Fallback)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_claude_health_check_real_probe():
|
|
adapter = ClaudeAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="claude-1",
|
|
provider="claude",
|
|
auth_config={"api_key": "sk-ant-testkey"},
|
|
)
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen({"data": []}, status=200)) as mock_urlopen:
|
|
ok = adapter.health_check(profile)
|
|
|
|
assert ok is True
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.get_full_url() == "https://api.anthropic.com/v1/models"
|
|
assert req_arg.headers.get("X-api-key") == "sk-ant-testkey"
|
|
assert req_arg.headers.get("Anthropic-version") == "2023-06-01"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_claude_discover_models_api_probe_and_fallback():
|
|
adapter = ClaudeAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="claude-1",
|
|
provider="claude",
|
|
auth_config={"access_token": "oauth-token-123"},
|
|
)
|
|
models_payload = {
|
|
"data": [
|
|
{"id": "claude-3-7-sonnet-20250219", "display_name": "Claude 3.7 Sonnet"},
|
|
{"id": "claude-3-5-haiku-20241022", "display_name": "Claude 3.5 Haiku"},
|
|
]
|
|
}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
|
|
models = adapter.discover_models(profile)
|
|
|
|
assert models == ["claude-3-5-haiku-20241022", "claude-3-7-sonnet-20250219"]
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.headers.get("Authorization") == "Bearer oauth-token-123"
|
|
assert req_arg.headers.get("Anthropic-beta") == "oauth-2025-04-20"
|
|
|
|
# Fallback test on error
|
|
with patch("urllib.request.urlopen", side_effect=OSError("API offline")):
|
|
fallback_models = adapter.discover_models(profile)
|
|
assert fallback_models == DEFAULT_CLAUDE_MODELS
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 3. OpenRouter Adapter Tests (Referer, Title, Context Length, Display Name)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_openrouter_headers_and_metadata_discovery():
|
|
adapter = OpenRouterAdapter()
|
|
profile = RouterProfileConfig(
|
|
profile_id="openrouter-1",
|
|
provider="openrouter",
|
|
auth_config={"api_key": "sk-or-v1-key"},
|
|
)
|
|
models_payload = {
|
|
"data": [
|
|
{
|
|
"id": "openai/gpt-4o",
|
|
"name": "OpenAI: GPT-4o",
|
|
"context_length": 128000,
|
|
},
|
|
{
|
|
"id": "anthropic/claude-3.5-sonnet",
|
|
"name": "Anthropic: Claude 3.5 Sonnet",
|
|
"context_length": 200000,
|
|
},
|
|
]
|
|
}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(models_payload)) as mock_urlopen:
|
|
models = adapter.discover_models(profile)
|
|
|
|
assert "openai/gpt-4o" in models
|
|
assert "anthropic/claude-3.5-sonnet" in models
|
|
|
|
req_arg = mock_urlopen.call_args[0][0]
|
|
assert req_arg.headers.get("Http-referer") == "https://github.com/ochenstarik-ui/hermes-hub"
|
|
assert req_arg.headers.get("X-openrouter-title") == "Hermes Hub"
|
|
|
|
# Check extracted metadata & context window
|
|
gpt4o_meta = adapter.get_model_metadata("openai/gpt-4o")
|
|
assert gpt4o_meta is not None
|
|
assert gpt4o_meta["display_name"] == "OpenAI: GPT-4o"
|
|
assert gpt4o_meta["context_length"] == 128000
|
|
assert adapter.get_context_window(profile, "openai/gpt-4o") == 128000
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 4. NVIDIA Adapter Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_nvidia_adapter_invoke_and_health():
|
|
adapter = get_adapter("nvidia")
|
|
assert isinstance(adapter, NvidiaAdapter)
|
|
|
|
profile = RouterProfileConfig(
|
|
profile_id="nvidia-1",
|
|
provider="nvidia",
|
|
auth_config={"api_key": "nvapi-test"},
|
|
)
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen(_chat_response("NV_OK"))):
|
|
resp = adapter.invoke(profile, {"model": "meta/llama-3.1-8b-instruct", "messages": [{"role": "user", "content": "hi"}]})
|
|
assert resp["choices"][0]["message"]["content"] == "NV_OK"
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_urlopen({}, status=200)):
|
|
assert adapter.health_check(profile) is True
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 5. Quota & Limits Export Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_generate_quotas_export_json_and_csv():
|
|
json_export = generate_quotas_export(format="json")
|
|
assert isinstance(json_export, dict)
|
|
assert "exported_at" in json_export
|
|
assert "total_profiles" in json_export
|
|
assert "profiles" in json_export
|
|
assert "rows" in json_export
|
|
assert isinstance(json_export["profiles"], list)
|
|
|
|
csv_export = generate_quotas_export(format="csv")
|
|
assert isinstance(csv_export, str)
|
|
reader = csv.reader(io.StringIO(csv_export))
|
|
header = next(reader)
|
|
assert "provider" in header
|
|
assert "profile_id" in header
|
|
assert "remaining_percent" in header
|
|
assert "status" in header
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_action_handler_export_quotas():
|
|
res_json = ActionExecutor.execute("export_quotas", {"format": "json"})
|
|
assert res_json["ok"] is True
|
|
assert res_json["data"]["format"] == "json"
|
|
assert "report" in res_json["data"]
|
|
|
|
res_csv = ActionExecutor.execute("export_quotas", {"format": "csv"})
|
|
assert res_csv["ok"] is True
|
|
assert res_csv["data"]["format"] == "csv"
|
|
assert "content" in res_csv["data"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_api_quotas_export_endpoint():
|
|
client = TestClient(app)
|
|
|
|
# JSON export
|
|
resp_json = client.get("/api/quotas/export?format=json")
|
|
assert resp_json.status_code == 200
|
|
data = resp_json.json()
|
|
assert "exported_at" in data
|
|
assert "profiles" in data
|
|
|
|
# CSV export
|
|
resp_csv = client.get("/api/quotas/export?format=csv")
|
|
assert resp_csv.status_code == 200
|
|
assert "text/csv" in resp_csv.headers["content-type"]
|
|
assert "hermes_quotas_export.csv" in resp_csv.headers["content-disposition"]
|
|
assert "provider,profile_id" in resp_csv.text
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 6. Multi-Profile Key Isolation Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_profile_credential_isolation(tmp_path):
|
|
with patch("antigravity_provider.paths.get_hermes_home", return_value=tmp_path):
|
|
ProfileAuthManager.save_profile_auth("ollama", "ollama-1", {"api_key": "key-ollama-1", "base_url": "http://127.0.0.1:11434/v1"})
|
|
ProfileAuthManager.save_profile_auth("ollama", "ollama-2", {"api_key": "key-ollama-2", "base_url": "http://192.168.1.50:11434/v1"})
|
|
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", {"api_key": "key-or-1"})
|
|
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-2", {"api_key": "key-or-2"})
|
|
|
|
p1 = ProfileAuthManager.load_profile_auth("ollama", "ollama-1")
|
|
p2 = ProfileAuthManager.load_profile_auth("ollama", "ollama-2")
|
|
or1 = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
|
or2 = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-2")
|
|
|
|
assert p1["api_key"] == "key-ollama-1"
|
|
assert p2["api_key"] == "key-ollama-2"
|
|
assert p1["base_url"] != p2["base_url"]
|
|
|
|
assert or1["api_key"] == "key-or-1"
|
|
assert or2["api_key"] == "key-or-2"
|
|
assert or1["api_key"] != or2["api_key"]
|