hermes-hub/src/antigravity_provider/router/connection_preflight.py
Hermes Team 9c57a7c607 fix(antigravity): рабочий аккаунт падал из-за незаданного уровня усилия
Новое сообщение об отказе agy показало причину. У рабочего аккаунта
victor.trushenko@gmail.com каталог получался (11 моделей), а вызов падал:

  invalid model selection (--model "gemini-3.7-flash" --effort ""):
  gemini-3.7-flash requires --effort (available: low, medium, high)

Каталог agy отдаёт идентификаторы с уровнем усилия внутри имени
(gemini-3.7-flash-high, -medium, -low), а в профиле хранится голое имя.
Вызов уходил с пустым --effort, и agy отказывался работать.

Теперь уровень определяется: если он зашит в имени, отделяется от модели;
если нет — берётся из каталога, предпочтительно medium. Проверено, что
claude-sonnet-4-6 при этом не разбирается ошибочно: -6 уровнем не
является.

Отдельно: восемь профилей Antigravity отвечают «Please sign in to view
available models» при HOME=~/.hermes/agy_profiles/<slot>. Это пустые
заготовки без учётных данных, а не поломка: agy запускается с подменённым
HOME ради изоляции аккаунтов, и в незаполненном каталоге ключей нет.

Подписочные аккаунты Codex и Claude: отказ по API-ключу теперь объясняет,
что такие аккаунты подключаются входом по ссылке, а не ключом. Проверка
обращается к каталогу моделей, где платформенному ключу нужны права
api.model.read, и подписочный токен там получает 401 или 403 при исправном
аккаунте.

610 passed, ruff clean, релизный гейт 10/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 03:05:44 +07:00

173 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Validate supplied credentials before creating any profile or writing auth files."""
import json
import urllib.error
import urllib.request
from urllib.parse import urlsplit
DEFAULT_URLS = {
"openrouter": "https://openrouter.ai/api/v1",
"nvidia": "https://integrate.api.nvidia.com/v1",
"local": "http://127.0.0.1:8081/v1", "vllm": "http://127.0.0.1:8081/v1",
"ollama": "http://127.0.0.1:11434", "claude": "https://api.anthropic.com/v1",
"opencode-go": "https://opencode.ai/zen/go/v1", "grok": "https://api.x.ai/v1",
"openai-codex": "https://api.openai.com/v1",
}
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ValueError("Перенаправление API запрещено: укажите конечный URL сервера")
def validate_connection(provider, token="", base_url="", preferred_model=""):
provider = {"nvidia-nim": "nvidia", "local-llm": "local", "llama.cpp": "local"}.get(provider, provider)
try:
base_url = (base_url or DEFAULT_URLS.get(provider, "")).rstrip("/")
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError("Укажите HTTP(S) URL сервера без пароля, параметров и фрагмента")
if provider not in DEFAULT_URLS:
raise ValueError("Для этого провайдера используйте вход через авторизацию")
if provider not in ("local", "vllm", "ollama") and not token:
raise ValueError("Не указан API-ключ")
# Подписочные аккаунты Codex (ChatGPT) и Claude работают по OAuth, а не
# по платформенному ключу. Проверка ниже обращается к каталогу моделей,
# где платформенному ключу нужны права api.model.read: подписочный
# токен там получает 403 или 401, хотя аккаунт исправен и в Hermes
# работает. Предупреждаем об этом заранее, чтобы отказ не выглядел
# поломкой аккаунта.
subscription_note = ""
if provider in ("openai-codex", "codex", "claude", "anthropic"):
subscription_note = (
" Если это аккаунт по подписке (ChatGPT Plus/Pro или Claude Max), "
"ключ здесь не подойдёт: такие аккаунты подключаются входом по ссылке "
"на предыдущем шаге мастера, а не API-ключом."
)
headers = {"Content-Type": "application/json"}
if provider == "claude":
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
elif token:
headers["Authorization"] = "Bearer " + token
opener = urllib.request.build_opener(_NoRedirect())
def request(path, body=None):
req = urllib.request.Request(base_url + path, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with opener.open(req, timeout=20) as response:
payload = json.load(response)
if not isinstance(payload, dict) or payload.get("error"):
raise ValueError("Провайдер вернул ошибку или неверный JSON")
return payload
if provider == "openrouter":
# The catalog is public: its HTTP 200 is not proof of a valid key.
request("/key")
if provider == "ollama":
base_url = base_url.removesuffix("/v1")
payload, field, key = request("/api/tags"), "models", "name"
else:
payload, field, key = request("/models"), "data", "id"
entries = payload.get(field)
if not isinstance(entries, list):
raise ValueError("Провайдер не вернул список моделей")
models = sorted({m[key] for m in entries if isinstance(m, dict) and isinstance(m.get(key), str) and m[key]})
if provider == "nvidia":
if not models:
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
if preferred_model and preferred_model not in models:
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
# Каталог NVIDIA общий для всех, а доступ к конкретной модели даётся
# по аккаунту. Успешный список уже доказывает, что ключ рабочий:
# сервер опознал аккаунт и ответил. Пробный запрос — уточнение, а не
# условие. Раньше его отказ («Function ... Not found for account»)
# объявлялся провалом подключения, хотя ключ был верным.
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
probe_target = preferred_model or (chat_models[0] if chat_models else None)
if probe_target:
try:
result = request("/chat/completions", {"model": probe_target,
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
if not isinstance(result.get("choices"), list) or not result["choices"]:
probe_note = f"модель {probe_target} не вернула результат"
else:
probe_note = ""
except Exception as probe_exc:
# 401 и 403 — ключ отвергнут, это отказ подключения.
# 404 и прочее — ключ принят, но модель аккаунту не выдана:
# NVIDIA отвечает «Function ... Not found for account <id>»,
# то есть аккаунт опознан. Валить подключение из-за этого нельзя.
code = getattr(probe_exc, "code", None)
if code in (401, 403):
raise
probe_note = f"модель {probe_target} недоступна вашему аккаунту ({str(probe_exc)[:120]})"
else:
probe_note = "чат-модель для пробы не найдена"
if probe_note:
return {"ok": True, "data": {"models": models, "base_url": base_url},
"message": f"Ключ принят, моделей в каталоге: {len(models)}. Проба: {probe_note}. Выберите доступную модель."}
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
except Exception as exc:
exc_str = str(exc)
exc_lower = exc_str.lower()
# У Ollama Cloud локального сервера нет вовсе: там только ключ. Если
# ключ задан, а локальный адрес не отвечает, это не отказ подключения —
# аккаунт облачный. Проверяем его по общедоступному каталогу и
# принимаем, честно сказав, что локальные модели недоступны.
if provider == "ollama" and token and any(
k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "refused", "failed to connect", "target machine actively refused")
):
try:
cloud = urllib.request.Request(
"https://ollama.com/api/tags",
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(cloud, timeout=15) as response:
payload = json.load(response)
cloud_models = sorted({
m.get("name") for m in (payload.get("models") or [])
if isinstance(m, dict) and isinstance(m.get("name"), str)
})
return {
"ok": True,
"message": (
f"Облачный аккаунт Ollama принят. Моделей в каталоге: {len(cloud_models)}. "
f"Локальный сервер по адресу {base_url} не отвечает — локальные модели недоступны."
),
"data": {"models": cloud_models, "base_url": base_url, "cloud_only": True},
}
except Exception as cloud_exc:
return {
"ok": False,
"message": (
f"Локальный сервер {base_url} не отвечает, и облачный каталог Ollama недоступен: "
f"{str(cloud_exc)[:160]}"
),
"data": {"models": []},
}
if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")):
return {
"ok": False,
"message": f"Не удалось подключиться к {base_url} (Connection refused). Если Ollama работает на сервере или другой машине, укажите её сетевой адрес (например, http://192.168.1.81:11434).",
"data": {"models": []},
}
if isinstance(exc, urllib.error.HTTPError):
reason = (exc.reason or 'провайдер отклонил запрос')
if exc.code in (401, 403) and subscription_note:
reason = str(reason) + subscription_note
try:
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
detail = body.get('error') or body.get('detail')
if isinstance(detail, dict):
detail = detail.get('message')
if isinstance(detail, str) and detail.strip():
reason = detail[:500]
except Exception:
pass
message = f"HTTP {exc.code}: {reason}"
else:
message = str(exc).strip() or type(exc).__name__
if token:
message = message.replace(token, "[скрыто]")
return {"ok": False, "message": message, "data": {"models": []}}