Новое сообщение об отказе 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>
173 lines
11 KiB
Python
173 lines
11 KiB
Python
"""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": []}}
|