From 0df7dba2ce9fb42351407471e90bad9af354208f Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Mon, 24 Aug 2026 20:29:08 +0700 Subject: [PATCH] =?UTF-8?q?feat(grok):=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=B4=D0=B8=D1=82=D0=BE=D0=B2=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B2=D0=BE=D1=82=D0=B0=20=D0=B8=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец был прав: «апи не нужен». Подтверждено на его аккаунте GROK PRO — api.x.ai/v1/chat/completions вернул 200 и ответ модели grok-4.3 БЕЗ покупки кредитов. Прежний 402 был целиком из-за того, что подключён был другой аккаунт, без подписки. Моё утверждение про «разные кошельки» окончательно снято. 1. Квота показывала «Н/Д» при живой подписке. Читались только prepaidBalance и onDemandCap — у подписчика оба нулевые. А расход подписки лежит в creditUsagePercent, и разбивка по продуктам в productUsage. Теперь оттуда и берётся: на живом аккаунте выходит 14% за неделю, GrokChat 13%, GrokBuild 1% — ровно то, что владелец видит на grok.com. Корзина кредитов создаётся только когда они реально заведены: нули у подписчика — норма, а не повод рисовать пустое. 2. Выбор модели отвергал настоящие имена: «кэш моделей для grok пуст, а модель grok-4.5 не найдена». Причина — в _probe_provider грока не было вовсе, обнаружение знало только antigravity, codex, opencode и local. При этом api.x.ai/v1/models принимает тот же OAuth-токен и отдаёт 12 моделей. Зонд добавлен; grok-4.5 теперь принимается, выдуманная grok-99-turbo — отклоняется. Тесты: 428 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/model_discovery_service.py | 33 ++++++ .../router/quota_collector.py | 107 +++++++++++------- tests/test_grok_quota.py | 77 ++++++++++--- 3 files changed, 159 insertions(+), 58 deletions(-) diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index 54c9aad..971bb9b 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -289,6 +289,39 @@ class ModelDiscoveryService: logger.debug("OpenCode model query failed on %s: %s", pid, exc) return None + 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 + 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 + try: + request = urllib.request.Request( + "https://api.x.ai/v1/models", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8") or "{}") + models = [ + str(item.get("id")) + for item in (payload.get("data") or []) + if isinstance(item, dict) and item.get("id") + ] + if models: + return sorted(set(models)) + except Exception as exc: + logger.debug("Grok model discovery failed for %s: %s", pid, exc) + return None + elif prov in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): from antigravity_provider.router.router_config import load_router_config cfg = load_router_config() diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index f3805e4..1c95fa3 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -794,51 +794,78 @@ class AccountQuotaService: buckets: List[QuotaBucket] = [] - # Процент считаем ТОЛЬКО когда есть от чего считать. При нулевом - # лимите доля не определена — показываем абсолютные значения, а не - # выдуманный ноль процентов. - remaining_pct: Optional[float] = None - used_pct: Optional[float] = None - if cap and cap > 0 and used is not None: - used_pct = max(0.0, min(100.0, used / cap * 100.0)) - remaining_pct = 100.0 - used_pct + # Главное — расход подписки. Именно это число владелец видит на + # grok.com: «Еженедельный лимит SuperGrok, 14% было в использовании». + # Раньше здесь читались только баланс и лимит трат, оба нулевые у + # подписчика, и квота показывалась как «Н/Д» при живой подписке. + credit_pct = _val(config.get("creditUsagePercent")) + if credit_pct is not None: + buckets.append( + QuotaBucket( + id="grok.subscription.weekly", + display_name="Подписка — неделя", + model_family="grok", + used_percent=credit_pct, + remaining_percent=max(0.0, 100.0 - credit_pct), + reset_at=period_end, + period="7d", + unit="percent", + scope="account", + status="exhausted" if credit_pct >= 100 else ("warning" if credit_pct >= 80 else "healthy"), + ) + ) - buckets.append( - QuotaBucket( - id="grok.on_demand", - display_name="Кредиты по мере использования", - model_family="grok", - used_percent=used_pct, - remaining_percent=remaining_pct, - used_absolute=int(used) if used is not None else None, - limit_absolute=int(cap) if cap is not None else None, - reset_at=period_end, - period="7d", - unit="currency", - scope="account", - status="exhausted" if (cap == 0 or (remaining_pct is not None and remaining_pct <= 0)) else "healthy", + # Разбивка по продуктам приходит тем же ответом: GrokChat, GrokBuild. + for entry in config.get("productUsage") or []: + if not isinstance(entry, dict): + continue + product = str(entry.get("product") or "").strip() + pct = _val(entry.get("usagePercent")) + if not product or pct is None: + continue + buckets.append( + QuotaBucket( + id=f"grok.product.{product.lower()}", + display_name=product, + model_family="grok", + used_percent=pct, + remaining_percent=max(0.0, 100.0 - pct), + reset_at=period_end, + period="7d", + unit="percent", + scope="account", + status="exhausted" if pct >= 100 else ("warning" if pct >= 80 else "healthy"), + ) ) - ) - buckets.append( - QuotaBucket( - id="grok.prepaid", - display_name="Предоплаченный баланс", - model_family="grok", - remaining_absolute=int(prepaid) if prepaid is not None else None, - unit="currency", - scope="account", - status="exhausted" if prepaid == 0 else "healthy", + + # Кредиты сверх подписки показываем, только если они вообще заведены. + # Нули у подписчика — норма, а не повод рисовать пустые корзины. + if (cap and cap > 0) or (prepaid and prepaid > 0): + used_pct = None + remaining_pct = None + if cap and cap > 0 and used is not None: + used_pct = max(0.0, min(100.0, used / cap * 100.0)) + remaining_pct = 100.0 - used_pct + buckets.append( + QuotaBucket( + id="grok.on_demand", + display_name="Дополнительные кредиты", + model_family="grok", + used_percent=used_pct, + remaining_percent=remaining_pct, + used_absolute=int(used) if used is not None else None, + limit_absolute=int(cap) if cap is not None else None, + remaining_absolute=int(prepaid) if prepaid is not None else None, + reset_at=period_end, + unit="currency", + scope="account", + status="healthy", + ) ) - ) reason = None - if not prepaid and not cap: - reason = ( - "У этого аккаунта нет ни предоплаченного баланса, ни лимита по " - "мере использования. Доступ даёт либо подписка SuperGrok на ЭТОМ " - "же аккаунте, либо купленные кредиты — проверьте, что подключён " - "тот аккаунт, на котором оформлена подписка." - ) + if not buckets: + reason = "Провайдер не вернул ни расхода подписки, ни кредитов" return QuotaSnapshot( account_id=profile_id, diff --git a/tests/test_grok_quota.py b/tests/test_grok_quota.py index 9eb4d4f..a1cada6 100644 --- a/tests/test_grok_quota.py +++ b/tests/test_grok_quota.py @@ -20,15 +20,18 @@ from unittest.mock import MagicMock, patch from antigravity_provider.router.quota_collector import AccountQuotaService -def _billing(prepaid: float, cap: float, used: float) -> MagicMock: - payload = { - "config": { - "currentPeriod": {"start": "2026-08-19T00:00:00+00:00", "end": "2026-08-26T00:00:00+00:00"}, - "prepaidBalance": {"val": prepaid}, - "onDemandCap": {"val": cap}, - "onDemandUsed": {"val": used}, - } +def _billing(prepaid: float, cap: float, used: float, credit_pct=None, products=None) -> MagicMock: + config = { + "currentPeriod": {"start": "2026-08-19T00:00:00+00:00", "end": "2026-08-26T00:00:00+00:00"}, + "prepaidBalance": {"val": prepaid}, + "onDemandCap": {"val": cap}, + "onDemandUsed": {"val": used}, } + if credit_pct is not None: + config["creditUsagePercent"] = credit_pct + if products: + config["productUsage"] = products + payload = {"config": config} resp = MagicMock() resp.__enter__.return_value.read.return_value = json.dumps(payload).encode("utf-8") return resp @@ -38,27 +41,33 @@ def _auth() -> dict: return {"token": {"access_token": "tok"}} -def test_exhausted_account_explains_two_wallets(): - """Нулевой баланс объясняется, а не показывается прочерком.""" +def test_account_without_subscription_or_credits_says_so(): + """Ни подписки, ни кредитов — честная причина, а не пустые корзины. + + Прежняя версия рисовала две корзины с нулями и утверждала, что подписка + SuperGrok «не пополняет кредиты». Это оказалось неверно: проверка на + живом аккаунте GROK PRO показала, что подписка даёт доступ и вызовы + проходят без покупки кредитов. + """ with patch("antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=_billing(0, 0, 0)): snap = AccountQuotaService()._collect_grok_quota("grok-orch", _auth()) assert snap.source == "provider_api" - assert "SuperGrok" in (snap.unavailable_reason or ""), "не объяснено, откуда берётся доступ" - assert all(b.status == "exhausted" for b in snap.buckets) + assert snap.buckets == [] + assert "не вернул" in (snap.unavailable_reason or "") def test_zero_cap_does_not_fabricate_percent(): - """При нулевом лимите доля не определена — процент выдумывать нельзя.""" + """При нулевом лимите корзина кредитов не создаётся вовсе. + + Доля от нуля не определена, и показывать «0%» было бы выдумкой. + """ with patch("antigravity_provider.router.quota_collector.urllib.request.urlopen", - return_value=_billing(0, 0, 0)): + return_value=_billing(0, 0, 0, credit_pct=5.0)): snap = AccountQuotaService()._collect_grok_quota("grok-orch", _auth()) - on_demand = next(b for b in snap.buckets if b.id == "grok.on_demand") - assert on_demand.remaining_percent is None - assert on_demand.used_percent is None - assert on_demand.limit_absolute == 0 + assert not any(b.id == "grok.on_demand" for b in snap.buckets) def test_account_with_credits_reports_real_percent(): @@ -71,3 +80,35 @@ def test_account_with_credits_reports_real_percent(): assert on_demand.used_percent == 25.0 assert on_demand.remaining_percent == 75.0 assert snap.unavailable_reason is None + + +def test_subscription_usage_is_reported(): + """Расход подписки — то самое число, что владелец видит на grok.com. + + Раньше читались только баланс и лимит трат, у подписчика оба нулевые, + и квота показывалась как «Н/Д» при живой подписке. + """ + products = [ + {"product": "GrokChat", "usagePercent": 13.0}, + {"product": "GrokBuild", "usagePercent": 1.0}, + ] + with patch("antigravity_provider.router.quota_collector.urllib.request.urlopen", + return_value=_billing(0, 0, 0, credit_pct=14.0, products=products)): + snap = AccountQuotaService()._collect_grok_quota("grok-orch", _auth()) + + weekly = next(b for b in snap.buckets if b.id == "grok.subscription.weekly") + assert weekly.used_percent == 14.0 + assert weekly.remaining_percent == 86.0 + assert snap.unavailable_reason is None + + names = {b.display_name for b in snap.buckets} + assert {"GrokChat", "GrokBuild"} <= names, "разбивка по продуктам потеряна" + + +def test_zero_credits_not_shown_for_subscriber(): + """Нулевые кредиты у подписчика — норма, пустых корзин рисовать не надо.""" + with patch("antigravity_provider.router.quota_collector.urllib.request.urlopen", + return_value=_billing(0, 0, 0, credit_pct=14.0)): + snap = AccountQuotaService()._collect_grok_quota("grok-orch", _auth()) + + assert not any(b.id == "grok.on_demand" for b in snap.buckets)