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:
parent
c7539b36d4
commit
ad07425c06
11 changed files with 1126 additions and 64 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")):
|
||||
|
|
|
|||
279
src/antigravity_provider/router/adapters/ollama_adapter.py
Normal file
279
src/antigravity_provider/router/adapters/ollama_adapter.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
389
tests/test_api_providers_a32.py
Normal file
389
tests/test_api_providers_a32.py
Normal 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"]
|
||||
Loading…
Reference in a new issue