feat(providers): A42 подключение OpenRouter, NVIDIA, Ollama, исправление discovery и ложной квоты Codex
1. P0-1: В action_handler.py в add_account реализовано реальное сохранение профилей и учетных данных для openrouter, nvidia, nvidia-nim, ollama, local, claude, opencode-go. Для некорректных действий возвращается честная ошибка ok: False вместо мнимого успеха. В AutoAssigner добавлены слоты и возможности для openrouter и nvidia. 2. P0-2: В ModelDiscoveryService убраны зашитые списки PID. Добавлены ветки openrouter, nvidia и выделенная ветка ollama (/api/tags и /v1/models). Ошибки серверов сохраняются и передаются в интерфейс. 3. P0-3: В unified_health.py разделены статусы временного отката ошибки (STATUS_COOLDOWN) и исчерпания квоты (STATUS_QUOTA_EXHAUSTED). RATE_LIMITED проверяется до кулдаунов. В health_tracker.py исключена пометка всего аккаунта при пустом model_name. В codex_adapter.py уточнена классификация ошибок. 4. tests/test_a42_provider_connect.py: 15 тестов, 500 passed, ruff чисто.
This commit is contained in:
parent
e7194d3220
commit
3b221b87cb
9 changed files with 1114 additions and 140 deletions
|
|
@ -565,27 +565,75 @@ class ActionExecutor:
|
|||
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
||||
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
||||
|
||||
# Подключение аккаунта: для локального сервера/Ollama это не навигация, а
|
||||
# настоящее сохранение профиля с адресом.
|
||||
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
|
||||
if action == 'add_account':
|
||||
prov_norm = (prov or data.get('provider') or '').strip().lower()
|
||||
if not prov_norm:
|
||||
return {'ok': False, 'message': 'Провайдер не указан'}
|
||||
|
||||
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 = data.get('profile_id') or AutoAssigner.find_free_slot(prov) or f'{prov}-1'
|
||||
AutoAssigner.ensure_profile_definition(prov, slot)
|
||||
auth_data = {
|
||||
"provider": prov,
|
||||
"profile_id": slot,
|
||||
"base_url": base_url,
|
||||
"api_key": token if token else None,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
ProfileAuthManager.save_profile_auth(prov, slot, auth_data)
|
||||
base_url = (data.get('base_url') or '').strip()
|
||||
token = (data.get('token') or data.get('api_key') or '').strip()
|
||||
slot = data.get('profile_id')
|
||||
|
||||
default_base_urls = {
|
||||
'openrouter': 'https://openrouter.ai/api/v1',
|
||||
'nvidia': 'https://integrate.api.nvidia.com/v1',
|
||||
'nvidia-nim': 'https://integrate.api.nvidia.com/v1',
|
||||
'ollama': 'http://127.0.0.1:11434',
|
||||
'local': 'http://127.0.0.1:8081/v1',
|
||||
'local-llm': 'http://127.0.0.1:8081/v1',
|
||||
'llama.cpp': 'http://127.0.0.1:8081/v1',
|
||||
'vllm': 'http://127.0.0.1:8081/v1',
|
||||
}
|
||||
|
||||
if not base_url and prov_norm in default_base_urls:
|
||||
base_url = default_base_urls[prov_norm]
|
||||
|
||||
# Validate required credentials per provider
|
||||
if prov_norm in ('openrouter',):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenRouter'}
|
||||
elif prov_norm in ('nvidia', 'nvidia-nim'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
||||
elif prov_norm in ('claude', 'anthropic'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
|
||||
elif prov_norm in ('opencode-go', 'opencode'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
||||
elif prov_norm in ('local', 'local-llm', 'llama.cpp', 'ollama', 'vllm'):
|
||||
if not base_url:
|
||||
return {'ok': False, 'message': f'Не указан URL сервера для {prov_norm}'}
|
||||
elif prov_norm in ('openai-codex', 'codex', 'grok', 'xai'):
|
||||
if not token:
|
||||
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
||||
else:
|
||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||
|
||||
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
|
||||
ok, def_msg = AutoAssigner.ensure_profile_definition(prov_norm, slot)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': def_msg}
|
||||
|
||||
auth_data: Dict[str, Any] = {
|
||||
"provider": prov_norm,
|
||||
"profile_id": slot,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
if base_url:
|
||||
auth_data["base_url"] = base_url
|
||||
if token:
|
||||
auth_data["api_key"] = token
|
||||
|
||||
try:
|
||||
ProfileAuthManager.save_profile_auth(prov_norm, slot, auth_data)
|
||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||
_rescan_after_auth()
|
||||
return {'ok': True, 'message': f'Сервер {prov} ({slot}) успешно подключен'}
|
||||
return {'ok': True, 'message': 'Навигация'}
|
||||
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) успешно подключен'}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
|
||||
|
||||
# Чисто навигационные действия. edit_route и assign_role сюда НЕ входят:
|
||||
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
|
||||
|
|
|
|||
|
|
@ -128,8 +128,23 @@ class CodexAdapter(BaseProviderAdapter):
|
|||
err_msg = str(exc)
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
# Quota / usage limit
|
||||
if any(k in err_lower for k in ("quota", "insufficient_quota", "usage_limit", "exceeded your current quota")):
|
||||
# 1. Rate limited (429) - must take precedence over generic limit checks
|
||||
if any(k in err_lower for k in ("429", "rate_limit", "rate limit", "tokens per min", "requests per min", "tpm", "rpm", "too many requests")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=60,
|
||||
)
|
||||
|
||||
# 2. Auth errors
|
||||
if any(k in err_lower for k in ("401", "403", "unauthorized", "forbidden", "invalid_api_key", "token_invalidated", "token_revoked", "invalid token")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
# 3. Real Quota exhausted
|
||||
if any(k in err_lower for k in ("insufficient_quota", "exceeded your current quota", "quota_exceeded", "quota exceeded", "credit balance is too low", "billing", "out of credits", "run out of credits")):
|
||||
reset_sec = 1800
|
||||
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
|
||||
if m_sec:
|
||||
|
|
@ -140,23 +155,8 @@ class CodexAdapter(BaseProviderAdapter):
|
|||
reset_duration_seconds=reset_sec,
|
||||
)
|
||||
|
||||
# Rate limited (429)
|
||||
if "429" in err_lower or "rate limit" in err_lower or "tokens per min" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=60,
|
||||
)
|
||||
|
||||
# Auth errors
|
||||
if any(k in err_lower for k in ("401", "unauthorized", "invalid_api_key", "token_invalidated", "token_revoked")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
# Transient
|
||||
if any(k in err_lower for k in ("timeout", "502", "503", "504", "connection reset")):
|
||||
# 4. Transient network / server errors
|
||||
if any(k in err_lower for k in ("timeout", "500", "502", "503", "504", "connection reset", "connection refused", "transport error")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ DEFAULT_SLOT_ROLES = {
|
|||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||
"local-1": ("Локальный сервер 1", "coder", "primary"),
|
||||
"local-2": ("Локальный сервер 2", "fast", "primary"),
|
||||
"openrouter-1": ("Кодер (OpenRouter 1)", "coder", "primary"),
|
||||
"openrouter-2": ("Исследователь (OpenRouter 2)", "researcher", "fallback"),
|
||||
"nvidia-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||
"nvidia-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
|
||||
"nvidia-nim-1": ("Кодер (NVIDIA NIM 1)", "coder", "primary"),
|
||||
"nvidia-nim-2": ("Быстрый агент (NVIDIA NIM 2)", "fast", "fallback"),
|
||||
"ag-spare-1": ("Резерв 1", "spare", "spare"),
|
||||
"ag-spare-2": ("Резерв 2", "spare", "spare"),
|
||||
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
|
||||
|
|
@ -128,6 +134,9 @@ class AutoAssigner:
|
|||
"llama.cpp": ["local-1", "local-2"],
|
||||
"ollama": ["ollama-1", "ollama-2"],
|
||||
"vllm": ["local-1", "local-2"],
|
||||
"openrouter": ["openrouter-1", "openrouter-2"],
|
||||
"nvidia": ["nvidia-1", "nvidia-2"],
|
||||
"nvidia-nim": ["nvidia-nim-1", "nvidia-nim-2"],
|
||||
}
|
||||
|
||||
candidates = list(provider_slots.get(provider_norm, []))
|
||||
|
|
@ -180,6 +189,12 @@ class AutoAssigner:
|
|||
elif p in ("local", "local-llm", "llama.cpp", "vllm"):
|
||||
for i in range(3, 200):
|
||||
yield f"local-{i}"
|
||||
elif p in ("openrouter",):
|
||||
for i in range(3, 200):
|
||||
yield f"openrouter-{i}"
|
||||
elif p in ("nvidia", "nvidia-nim"):
|
||||
for i in range(3, 200):
|
||||
yield f"{p}-{i}"
|
||||
else:
|
||||
for i in range(1, 200):
|
||||
yield f"{p}-{i}"
|
||||
|
|
@ -218,6 +233,9 @@ class AutoAssigner:
|
|||
"llama.cpp": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"ollama": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"vllm": ["reviewer", "coding", "reasoning", "fast", "research"],
|
||||
"openrouter": ["coding", "reasoning", "research", "fast", "reviewer"],
|
||||
"nvidia": ["coding", "reasoning", "fast"],
|
||||
"nvidia-nim": ["coding", "reasoning", "fast"],
|
||||
}
|
||||
capabilities = capabilities_map.get(provider, ["coding", "reasoning"])
|
||||
|
||||
|
|
@ -505,6 +523,9 @@ class AutoAssigner:
|
|||
"llama.cpp": "Local LLM (llama.cpp)",
|
||||
"ollama": "Ollama",
|
||||
"vllm": "vLLM",
|
||||
"openrouter": "OpenRouter",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"nvidia-nim": "NVIDIA NIM",
|
||||
}
|
||||
provider_label = prov_labels.get(pcfg.provider.lower(), pcfg.provider)
|
||||
|
||||
|
|
|
|||
|
|
@ -479,8 +479,6 @@ class HealthTracker:
|
|||
record.last_used = now
|
||||
record.last_error = reason
|
||||
record.simulated = simulated
|
||||
if not model_name or model_name == "default":
|
||||
record.overall_state = QUOTA_EXHAUSTED
|
||||
|
||||
family = extract_model_family(model_name)
|
||||
if family not in record.families:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import time
|
|||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("hermes.router.model_discovery")
|
||||
|
||||
|
|
@ -93,18 +93,27 @@ class ModelDiscoveryService:
|
|||
"discovered_at": None,
|
||||
"is_stale": True,
|
||||
"has_cache": False,
|
||||
"error": entry.get("error") if entry else None,
|
||||
}
|
||||
|
||||
discovered_at = entry.get("discovered_at", 0)
|
||||
is_stale = (time.time() - discovered_at) > self._ttl_seconds
|
||||
discovered_at = entry.get("discovered_at")
|
||||
is_stale = (time.time() - float(discovered_at)) > self._ttl_seconds if discovered_at else True
|
||||
models = entry.get("models")
|
||||
return {
|
||||
"provider": provider,
|
||||
"models": list(entry["models"]),
|
||||
"models": list(models) if models else None,
|
||||
"discovered_at": discovered_at,
|
||||
"is_stale": is_stale,
|
||||
"has_cache": True,
|
||||
"has_cache": bool(models),
|
||||
"error": entry.get("error"),
|
||||
}
|
||||
|
||||
def get_error(self, provider: str) -> Optional[str]:
|
||||
"""Return last discovery error message for provider if any."""
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower())
|
||||
return entry.get("error") if entry else None
|
||||
|
||||
def get_cached(self, provider: str) -> Dict[str, Any]:
|
||||
"""Convenience alias for get_models_with_metadata."""
|
||||
return self.get_models_with_metadata(provider)
|
||||
|
|
@ -144,9 +153,19 @@ class ModelDiscoveryService:
|
|||
on_complete: Optional[Callable[[Dict[str, Optional[List[str]]]], None]] = None,
|
||||
timeout: float = 15.0,
|
||||
) -> None:
|
||||
"""Discover models for all 5 providers concurrently in background."""
|
||||
"""Discover models for all configured providers concurrently in background."""
|
||||
def _worker():
|
||||
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok", "local"]
|
||||
providers = [
|
||||
"antigravity",
|
||||
"openai-codex",
|
||||
"opencode-go",
|
||||
"claude",
|
||||
"grok",
|
||||
"openrouter",
|
||||
"nvidia",
|
||||
"ollama",
|
||||
"local",
|
||||
]
|
||||
results: Dict[str, Optional[List[str]]] = {}
|
||||
threads = []
|
||||
|
||||
|
|
@ -172,13 +191,15 @@ class ModelDiscoveryService:
|
|||
def discover_models_sync(self, provider: str, timeout: float = 15.0) -> Optional[List[str]]:
|
||||
"""Synchronously probe models with strict timeout without blocking indefinite hangs."""
|
||||
result_holder: List[Optional[List[str]]] = [None]
|
||||
error_holder: List[Optional[Exception]] = [None]
|
||||
error_holder: List[Optional[str]] = [None]
|
||||
|
||||
def _do_probe():
|
||||
try:
|
||||
result_holder[0] = self._probe_provider(provider)
|
||||
models, err_msg = self._probe_provider(provider)
|
||||
result_holder[0] = models
|
||||
error_holder[0] = err_msg
|
||||
except Exception as exc:
|
||||
error_holder[0] = exc
|
||||
error_holder[0] = str(exc)
|
||||
|
||||
worker = threading.Thread(target=_do_probe, daemon=True)
|
||||
worker.start()
|
||||
|
|
@ -186,51 +207,139 @@ class ModelDiscoveryService:
|
|||
|
||||
if worker.is_alive():
|
||||
logger.warning("Model discovery for provider '%s' timed out (> %.1fs)", provider, timeout)
|
||||
# Timeout: retain existing cache if any
|
||||
timeout_msg = f"Превышено время ожидания ответа от сервера ({timeout:.1f}с)"
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower())
|
||||
return list(entry["models"]) if entry and "models" in entry else None
|
||||
|
||||
if error_holder[0]:
|
||||
logger.info("Model discovery probe for '%s' returned error: %s", provider, error_holder[0])
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower())
|
||||
return list(entry["models"]) if entry and "models" in entry else None
|
||||
entry = self._cache.get(provider.lower(), {})
|
||||
existing_models = entry.get("models")
|
||||
self._cache[provider.lower()] = {
|
||||
"models": existing_models,
|
||||
"discovered_at": entry.get("discovered_at"),
|
||||
"error": timeout_msg,
|
||||
}
|
||||
self._save_cache_to_disk()
|
||||
return list(existing_models) if existing_models else None
|
||||
|
||||
models = result_holder[0]
|
||||
err_text = error_holder[0]
|
||||
|
||||
if models:
|
||||
with self._cache_lock:
|
||||
self._cache[provider.lower()] = {
|
||||
"models": models,
|
||||
"discovered_at": time.time(),
|
||||
"error": None,
|
||||
}
|
||||
self._save_cache_to_disk()
|
||||
logger.info("Discovered %d models for provider '%s': %s", len(models), provider, models)
|
||||
return models
|
||||
|
||||
# If probe returned None or empty, retain existing cache if any
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower())
|
||||
return list(entry["models"]) if entry and "models" in entry else None
|
||||
if err_text:
|
||||
logger.info("Model discovery probe for '%s' returned error: %s", provider, err_text)
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower(), {})
|
||||
existing_models = entry.get("models")
|
||||
self._cache[provider.lower()] = {
|
||||
"models": existing_models,
|
||||
"discovered_at": entry.get("discovered_at"),
|
||||
"error": err_text,
|
||||
}
|
||||
self._save_cache_to_disk()
|
||||
return list(existing_models) if existing_models else None
|
||||
|
||||
def _probe_provider(self, provider: str) -> Optional[List[str]]:
|
||||
"""Perform provider-specific model discovery."""
|
||||
with self._cache_lock:
|
||||
entry = self._cache.get(provider.lower(), {})
|
||||
existing_models = entry.get("models")
|
||||
self._cache[provider.lower()] = {
|
||||
"models": existing_models,
|
||||
"discovered_at": entry.get("discovered_at"),
|
||||
"error": entry.get("error") or "Модели не найдены",
|
||||
}
|
||||
self._save_cache_to_disk()
|
||||
return list(existing_models) if existing_models else None
|
||||
|
||||
def _extract_http_error(self, http_err: urllib.error.HTTPError) -> str:
|
||||
try:
|
||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
||||
err_json = json.loads(raw_err)
|
||||
if isinstance(err_json, dict):
|
||||
if "error" in err_json:
|
||||
err_obj = err_json["error"]
|
||||
if isinstance(err_obj, dict):
|
||||
msg = err_obj.get("message") or str(err_obj)
|
||||
else:
|
||||
msg = str(err_obj)
|
||||
elif "message" in err_json:
|
||||
msg = str(err_json["message"])
|
||||
elif "detail" in err_json:
|
||||
msg = str(err_json["detail"])
|
||||
else:
|
||||
msg = raw_err
|
||||
else:
|
||||
msg = raw_err
|
||||
return f"HTTP {http_err.code}: {msg}"
|
||||
except Exception:
|
||||
return f"HTTP {http_err.code}: {http_err.reason}"
|
||||
|
||||
def _get_provider_candidate_profiles(self, prov: str) -> List[Tuple[str, Optional[Any]]]:
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
cfg = load_router_config()
|
||||
p_lower = prov.lower()
|
||||
matched = [
|
||||
(pid, pcfg)
|
||||
for pid, pcfg in cfg.profiles.items()
|
||||
if pcfg.provider.lower() == p_lower
|
||||
or (p_lower in ("nvidia", "nvidia-nim") and pcfg.provider.lower() in ("nvidia", "nvidia-nim"))
|
||||
or (p_lower in ("openai-codex", "codex") and pcfg.provider.lower() in ("openai-codex", "codex"))
|
||||
or (p_lower in ("opencode-go", "opencode") and pcfg.provider.lower() in ("opencode-go", "opencode"))
|
||||
or (p_lower in ("claude", "anthropic") and pcfg.provider.lower() in ("claude", "anthropic"))
|
||||
or (p_lower in ("grok", "xai") and pcfg.provider.lower() in ("grok", "xai"))
|
||||
or (p_lower in ("local", "local-llm", "llama.cpp", "vllm") and pcfg.provider.lower() in ("local", "local-llm", "llama.cpp", "vllm"))
|
||||
]
|
||||
if matched:
|
||||
return matched
|
||||
|
||||
default_slots = {
|
||||
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
|
||||
"codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
|
||||
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
|
||||
"opencode": ["opengo-1", "opengo-2", "opengo-3"],
|
||||
"grok": ["grok-orch", "grok-worker-1", "grok-worker-2"],
|
||||
"xai": ["grok-orch", "grok-worker-1", "grok-worker-2"],
|
||||
"claude": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
||||
"anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"],
|
||||
"openrouter": ["openrouter-1", "openrouter-2"],
|
||||
"nvidia": ["nvidia-1", "nvidia-2"],
|
||||
"nvidia-nim": ["nvidia-nim-1", "nvidia-nim-2"],
|
||||
"ollama": ["ollama-1", "ollama-2"],
|
||||
"local": ["local-1", "local-2"],
|
||||
"local-llm": ["local-1", "local-2"],
|
||||
"llama.cpp": ["local-1", "local-2"],
|
||||
"vllm": ["local-1", "local-2"],
|
||||
}
|
||||
candidates = default_slots.get(p_lower, [f"{p_lower}-1", f"{p_lower}-2"])
|
||||
return [(pid, cfg.get_profile(pid)) for pid in candidates]
|
||||
|
||||
def _probe_provider(self, provider: str) -> Tuple[Optional[List[str]], Optional[str]]:
|
||||
"""Perform provider-specific model discovery returning (models_list, error_msg)."""
|
||||
prov = provider.lower()
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
|
||||
if prov == "antigravity":
|
||||
if prov in ("antigravity", "google-antigravity"):
|
||||
from antigravity_provider.agy_subprocess import discover_models
|
||||
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
||||
res = discover_models(profile_id=main_p)
|
||||
if res:
|
||||
return sorted(list(set(res.values())))
|
||||
return None
|
||||
main_p = ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
|
||||
try:
|
||||
res = discover_models(profile_id=main_p)
|
||||
if res:
|
||||
return sorted(list(set(res.values()))), None
|
||||
return None, "Модели Google Antigravity не обнаружены"
|
||||
except Exception as exc:
|
||||
return None, str(exc)
|
||||
|
||||
elif prov in ("openai-codex", "codex"):
|
||||
for pid in ["codex-orch", "codex-worker-1", "codex-worker-2"]:
|
||||
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid)
|
||||
if not auth:
|
||||
continue
|
||||
profiles = self._get_provider_candidate_profiles("openai-codex")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("openai-codex", pid) or {}
|
||||
tokens = auth.get("token") or auth.get("tokens") or auth
|
||||
access_token = (
|
||||
tokens.get("access_token")
|
||||
|
|
@ -239,9 +348,13 @@ class ModelDiscoveryService:
|
|||
)
|
||||
if not access_token:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.openai.com/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://api.openai.com/v1/models",
|
||||
f"{base_url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
|
|
@ -257,22 +370,31 @@ class ModelDiscoveryService:
|
|||
m for m in models
|
||||
if any(x in m for x in ("gpt-4", "gpt-3.5", "o1", "o3", "codex", "chatgpt"))
|
||||
]
|
||||
return sorted(chat_models or models)
|
||||
if chat_models or models:
|
||||
return sorted(chat_models or models), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("Codex model query failed on %s: %s", pid, exc)
|
||||
return None
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
|
||||
|
||||
elif prov in ("opencode-go", "opencode"):
|
||||
for pid in ["opengo-1", "opengo-2", "opengo-3"]:
|
||||
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid)
|
||||
if not auth:
|
||||
continue
|
||||
profiles = self._get_provider_candidate_profiles("opencode-go")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("opencode-go", pid) or {}
|
||||
api_key = auth.get("api_key")
|
||||
if not api_key:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://opencode.ai/zen/go/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://opencode.ai/zen/go/v1/models",
|
||||
f"{base_url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
|
|
@ -284,32 +406,36 @@ class ModelDiscoveryService:
|
|||
items = data.get("data") or data.get("models") or []
|
||||
if isinstance(items, list):
|
||||
models = [str(m.get("id") or m) for m in items if m]
|
||||
return sorted(models)
|
||||
if models:
|
||||
return sorted(models), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
|
||||
return None
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
|
||||
|
||||
elif prov == "grok":
|
||||
# Провайдера здесь не было вовсе, поэтому кэш моделей Grok всегда
|
||||
# оставался пустым, и выбор модели отвергал даже настоящие имена:
|
||||
# «модель grok-4.5 не найдена в списке известных». При этом
|
||||
# api.x.ai/v1/models принимает тот же OAuth-токен, что и вызовы, и
|
||||
# отдаёт полный список.
|
||||
for pid in ("grok-orch", "grok-worker-1", "grok-worker-2"):
|
||||
auth = ProfileAuthManager.load_profile_auth("grok", pid)
|
||||
if not auth:
|
||||
continue
|
||||
elif prov in ("grok", "xai"):
|
||||
profiles = self._get_provider_candidate_profiles("grok")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("grok", pid) or {}
|
||||
tokens = auth.get("token") or auth.get("tokens") or {}
|
||||
token = tokens.get("access_token") if isinstance(tokens, dict) else None
|
||||
token = token or auth.get("access_token") or auth.get("api_key")
|
||||
if not token:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.x.ai/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
"https://api.x.ai/v1/models",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "hermes-hub/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
payload = json.loads(response.read().decode("utf-8") or "{}")
|
||||
models = [
|
||||
str(item.get("id"))
|
||||
|
|
@ -317,25 +443,215 @@ class ModelDiscoveryService:
|
|||
if isinstance(item, dict) and item.get("id")
|
||||
]
|
||||
if models:
|
||||
return sorted(set(models))
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
|
||||
return None
|
||||
return None, last_err or "Отсутствуют учетные данные для Grok"
|
||||
|
||||
elif prov in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
cfg = load_router_config()
|
||||
for pid in ["local-1", "local-2"]:
|
||||
pcfg = cfg.get_profile(pid)
|
||||
auth = ProfileAuthManager.load_profile_auth("local", pid) or {}
|
||||
base_url = (
|
||||
(pcfg.custom_base_url if pcfg else None)
|
||||
or auth.get("base_url")
|
||||
or os.environ.get("LOCAL_LLM_BASE_URL")
|
||||
or "http://127.0.0.1:8081/v1"
|
||||
)
|
||||
if not base_url:
|
||||
elif prov in ("claude", "anthropic"):
|
||||
profiles = self._get_provider_candidate_profiles("claude")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("claude", pid) or {}
|
||||
tokens = auth.get("token") or auth.get("tokens") or {}
|
||||
token = tokens.get("access_token") if isinstance(tokens, dict) else None
|
||||
token = token or auth.get("access_token") or auth.get("api_key") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
||||
if not token:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or "https://api.anthropic.com/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"User-Agent": "hermes-hub/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"
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8") or "{}")
|
||||
items = payload.get("data") or payload.get("models") or []
|
||||
models = [str(item.get("id") or item) for item in items if item]
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
return None, last_err or "Отсутствуют учетные данные для Claude"
|
||||
|
||||
elif prov in ("openrouter",):
|
||||
profiles = self._get_provider_candidate_profiles("openrouter")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("openrouter", pid) or {}
|
||||
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OPENROUTER_BASE_URL") or "https://openrouter.ai/api/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
|
||||
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 = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"HTTP-Referer": referer,
|
||||
"X-OpenRouter-Title": title,
|
||||
"X-Title": title,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-hub/1.0",
|
||||
}
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8") or "{}")
|
||||
items = payload.get("data") or payload.get("models") or []
|
||||
models = []
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
mid = item.get("id") if isinstance(item, dict) else str(item)
|
||||
if mid:
|
||||
models.append(str(mid))
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
|
||||
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
|
||||
|
||||
elif prov in ("nvidia", "nvidia-nim"):
|
||||
profiles = self._get_provider_candidate_profiles("nvidia")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("nvidia", pid) or ProfileAuthManager.load_profile_auth("nvidia-nim", pid) or {}
|
||||
api_key = auth.get("api_key") or auth.get("token") or os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
|
||||
if not api_key:
|
||||
continue
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("NVIDIA_BASE_URL") or "https://integrate.api.nvidia.com/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"https://{base_url}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-hub/1.0",
|
||||
}
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8") or "{}")
|
||||
items = payload.get("data") or payload.get("models") or []
|
||||
models = []
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
mid = item.get("id") if isinstance(item, dict) else str(item)
|
||||
if mid:
|
||||
models.append(str(mid))
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
|
||||
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
|
||||
|
||||
elif prov == "ollama":
|
||||
profiles = self._get_provider_candidate_profiles("ollama")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("ollama", pid) or {}
|
||||
raw_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_HOST") or "http://127.0.0.1:11434"
|
||||
raw_url = str(raw_url).strip().rstrip("/")
|
||||
if not raw_url.startswith(("http://", "https://")):
|
||||
raw_url = f"http://{raw_url}"
|
||||
|
||||
native_host = raw_url[:-3] if raw_url.endswith("/v1") else raw_url
|
||||
v1_url = raw_url if raw_url.endswith("/v1") else f"{raw_url}/v1"
|
||||
|
||||
token = auth.get("api_key") or auth.get("token") or os.environ.get("OLLAMA_API_KEY")
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "hermes-hub/1.0",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
# 1. Try native Ollama endpoint /api/tags
|
||||
try:
|
||||
req = urllib.request.Request(f"{native_host}/api/tags", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
|
||||
items = data.get("models") or []
|
||||
models = []
|
||||
if isinstance(items, list):
|
||||
for m in items:
|
||||
name = m.get("name") or m.get("model") if isinstance(m, dict) else str(m)
|
||||
if name:
|
||||
models.append(str(name))
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
|
||||
|
||||
# 2. Try OpenAI-compatible endpoint /v1/models
|
||||
try:
|
||||
req = urllib.request.Request(f"{v1_url}/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
|
||||
items = data.get("data") or data.get("models") or []
|
||||
models = []
|
||||
if isinstance(items, list):
|
||||
for m in items:
|
||||
mid = m.get("id") or m.get("name") if isinstance(m, dict) else str(m)
|
||||
if mid:
|
||||
models.append(str(mid))
|
||||
if models:
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
|
||||
|
||||
return None, last_err or "Не удалось подключиться к серверу Ollama"
|
||||
|
||||
elif prov in ("local", "local-llm", "llama.cpp", "vllm"):
|
||||
profiles = self._get_provider_candidate_profiles("local")
|
||||
last_err = None
|
||||
for pid, pcfg in profiles:
|
||||
auth = ProfileAuthManager.load_profile_auth("local", pid) or {}
|
||||
base_url = (pcfg.custom_base_url if pcfg else None) or auth.get("base_url") or os.environ.get("LOCAL_LLM_BASE_URL") or "http://127.0.0.1:8081/v1"
|
||||
base_url = str(base_url).strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = f"http://{base_url}"
|
||||
|
|
@ -348,10 +664,7 @@ class ModelDiscoveryService:
|
|||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
)
|
||||
req = urllib.request.Request(f"{base_url}/models", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
|
||||
items = data.get("data") or data.get("models") or []
|
||||
|
|
@ -362,9 +675,13 @@ class ModelDiscoveryService:
|
|||
if m
|
||||
]
|
||||
if models:
|
||||
return sorted(models)
|
||||
return sorted(set(models)), None
|
||||
except urllib.error.HTTPError as http_err:
|
||||
last_err = self._extract_http_error(http_err)
|
||||
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
|
||||
except Exception as exc:
|
||||
last_err = str(exc)
|
||||
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
|
||||
return None
|
||||
return None, last_err or "Не удалось подключиться к локальному серверу LLM"
|
||||
|
||||
return None
|
||||
return None, f"Неизвестный провайдер: {provider}"
|
||||
|
|
|
|||
|
|
@ -316,8 +316,8 @@ class ProfileAuthManager:
|
|||
except Exception as e:
|
||||
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
|
||||
|
||||
# For Local provider, synchronize custom_base_url in router_profiles.yaml
|
||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||||
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
|
||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
|
||||
base_url = auth_data.get("base_url")
|
||||
if base_url:
|
||||
try:
|
||||
|
|
@ -438,6 +438,18 @@ class ProfileAuthManager:
|
|||
if val:
|
||||
return {"provider": provider, "profile_id": profile_id, "base_url": val}
|
||||
|
||||
elif provider in ("openrouter",):
|
||||
env_var = f"OPENROUTER_API_KEY_{profile_id.upper().replace('-', '_')}"
|
||||
val = os.environ.get(env_var) or os.environ.get("OPENROUTER_API_KEY")
|
||||
if val:
|
||||
return {"provider": "openrouter", "profile_id": profile_id, "api_key": val}
|
||||
|
||||
elif provider in ("nvidia", "nvidia-nim"):
|
||||
env_var = f"NVIDIA_API_KEY_{profile_id.upper().replace('-', '_')}"
|
||||
val = os.environ.get(env_var) or os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
|
||||
if val:
|
||||
return {"provider": provider, "profile_id": profile_id, "api_key": val}
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -840,6 +852,34 @@ class ProfileAuthManager:
|
|||
"error": None if is_auth else "URL сервера не настроен",
|
||||
}
|
||||
|
||||
elif provider in ("openrouter",):
|
||||
key = auth_data.get("api_key") or auth_data.get("token") or ""
|
||||
is_auth = bool(key)
|
||||
masked = f"sk-or-...{key[-4:]}" if len(key) > 8 else ("sk-or-***" if key else None)
|
||||
return {
|
||||
"authenticated": is_auth,
|
||||
"provider": provider,
|
||||
"profile_id": profile_id,
|
||||
"auth_mode": "api_key",
|
||||
"account_id_masked": masked or "Not configured",
|
||||
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
|
||||
"error": None if is_auth else "API-ключ не настроен",
|
||||
}
|
||||
|
||||
elif provider in ("nvidia", "nvidia-nim"):
|
||||
key = auth_data.get("api_key") or auth_data.get("token") or ""
|
||||
is_auth = bool(key)
|
||||
masked = f"nvapi-...{key[-4:]}" if len(key) > 8 else ("nvapi-***" if key else None)
|
||||
return {
|
||||
"authenticated": is_auth,
|
||||
"provider": provider,
|
||||
"profile_id": profile_id,
|
||||
"auth_mode": "api_key",
|
||||
"account_id_masked": masked or "Not configured",
|
||||
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
|
||||
"error": None if is_auth else "API-ключ не настроен",
|
||||
}
|
||||
|
||||
return {
|
||||
"authenticated": False,
|
||||
"provider": provider,
|
||||
|
|
|
|||
|
|
@ -421,15 +421,16 @@ class UnifiedHealthService:
|
|||
else:
|
||||
f_status = STATUS_NOT_CONFIGURED
|
||||
f_lbl = "Аккаунт не добавлен"
|
||||
elif f_cd > 0:
|
||||
f_status = STATUS_QUOTA_EXHAUSTED
|
||||
f_lbl = f"Квота исчерпана ({f_cd}s)"
|
||||
elif frec and frec.state == QUOTA_EXHAUSTED:
|
||||
f_status = STATUS_QUOTA_EXHAUSTED
|
||||
f_lbl = "Квота исчерпана"
|
||||
elif frec and frec.state == RATE_LIMITED:
|
||||
f_status = STATUS_RATE_LIMITED
|
||||
f_lbl = "Лимит запросов"
|
||||
f_lbl = f"Лимит запросов ({f_cd}s)" if f_cd > 0 else "Лимит запросов"
|
||||
elif frec and frec.state == QUOTA_EXHAUSTED:
|
||||
f_status = STATUS_QUOTA_EXHAUSTED
|
||||
f_lbl = f"Квота исчерпана ({f_cd}s)" if f_cd > 0 else "Квота исчерпана"
|
||||
elif f_cd > 0 or (frec and frec.state == COOLDOWN):
|
||||
f_status = STATUS_COOLDOWN
|
||||
reason_suffix = f": {frec.reason}" if (frec and frec.reason) else ""
|
||||
f_lbl = f"Откат ({f_cd}s){reason_suffix}" if f_cd > 0 else "Откат"
|
||||
elif frec and frec.state == HT_UNHEALTHY:
|
||||
f_status = STATUS_UNHEALTHY
|
||||
f_lbl = "Ошибка"
|
||||
|
|
@ -463,19 +464,32 @@ class UnifiedHealthService:
|
|||
else:
|
||||
health_state = STATUS_NOT_CONFIGURED
|
||||
health_lbl = "Аккаунт не добавлен"
|
||||
# 3. Active Cooldown / Quota exhausted
|
||||
elif max_cd > 0 or precord.overall_state == QUOTA_EXHAUSTED:
|
||||
health_state = STATUS_QUOTA_EXHAUSTED
|
||||
health_lbl = "Квота исчерпана"
|
||||
# 4. Rate limited
|
||||
elif precord.overall_state == RATE_LIMITED:
|
||||
# 3. Rate limited (checked before error cooldown)
|
||||
elif precord.overall_state == RATE_LIMITED or (model_states and any(m.status == STATUS_RATE_LIMITED for m in model_states.values())):
|
||||
health_state = STATUS_RATE_LIMITED
|
||||
health_lbl = "Лимит запросов"
|
||||
# 5. Unhealthy probe error
|
||||
elif precord.overall_state == HT_UNHEALTHY:
|
||||
health_lbl = f"Лимит запросов ({max_cd}s)" if max_cd > 0 else "Лимит запросов"
|
||||
# 4. Quota exhausted
|
||||
elif precord.overall_state == QUOTA_EXHAUSTED or (model_states and all(m.status == STATUS_QUOTA_EXHAUSTED for m in model_states.values())):
|
||||
health_state = STATUS_QUOTA_EXHAUSTED
|
||||
health_lbl = f"Квота исчерпана ({max_cd}s)" if max_cd > 0 else "Квота исчерпана"
|
||||
# 5. Temporary error cooldown / rollback (shows cooldown and reason, not quota)
|
||||
elif max_cd > 0 or precord.overall_state == COOLDOWN or (model_states and any(m.status == STATUS_COOLDOWN for m in model_states.values())):
|
||||
health_state = STATUS_COOLDOWN
|
||||
cooldown_reason = precord.last_error
|
||||
if not cooldown_reason:
|
||||
for m in model_states.values():
|
||||
if m.reason:
|
||||
cooldown_reason = m.reason
|
||||
break
|
||||
if cooldown_reason:
|
||||
health_lbl = f"Откат ({max_cd}s): {cooldown_reason}"
|
||||
else:
|
||||
health_lbl = f"Откат ({max_cd}s)"
|
||||
# 6. Unhealthy probe error
|
||||
elif precord.overall_state == HT_UNHEALTHY or (model_states and any(m.status == STATUS_UNHEALTHY for m in model_states.values())):
|
||||
health_state = STATUS_UNHEALTHY
|
||||
health_lbl = "Ошибка"
|
||||
# 6. Live healthy or untested
|
||||
# 7. Live healthy or untested
|
||||
else:
|
||||
if precord.last_success is not None:
|
||||
health_state = STATUS_HEALTHY
|
||||
|
|
@ -499,6 +513,14 @@ class UnifiedHealthService:
|
|||
"anthropic": "Claude",
|
||||
"grok": "Grok",
|
||||
"xai": "Grok",
|
||||
"openrouter": "OpenRouter",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"nvidia-nim": "NVIDIA NIM",
|
||||
"ollama": "Ollama",
|
||||
"local": "Local LLM",
|
||||
"local-llm": "Local LLM",
|
||||
"llama.cpp": "Local LLM (llama.cpp)",
|
||||
"vllm": "vLLM",
|
||||
}.get(prov.lower(), prov)
|
||||
|
||||
last_success_str = datetime.datetime.fromtimestamp(precord.last_success).strftime("%H:%M:%S") if precord.last_success else None
|
||||
|
|
|
|||
515
tests/test_a42_provider_connect.py
Normal file
515
tests/test_a42_provider_connect.py
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
"""Tests for A42: Real provider connection, model discovery, quota and health fixes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.adapters.base_adapter import ErrorCategory
|
||||
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.health_tracker import (
|
||||
HEALTHY,
|
||||
QUOTA_EXHAUSTED,
|
||||
RATE_LIMITED,
|
||||
COOLDOWN,
|
||||
HealthTracker,
|
||||
)
|
||||
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.router_config import (
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
load_router_config,
|
||||
save_router_config,
|
||||
)
|
||||
from antigravity_provider.router.unified_health import (
|
||||
STATUS_COOLDOWN,
|
||||
STATUS_HEALTHY,
|
||||
STATUS_QUOTA_EXHAUSTED,
|
||||
STATUS_RATE_LIMITED,
|
||||
STATUS_UNHEALTHY,
|
||||
UnifiedHealthService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_environment(tmp_path, monkeypatch):
|
||||
"""Isolate Hermes home and configuration for all tests."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
profiles_dir = hermes_home / "profiles"
|
||||
profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
# Initialize empty config
|
||||
cfg = RouterConfig()
|
||||
save_router_config(cfg)
|
||||
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# P0-1: Real connection of OpenRouter, NVIDIA, Ollama, Claude, Local
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_auto_assigner_provider_slots_and_capabilities():
|
||||
"""Verify openrouter, nvidia, nvidia-nim are present in provider_slots and capabilities_map."""
|
||||
slot_or = AutoAssigner.find_free_slot("openrouter")
|
||||
assert slot_or in ("openrouter-1", "openrouter-2")
|
||||
|
||||
slot_nv = AutoAssigner.find_free_slot("nvidia")
|
||||
assert slot_nv in ("nvidia-1", "nvidia-2")
|
||||
|
||||
slot_nim = AutoAssigner.find_free_slot("nvidia-nim")
|
||||
assert slot_nim in ("nvidia-nim-1", "nvidia-nim-2")
|
||||
|
||||
# Capabilities definition
|
||||
ok, _ = AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
|
||||
assert ok is True
|
||||
cfg = load_router_config()
|
||||
pcfg = cfg.get_profile("openrouter-1")
|
||||
assert pcfg is not None
|
||||
assert "coding" in pcfg.capabilities
|
||||
|
||||
ok2, _ = AutoAssigner.ensure_profile_definition("nvidia", "nvidia-1")
|
||||
assert ok2 is True
|
||||
cfg2 = load_router_config()
|
||||
pcfg2 = cfg2.get_profile("nvidia-1")
|
||||
assert pcfg2 is not None
|
||||
assert "reasoning" in pcfg2.capabilities
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_openrouter_default_base_url():
|
||||
"""Verify add_account for openrouter substitutes default base_url and saves auth."""
|
||||
res = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"token": "sk-or-v1-test-key-12345",
|
||||
"base_url": "", # Empty base url
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert "успешно подключен" in res["message"]
|
||||
|
||||
# Verify profile created and auth saved
|
||||
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
|
||||
assert auth is not None
|
||||
assert auth["api_key"] == "sk-or-v1-test-key-12345"
|
||||
assert auth["base_url"] == "https://openrouter.ai/api/v1"
|
||||
|
||||
status = ProfileAuthManager.get_profile_status("openrouter", "openrouter-1")
|
||||
assert status["authenticated"] is True
|
||||
assert "sk-or-" in status["account_id_masked"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_nvidia_default_base_url():
|
||||
"""Verify add_account for nvidia substitutes default base_url and saves auth."""
|
||||
res = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "nvidia",
|
||||
"token": "nvapi-test-key-abcde",
|
||||
"base_url": "",
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert "успешно подключен" in res["message"]
|
||||
|
||||
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
|
||||
assert auth is not None
|
||||
assert auth["api_key"] == "nvapi-test-key-abcde"
|
||||
assert auth["base_url"] == "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
status = ProfileAuthManager.get_profile_status("nvidia", "nvidia-1")
|
||||
assert status["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_ollama_default_base_url():
|
||||
"""Verify add_account for ollama substitutes default base_url http://127.0.0.1:11434."""
|
||||
res = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "ollama",
|
||||
"base_url": "", # left empty
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
auth = ProfileAuthManager.load_profile_auth("ollama", "ollama-1")
|
||||
assert auth is not None
|
||||
assert auth["base_url"] == "http://127.0.0.1:11434"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_local_default_base_url():
|
||||
"""Verify add_account for local substitutes default base_url http://127.0.0.1:8081/v1."""
|
||||
res = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "local",
|
||||
"base_url": "",
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
auth = ProfileAuthManager.load_profile_auth("local", "local-1")
|
||||
assert auth is not None
|
||||
assert auth["base_url"] == "http://127.0.0.1:8081/v1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_claude_and_opencode():
|
||||
"""Verify add_account for claude and opencode-go with API keys."""
|
||||
res_claude = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "claude",
|
||||
"token": "sk-ant-api03-test-1234567890",
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res_claude["ok"] is True
|
||||
|
||||
res_opencode = ActionExecutor.execute(
|
||||
"add_account",
|
||||
{
|
||||
"provider": "opencode-go",
|
||||
"token": "opencode-test-key-12345",
|
||||
"target_role": "developer-1",
|
||||
},
|
||||
)
|
||||
assert res_opencode["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_1_add_account_honest_rejections():
|
||||
"""Verify add_account returns honest errors and NEVER fake {'ok': True, 'message': 'Навигация'}."""
|
||||
# Missing API key for openrouter
|
||||
res_or = ActionExecutor.execute("add_account", {"provider": "openrouter", "token": ""})
|
||||
assert res_or["ok"] is False
|
||||
assert "API-ключ" in res_or["message"]
|
||||
|
||||
# Missing API key for nvidia
|
||||
res_nv = ActionExecutor.execute("add_account", {"provider": "nvidia", "token": ""})
|
||||
assert res_nv["ok"] is False
|
||||
assert "API-ключ" in res_nv["message"]
|
||||
|
||||
# Missing API key for claude
|
||||
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
|
||||
assert res_cl["ok"] is False
|
||||
assert "API-ключ" in res_cl["message"]
|
||||
|
||||
# Unsupported provider
|
||||
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
|
||||
assert res_unsupp["ok"] is False
|
||||
assert "не поддерживается" in res_unsupp["message"]
|
||||
assert res_unsupp.get("message") != "Навигация"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# P0-2: Model Discovery for OpenRouter, NVIDIA, Ollama + Error Preservation
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_openrouter_discovery_headers(tmp_path):
|
||||
"""Verify OpenRouter model discovery queries {base_url}/models with required headers."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
service = ModelDiscoveryService(cache_path=cache_file)
|
||||
|
||||
# Save auth for openrouter profile
|
||||
auth_data = {
|
||||
"provider": "openrouter",
|
||||
"profile_id": "openrouter-1",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-or-test-key",
|
||||
}
|
||||
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", auth_data)
|
||||
AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
|
||||
|
||||
captured_request = []
|
||||
|
||||
def _mock_urlopen(req, timeout=15):
|
||||
captured_request.append(req)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"data": [
|
||||
{"id": "anthropic/claude-3.7-sonnet"},
|
||||
{"id": "openai/gpt-4o"},
|
||||
{"id": "deepseek/deepseek-r1"},
|
||||
]
|
||||
}).encode("utf-8")
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
|
||||
models = service.discover_models_sync("openrouter", timeout=5.0)
|
||||
|
||||
assert models is not None
|
||||
assert "anthropic/claude-3.7-sonnet" in models
|
||||
assert "openai/gpt-4o" in models
|
||||
|
||||
assert len(captured_request) == 1
|
||||
req = captured_request[0]
|
||||
assert req.full_url == "https://openrouter.ai/api/v1/models"
|
||||
assert req.headers.get("Authorization") == "Bearer sk-or-test-key"
|
||||
assert "Http-referer" in req.headers or "HTTP-Referer" in req.headers
|
||||
assert "X-openrouter-title" in req.headers or "X-OpenRouter-Title" in req.headers
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_nvidia_discovery(tmp_path):
|
||||
"""Verify NVIDIA model discovery queries {base_url}/models with Bearer token."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
service = ModelDiscoveryService(cache_path=cache_file)
|
||||
|
||||
auth_data = {
|
||||
"provider": "nvidia",
|
||||
"profile_id": "nvidia-1",
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key": "nvapi-test-key",
|
||||
}
|
||||
ProfileAuthManager.save_profile_auth("nvidia", "nvidia-1", auth_data)
|
||||
AutoAssigner.ensure_profile_definition("nvidia", "nvidia-1")
|
||||
|
||||
captured_request = []
|
||||
|
||||
def _mock_urlopen(req, timeout=15):
|
||||
captured_request.append(req)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"data": [
|
||||
{"id": "meta/llama-3.1-405b-instruct"},
|
||||
{"id": "nvidia/nemotron-4-340b-instruct"},
|
||||
]
|
||||
}).encode("utf-8")
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
|
||||
models = service.discover_models_sync("nvidia", timeout=5.0)
|
||||
|
||||
assert models is not None
|
||||
assert "meta/llama-3.1-405b-instruct" in models
|
||||
assert captured_request[0].headers.get("Authorization") == "Bearer nvapi-test-key"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_ollama_native_tags_discovery(tmp_path):
|
||||
"""Verify Ollama discovery queries native endpoint /api/tags."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
service = ModelDiscoveryService(cache_path=cache_file)
|
||||
|
||||
auth_data = {
|
||||
"provider": "ollama",
|
||||
"profile_id": "ollama-1",
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
}
|
||||
ProfileAuthManager.save_profile_auth("ollama", "ollama-1", auth_data)
|
||||
AutoAssigner.ensure_profile_definition("ollama", "ollama-1")
|
||||
|
||||
def _mock_urlopen(req, timeout=5):
|
||||
if "/api/tags" in req.full_url:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"models": [
|
||||
{"name": "llama3.3:latest"},
|
||||
{"name": "qwen2.5-coder:32b"},
|
||||
]
|
||||
}).encode("utf-8")
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
return mock_resp
|
||||
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=_mock_urlopen):
|
||||
models = service.discover_models_sync("ollama", timeout=5.0)
|
||||
|
||||
assert models is not None
|
||||
assert "llama3.3:latest" in models
|
||||
assert "qwen2.5-coder:32b" in models
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_discovery_error_preservation(tmp_path):
|
||||
"""Verify exact HTTP / connection error is preserved in cache for UI display."""
|
||||
cache_file = tmp_path / "models_cache.json"
|
||||
service = ModelDiscoveryService(cache_path=cache_file)
|
||||
|
||||
auth_data = {
|
||||
"provider": "openrouter",
|
||||
"profile_id": "openrouter-1",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-or-invalid-key",
|
||||
}
|
||||
ProfileAuthManager.save_profile_auth("openrouter", "openrouter-1", auth_data)
|
||||
AutoAssigner.ensure_profile_definition("openrouter", "openrouter-1")
|
||||
|
||||
err_body = json.dumps({"error": {"message": "Invalid API key provided"}}).encode("utf-8")
|
||||
http_error = urllib.error.HTTPError(
|
||||
url="https://openrouter.ai/api/v1/models",
|
||||
code=401,
|
||||
msg="Unauthorized",
|
||||
hdrs={},
|
||||
fp=MagicMock(read=MagicMock(return_value=err_body)),
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=http_error):
|
||||
res = service.discover_models_sync("openrouter", timeout=5.0)
|
||||
|
||||
assert res is None
|
||||
error_msg = service.get_error("openrouter")
|
||||
assert error_msg is not None
|
||||
assert "401" in error_msg
|
||||
assert "Invalid API key" in error_msg
|
||||
|
||||
meta = service.get_models_with_metadata("openrouter")
|
||||
assert meta["error"] == error_msg
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# P0-3: Health & Quota Status Fixes
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_cooldown_vs_quota_exhausted_separation(tmp_path):
|
||||
"""Verify temporary error cooldown (frec.reset_at > now) is STATUS_COOLDOWN, not QUOTA_EXHAUSTED."""
|
||||
cfg = RouterConfig()
|
||||
cfg.profiles["codex-orch"] = RouterProfileConfig(
|
||||
profile_id="codex-orch",
|
||||
provider="openai-codex",
|
||||
account_id="codex-orch",
|
||||
preferred_models=["gpt-4o"],
|
||||
enabled=True,
|
||||
)
|
||||
save_router_config(cfg)
|
||||
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-test", "provider": "openai-codex"})
|
||||
|
||||
from antigravity_provider.router.router_engine import get_router_engine
|
||||
from antigravity_provider.router.health_tracker import FamilyHealthRecord
|
||||
engine = get_router_engine()
|
||||
now = time.time()
|
||||
rec = engine.health.get_or_create("codex-orch")
|
||||
rec.overall_state = HEALTHY
|
||||
rec.last_error = "Server 500 Error"
|
||||
rec.families["gpt"] = FamilyHealthRecord(
|
||||
family="gpt",
|
||||
state=COOLDOWN,
|
||||
reset_at=now + 120,
|
||||
reason="500 Server Error Backoff",
|
||||
)
|
||||
|
||||
service = UnifiedHealthService.get()
|
||||
profiles = service.scan_all(force=True)
|
||||
codex_vm = next((p for p in profiles["openai-codex"] if p.profile_id == "codex-orch"), None)
|
||||
|
||||
assert codex_vm is not None
|
||||
# Must be COOLDOWN, NOT QUOTA_EXHAUSTED
|
||||
assert codex_vm.health_state == STATUS_COOLDOWN
|
||||
assert "Откат" in codex_vm.health_label_ru
|
||||
assert "Квота исчерпана" not in codex_vm.health_label_ru
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_rate_limited_checked_before_cooldown(tmp_path):
|
||||
"""Verify RATE_LIMITED is prioritized before error cooldowns."""
|
||||
cfg = RouterConfig()
|
||||
cfg.profiles["codex-orch"] = RouterProfileConfig(
|
||||
profile_id="codex-orch",
|
||||
provider="openai-codex",
|
||||
account_id="codex-orch",
|
||||
preferred_models=["gpt-4o"],
|
||||
enabled=True,
|
||||
)
|
||||
save_router_config(cfg)
|
||||
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-test", "provider": "openai-codex"})
|
||||
|
||||
from antigravity_provider.router.router_engine import get_router_engine
|
||||
from antigravity_provider.router.health_tracker import FamilyHealthRecord
|
||||
engine = get_router_engine()
|
||||
now = time.time()
|
||||
rec = engine.health.get_or_create("codex-orch")
|
||||
rec.overall_state = RATE_LIMITED
|
||||
rec.last_error = "429 Too Many Requests"
|
||||
rec.families["gpt"] = FamilyHealthRecord(
|
||||
family="gpt",
|
||||
state=RATE_LIMITED,
|
||||
reset_at=now + 60,
|
||||
reason="Rate Limit 429",
|
||||
)
|
||||
|
||||
service = UnifiedHealthService.get()
|
||||
profiles = service.scan_all(force=True)
|
||||
codex_vm = next((p for p in profiles["openai-codex"] if p.profile_id == "codex-orch"), None)
|
||||
|
||||
assert codex_vm is not None
|
||||
assert codex_vm.health_state == STATUS_RATE_LIMITED
|
||||
assert "Лимит запросов" in codex_vm.health_label_ru
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_health_tracker_no_overall_quota_on_missing_model():
|
||||
"""Verify HealthTracker.mark_quota_exhausted does NOT set overall_state = QUOTA_EXHAUSTED on missing/default model."""
|
||||
ht = HealthTracker()
|
||||
rec = ht.get_or_create("codex-orch")
|
||||
rec.overall_state = HEALTHY
|
||||
|
||||
# Call with model_name=None
|
||||
ht.mark_quota_exhausted("codex-orch", model_name=None, duration=600, reason="Test")
|
||||
assert rec.overall_state == HEALTHY
|
||||
|
||||
# Call with model_name="default"
|
||||
ht.mark_quota_exhausted("codex-orch", model_name="default", duration=600, reason="Test")
|
||||
assert rec.overall_state == HEALTHY
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_codex_adapter_classify_error():
|
||||
"""Verify CodexAdapter.classify_error correctly distinguishes rate limit, auth, transient, and quota."""
|
||||
adapter = CodexAdapter()
|
||||
|
||||
# Rate limited
|
||||
c1 = adapter.classify_error(Exception("HTTP 429: Too Many Requests"))
|
||||
assert c1.category == ErrorCategory.RATE_LIMITED
|
||||
|
||||
c2 = adapter.classify_error(Exception("Rate limit reached for requests per min (RPM)"))
|
||||
assert c2.category == ErrorCategory.RATE_LIMITED
|
||||
|
||||
# Auth
|
||||
c3 = adapter.classify_error(Exception("HTTP 401: Invalid API key"))
|
||||
assert c3.category == ErrorCategory.AUTH_REQUIRED
|
||||
|
||||
# Real Quota
|
||||
c4 = adapter.classify_error(Exception("You exceeded your current quota, please check your plan and billing details."))
|
||||
assert c4.category == ErrorCategory.QUOTA_EXHAUSTED
|
||||
|
||||
c5 = adapter.classify_error(Exception("insufficient_quota"))
|
||||
assert c5.category == ErrorCategory.QUOTA_EXHAUSTED
|
||||
|
||||
# Transient
|
||||
c6 = adapter.classify_error(Exception("HTTP 502: Bad Gateway"))
|
||||
assert c6.category == ErrorCategory.TRANSIENT
|
||||
|
||||
c7 = adapter.classify_error(Exception("Connection reset by peer"))
|
||||
assert c7.category == ErrorCategory.TRANSIENT
|
||||
|
|
@ -44,8 +44,21 @@ def test_profile_view_model_mapping():
|
|||
for p in all_profs:
|
||||
assert isinstance(p, ProfileViewModel)
|
||||
assert p.profile_id
|
||||
assert p.display_name
|
||||
assert p.provider in ("antigravity", "openai-codex", "opencode-go", "claude", "grok", "local")
|
||||
assert p.provider in (
|
||||
"antigravity",
|
||||
"openai-codex",
|
||||
"opencode-go",
|
||||
"claude",
|
||||
"grok",
|
||||
"local",
|
||||
"openrouter",
|
||||
"nvidia",
|
||||
"nvidia-nim",
|
||||
"ollama",
|
||||
"local-llm",
|
||||
"llama.cpp",
|
||||
"vllm",
|
||||
)
|
||||
assert p.health_state in (
|
||||
"healthy", "quota_low", "quota_exhausted", "cooldown", "rate_limited",
|
||||
"auth_required", "auth_expired", "disabled", "cold_spare", "unhealthy", "not_tested", "not_configured"
|
||||
|
|
|
|||
Loading…
Reference in a new issue