Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو

ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگ‌های صفحهٔ مدل‌ها) با ۸ کامیت محلی (ارائه‌دهندهٔ agy + رفع فلو دعوت).

تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.

راه‌حل:
- `app/main.py`: فقط `health` و `realtime_router` می‌مانند؛ ایمپورت‌های اضافی
  حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
  `GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
  قابل دسترس باشند.

`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد می‌شود،
پس هیچ تغییری در فراخوانی‌ها لازم نبود.

رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر می‌کرد و `useRealtime` استثنا می‌داد. حالا اسکلت
همان‌طور که در `app/layout.tsx` سیم‌کشی شده رندر می‌شود و سوکت در تست stub
شده است.

اعتبارسنجی روی درخت ادغام‌شده:
- بک‌اند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.

نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` می‌آیند و به این ادغام ربطی
ندارند؛ دست‌نخورده رها شدند.
This commit is contained in:
mjalmoudy94
2026-10-13 18:09:03 +03:30
49 changed files with 4370 additions and 294 deletions
+282 -45
View File
@@ -31,12 +31,16 @@ import base64
import hashlib
import json
import logging
import os
import secrets
import shutil
import tempfile
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
@@ -106,9 +110,8 @@ EFFORT_THINKING_BUDGETS: dict[str, int] = {
#: a model that does not support it — that is a hard 400 from the backend.
_THINKING_MARKERS: tuple[str, ...] = (
"thinking",
"gemini-3",
"gemini-2.5-pro",
"gemini-2.5-flash",
"tiered",
"gemini-",
)
@@ -118,6 +121,7 @@ def _supports_thinking(model_name: str) -> bool:
return False
return any(marker in lowered for marker in _THINKING_MARKERS)
_StatusFn = Callable[..., Awaitable[None]]
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
@@ -209,6 +213,18 @@ class AgyLoginResult:
refresh_token: str
@dataclass(slots=True, kw_only=True)
class AgyAccountModelInfo:
"""Model catalogue item as reported by an agy account."""
id: str
display_name: str
supports_thinking: bool = False
supported_efforts: list[str] = field(default_factory=list)
default_effort: str | None = None
supports_images: bool = False
class AgyOAuthError(ValidationError):
"""The pasted URL or the token exchange was rejected by Google."""
@@ -309,9 +325,7 @@ class AgyAuthClient:
self._fetch_email(tokens.get("access_token", "")),
self._fetch_project_id(tokens.get("access_token", "")),
)
return AgyLoginResult(
email=email, project_id=project_id, refresh_token=refresh_token
)
return AgyLoginResult(email=email, project_id=project_id, refresh_token=refresh_token)
async def refresh(self, refresh_token: str) -> str:
tokens = await self._post_token(
@@ -436,9 +450,7 @@ async def redeem_login(login_id: str, callback_url: str) -> AgyLoginResult:
return await AgyAuthClient().exchange(challenge, callback_url)
async def redeem_login_by_waiting(
login_id: str, *, timeout_seconds: float = 300
) -> AgyLoginResult:
async def redeem_login_by_waiting(login_id: str, *, timeout_seconds: float = 300) -> AgyLoginResult:
"""Listener mode: capture the redirect on the loopback callback port.
Used by the operator-side script (and by a same-machine deployment): Google
@@ -453,9 +465,7 @@ async def redeem_login_by_waiting(
loop = asyncio.get_running_loop()
future: asyncio.Future[str] = loop.create_future()
async def _handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request_line = await reader.readline()
parts = request_line.decode("latin-1").split(" ")
@@ -579,8 +589,10 @@ class AgyModelAdapter:
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
pool = credentials if credentials is not None else (
[credential] if credential is not None else await self._load()
pool = (
credentials
if credentials is not None
else ([credential] if credential is not None else await self._load())
)
accounts = [a for a in (_account_of(c) for c in pool) if a.refresh_token]
if not accounts:
@@ -593,8 +605,12 @@ class AgyModelAdapter:
for account in ordered:
try:
response = await self._complete_with(
account, model=model, messages=messages, system=system,
temperature=temperature, max_tokens=max_tokens,
account,
model=model,
messages=messages,
system=system,
temperature=temperature,
max_tokens=max_tokens,
)
except _AgyRateLimited as exc:
failures.append(f"{account.email or account.credential_id}: محدودیت نرخ")
@@ -630,24 +646,30 @@ class AgyModelAdapter:
accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
if not accounts:
return []
return await self._fetch_models(accounts[0])
items = await self._fetch_models(accounts[0])
return [m.id for m in items]
async def available_models_for(self, credential: ModelCredential) -> list[str]:
async def available_models_for(self, credential: ModelCredential) -> list[AgyAccountModelInfo]:
"""Live model catalog of exactly one account.
Called per account selection in the admin console, so the list is
fetched fresh from Google every time (the access token is cached, the
``fetchAvailableModels`` round-trip is not) and reflects what *this*
subscription is entitled to.
subscription is entitled to along with each model's supported efforts.
"""
account = _account_of(credential)
if not account.refresh_token:
raise ProviderUnavailableError(
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
)
raise ProviderUnavailableError("این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید.")
return await self._fetch_models(account)
async def _fetch_models(self, account: AgyAccount) -> list[str]:
async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]:
# Priority 1: Query models dynamically using headless agy CLI for this account
if not os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("AGY_TEST_CLI"):
cli_models = await self._fetch_models_via_cli(account)
if cli_models:
return cli_models
# Priority 2: Direct HTTP endpoint (used as fallback or in unit tests with HTTP mocks)
token = await self._access_token(account)
for base in BASE_URLS:
try:
@@ -663,11 +685,218 @@ class AgyModelAdapter:
)
if response.is_success:
models = response.json().get("models") or {}
return sorted(models)
return self._parse_model_catalog(models)
except httpx.HTTPError:
continue
return []
@classmethod
async def _fetch_models_via_cli(cls, account: AgyAccount) -> list[AgyAccountModelInfo]:
"""Query models live from the headless `agy` CLI application for this account."""
agy_bin = shutil.which("agy") or (
"/root/.local/bin/agy" if os.path.exists("/root/.local/bin/agy") else None
)
if not agy_bin or not account.refresh_token:
return []
# Prepare isolated app data directory with this account's oauth credentials
account_data_dir = tempfile.mkdtemp(prefix=f"agy_acc_{account.credential_id[:8]}_")
token_path = os.path.join(account_data_dir, "antigravity-oauth-token")
try:
with open(token_path, "w", encoding="utf-8") as f:
json.dump(
{
"token": {
"access_token": "",
"token_type": "Bearer",
"refresh_token": account.refresh_token,
"expiry": "2000-01-01T00:00:00Z",
},
"auth_method": "oauth",
},
f,
)
env = os.environ.copy()
env["ANTIGRAVITY_APP_DATA_DIR"] = account_data_dir
env["HOME"] = "/root"
proc = await asyncio.create_subprocess_exec(
agy_bin,
"models",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=20.0)
except TimeoutError:
proc.kill()
return []
if proc.returncode != 0:
return []
return cls._parse_cli_models_output(stdout_bytes.decode("utf-8", errors="replace"))
except Exception:
logger.warning(
"Failed to fetch models via agy CLI, falling back to HTTP", exc_info=True
)
return []
finally:
shutil.rmtree(account_data_dir, ignore_errors=True)
@classmethod
def _parse_cli_models_output(cls, output: str) -> list[AgyAccountModelInfo]:
items: list[AgyAccountModelInfo] = []
for line in output.splitlines():
line = line.strip()
if not line or "Fetching" in line:
continue
# Output format is tab-separated: <model_id>\t<display_name>
parts = line.split("\t", 1)
if len(parts) == 2:
model_id = parts[0].strip()
display_name = parts[1].strip()
else:
tokens = line.split(None, 1)
model_id = tokens[0].strip()
display_name = tokens[1].strip() if len(tokens) > 1 else model_id
lowered = model_id.lower()
dn_lowered = display_name.lower()
supports_thinking = (
"thinking" in lowered
or "thinking" in dn_lowered
or "-high" in lowered
or "-medium" in lowered
or "-low" in lowered
)
supported_efforts: list[str] = []
default_effort: str | None = None
if "-high" in lowered or "(high)" in dn_lowered:
supported_efforts = ["high"]
default_effort = "high"
elif "-medium" in lowered or "(medium)" in dn_lowered:
supported_efforts = ["medium"]
default_effort = "medium"
elif "-low" in lowered or "(low)" in dn_lowered:
supported_efforts = ["low"]
default_effort = "low"
elif "thinking" in lowered or "thinking" in dn_lowered:
if "gemini" in lowered:
supported_efforts = ["low", "medium", "high"]
default_effort = "high"
else:
supported_efforts = []
default_effort = None
items.append(
AgyAccountModelInfo(
id=model_id,
display_name=display_name,
supports_thinking=supports_thinking,
supported_efforts=supported_efforts,
default_effort=default_effort,
supports_images=True,
)
)
return items
@classmethod
def _parse_model_catalog(cls, raw_models: dict[str, Any]) -> list[AgyAccountModelInfo]:
items: list[AgyAccountModelInfo] = []
for model_id, details in sorted(raw_models.items()):
if not isinstance(details, dict):
details = {}
# Omit internal non-generative / tab-completion preview models
if details.get("isInternal") and not details.get("displayName"):
continue
if model_id.startswith("tab_") or model_id.startswith("chat_"):
continue
raw_dn = details.get("displayName")
if raw_dn and str(raw_dn).strip():
display_name = str(raw_dn).strip()
else:
display_name = cls._format_model_name(model_id)
supports_images = bool(details.get("supportsImages"))
supports_thinking = bool(details.get("supportsThinking"))
if not supports_thinking and _supports_thinking(model_id):
supports_thinking = True
supported_efforts = cls._supported_efforts_for(model_id, details)
default_effort = cls._default_effort_for(supported_efforts)
items.append(
AgyAccountModelInfo(
id=model_id,
display_name=display_name,
supports_thinking=supports_thinking,
supported_efforts=supported_efforts,
default_effort=default_effort,
supports_images=supports_images,
)
)
return items
@classmethod
def _format_model_name(cls, model_id: str) -> str:
"""Dynamic fallback formatter for models without a displayName from agy."""
parts = model_id.split("-")
words: list[str] = []
for p in parts:
if p == "tiered":
words.append("(Tiered)")
elif p in ("agent", "thinking", "image", "preview"):
words.append(f"({p.title()})")
elif p.isdigit() or any(c.isdigit() for c in p):
words.append(p)
else:
words.append(p.title())
return " ".join(words)
@classmethod
def _supported_efforts_for(cls, model_id: str, details: dict[str, Any]) -> list[str]:
lowered = model_id.lower()
if "image" in lowered:
return []
if "claude" in lowered:
return []
supports_thinking = details.get("supportsThinking")
if supports_thinking is False:
return []
if supports_thinking is None and not _supports_thinking(model_id):
return []
if "gpt" in lowered:
return ["medium"]
if "pro" in lowered and "3.1" in lowered:
return ["low", "high"]
budget = details.get("thinkingBudget")
if isinstance(budget, int) and budget > 0:
if budget <= 4096:
return ["low"]
if budget <= 8192:
return ["medium"]
return ["high"]
return ["low", "medium", "high"]
@staticmethod
def _default_effort_for(supported: list[str]) -> str | None:
if not supported:
return None
if "high" in supported:
return "high"
if "medium" in supported:
return "medium"
return supported[0]
# -- internals ----------------------------------------------------------
async def _load(self) -> list[ModelCredential]:
@@ -758,7 +987,12 @@ class AgyModelAdapter:
) -> ModelResponse:
token = await self._access_token(account)
payload = _build_generate_payload(
account, model.internal_name, messages, system, temperature, max_tokens,
account,
model.internal_name,
messages,
system,
temperature,
max_tokens,
reasoning_effort=model.reasoning_effort,
)
headers = {
@@ -783,9 +1017,9 @@ class AgyModelAdapter:
# Both backends share one quota, but a 429 can also be a
# backend hiccup — try the other base URL before benching the
# whole account.
retry_after = _retry_after_seconds(
response.headers.get("Retry-After")
) or retry_after
retry_after = (
_retry_after_seconds(response.headers.get("Retry-After")) or retry_after
)
continue
if response.status_code in (401, 403):
raise _AgyAuthError(
@@ -798,12 +1032,9 @@ class AgyModelAdapter:
# Our request shape is wrong — another account will fail the
# same way, so surface it instead of burning the pool.
raise ProviderUnavailableError(
f"agy درخواست را رد کرد ({response.status_code}): "
f"{response.text[:300]}"
f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}"
)
return _parse_generate_response(
response.json(), model=model, started=started
)
return _parse_generate_response(response.json(), model=model, started=started)
if retry_after is not None:
raise _AgyRateLimited(retry_after)
raise last_transient or _AgyTransientError("هیچ بک‌اند agy پاسخ نداد.")
@@ -833,14 +1064,26 @@ def _build_generate_payload(
role = "model" if message.role == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": message.content}]})
backend_model = model_name
effective_effort = reasoning_effort
if model_name == "gemini-3.8-flash-high":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "high"
elif model_name == "gemini-3.8-flash-medium":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "medium"
elif model_name == "gemini-3.8-flash-low":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "low"
generation: dict = {
"temperature": temperature,
"maxOutputTokens": max_tokens,
}
# Reasoning effort is only sent to models that can think; the backend
# rejects ``thinkingConfig`` everywhere else with a 400.
if reasoning_effort and _supports_thinking(model_name):
budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192)
if effective_effort and _supports_thinking(backend_model):
budget = EFFORT_THINKING_BUDGETS.get(effective_effort, 8192)
generation["thinkingConfig"] = {
"thinkingBudget": budget,
"includeThoughts": True,
@@ -864,16 +1107,14 @@ def _build_generate_payload(
}
return {
"project": account.project_id or _fallback_project_id(),
"model": model_name,
"model": backend_model,
"userAgent": "antigravity",
"requestId": f"agent-{uuid.uuid4()}",
"request": request,
}
def _parse_generate_response(
data: dict, *, model: AiModel, started: float
) -> ModelResponse:
def _parse_generate_response(data: dict, *, model: AiModel, started: float) -> ModelResponse:
candidates = data.get("candidates") or [{}]
candidate = candidates[0] if isinstance(candidates[0], dict) else {}
text_parts: list[str] = []
@@ -914,14 +1155,10 @@ async def load_agy_accounts() -> list[ModelCredential]:
async with SessionFactory() as session:
providers = await ProviderRepository(session).list()
provider = next(
(p for p in providers if p.type.value == "agy" and p.is_active), None
)
provider = next((p for p in providers if p.type.value == "agy" and p.is_active), None)
if provider is None:
return []
return await ModelCredentialRepository(session).list_active_for_provider(
provider.id
)
return await ModelCredentialRepository(session).list_active_for_provider(provider.id)
async def report_agy_state(
+13
View File
@@ -255,6 +255,19 @@ async def record_error(
request_id or "-",
fingerprint,
)
# Live push to the platform-owner console. Best-effort like everything here:
# a failed publish must not turn a recorded error into an unrecorded one.
from app.application.ports.realtime import emit_error_recorded
await emit_error_recorded(
error_id=event.id,
kind=event.kind,
message=event.message,
severity=str(severity),
source=str(source),
organization_id=event.organization_id,
)
return event.id
@@ -281,6 +281,14 @@ class AiModelRepository:
await self.session.flush()
return _model_to_domain(row)
async def delete(self, model_id: str) -> bool:
row = await self.session.get(AiModelRow, model_id)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
async def pick_for_mode(self, mode: RoutingMode) -> AiModel | None:
"""Choose a model for a routing mode.