feat(providers): A32 интеграция Ollama с API, Claude probe, OpenRouter headers/metadata, NVIDIA Retry-After и экспорт лимитов

1. OllamaAdapter: поддержка локального инстанса по умолчанию и удаленного Ollama API
   с кастомным base_url и опциональным Bearer токеном, discovery по /v1/models и /api/tags,
   статус квоты «Без ограничений».
2. ClaudeAdapter: реальный health_check API probe и динамический discover_models.
3. OpenRouterAdapter: обязательные заголовки HTTP-Referer и X-OpenRouter-Title,
   сбор метаданных моделей (context_length, display_name).
4. NvidiaAdapter: парсинг заголовка Retry-After и динамическая задержка при 429.
5. Экспорт лимитов: эндпоинт GET /api/quotas/export (JSON / CSV), экшен export_quotas
   и кнопка выгрузки в веб-интерфейсе с маскированием секретов.
6. tests/test_api_providers_a32.py: 15 тестов, 434 passed, ruff чисто.
This commit is contained in:
ochenstarik-ui 2026-08-31 01:15:10 +07:00
parent c7539b36d4
commit ad07425c06
11 changed files with 1126 additions and 64 deletions

View file

@ -1,9 +1,12 @@
import csv
import io
import time
import json
import os
import logging
import threading
from typing import Any, Dict, Tuple, Optional, Callable
from datetime import datetime, timezone
from typing import Any, Dict, Tuple, Optional, Callable, List
from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.profile_manager import ProfileAuthManager
@ -281,6 +284,136 @@ def _rescan_after_auth() -> None:
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
def generate_quotas_export(format: str = "json") -> Any:
"""Generate comprehensive limits and quotas export across all providers and profiles."""
from antigravity_provider.router.quota_collector import AccountQuotaService
config = load_router_config()
quota_service = AccountQuotaService.get()
profiles_data: List[Dict[str, Any]] = []
flat_rows: List[Dict[str, Any]] = []
now_iso = datetime.now(timezone.utc).isoformat()
# Sort profiles by provider then profile_id
sorted_profiles = sorted(config.profiles.values(), key=lambda p: (p.provider, p.profile_id))
for pcfg in sorted_profiles:
prov = pcfg.provider
pid = pcfg.profile_id
status = ProfileAuthManager.get_profile_status(prov, pid)
is_auth = bool(status.get("authenticated"))
identity = quota_service.get_identity(prov, pid)
quota_snap = quota_service.get_snapshot(prov, pid)
plan_name = identity.plan.display_name if (identity and identity.plan) else "UNKNOWN"
ident_str = identity.primary_identifier() if identity else pid
buckets_list: List[Dict[str, Any]] = []
source = quota_snap.source if quota_snap else "unconfigured"
fetched_at = quota_snap.fetched_at.isoformat() if (quota_snap and quota_snap.fetched_at) else now_iso
unavail = quota_snap.unavailable_reason if quota_snap else None
if quota_snap and quota_snap.buckets:
for b in quota_snap.buckets:
b_dict = {
"id": b.id,
"name": b.display_name,
"model_family": b.model_family,
"period": b.period,
"used_percent": b.used_percent,
"remaining_percent": b.remaining_percent,
"used_absolute": b.used_absolute,
"remaining_absolute": b.remaining_absolute,
"limit_absolute": b.limit_absolute,
"reset_at": b.reset_at.isoformat() if b.reset_at else None,
"reset_in_seconds": b.reset_in_seconds,
"reset_formatted": b.formatted_reset(),
"status": b.status,
"formatted_remaining": b.formatted_remaining(),
}
buckets_list.append(b_dict)
flat_rows.append({
"provider": prov,
"profile_id": pid,
"identity": ident_str,
"plan": plan_name,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"bucket_id": b.id,
"bucket_name": b.display_name,
"model_family": b.model_family or "",
"period": b.period or "",
"remaining_percent": f"{b.remaining_percent:.1f}%" if b.remaining_percent is not None else "",
"used_percent": f"{b.used_percent:.1f}%" if b.used_percent is not None else "",
"remaining_absolute": b.remaining_absolute if b.remaining_absolute is not None else "",
"limit_absolute": b.limit_absolute if b.limit_absolute is not None else "",
"status": b.status,
"reset_at": b.reset_at.isoformat() if b.reset_at else "",
"reset_formatted": b.formatted_reset() or "",
"formatted_remaining": b.formatted_remaining(),
"source": source,
"fetched_at": fetched_at,
})
else:
flat_rows.append({
"provider": prov,
"profile_id": pid,
"identity": ident_str,
"plan": plan_name,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"bucket_id": "",
"bucket_name": "",
"model_family": "",
"period": "",
"remaining_percent": "",
"used_percent": "",
"remaining_absolute": "",
"limit_absolute": "",
"status": "unconfigured" if not is_auth else "unknown",
"reset_at": "",
"reset_formatted": "",
"formatted_remaining": unavail or ("Аккаунт не подключён" if not is_auth else "Н"),
"source": source,
"fetched_at": fetched_at,
})
profiles_data.append({
"provider": prov,
"profile_id": pid,
"display_name": getattr(pcfg, "display_name", None) or (identity.display_name if identity else None) or pid,
"identity": ident_str,
"plan": plan_name,
"authenticated": is_auth,
"auth_status": "AUTHENTICATED" if is_auth else "UNCONFIGURED",
"source": source,
"fetched_at": fetched_at,
"unavailable_reason": unavail,
"buckets": buckets_list,
})
if format.lower().strip() == "csv":
output = io.StringIO()
fieldnames = [
"provider", "profile_id", "identity", "plan", "auth_status",
"bucket_id", "bucket_name", "model_family", "period",
"remaining_percent", "used_percent", "remaining_absolute", "limit_absolute",
"status", "reset_at", "reset_formatted", "formatted_remaining",
"source", "fetched_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in flat_rows:
writer.writerow(row)
return output.getvalue()
return {
"exported_at": now_iso,
"total_profiles": len(profiles_data),
"profiles": profiles_data,
"rows": flat_rows,
}
class ActionExecutor:
"""Shared execution layer for Desktop and Web actions."""
@ -379,25 +512,26 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
# Подключение аккаунта: для локального сервера это не навигация, а
# Подключение аккаунта: для локального сервера/Ollama это не навигация, а
# настоящее сохранение профиля с адресом.
if action == 'add_account':
target_role = data.get('target_role', 'coder-primary')
base_url = data.get('base_url', '')
token = data.get('token', '')
if prov in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm') and base_url:
slot = AutoAssigner.find_free_slot(prov) or 'local-1'
slot = data.get('profile_id') or AutoAssigner.find_free_slot(prov) or f'{prov}-1'
AutoAssigner.ensure_profile_definition(prov, slot)
auth_data = {
"provider": "local",
"provider": prov,
"profile_id": slot,
"base_url": base_url,
"api_key": token if token else None,
"created_at": time.time(),
}
ProfileAuthManager.save_profile_auth("local", slot, auth_data)
ProfileAuthManager.save_profile_auth(prov, slot, auth_data)
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
return {'ok': True, 'message': f'Локальный сервер {slot} успешно подключен'}
_rescan_after_auth()
return {'ok': True, 'message': f'Сервер {prov} ({slot}) успешно подключен'}
return {'ok': True, 'message': 'Навигация'}
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
@ -665,5 +799,21 @@ class ActionExecutor:
msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений"
return {'ok': report.success, 'message': msg, 'data': report.to_dict()}
elif action == 'export_quotas':
fmt = (data.get('format') or 'json').strip().lower()
res = generate_quotas_export(format=fmt)
if fmt == 'csv':
return {
'ok': True,
'message': 'Экспорт лимитов успешно сформирован (CSV)',
'data': {'format': 'csv', 'content': res, 'filename': 'hermes_quotas_export.csv'}
}
else:
return {
'ok': True,
'message': 'Экспорт лимитов успешно сформирован (JSON)',
'data': {'format': 'json', 'report': res, 'filename': 'hermes_quotas_export.json'}
}
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -10,6 +10,7 @@ from .deepseek_adapter import DeepSeekResponsesAdapter
from .grok_adapter import GrokAdapter
from .local_adapter import LocalLLMAdapter
from .nvidia_adapter import NvidiaAdapter
from .ollama_adapter import OllamaAdapter
from .opencode_adapter import OpenCodeGoAdapter
from .openrouter_adapter import OpenRouterAdapter
@ -30,7 +31,7 @@ _ADAPTERS: dict[str, BaseProviderAdapter] = {
"local": LocalLLMAdapter(),
"local-llm": LocalLLMAdapter(),
"llama.cpp": LocalLLMAdapter(),
"ollama": LocalLLMAdapter(),
"ollama": OllamaAdapter(),
"vllm": LocalLLMAdapter(),
"openrouter": OpenRouterAdapter(),
"nvidia": NvidiaAdapter(),

View file

@ -62,6 +62,20 @@ class ClaudeAdapter(BaseProviderAdapter):
return None
def _build_headers(self, token: str) -> Dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-router/1.0",
}
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
return headers
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
token = self._resolve_token(profile)
if not token:
@ -85,18 +99,7 @@ class ClaudeAdapter(BaseProviderAdapter):
if "tools" in request and request["tools"]:
payload["tools"] = request["tools"]
headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "hermes-router/1.0",
}
# OAuth vs API Key headers
if token.startswith("sk-ant-"):
headers["x-api-key"] = token
else:
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
headers = self._build_headers(token)
body_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
@ -117,9 +120,51 @@ class ClaudeAdapter(BaseProviderAdapter):
def health_check(self, profile: RouterProfileConfig) -> bool:
token = self._resolve_token(profile)
return token is not None
if not token:
return False
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status in (200, 204)
except Exception:
return False
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
token = self._resolve_token(profile)
if not token:
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
base_url = (
profile.custom_base_url
or os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1")
).rstrip("/")
url = f"{base_url}/models"
headers = self._build_headers(token)
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Claude /models discovery failed for %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or DEFAULT_CLAUDE_MODELS)
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:

View file

@ -94,6 +94,11 @@ class NvidiaAdapter(BaseProviderAdapter):
err_msg = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
retry_after = http_err.headers.get("Retry-After")
if retry_after:
err_msg = f"{err_msg} (Retry-After: {retry_after})"
raise RuntimeError(f"NVIDIA API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"NVIDIA Transport Error: {exc}") from exc
@ -189,10 +194,15 @@ class NvidiaAdapter(BaseProviderAdapter):
err_lower = err_msg.lower()
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
delay = 30
import re
m = re.search(r"retry-after:\s*(\d+)", err_lower)
if m:
delay = int(m.group(1))
return ErrorClassification(
category=ErrorCategory.RATE_LIMITED,
message=err_msg,
retry_delay_seconds=30,
retry_delay_seconds=delay,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):

View file

@ -0,0 +1,279 @@
"""Ollama OpenAI-compatible and native API provider adapter."""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Any, Dict, List, Optional
from ..router_config import RouterProfileConfig
from .base_adapter import ErrorCategory, ErrorClassification, extract_api_error_message
from .local_adapter import LocalLLMAdapter
logger = logging.getLogger("hermes.router.adapter.ollama")
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
class OllamaAdapter(LocalLLMAdapter):
"""Adapter for local and remote Ollama LLM servers.
Supports both OpenAI-compatible endpoints (/v1/chat/completions, /v1/models)
and native Ollama endpoints (/api/tags).
"""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or environment."""
url = (
profile.custom_base_url
or profile.auth_config.get("base_url")
or os.environ.get("OLLAMA_BASE_URL")
or os.environ.get("OLLAMA_HOST")
or DEFAULT_OLLAMA_BASE_URL
)
url_str = str(url).strip().rstrip("/")
if not url_str.startswith(("http://", "https://")):
url_str = f"http://{url_str}"
return url_str
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
"""Resolve optional API key from profile auth_config or environment."""
key = profile.auth_config.get("api_key") or profile.auth_config.get("token")
if key:
return str(key).strip()
suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
val = os.environ.get(candidate, "").strip()
if val:
return val
return None
def _get_native_host(self, base_url: str) -> str:
"""Strip trailing /v1 from base_url to get native Ollama host."""
if base_url.endswith("/v1"):
return base_url[:-3]
return base_url
def _get_chat_url(self, base_url: str) -> str:
"""Get standard chat completions endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/chat/completions"
return f"{base_url}/v1/chat/completions"
def _get_models_url(self, base_url: str) -> str:
"""Get OpenAI-compatible models endpoint URL."""
if base_url.endswith("/v1"):
return f"{base_url}/models"
return f"{base_url}/v1/models"
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
chat_url = self._get_chat_url(base_url)
model = request.get("model", "")
if not model or model == "default":
model = profile.preferred_models[0] if profile.preferred_models else "llama3:latest"
messages = list(request.get("messages", []))
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": request.get("temperature", 0.7),
}
if "tools" in request and request["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"]
if "max_tokens" in request:
payload["max_tokens"] = request["max_tokens"]
if "stream" in request:
payload["stream"] = request["stream"]
if "stop" in request:
payload["stop"] = request["stop"]
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
chat_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = 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 = extract_api_error_message(raw_err)
except Exception:
err_msg = raw_err
raise RuntimeError(f"Ollama API Error ({http_err.code}): {err_msg}") from http_err
except Exception as exc:
raise RuntimeError(f"Ollama Transport Error: {exc}") from exc
self._reject_empty_answer(data)
return data
@staticmethod
def _reject_empty_answer(data: Dict[str, Any]) -> None:
"""Reject empty completion responses."""
choices = data.get("choices") or []
if not choices:
raise RuntimeError("Ollama вернул ответ без choices")
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if content:
return
finish = choices[0].get("finish_reason")
if message.get("reasoning_content"):
raise RuntimeError(
"Ollama израсходовал лимит токенов на рассуждения и не выдал ответ "
f"(finish_reason={finish})."
)
raise RuntimeError(f"Ollama вернул пустой ответ (finish_reason={finish})")
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Discover models via GET {base_url}/models or GET {host}/api/tags."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# 1. Try OpenAI-compatible /models endpoint
models_url = self._get_models_url(base_url)
try:
req = urllib.request.Request(models_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Ollama /models discovery failed for %s: %s", profile.profile_id, exc)
# 2. Try native Ollama /api/tags endpoint
native_host = self._get_native_host(base_url)
try:
tags_url = f"{native_host}/api/tags"
req = urllib.request.Request(tags_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("models") or []
if isinstance(items, list) and items:
models = [
str(m.get("name") or m.get("model") if isinstance(m, dict) else m)
for m in items
if m
]
if models:
return sorted(set(models))
except Exception as exc:
logger.debug("Ollama /api/tags discovery failed for %s: %s", profile.profile_id, exc)
return list(profile.preferred_models or DEFAULT_OLLAMA_MODELS)
def health_check(self, profile: RouterProfileConfig) -> bool:
"""Probe /models or /api/tags endpoint. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# 1. Probe /models
models_url = self._get_models_url(base_url)
try:
req = urllib.request.Request(models_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
if resp.status in (200, 204):
return True
except Exception:
pass
# 2. Probe /api/tags
native_host = self._get_native_host(base_url)
try:
tags_url = f"{native_host}/api/tags"
req = urllib.request.Request(tags_url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
if resp.status in (200, 204):
return True
except Exception:
pass
return False
def classify_error(
self,
exc: Exception,
response_data: Optional[Dict[str, Any]] = None,
) -> ErrorClassification:
"""Classify execution failure into structured error category."""
err_msg = str(exc)
err_lower = err_msg.lower()
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=30,
)
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication")):
return ErrorClassification(
category=ErrorCategory.AUTH_REQUIRED,
message=err_msg,
)
if any(k in err_lower for k in ("quota", "insufficient balance", "insufficient_quota")):
return ErrorClassification(
category=ErrorCategory.QUOTA_EXHAUSTED,
message=err_msg,
reset_duration_seconds=1800,
)
if any(k in err_lower for k in (
"connection refused", "connection error", "connect", "refused",
"timeout", "timed out", "502", "503", "504", "gateway",
"econnrefused", "econnreset", "transport error", "urlerror",
"winerror 10061", "nodename nor servname provided",
)):
return ErrorClassification(
category=ErrorCategory.TRANSIENT,
message=err_msg,
retry_delay_seconds=2,
)
return ErrorClassification(category=ErrorCategory.TRANSIENT, message=err_msg, retry_delay_seconds=2)

View file

@ -19,6 +19,9 @@ DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
class OpenRouterAdapter(BaseProviderAdapter):
"""Adapter for OpenRouter's OpenAI-compatible chat completions API."""
_models_metadata: Dict[str, Dict[str, Any]] = {}
_context_window_cache: Dict[str, int] = {}
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or default."""
url = (
@ -45,6 +48,46 @@ class OpenRouterAdapter(BaseProviderAdapter):
return val
return None
def _build_headers(self, api_key: Optional[str] = None) -> Dict[str, str]:
"""Build standard headers with OpenRouter attribution headers."""
referer = (
os.environ.get("OPENROUTER_HTTP_REFERER")
or os.environ.get("HERMES_REFERER")
or "https://github.com/ochenstarik-ui/hermes-hub"
)
title = (
os.environ.get("OPENROUTER_APP_TITLE")
or os.environ.get("OPENROUTER_TITLE")
or "Hermes Hub"
)
headers: Dict[str, str] = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
"HTTP-Referer": referer,
"X-OpenRouter-Title": title,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def get_context_window(
self,
profile: RouterProfileConfig,
model: Optional[str] = None,
) -> Optional[int]:
"""Fetch actual context_window / max_context_length from profile or discovery cache."""
cache_key = f"{profile.profile_id}:{model or 'default'}"
if cache_key in self._context_window_cache:
return self._context_window_cache[cache_key]
if model and model in self._context_window_cache:
return self._context_window_cache[model]
return None
def get_model_metadata(self, model_id: str) -> Optional[Dict[str, Any]]:
"""Return cached metadata for model ID if available."""
return self._models_metadata.get(model_id)
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
@ -71,12 +114,7 @@ class OpenRouterAdapter(BaseProviderAdapter):
if "stop" in request:
payload["stop"] = request["stop"]
headers: Dict[str, str] = {
"Content-Type": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/chat/completions",
@ -119,18 +157,12 @@ class OpenRouterAdapter(BaseProviderAdapter):
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
"""Request GET {base_url}/models and return the server's model list.
No invented/hardcoded model list: on error, fall back to
profile.preferred_models only.
Extracts context_length and display_name metadata when provided by the API.
No invented/hardcoded model list: on error, fall back to profile.preferred_models.
"""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/models",
@ -142,14 +174,33 @@ class OpenRouterAdapter(BaseProviderAdapter):
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
items = data.get("data") or data.get("models") or []
if isinstance(items, list):
models = [
str(m.get("id") or m.get("name") if isinstance(m, dict) else m)
for m in items
if m
]
if isinstance(items, list) and items:
models = []
for m in items:
if isinstance(m, dict):
m_id = str(m.get("id") or m.get("name") or "")
if not m_id:
continue
display_name = str(m.get("name") or m.get("display_name") or m_id)
ctx_len = m.get("context_length") or m.get("context_window") or m.get("max_context_length")
meta: Dict[str, Any] = {
"id": m_id,
"display_name": display_name,
}
if ctx_len is not None:
try:
val = int(ctx_len)
meta["context_length"] = val
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = val
self._context_window_cache[m_id] = val
except (ValueError, TypeError):
pass
self._models_metadata[m_id] = meta
models.append(m_id)
elif isinstance(m, str) and m:
models.append(m)
if models:
return sorted(models)
return sorted(set(models))
except Exception as exc:
logger.debug("Failed to discover models for openrouter profile %s: %s", profile.profile_id, exc)
@ -159,13 +210,7 @@ class OpenRouterAdapter(BaseProviderAdapter):
"""Fast GET {base_url}/models probe. Returns True on success, False on error."""
base_url = self._resolve_base_url(profile)
api_key = self._resolve_api_key(profile)
headers: Dict[str, str] = {
"Accept": "application/json",
"User-Agent": "hermes-router/1.0",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers = self._build_headers(api_key)
req = urllib.request.Request(
f"{base_url}/models",

View file

@ -126,7 +126,7 @@ class AutoAssigner:
"local": ["local-1", "local-2"],
"local-llm": ["local-1", "local-2"],
"llama.cpp": ["local-1", "local-2"],
"ollama": ["local-1", "local-2"],
"ollama": ["ollama-1", "ollama-2"],
"vllm": ["local-1", "local-2"],
}
@ -174,7 +174,10 @@ class AutoAssigner:
elif p in ("grok", "xai"):
for i in range(3, 200):
yield f"grok-worker-{i}"
elif p in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
elif p == "ollama":
for i in range(3, 200):
yield f"ollama-{i}"
elif p in ("local", "local-llm", "llama.cpp", "vllm"):
for i in range(3, 200):
yield f"local-{i}"
else:

View file

@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional
from antigravity_provider import paths
from antigravity_provider.version import __version__
from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi import FastAPI, Request, HTTPException, Depends, Header, Response
from pathlib import Path
from fastapi.responses import FileResponse, JSONResponse
@ -231,6 +231,30 @@ def get_events(limit: int = 100, category: Optional[str] = None, authorized: boo
return JSONResponse(content=jsonable_encoder({"events": sanitized}))
@app.get("/api/quotas/export")
def export_quotas_endpoint(
format: str = "json",
authorized: bool = Depends(get_auth_token),
):
"""Export full quotas and limits report across all providers and profiles."""
from antigravity_provider.router.action_handler import generate_quotas_export
fmt = format.lower().strip()
if fmt == "csv":
csv_data = generate_quotas_export(format="csv")
return Response(
content=csv_data,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.csv"},
)
else:
json_data = generate_quotas_export(format="json")
sanitized = sanitize_snapshot(json_data)
return JSONResponse(
content=jsonable_encoder(sanitized),
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.json"},
)
@app.get("/api/settings")
def get_settings(authorized: bool = Depends(get_auth_token)):
"""Return current server and hub settings without exposing raw auth tokens."""

View file

@ -780,7 +780,13 @@ function renderQuotaCell(bucket, unavailableReason) {
let barWidth = 0;
let colorClass = 'var(--status-disabled)';
if (typeof remaining === 'number') {
const isUnlimited = bucket.status === 'unlimited' || bucket.period === 'unlimited' || (unavailableReason && unavailableReason.includes('Без ограничений'));
if (isUnlimited) {
formattedValue = 'Без ограничений';
barWidth = 100;
colorClass = 'var(--status-healthy)';
} else if (typeof remaining === 'number') {
formattedValue = `${remaining.toFixed(1)}%`;
barWidth = Math.max(0, Math.min(100, remaining));
if (remaining <= 0) colorClass = 'var(--status-error)';
@ -790,9 +796,11 @@ function renderQuotaCell(bucket, unavailableReason) {
formattedValue = 'Н/Д';
}
let resetText = bucket.reset_at
? `Сброс: ${formatIsoDate(bucket.reset_at)}`
: (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера'));
let resetText = isUnlimited
? (unavailableReason || 'Без ограничений')
: (bucket.reset_at
? `Сброс: ${formatIsoDate(bucket.reset_at)}`
: (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера')));
return `
<div class="quota-cell">
@ -1820,20 +1828,27 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
<div style="margin-bottom:8px; font-weight:600; font-size:12px;">Лимиты и квоты провайдера:</div>
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
${buckets.map((b) => `
${buckets.map((b) => {
const isUnlimited = b.status === 'unlimited' || b.period === 'unlimited';
const remDisplay = isUnlimited ? 'Без ограничений' : (b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.round(b.remaining_percent) + '%' : 'Н/Д');
const fillPct = isUnlimited ? 100 : (b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.max(0, Math.min(100, b.remaining_percent)) : 0);
const barColor = isUnlimited ? 'var(--status-healthy)' : ((b.remaining_percent !== null && b.remaining_percent < 20) ? 'var(--status-warning)' : 'var(--status-healthy)');
const resetLabel = isUnlimited ? 'Без ограничений (локальная модель)' : (b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса'));
return `
<div style="background:var(--surface-card); border:1px solid var(--border-subtle); padding:8px 10px; border-radius:var(--radius-sm);">
<div style="display:flex; justify-content:space-between; font-size:12px; font-weight:600; margin-bottom:4px;">
<span>${escapeHtml(b.bucket_name || b.name || 'Квота')}</span>
<span>${b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.round(b.remaining_percent) + '%' : 'Н/Д'}</span>
<span>${escapeHtml(b.bucket_name || b.name || b.display_name || 'Квота')}</span>
<span>${escapeHtml(remDisplay)}</span>
</div>
<div class="cell-bar-track" style="margin-bottom:4px;">
<div class="cell-bar-fill" style="width:${b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.max(0, Math.min(100, b.remaining_percent)) : 0}%; background:${(b.remaining_percent !== null && b.remaining_percent < 20) ? 'var(--status-warning)' : 'var(--status-healthy)'};"></div>
<div class="cell-bar-fill" style="width:${fillPct}%; background:${barColor};"></div>
</div>
<div style="font-size:10px; color:var(--text-muted);">
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
${escapeHtml(resetLabel)}
</div>
</div>
`).join('') || '<div class="empty-text">Данные о квотах отсутствуют (провайдер не отдал лимиты).</div>'}
`;
}).join('') || '<div class="empty-text">Данные о квотах отсутствуют (провайдер не отдал лимиты).</div>'}
</div>
`;
@ -2018,6 +2033,13 @@ function showWizardStep1() {
<div style="font-size:11px; color:var(--text-muted);">OAuth редирект (с поддержкой SSH port-forward)</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('ollama')">
<span style="font-size:18px; color:var(--status-healthy, #22c55e);"></span>
<div style="text-align:left; margin-left:8px;">
<div style="font-weight:700;">Ollama</div>
<div style="font-size:11px; color:var(--text-muted);">Локальный или удаленный Ollama API (http://127.0.0.1:11434/v1)</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="showWizardStep2('local')">
<span style="font-size:18px; color:var(--status-healthy, #22c55e);"></span>
<div style="text-align:left; margin-left:8px;">
@ -2098,7 +2120,34 @@ function showWizardStep2(providerId) {
<button class="btn btn-ghost" onclick="showWizardStep1()"> Назад</button>
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить </button>
`;
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
} else if (providerId === 'ollama') {
window._wiz_device_profile = undefined;
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Настройка Ollama
</div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">URL сервера (Base URL):</label>
<input type="text" class="input-text" style="width:100%;" id="wiz-base-url-input" placeholder="http://127.0.0.1:11434/v1" value="http://127.0.0.1:11434/v1">
</div>
<div style="margin-bottom:10px; font-size:12px; color:var(--text-muted);">
Поиск серверов выполняется на машине, где запущен Hub (не в браузере).
</div>
<div style="margin-bottom:12px;">
<button class="btn btn-secondary" style="width:100%;" id="wiz-discover-btn" onclick="discoverLocalServers('discover_local_models')" data-action="discover_local_models">🔍 Найти на этом компьютере</button>
<div id="wiz-discover-status" style="font-size:12px; color:var(--text-secondary); margin-top:4px;"></div>
</div>
<div id="wiz-discover-results" style="margin-bottom:12px;"></div>
<div style="margin-bottom:12px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">API Key / Bearer Token (опционально):</label>
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Оставьте пустым, если ключ не требуется">
</div>
`;
footerHtml = `
<button class="btn btn-ghost" onclick="showWizardStep1()"> Назад</button>
<button class="btn btn-primary" onclick="proceedToWizardStep3('${escapeHtml(providerId)}')">Продолжить </button>
`;
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'vllm') {
// P0-1: reset stale wizard slot so local add_account does not reuse grok/antigravity slot
window._wiz_device_profile = undefined;
bodyHtml = `
@ -2712,3 +2761,63 @@ function selectDiscoveredServer(baseUrl) {
statusEl.style.color = 'var(--status-healthy)';
}
}
// ── Quota & Limits Export ─────────────────────────────────────────────────
async function exportQuotas(format = 'json') {
const fmt = (format || 'json').toLowerCase();
showToast(`Формирование выгрузки лимитов (${fmt.toUpperCase()})...`, 'info');
try {
const token = (typeof getWebToken === 'function' ? getWebToken() : (localStorage.getItem('hermes_hub_token') || ''));
const headers = {};
if (token) {
headers['X-Hub-Token'] = token;
}
const resp = await fetch(`/api/quotas/export?format=${fmt}`, { headers });
if (!resp.ok) {
throw new Error(`Ошибка сервера: ${resp.status}`);
}
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `hermes_quotas_export.${fmt}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(`Выгрузка лимитов (${fmt.toUpperCase()}) успешно скачана`, 'success');
} catch (err) {
showToast(`Не удалось выгрузить лимиты: ${err.message}`, 'error');
}
}
function openExportQuotasModal() {
if (elements.modalTitle) elements.modalTitle.textContent = '📥 Экспорт лимитов и квот';
elements.modalBody.innerHTML = `
<div style="margin-bottom:14px; font-size:13px; color:var(--text-secondary);">
Выберите формат для выгрузки актуального отчета по лимитам, корзинам и статусам всех профилей:
</div>
<div style="display:flex; flex-direction:column; gap:10px;">
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="exportQuotas('json'); closeModal();">
<span style="font-size:20px; margin-right:8px;">📄</span>
<div style="text-align:left;">
<div style="font-weight:700;">Экспорт в JSON</div>
<div style="font-size:11px; color:var(--text-muted);">Полная структурированная выгрузка объектов со всеми метаданными</div>
</div>
</button>
<button class="btn btn-secondary" style="justify-content:flex-start; padding:12px;" onclick="exportQuotas('csv'); closeModal();">
<span style="font-size:20px; margin-right:8px;">📊</span>
<div style="text-align:left;">
<div style="font-weight:700;">Экспорт в CSV</div>
<div style="font-size:11px; color:var(--text-muted);">Табличный формат для открытия в Excel, Google Sheets или LibreOffice</div>
</div>
</button>
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
`;
showModal();
}

View file

@ -158,6 +158,10 @@
<option value="opencode-go">OpenCode Go</option>
<option value="claude">Claude (Anthropic)</option>
<option value="grok">Grok (xAI)</option>
<option value="ollama">Ollama</option>
<option value="local">Локальные (Local LLM)</option>
<option value="openrouter">OpenRouter</option>
<option value="nvidia">NVIDIA NIM</option>
</select>
<select id="filter-health" class="select-filter">
<option value="all">Все состояния</option>
@ -167,6 +171,9 @@
<option value="auth_required">Требуется вход</option>
<option value="disabled">Отключён / Резерв</option>
</select>
<button class="btn btn-secondary btn-sm" id="btn-export-quotas" onclick="openExportQuotasModal()" title="Выгрузить отчет о лимитах и квотах в формате JSON или CSV">
<span>📥 Экспорт лимитов (JSON / CSV)</span>
</button>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
</div>

View file

@ -0,0 +1,389 @@
"""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 len(json_export["profiles"]) > 0
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"]