feat(grok): подписка вместо кредитов — квота и модели заработали
Владелец был прав: «апи не нужен». Подтверждено на его аккаунте 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>
This commit is contained in:
parent
a40bcac3c3
commit
0df7dba2ce
3 changed files with 159 additions and 58 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue