fix(ollama): облачный аккаунт объявлялся неработающим и безлимитным

Адрес. Адаптер всегда шёл на 127.0.0.1:11434, получал Connection refused и
помечал аккаунт нерабочим — при том что каталог из девятнадцати облачных
моделей у него получался. У Ollama Cloud локального сервера нет вовсе,
есть только ключ. Теперь при заданном ключе и незаданном адресе адаптер
идёт на ollama.com; явно указанный адрес по-прежнему в приоритете.
Проверено: только ключ даёт https://ollama.com, заданный адрес — его же.

Ключ. Как у NVIDIA и OpenRouter, он читался только из auth_config в
router_profiles.yaml, куда мастер его не кладёт. Добавлено чтение из
хранилища учётных данных.

Лимиты. Провайдер ollama безусловно относился к локальным и получал
подпись «Без ограничений (локальная модель)». Для облачного аккаунта это
неправда: лимиты у него есть. Теперь облачный аккаунт (узнаётся по
наличию ключа) показывает «Н/Д: лимиты не измерены — провайдер не
сообщает их через API».

Отдельно: отказ agy теперь доносит причину. Прежнее «код 1; каталог не
получен» скрывало и текст ошибки, и главное — что agy запускается с HOME,
подменённым на каталог профиля ради изоляции учётных данных. Если вход
делался обычным agy в оболочке, ключи легли в настоящий домашний каталог,
и профиль пуст. Теперь в сообщении и ответ agy, и использованный HOME.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 02:55:39 +07:00
parent afaf600f56
commit 9761362bc0
3 changed files with 75 additions and 3 deletions

View file

@ -228,7 +228,18 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
raw = result.stdout.strip() raw = result.stdout.strip()
if not raw or result.returncode != 0: if not raw or result.returncode != 0:
if profile_id: if profile_id:
raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен") # Прежнее сообщение «код 1; каталог не получен» скрывало причину.
# agy пишет её в stderr, и без неё непонятно главное: он
# запускается с HOME, подменённым на каталог профиля (ради
# изоляции учётных данных между аккаунтами). Если вход
# выполнялся обычным agy в оболочке, ключи легли в настоящий
# домашний каталог, и профиль пуст — отсюда отказ.
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
detail = detail.splitlines()[-1][:300] if detail else "вывод пуст"
raise RuntimeError(
f"agy models: код {result.returncode}. Ответ agy: {detail}. "
f"Запуск с HOME={env.get('HOME') or env.get('USERPROFILE') or 'не задан'}"
)
return dict(_AGY_MODEL_CACHE or {}) return dict(_AGY_MODEL_CACHE or {})
except Exception: except Exception:
if profile_id: if profile_id:

View file

@ -15,6 +15,7 @@ from .local_adapter import LocalLLMAdapter
logger = logging.getLogger("hermes.router.adapter.ollama") logger = logging.getLogger("hermes.router.adapter.ollama")
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
CLOUD_OLLAMA_BASE_URL = "https://ollama.com"
DEFAULT_OLLAMA_MODELS = ["llama3:latest"] DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter):
and native Ollama endpoints (/api/tags). and native Ollama endpoints (/api/tags).
""" """
def _stored_base_url(self, profile: RouterProfileConfig) -> str:
"""Адрес из хранилища учётных данных, если владелец его задавал."""
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
return str(stored.get("base_url") or "").strip()
except Exception:
return ""
def _resolve_base_url(self, profile: RouterProfileConfig) -> str: def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
"""Resolve base_url from profile custom_base_url, auth_config, or environment.""" """Адрес сервера: локальный либо облачный.
url = (
У аккаунта Ollama Cloud локального сервера нет вовсе только ключ.
Раньше адаптер всегда шёл на 127.0.0.1:11434, получал «Connection
refused» и объявлял облачный аккаунт неработающим, хотя каталог из
девятнадцати моделей у него получался.
"""
explicit = (
profile.custom_base_url profile.custom_base_url
or profile.auth_config.get("base_url") or profile.auth_config.get("base_url")
or self._stored_base_url(profile)
)
if not explicit and self._resolve_api_key(profile):
return CLOUD_OLLAMA_BASE_URL
url = (
explicit
or os.environ.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_BASE_URL")
or os.environ.get("OLLAMA_HOST") or os.environ.get("OLLAMA_HOST")
or DEFAULT_OLLAMA_BASE_URL or DEFAULT_OLLAMA_BASE_URL
@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter):
if key: if key:
return str(key).strip() return str(key).strip()
# Ключ, введённый в мастере, лежит в хранилище учётных данных, а не в
# auth_config из router_profiles.yaml.
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
key = stored.get("api_key") or stored.get("token")
if key:
return str(key).strip()
except Exception:
pass
suffix = profile.profile_id.upper().replace("-", "_") suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"): for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
val = os.environ.get(candidate, "").strip() val = os.environ.get(candidate, "").strip()

View file

@ -918,6 +918,31 @@ class AccountQuotaService:
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot: def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
"""Truthful offline baseline with provider-specific independent limit pools.""" """Truthful offline baseline with provider-specific independent limit pools."""
now = _utc_now() now = _utc_now()
# У облачного аккаунта Ollama лимиты есть, и объявлять их
# отсутствующими нельзя. Локальный сервер узнаётся по отсутствию
# ключа: облачный доступ без ключа невозможен.
if provider == "ollama":
try:
from .profile_manager import ProfileAuthManager
auth = ProfileAuthManager.load_profile_auth(provider, profile_id) or {}
is_cloud = bool((auth.get("api_key") or auth.get("token") or "").strip())
except Exception:
is_cloud = False
if is_cloud:
return QuotaSnapshot(
account_id=profile_id,
provider=provider,
buckets=[],
fetched_at=now,
source="cloud_provider",
unavailable_reason=(
"Н/Д: лимиты облачного аккаунта Ollama не измерены — "
"провайдер не сообщает их через API"
),
is_loading=False,
)
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
b_unlimited = QuotaBucket( b_unlimited = QuotaBucket(
id="local.unlimited", id="local.unlimited",