From ad07425c06cb7ea60e3ef3ad4326c245fc7354bd Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:15:10 +0700 Subject: [PATCH] =?UTF-8?q?feat(providers):=20A32=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20Ollama=20=D1=81=20?= =?UTF-8?q?API,=20Claude=20probe,=20OpenRouter=20headers/metadata,=20NVIDI?= =?UTF-8?q?A=20Retry-After=20=D0=B8=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 чисто. --- .../router/action_handler.py | 162 +++++++- .../router/adapters/__init__.py | 3 +- .../router/adapters/claude_adapter.py | 71 +++- .../router/adapters/nvidia_adapter.py | 12 +- .../router/adapters/ollama_adapter.py | 279 +++++++++++++ .../router/adapters/openrouter_adapter.py | 103 +++-- .../router/auto_assigner.py | 7 +- src/antigravity_provider/router/web/server.py | 26 +- .../router/web/static/app.js | 131 +++++- .../router/web/static/index.html | 7 + tests/test_api_providers_a32.py | 389 ++++++++++++++++++ 11 files changed, 1126 insertions(+), 64 deletions(-) create mode 100644 src/antigravity_provider/router/adapters/ollama_adapter.py create mode 100644 tests/test_api_providers_a32.py diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 5878d02..4e8a31c 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -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} diff --git a/src/antigravity_provider/router/adapters/__init__.py b/src/antigravity_provider/router/adapters/__init__.py index c01fd84..8f109c8 100644 --- a/src/antigravity_provider/router/adapters/__init__.py +++ b/src/antigravity_provider/router/adapters/__init__.py @@ -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(), diff --git a/src/antigravity_provider/router/adapters/claude_adapter.py b/src/antigravity_provider/router/adapters/claude_adapter.py index 8681ea6..109c6dd 100644 --- a/src/antigravity_provider/router/adapters/claude_adapter.py +++ b/src/antigravity_provider/router/adapters/claude_adapter.py @@ -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: diff --git a/src/antigravity_provider/router/adapters/nvidia_adapter.py b/src/antigravity_provider/router/adapters/nvidia_adapter.py index 990e518..af8520e 100644 --- a/src/antigravity_provider/router/adapters/nvidia_adapter.py +++ b/src/antigravity_provider/router/adapters/nvidia_adapter.py @@ -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")): diff --git a/src/antigravity_provider/router/adapters/ollama_adapter.py b/src/antigravity_provider/router/adapters/ollama_adapter.py new file mode 100644 index 0000000..330e216 --- /dev/null +++ b/src/antigravity_provider/router/adapters/ollama_adapter.py @@ -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) diff --git a/src/antigravity_provider/router/adapters/openrouter_adapter.py b/src/antigravity_provider/router/adapters/openrouter_adapter.py index 65ce023..f4b0777 100644 --- a/src/antigravity_provider/router/adapters/openrouter_adapter.py +++ b/src/antigravity_provider/router/adapters/openrouter_adapter.py @@ -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", diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 42a1c52..1ff937b 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -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: diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 60d4e0b..9a1f337 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -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.""" diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 590ed1f..7637815 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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 `
@@ -1820,20 +1828,27 @@ function openAccountDetailsModal(profileId, isRefresh = false) {
Лимиты и квоты провайдера:
- ${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 `
- ${escapeHtml(b.bucket_name || b.name || 'Квота')} - ${b.remaining_percent !== null && b.remaining_percent !== undefined ? Math.round(b.remaining_percent) + '%' : 'Н/Д'} + ${escapeHtml(b.bucket_name || b.name || b.display_name || 'Квота')} + ${escapeHtml(remDisplay)}
-
+
- ${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')} + ${escapeHtml(resetLabel)}
- `).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'} + `; + }).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'}
`; @@ -2018,6 +2033,13 @@ function showWizardStep1() {
OAuth редирект (с поддержкой SSH port-forward)
+ `; - } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') { + } else if (providerId === 'ollama') { + window._wiz_device_profile = undefined; + bodyHtml = ` +
+ Шаг 2 из 3: Настройка Ollama +
+
+ + +
+
+ Поиск серверов выполняется на машине, где запущен Hub (не в браузере). +
+
+ +
+
+
+
+ + +
+ `; + footerHtml = ` + + + `; + } 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 = ` +
+ Выберите формат для выгрузки актуального отчета по лимитам, корзинам и статусам всех профилей: +
+
+ + +
+ `; + elements.modalFooter.innerHTML = ` + + `; + showModal(); +} diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 92f0fec..1c043e2 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -158,6 +158,10 @@ + + + + +
Показано: 0 из 0 аккаунтов
diff --git a/tests/test_api_providers_a32.py b/tests/test_api_providers_a32.py new file mode 100644 index 0000000..dfc27c9 --- /dev/null +++ b/tests/test_api_providers_a32.py @@ -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"]