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 `