Владелец был прав: «апи не нужен». Подтверждено на его аккаунте 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 <noreply@anthropic.com>
114 lines
5.5 KiB
Python
114 lines
5.5 KiB
Python
"""Квота Grok читается у провайдера, а не показывается как «Н/Д».
|
||
|
||
Владелец спросил: «где закончились лимиты?» — и показал экран, где
|
||
еженедельный лимит SuperGrok израсходован на 14%. Но вызовы через Hub
|
||
падали с 402.
|
||
|
||
Разгадка: это ДВА РАЗНЫХ кошелька. Подписка SuperGrok покрывает чат
|
||
grok.com, а программный доступ списывается с кредитов API. Их у аккаунта
|
||
ноль — подтверждено биллинговым эндпоинтом.
|
||
|
||
Раньше _collect_grok_quota строила пустые корзины и никуда не ходила,
|
||
поэтому Hub показывал «Н/Д» и объяснить ничего не мог.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
|
||
|
||
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
|
||
|
||
|
||
def _auth() -> dict:
|
||
return {"token": {"access_token": "tok"}}
|
||
|
||
|
||
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 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, credit_pct=5.0)):
|
||
snap = AccountQuotaService()._collect_grok_quota("grok-orch", _auth())
|
||
|
||
assert not any(b.id == "grok.on_demand" for b in snap.buckets)
|
||
|
||
|
||
def test_account_with_credits_reports_real_percent():
|
||
"""Когда лимит есть, доля считается по измеренным значениям."""
|
||
with patch("antigravity_provider.router.quota_collector.urllib.request.urlopen",
|
||
return_value=_billing(50, 200, 50)):
|
||
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.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)
|