diff --git a/.env.example b/.env.example index 01c389a..23132f8 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,25 @@ JWT_SECRET=change-me ENCRYPTION_KEY=change-me-32-bytes # --- AI models (hidden from customers) ------------------------------------- -# mock | openai_compatible +# mock | openai_compatible | agy AI_DEFAULT_PROVIDER=mock OPENAI_COMPATIBLE_BASE_URL= OPENAI_COMPATIBLE_API_KEY= +# --- agy (Google Antigravity) ---------------------------------------------- +# Only used when AI_DEFAULT_PROVIDER=agy. There are no API keys: accounts are +# Google identities logged in via OAuth from the admin console +# (/admin/accounts → افزودن اکانت agy). Any number of accounts can be added — +# the rotation pool spreads load across them and benches a rate-limited one +# until its quota window resets. AGY_CLIENT_ID/SECRET default to the public +# Antigravity CLI application; override them to use your own Google OAuth +# client. AGY_REDIRECT_URI must stay a loopback URL. +AGY_CLIENT_ID= +AGY_CLIENT_SECRET= +AGY_REDIRECT_URI=http://localhost:51121/oauth-callback +# Seconds an account is benched after a 429 when Google sends no Retry-After. +AGY_429_COOLDOWN_SECONDS=900 + # --- Git provider ---------------------------------------------------------- # mock | gitea GIT_PROVIDER=mock diff --git a/.gitignore b/.gitignore index 1ec7afe..9197675 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ venv/ *.log logs/ +# pending agy OAuth login challenge (contains a PKCE verifier — never commit) +.agy-login-state.json +apps/api/.agy-login-state.json + # editor / os .DS_Store .idea/ diff --git a/README.md b/README.md index 4de69f4..1d5234e 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ web/ | قابلیت | env | مقادیر | |---|---|---| -| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` | +| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` \| `agy` | | Git | `GIT_PROVIDER` | `mock` \| `gitea` | | دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` | | Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | @@ -226,6 +226,25 @@ web/ اگر credential واقعی نداریم، **قابلیت حذف نمیشود**: مدل دیتابیس، interface، UI و adapter mock کامل ساخته میشود و adapter واقعی پشت همین flag آماده میماند. +### ارائهدهندهٔ agy (Google Antigravity) + +agy کلید API ندارد؛ **اکانت**ها هویت Google هستند و از طریق OAuth وارد +میشوند. برای فعالسازی: + +1. `AI_DEFAULT_PROVIDER=agy` را در `.env` بگذارید و `pnpm db:seed` را دوباره + اجرا کنید (کتالوگ مدلهای agy فعال و کاتالوگ mock غیرفعال میشود). +2. با حساب مالک سامانه وارد کنسول مدیریت شوید و از + `/admin/accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل + باز میشود، بعد از تأیید، URL بازگشت را از نوار آدرس مرورگر کپی و Paste + کنید (صفحهٔ مقصد عمداً بارگذاری نمیشود؛ پارامترها همانجا هستند). +3. هر تعداد اکانت Google که بخواهید اضافه کنید. مدلها را در + `/admin/models` در صورت نیاز فعال کنید. + +پول چرخشی، بار را بین اکانتها پخش میکند و اکانتی که ۴۲۹ گرفته تا پایان +پنجرهٔ سهمیهٔ آن کنار گذاشته میشود (هم در حافظه و هم در دیتابیس، تا workerها +هم همان تصویر را ببینند). refresh tokenها فقط encrypted at rest ذخیره +میشوند و هیچوقت از API برنمیگردند. + --- ## ۹. Workerهای پسزمینه diff --git a/apps/api/alembic/versions/b1c2d3e4f5a6_add_agy_account_columns.py b/apps/api/alembic/versions/b1c2d3e4f5a6_add_agy_account_columns.py new file mode 100644 index 0000000..25422e9 --- /dev/null +++ b/apps/api/alembic/versions/b1c2d3e4f5a6_add_agy_account_columns.py @@ -0,0 +1,57 @@ +"""add agy account columns to ai_model_credentials + +Turns ``ai_model_credentials`` into a store that can hold *accounts* for the +agy (Google Antigravity) provider, not just API keys: ``email`` / ``project_id`` +carry the account identity, ``status`` + ``cooldown_until`` drive the rotation +pool (skip a rate-limited account until its reset time), and +``last_error`` / ``last_used_at`` support the admin console. + +Every new NOT NULL column carries a ``server_default`` so existing rows +backfill without a table rewrite and stay insertable from plain SQL. + +Revision ID: b1c2d3e4f5a6 +Revises: f5ba57af2906 +Create Date: 2026-10-13 02:10:00.000000 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = 'b1c2d3e4f5a6' +down_revision: str | None = 'f5ba57af2906' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +#: ``(name, type, server_default)``. ``None`` default means nullable, no default. +_NEW_COLUMNS: tuple[tuple[str, sa.types.TypeEngine, str | None], ...] = ( + ("email", sa.String(length=320), "''"), + ("project_id", sa.String(length=160), "''"), + ("status", sa.String(length=16), "'active'"), + ("cooldown_until", sa.DateTime(timezone=True), None), + ("last_error", sa.String(length=512), "''"), + ("last_used_at", sa.DateTime(timezone=True), None), +) + + +def upgrade() -> None: + for name, column_type, default in _NEW_COLUMNS: + op.add_column( + "ai_model_credentials", + sa.Column( + name, + column_type, + nullable=default is None, + server_default=None if default is None else sa.text(default), + ), + ) + + +def downgrade() -> None: + for name, _type, _default in _NEW_COLUMNS: + op.drop_column("ai_model_credentials", name) diff --git a/apps/api/app/application/ports/model_provider.py b/apps/api/app/application/ports/model_provider.py index 0a19d87..d51523b 100644 --- a/apps/api/app/application/ports/model_provider.py +++ b/apps/api/app/application/ports/model_provider.py @@ -44,13 +44,21 @@ class ModelProviderPort(Protocol): self, *, model: AiModel, - credential: ModelCredential | None, + credential: ModelCredential | None = None, + credentials: list[ModelCredential] | None = None, messages: list[ChatMessage], system: str = "", temperature: float = 0.2, max_tokens: int = 2048, ) -> ModelResponse: - """Run one completion. Must raise ``ProviderUnavailableError`` on failure.""" + """Run one completion. Must raise ``ProviderUnavailableError`` on failure. + + ``credential`` is the single active credential (the only input key-based + providers need). ``credentials`` is the full usable pool: providers whose + accounts rotate (agy) accept it and fan out across the set, keeping one + account's rate limit from becoming everyone's outage; providers without + a pool ignore it. + """ ... async def health(self) -> ModelHealth: diff --git a/apps/api/app/application/services/workforce_service.py b/apps/api/app/application/services/workforce_service.py index 0175b0d..c6441f9 100644 --- a/apps/api/app/application/services/workforce_service.py +++ b/apps/api/app/application/services/workforce_service.py @@ -714,10 +714,17 @@ class WorkforceService: ) -> str: """Run one completion and record its usage. The single model entry point.""" model = await self._resolve_model(employee) - credential = await self.credentials.get_active_for_provider(model.provider_id) + # Pool-aware: rotation providers (agy) fan out across every usable + # account; key-based providers simply get their one active credential. + pool = await self.credentials.list_active_for_provider(model.provider_id) + credential = pool[0] if pool else None response = await self.provider.complete( - model=model, credential=credential, messages=messages, system=system + model=model, + credential=credential, + credentials=pool or None, + messages=messages, + system=system, ) await self._record_usage( diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 68c6a50..703d531 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -64,6 +64,21 @@ class Settings(BaseSettings): openai_compatible_base_url: str = "" openai_compatible_api_key: str = "" + # --- agy (Google Antigravity) -------------------------------------------- + #: OAuth client of the public Antigravity CLI application. Overridable so a + #: private Google Cloud OAuth client can be substituted without a code change. + agy_client_id: str = ( + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + ) + agy_client_secret: str = "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" + #: Must stay loopback — Google only allows it for public installed clients. + agy_redirect_uri: str = "http://localhost:51121/oauth-callback" + agy_user_agent: str = "antigravity/1.11.5 windows/amd64" + #: Seconds an account is benched after Google answers 429 before the retry + #: that reverses the cooldown succeeds. Conservative because the real reset + #: wall is only known from quota headers when present. + agy_429_cooldown_seconds: int = 900 + # --- git ----------------------------------------------------------------- git_provider: str = "mock" gitea_url: str = "" diff --git a/apps/api/app/domain/ai_control/models.py b/apps/api/app/domain/ai_control/models.py index 826c800..9088b5f 100644 --- a/apps/api/app/domain/ai_control/models.py +++ b/apps/api/app/domain/ai_control/models.py @@ -19,6 +19,19 @@ class ProviderType(StrEnum): ANTHROPIC = "anthropic" GEMINI = "gemini" OPENROUTER = "openrouter" + #: Google Antigravity (``agy``) — OAuth accounts billed through a Google AI + #: subscription. Credentials of this type are *accounts*, not API keys. + AGY = "agy" + + +class CredentialStatus(StrEnum): + """Lifecycle of one agy account in the rotation pool.""" + + ACTIVE = "active" + #: Rate limited by Google; skipped until ``cooldown_until``. + COOLDOWN = "cooldown" + #: Refresh token rejected or another hard error; needs re-login. + ERROR = "error" class RoutingMode(StrEnum): @@ -61,12 +74,29 @@ class Provider: @dataclass(slots=True, kw_only=True) class ModelCredential: + """One credential for a provider. + + For key-based providers ``encrypted_api_key`` holds the encrypted API key and + the agy fields stay empty. For the agy provider the credential *is an + account*: ``encrypted_api_key`` holds the encrypted JSON blob + ``{"refresh_token": ..., "project_id": ..., "email": ...}`` while ``email`` / + ``project_id`` duplicate the identity columns so the admin console can show + and rotate accounts without touching the ciphertext. + """ + provider_id: str name: str encrypted_api_key: str id: str = field(default_factory=new_id) is_active: bool = True created_at: datetime = field(default_factory=utcnow) + #: agy account identity (empty for API-key credentials). + email: str = "" + project_id: str = "" + status: str = CredentialStatus.ACTIVE.value + cooldown_until: datetime | None = None + last_error: str = "" + last_used_at: datetime | None = None @dataclass(slots=True, kw_only=True) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py new file mode 100644 index 0000000..ee61b13 --- /dev/null +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -0,0 +1,904 @@ +"""agy provider adapter — Google Antigravity accounts behind ModelProviderPort. + +``agy`` is Google's Antigravity: a Google-AI-subscription-billed route to the +Gemini 3 / Claude 4.5 / GPT-OSS families. There is no public API key; instead +each *account* is a Google OAuth identity whose refresh token is exchanged for +short-lived access tokens. This module implements that protocol directly, so +the platform never shells out to the CLI binary: + +* :class:`AgyAuthClient` — PKCE OAuth in two shapes: **listener mode** (a + loopback HTTP server captures the redirect; used when the operator's browser + runs on the same machine) and **paste mode** (the operator copies the + callback URL from the address bar and hands it back; the headless/remote + case — the page after redirect never finishes loading, and that is expected). +* :class:`AgyModelAdapter` — the ``ModelProviderPort`` implementation. Converts + our message format to the ``/v1internal:generateContent`` envelope, calls + Google's cloudcode-pa backend, parses the Gemini-shaped response. +* :class:`AgyAccountPool` state inside the adapter — round-robin across the + logged-in accounts with automatic 429 benching: a rate-limited account is + skipped until its cooldown expires, both in-process (fast) and in the + database (so background workers respect a cooldown set by the API). + +Nothing here ever returns or logs a refresh token: :func:`load_agy_accounts` +hands the adapter decrypted material in memory only, and admin endpoints see +email/project/status, never ciphertext. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import secrets +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + +from app.application.ports import ChatMessage, ModelHealth, ModelResponse +from app.core.config import settings +from app.core.crypto import decrypt +from app.core.errors import ProviderUnavailableError, ValidationError +from app.domain.ai_control.models import AiModel, ModelCredential +from app.domain.base import utcnow + +logger = logging.getLogger("agy.adapter") + +# --------------------------------------------------------------------------- +# Protocol constants (the public Antigravity CLI OAuth application and the +# cloudcode-pa backend it talks to). +# --------------------------------------------------------------------------- + +OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" + +#: ``daily-cloudcode-pa`` first: it carries the free daily quota and 429s more +#: gracefully. ``cloudcode-pa`` is the fallback. +BASE_URLS: tuple[str, ...] = ( + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +) +PATH_GENERATE = "/v1internal:generateContent" +PATH_MODELS = "/v1internal:fetchAvailableModels" +PATH_LOAD_PROJECT = "/v1internal:loadCodeAssist" + +OAUTH_SCOPES: tuple[str, ...] = ( + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +) + +#: Same all-OFF matrix the Antigravity client sends. Without it Google applies +#: its defaults and refuses coding prompts it considers dangerous. +SAFETY_SETTINGS: list[dict[str, str]] = [ + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"}, +] + +#: How long a started login stays redeemable. Two OAuth round-trips plus a +#: manual paste must fit inside it comfortably. +CHALLENGE_TTL_SECONDS = 600 + +_StatusFn = Callable[..., Awaitable[None]] +_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]] + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + + +def _aware(value: datetime | None) -> datetime | None: + """SQLite hands back naive datetimes; comparisons need aware ones.""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def _pkce_pair() -> tuple[str, str]: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return verifier, challenge + + +def _random_session_id() -> str: + return f"-{secrets.randbelow(9_000_000_000_000_000_000)}" + + +def _fallback_project_id() -> str: + """Ad-hoc project when ``loadCodeAssist`` did not report one.""" + adjectives = ("useful", "bright", "swift", "calm", "bold") + nouns = ("fuze", "wave", "spark", "flow", "core") + adj = secrets.choice(adjectives) + noun = secrets.choice(nouns) + return f"{adj}-{noun}-{uuid.uuid4().hex[:5].lower()}" + + +def _retry_after_seconds(value: str | None) -> int | None: + if not value: + return None + try: + seconds = int(value.strip()) + except ValueError: + return None + return max(1, min(seconds, 4 * 3600)) + + +# --------------------------------------------------------------------------- +# Internal failure taxonomy (never crosses the port boundary) +# --------------------------------------------------------------------------- + + +class _AgyRateLimited(Exception): + def __init__(self, retry_after: int | None) -> None: + super().__init__("agy account rate limited") + self.retry_after = retry_after + + +class _AgyAuthError(Exception): + """Refresh token rejected / account not entitled — needs a fresh login.""" + + +class _AgyTransientError(Exception): + """5xx or transport failure on one backend URL — try the next account.""" + + +# --------------------------------------------------------------------------- +# OAuth login +# --------------------------------------------------------------------------- + + +@dataclass(slots=True, kw_only=True) +class AgyLoginChallenge: + login_id: str + auth_url: str + redirect_uri: str + verifier: str + state: str + created_at: float + + +@dataclass(slots=True, kw_only=True) +class AgyLoginResult: + """Decrypted account material, produced by a completed OAuth exchange.""" + + email: str + project_id: str + refresh_token: str + + +class AgyOAuthError(ValidationError): + """The pasted URL or the token exchange was rejected by Google.""" + + +class AgyAuthClient: + """Google OAuth (PKCE, installed-app client) for agy accounts.""" + + def __init__( + self, + *, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + timeout: float = 30.0, + ) -> None: + self.client_id = client_id or settings.agy_client_id + self.client_secret = client_secret or settings.agy_client_secret + self.redirect_uri = redirect_uri or settings.agy_redirect_uri + self.timeout = timeout + + # -- flow ------------------------------------------------------------- + + def begin(self) -> AgyLoginChallenge: + """Open a login: returns the URL the operator must open in a browser. + + The verifier stays in the returned challenge — it is the proof that + whoever redeems the login is whoever started it, and it is never + serialised out of the process. + """ + verifier, challenge = _pkce_pair() + login_id = uuid.uuid4().hex + state = uuid.uuid4().hex + params = urlencode( + { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": " ".join(OAUTH_SCOPES), + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "access_type": "offline", + "prompt": "consent", + } + ) + return AgyLoginChallenge( + login_id=login_id, + auth_url=f"{OAUTH_AUTH_URL}?{params}", + redirect_uri=self.redirect_uri, + verifier=verifier, + state=state, + created_at=time.monotonic(), + ) + + async def exchange(self, challenge: AgyLoginChallenge, callback_url: str) -> AgyLoginResult: + """Redeem a started login with the callback URL from the browser. + + The operator may paste the full ``http://localhost:51121/oauth-callback? + …`` URL (it never finishes loading — that is the documented shape of + this flow, the parameters are already in the address bar). + """ + parsed = urlparse(callback_url.strip()) + query = parse_qs(parsed.query) + if query.get("error"): + raise AgyOAuthError(f"Google خطای احراز هویت داد: {query['error'][0]}") + codes = query.get("code") or [] + states = query.get("state") or [] + if not codes or not states: + raise AgyOAuthError( + "URL بازگشت معتبر نیست؛ باید پارامترهای code و state را داشته باشد." + ) + if states[0] != challenge.state: + raise AgyOAuthError("state بررسی نشد؛ این URL به این درخواست لاگین تعلق ندارد.") + return await self._exchange_code(challenge, codes[0]) + + async def _exchange_code(self, challenge: AgyLoginChallenge, code: str) -> AgyLoginResult: + tokens = await self._post_token( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": challenge.redirect_uri, + "code_verifier": challenge.verifier, + } + ) + refresh_token = tokens.get("refresh_token") or "" + if not refresh_token: + # Happens when the consent screen was already granted for these + # scopes: Google omits the refresh token. A re-run with + # ``prompt=consent`` (which begin() always sends) fixes it, so the + # operator message says exactly that. + raise AgyOAuthError( + "Google refresh_token برنگرداند. یک بار دیگر لاگین کنید و در صفحهٔ " + "Google حتماً «Block/Allow» را تأیید کنید." + ) + email, project_id = await asyncio.gather( + 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 + ) + + async def refresh(self, refresh_token: str) -> str: + tokens = await self._post_token( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + ) + access_token = tokens.get("access_token") + if not access_token: + raise _AgyAuthError("Google refresh را نپذیرفت (توکن منقضی یا باطل شده).") + return access_token + + # -- Google endpoints -------------------------------------------------- + + async def _post_token(self, data: dict[str, str]) -> dict: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + OAUTH_TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except httpx.HTTPError as exc: + raise ProviderUnavailableError("اتصال به سرور OAuth گوگل برقرار نشد.") from exc + if response.status_code >= 400: + body = response.text[:400] + # A rejected refresh token is an account problem, not an outage. + if "invalid_grant" in body: + raise _AgyAuthError(f"Google refresh را نپذیرفت: {body}") + raise AgyOAuthError(f"تبادل توکن ناموفق بود ({response.status_code}): {body}") + return response.json() + + async def _fetch_email(self, access_token: str) -> str: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"} + ) + if response.is_success: + return str(response.json().get("email", "")) + except httpx.HTTPError: + logger.debug("agy userinfo fetch failed", exc_info=True) + return "" + + async def _fetch_project_id(self, access_token: str) -> str: + for base in BASE_URLS: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{base}{PATH_LOAD_PROJECT}", + headers=self._api_headers(access_token), + json={ + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + } + }, + ) + if response.is_success: + project = response.json().get("cloudaicompanionProject") + if isinstance(project, str) and project: + return project + if isinstance(project, dict) and project.get("id"): + return str(project["id"]) + except httpx.HTTPError: + continue + return "" + + def _api_headers(self, access_token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": settings.agy_user_agent, + } + + +# --------------------------------------------------------------------------- +# Pending-login store (process-local, bounded, TTL'd) +# --------------------------------------------------------------------------- + +_CHALLENGES: dict[str, AgyLoginChallenge] = {} +_CHALLENGES_LOCK = asyncio.Lock() + + +def _prune_challenges(now: float) -> None: + expired = [ + login_id + for login_id, challenge in _CHALLENGES.items() + if now - challenge.created_at > CHALLENGE_TTL_SECONDS + ] + for login_id in expired: + _CHALLENGES.pop(login_id, None) + + +async def begin_login() -> tuple[str, str]: + """Start a headless login. Returns ``(login_id, auth_url)``. + + ``auth_url`` is what the admin console shows the platform owner; the owner + opens it, signs in with Google, and the resulting callback URL (or, in + listener mode, the captured redirect) is redeemed against ``login_id``. + """ + challenge = AgyAuthClient().begin() + now = time.monotonic() + async with _CHALLENGES_LOCK: + _prune_challenges(now) + if len(_CHALLENGES) >= 32: + # Bound memory: pending logins are transient by nature. + oldest = min(_CHALLENGES, key=lambda k: _CHALLENGES[k].created_at) + _CHALLENGES.pop(oldest, None) + _CHALLENGES[challenge.login_id] = challenge + return challenge.login_id, challenge.auth_url + + +async def redeem_login(login_id: str, callback_url: str) -> AgyLoginResult: + challenge = _CHALLENGES.pop(login_id, None) + if challenge is None: + raise AgyOAuthError("این درخواست لاگین منقضی شده است؛ دوباره آغاز کنید.") + return await AgyAuthClient().exchange(challenge, callback_url) + + +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 + redirects the browser to ``redirect_uri``; this server catches it, closes, + and redeems the login without any copy-paste. + """ + challenge = _CHALLENGES.get(login_id) + if challenge is None: + raise AgyOAuthError("این درخواست لاگین منقضی شده است؛ دوباره آغاز کنید.") + callback_path = urlparse(challenge.redirect_uri).path or "/" + port = int(urlparse(challenge.redirect_uri).port or 51121) + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + + async def _handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + request_line = await reader.readline() + parts = request_line.decode("latin-1").split(" ") + target = parts[1] if len(parts) > 1 else "/" + if urlparse(target).path != callback_path: + writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + return + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n" + b"Content-Length: 46\r\nConnection: close\r\n\r\n" + b"
OK - you may close this tab." + ) + await writer.drain() + if not future.done(): + future.set_result(target) + except Exception: # noqa: BLE001 - the future carries the real error + if not future.done(): + future.set_exception(AgyOAuthError("دریافت بازگشت OAuth ناموفق بود.")) + finally: + writer.close() + + server = await asyncio.start_server(_handle, "127.0.0.1", port) + try: + async with asyncio.timeout(timeout_seconds): + target = await future + except TimeoutError as exc: + raise AgyOAuthError("مهلت لاگین تمام شد؛ دوباره آغاز کنید.") from exc + finally: + server.close() + await server.wait_closed() + _CHALLENGES.pop(login_id, None) + redirect_uri = challenge.redirect_uri + return await AgyAuthClient(redirect_uri=redirect_uri).exchange( + challenge, f"http://127.0.0.1:{port}{target}" + ) + + +# --------------------------------------------------------------------------- +# Account pool +# --------------------------------------------------------------------------- + + +@dataclass(slots=True, kw_only=True) +class AgyAccount: + """One usable account: decrypted material plus its rotation identity.""" + + credential_id: str + email: str + project_id: str + refresh_token: str + + +@dataclass(slots=True) +class _PoolState: + #: Monotonic rotation cursor — spreads load across the account set. + cursor: int = 0 + #: credential_id -> epoch seconds until which the account is benched. + benched_until: dict[str, float] = field(default_factory=dict) + #: credential_id -> (access token, expiry epoch) — avoids a Google + #: round-trip on every single completion. + tokens: dict[str, tuple[str, float]] = field(default_factory=dict) + + +def _account_of(credential: ModelCredential) -> AgyAccount: + """Decrypt one credential row into usable login material. + + The ciphertext is a JSON envelope written at login time; older key-shaped + credentials (empty email) are treated as unusable for agy. + """ + try: + payload = json.loads(decrypt(credential.encrypted_api_key)) + return AgyAccount( + credential_id=credential.id, + email=str(payload.get("email") or credential.email or ""), + project_id=str(payload.get("project_id") or credential.project_id or ""), + refresh_token=str(payload.get("refresh_token") or ""), + ) + except Exception: + return AgyAccount( + credential_id=credential.id, + email=credential.email, + project_id=credential.project_id, + refresh_token="", + ) + + +class AgyModelAdapter: + """ModelProviderPort over the agy account pool. + + One instance per process (the adapter registry caches it), so the rotation + cursor and the in-memory bench are shared by every worker and every + request. Hard state (cooldown / error / last-used) is mirrored to the + database through the injected ``report`` callback so the background + workers see the same picture. + """ + + def __init__( + self, + *, + loader: _LoaderFn | None = None, + report: _StatusFn | None = None, + timeout: float = 240.0, + ) -> None: + self._loader = loader + self._report = report + self._timeout = timeout + self._state = _PoolState() + self._lock = asyncio.Lock() + + # -- port --------------------------------------------------------------- + + async def complete( + self, + *, + model: AiModel, + credential: ModelCredential | None = None, + credentials: list[ModelCredential] | None = None, + messages: list[ChatMessage], + system: str = "", + 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() + ) + accounts = [a for a in (_account_of(c) for c in pool) if a.refresh_token] + if not accounts: + raise ProviderUnavailableError( + "هیچ اکانت agy فعالی ثبت نشده است. مالک سامانه باید ابتدا از " + "کنسول مدیریت یک اکانت Google اضافه کند." + ) + ordered = self._order_accounts(accounts) + failures: list[str] = [] + for account in ordered: + try: + response = await self._complete_with( + 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}: محدودیت نرخ") + await self._bench(account, exc.retry_after) + continue + except _AgyAuthError as exc: + failures.append(f"{account.email or account.credential_id}: {exc}") + await self._bench(account, 3600, status="error", error=str(exc)) + continue + except _AgyTransientError as exc: + failures.append(f"{account.email or account.credential_id}: {exc}") + continue + await self._succeed(account) + return response + raise ProviderUnavailableError( + "همهٔ اکانتهای agy در دسترس نبودند: " + "; ".join(failures[:4]) + ) + + async def health(self) -> ModelHealth: + accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token] + if not accounts: + return ModelHealth(ok=False, detail="هیچ اکانت agy ثبت نشده است.") + try: + await self._access_token(accounts[0]) + except _AgyAuthError as exc: + return ModelHealth(ok=False, detail=str(exc)) + except Exception as exc: # noqa: BLE001 - health must not raise + return ModelHealth(ok=False, detail=str(exc)) + return ModelHealth(ok=True, detail=f"{len(accounts)} اکانت فعال") + + async def available_models(self) -> list[str]: + """Models the logged-in subscription exposes (admin console helper).""" + accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token] + if not accounts: + return [] + token = await self._access_token(accounts[0]) + for base in BASE_URLS: + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{base}{PATH_MODELS}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": settings.agy_user_agent, + }, + json={}, + ) + if response.is_success: + models = response.json().get("models") or {} + return sorted(models) + except httpx.HTTPError: + continue + return [] + + # -- internals ---------------------------------------------------------- + + async def _load(self) -> list[ModelCredential]: + if self._loader is None: + return [] + return await self._loader() + + async def _report_state( + self, + credential_id: str, + *, + status: str | None = None, + cooldown_until: datetime | None = None, + error: str | None = None, + used: bool = False, + ) -> None: + if self._report is None: + return + try: + await self._report( + credential_id, + status=status, + cooldown_until=cooldown_until, + error=error, + used=used, + ) + except Exception: # noqa: BLE001 - reporting must never kill a call + logger.debug("agy state report failed", exc_info=True) + + def _order_accounts(self, accounts: list[AgyAccount]) -> list[AgyAccount]: + """Round-robin, skipping accounts benched in this process. + + The DB-shaped cooldown is already applied by the query that produced + the pool; this pass additionally honours recent 429s that have not been + persisted yet (or a reporter that is not wired). + """ + now = time.time() + usable = [a for a in accounts if self._state.benched_until.get(a.credential_id, 0) <= now] + if not usable: + # Everything is benched in memory — fall through to the DB's view + # rather than failing a request the pool could still serve. + usable = list(accounts) + n = len(usable) + self._state.cursor += 1 + start = self._state.cursor % n + return usable[start:] + usable[:start] + + async def _bench( + self, + account: AgyAccount, + retry_after: int | None, + *, + status: str = "cooldown", + error: str = "", + ) -> None: + seconds = retry_after or settings.agy_429_cooldown_seconds + until = datetime.now(UTC) + timedelta(seconds=seconds) + async with self._lock: + self._state.benched_until[account.credential_id] = until.timestamp() + await self._report_state( + account.credential_id, status=status, cooldown_until=until, error=error + ) + + async def _succeed(self, account: AgyAccount) -> None: + async with self._lock: + self._state.benched_until.pop(account.credential_id, None) + await self._report_state(account.credential_id, status="active", used=True) + + async def _access_token(self, account: AgyAccount) -> str: + """Cached refresh: Google tokens live ~1h; one call serves the burst.""" + cached = self._state.tokens.get(account.credential_id) + if cached and cached[1] > time.time() + 30: + return cached[0] + token = await AgyAuthClient().refresh(account.refresh_token) + async with self._lock: + self._state.tokens[account.credential_id] = (token, time.time() + 3300) + return token + + async def _complete_with( + self, + account: AgyAccount, + *, + model: AiModel, + messages: list[ChatMessage], + system: str, + temperature: float, + max_tokens: int, + ) -> ModelResponse: + token = await self._access_token(account) + payload = _build_generate_payload( + account, model.internal_name, messages, system, temperature, max_tokens + ) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": settings.agy_user_agent, + "Accept": "application/json", + } + started = time.perf_counter() + last_transient: Exception | None = None + retry_after: int | None = None + for base in BASE_URLS: + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post( + f"{base}{PATH_GENERATE}", json=payload, headers=headers + ) + except httpx.HTTPError: + last_transient = _AgyTransientError(f"اتصال به {base} برقرار نشد.") + continue + if response.status_code == 429: + # 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 + continue + if response.status_code in (401, 403): + raise _AgyAuthError( + f"اکانت {account.email or 'agy'} دسترسی ندارد یا نشست منقضی شده است." + ) + if response.status_code >= 500: + last_transient = _AgyTransientError(f"{base} خطای {response.status_code} داد.") + continue + if response.status_code >= 400: + # 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]}" + ) + 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 پاسخ نداد.") + + +# --------------------------------------------------------------------------- +# Message / payload translation (portable, pure functions — unit-tested) +# --------------------------------------------------------------------------- + + +def _build_generate_payload( + account: AgyAccount, + model_name: str, + messages: list[ChatMessage], + system: str, + temperature: float, + max_tokens: int, +) -> dict: + """Translate the port's OpenAI-shaped call into the Antigravity envelope.""" + system_parts: list[str] = [system] if system else [] + contents: list[dict] = [] + for message in messages: + if message.role == "system": + system_parts.append(message.content) + continue + role = "model" if message.role == "assistant" else "user" + contents.append({"role": role, "parts": [{"text": message.content}]}) + + request: dict = { + "contents": contents, + "sessionId": _random_session_id(), + "generationConfig": { + "temperature": temperature, + "maxOutputTokens": max_tokens, + }, + "toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}}, + "safetySettings": SAFETY_SETTINGS, + } + if system_parts: + request["systemInstruction"] = { + "role": "user", + "parts": [{"text": "\n\n".join(p for p in system_parts if p)}], + } + return { + "project": account.project_id or _fallback_project_id(), + "model": model_name, + "userAgent": "antigravity", + "requestId": f"agent-{uuid.uuid4()}", + "request": request, + } + + +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] = [] + for part in (candidate.get("content") or {}).get("parts") or []: + if not isinstance(part, dict): + continue + text = part.get("text") + if text and not part.get("thought"): + text_parts.append(text) + usage = data.get("usageMetadata") or {} + latency_ms = int((time.perf_counter() - started) * 1000) + return ModelResponse( + content="".join(text_parts), + model_internal_name=model.internal_name, + input_tokens=int(usage.get("promptTokenCount", 0) or 0), + output_tokens=int(usage.get("candidatesTokenCount", 0) or 0), + latency_ms=latency_ms, + raw={ + "finish_reason": candidate.get("finishReason", ""), + "thought_tokens": int(usage.get("thoughtsTokenCount", 0) or 0), + }, + ) + + +# --------------------------------------------------------------------------- +# Database wiring for the registry (each call opens its own short session, so +# the adapter never outlives or depends on an HTTP request's session). +# --------------------------------------------------------------------------- + + +async def load_agy_accounts() -> list[ModelCredential]: + """Every usable agy credential row, ordered least-recently-used first.""" + from app.core.database import SessionFactory + from app.infrastructure.repositories.ai_control import ( + ModelCredentialRepository, + ProviderRepository, + ) + + 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 + ) + if provider is None: + return [] + return await ModelCredentialRepository(session).list_active_for_provider( + provider.id + ) + + +async def report_agy_state( + credential_id: str, + *, + status: str | None = None, + cooldown_until: datetime | None = None, + error: str | None = None, + used: bool = False, +) -> None: + """Mirror rotation state into the DB for the workers' benefit.""" + from app.core.database import SessionFactory + from app.infrastructure.repositories.ai_control import ModelCredentialRepository + + async with SessionFactory() as session: + repo = ModelCredentialRepository(session) + if status is not None: + await repo.set_status( + credential_id, + status=status, + cooldown_until=cooldown_until, + error=error or "", + ) + if used: + await repo.mark_used(credential_id, when=utcnow()) + await session.commit() + + +__all__ = [ + "AgyAccount", + "AgyAuthClient", + "AgyLoginChallenge", + "AgyLoginResult", + "AgyModelAdapter", + "AgyOAuthError", + "begin_login", + "load_agy_accounts", + "redeem_login", + "redeem_login_by_waiting", + "report_agy_state", +] diff --git a/apps/api/app/infrastructure/adapters/mock.py b/apps/api/app/infrastructure/adapters/mock.py index a81479b..263cac4 100644 --- a/apps/api/app/infrastructure/adapters/mock.py +++ b/apps/api/app/infrastructure/adapters/mock.py @@ -66,6 +66,7 @@ class MockModelProviderAdapter: *, model: AiModel, credential=None, + credentials=None, # accepted for port parity; the mock has no pool messages: list[ChatMessage], system: str = "", temperature: float = 0.2, diff --git a/apps/api/app/infrastructure/adapters/openai_compatible.py b/apps/api/app/infrastructure/adapters/openai_compatible.py index d3d4620..c587ddc 100644 --- a/apps/api/app/infrastructure/adapters/openai_compatible.py +++ b/apps/api/app/infrastructure/adapters/openai_compatible.py @@ -34,7 +34,8 @@ class OpenAICompatibleModelAdapter: self, *, model: AiModel, - credential: ModelCredential | None, + credential: ModelCredential | None = None, + credentials: list[ModelCredential] | None = None, # no pool here messages: list[ChatMessage], system: str = "", temperature: float = 0.2, diff --git a/apps/api/app/infrastructure/adapters/registry.py b/apps/api/app/infrastructure/adapters/registry.py index 641b0b8..65b28bc 100644 --- a/apps/api/app/infrastructure/adapters/registry.py +++ b/apps/api/app/infrastructure/adapters/registry.py @@ -28,9 +28,17 @@ def get_model_provider(): base_url=settings.openai_compatible_base_url, default_api_key=settings.openai_compatible_api_key, ) + if provider == "agy": + from app.infrastructure.adapters.agy import ( + AgyModelAdapter, + load_agy_accounts, + report_agy_state, + ) + + return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state) raise ProviderUnavailableError( f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. " - "مقادیر مجاز: mock | openai_compatible" + "مقادیر مجاز: mock | openai_compatible | agy" ) diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py index 17111ac..410ce35 100644 --- a/apps/api/app/infrastructure/db/models.py +++ b/apps/api/app/infrastructure/db/models.py @@ -132,6 +132,17 @@ class AiModelCredentialRow(Base): encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) created_at: Mapped[datetime] = _created() + # --- agy account columns (empty for API-key credentials) ----------------- + #: Google account email this credential belongs to; also the rotation label. + email: Mapped[str] = mapped_column(String(320), default="", nullable=False) + #: Antigravity ``cloudaicompanionProject`` id resolved during login. + project_id: Mapped[str] = mapped_column(String(160), default="", nullable=False) + #: ``active`` | ``cooldown`` | ``error`` — see ``CredentialStatus``. + status: Mapped[str] = mapped_column(String(16), default="active", nullable=False) + #: When set and in the future, the rotation pool skips this account. + cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str] = mapped_column(String(512), default="", nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class AiModelRow(Base): diff --git a/apps/api/app/infrastructure/repositories/ai_control.py b/apps/api/app/infrastructure/repositories/ai_control.py index 4b84a37..7afa60d 100644 --- a/apps/api/app/infrastructure/repositories/ai_control.py +++ b/apps/api/app/infrastructure/repositories/ai_control.py @@ -7,6 +7,8 @@ move rows. from __future__ import annotations +from datetime import datetime + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +22,7 @@ from app.domain.ai_control.models import ( RoutingMode, RoutingPolicy, ) +from app.domain.base import utcnow from app.infrastructure.db.models import ( AiModelCredentialRow, AiModelRow, @@ -41,6 +44,9 @@ def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential: id=row.id, provider_id=row.provider_id, name=row.name, encrypted_api_key=row.encrypted_api_key, is_active=row.is_active, created_at=row.created_at, + email=row.email or "", project_id=row.project_id or "", + status=row.status or "active", cooldown_until=row.cooldown_until, + last_error=row.last_error or "", last_used_at=row.last_used_at, ) @@ -128,22 +134,115 @@ class ModelCredentialRepository: .where( AiModelCredentialRow.provider_id == provider_id, AiModelCredentialRow.is_active.is_(True), + AiModelCredentialRow.status == "active", ) .order_by(AiModelCredentialRow.created_at.desc()) ) row = (await self.session.execute(stmt)).scalars().first() return _credential_to_domain(row) if row else None + async def list_active_for_provider(self, provider_id: str) -> list[ModelCredential]: + """Every credential the rotation pool may use. + + An account in ``cooldown`` whose window has passed is usable again, so + the status filter is applied in SQL only for hard errors; the pool + itself checks ``cooldown_until`` against the clock to stay race-free + with concurrent requests. + """ + now = utcnow() + stmt = ( + select(AiModelCredentialRow) + .where( + AiModelCredentialRow.provider_id == provider_id, + AiModelCredentialRow.is_active.is_(True), + AiModelCredentialRow.status != "error", + (AiModelCredentialRow.status != "cooldown") + | (AiModelCredentialRow.cooldown_until.is_(None)) + | (AiModelCredentialRow.cooldown_until <= now), + ) + .order_by(AiModelCredentialRow.last_used_at.asc().nulls_first()) + ) + return [ + _credential_to_domain(r) + for r in (await self.session.execute(stmt)).scalars() + ] + + async def find_by_email(self, provider_id: str, email: str) -> ModelCredential | None: + """One account per Google address — re-login replaces, never duplicates.""" + stmt = select(AiModelCredentialRow).where( + AiModelCredentialRow.provider_id == provider_id, + AiModelCredentialRow.email == email, + ) + row = (await self.session.execute(stmt)).scalars().first() + return _credential_to_domain(row) if row else None + async def create(self, credential: ModelCredential) -> ModelCredential: row = AiModelCredentialRow( id=credential.id, provider_id=credential.provider_id, name=credential.name, encrypted_api_key=credential.encrypted_api_key, is_active=credential.is_active, created_at=credential.created_at, + email=credential.email, project_id=credential.project_id, + status=credential.status, cooldown_until=credential.cooldown_until, + last_error=credential.last_error, last_used_at=credential.last_used_at, ) self.session.add(row) await self.session.flush() return _credential_to_domain(row) + async def replace_material( + self, credential_id: str, *, encrypted_api_key: str, project_id: str + ) -> ModelCredential | None: + """Re-login of a known account: swap ciphertext and clear error state.""" + row = await self.session.get(AiModelCredentialRow, credential_id) + if row is None: + return None + row.encrypted_api_key = encrypted_api_key + row.project_id = project_id + row.status = "active" + row.cooldown_until = None + row.last_error = "" + await self.session.flush() + return _credential_to_domain(row) + + async def set_status( + self, + credential_id: str, + *, + status: str, + cooldown_until: datetime | None = None, + error: str = "", + ) -> None: + row = await self.session.get(AiModelCredentialRow, credential_id) + if row is not None: + row.status = status + row.cooldown_until = cooldown_until + row.last_error = error + await self.session.flush() + + async def mark_used(self, credential_id: str, *, when: datetime) -> None: + row = await self.session.get(AiModelCredentialRow, credential_id) + if row is not None: + row.last_used_at = when + await self.session.flush() + + async def set_active(self, credential_id: str, active: bool) -> None: + row = await self.session.get(AiModelCredentialRow, credential_id) + if row is not None: + row.is_active = active + if active and row.status == "error": + row.status = "active" + row.cooldown_until = None + row.last_error = "" + await self.session.flush() + + async def delete(self, credential_id: str) -> bool: + row = await self.session.get(AiModelCredentialRow, credential_id) + if row is None: + return False + await self.session.delete(row) + await self.session.flush() + return True + class AiModelRepository: def __init__(self, session: AsyncSession) -> None: diff --git a/apps/api/app/interfaces/http/routers/agy.py b/apps/api/app/interfaces/http/routers/agy.py new file mode 100644 index 0000000..edf5fc0 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/agy.py @@ -0,0 +1,343 @@ +"""agy account management — Google OAuth headless login for the AI provider. + +The agy provider has no API keys: an *account* is a Google identity. This +router implements the headless paste-URL login loop for the platform owner: + +1. ``POST /admin/agy/accounts/login/start`` — returns an ``auth_url`` the owner + opens in a browser and completes the Google consent screen. +2. Google redirects to the loopback callback URL. Nothing is listening there + on purpose — the parameters (``code``, ``state``) are already in the address + bar, and the owner pastes the whole URL back. +3. ``POST /admin/agy/accounts/login/complete`` — exchanges the code against + Google (PKCE proof stays server-side), fetches the account email and + Antigravity project id, and stores the refresh token **encrypted at rest**. + +Every response is deliberately free of credentials: the console sees email, +status and cooldown, never ciphertext. Same deny-by-default guard as the rest +of the admin console. +""" + +from __future__ import annotations + +import json +from datetime import datetime + +from fastapi import APIRouter, Depends, Request, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.services.audit_service import record_audit +from app.core.crypto import encrypt +from app.core.database import get_session +from app.core.errors import NotFoundError +from app.domain.ai_control.models import AiModel, ModelCredential, ProviderType +from app.domain.audit.models import AuditAction +from app.domain.identity.models import User +from app.infrastructure.repositories.ai_control import ( + ModelCredentialRepository, + ProviderRepository, +) +from app.interfaces.http.deps import client_meta, require_platform_owner + +router = APIRouter( + prefix="/admin/agy", + tags=["admin-agy"], + # Deny-by-default, same rule as the admin console: an endpoint added later + # is owner-only without its author having to remember. + dependencies=[Depends(require_platform_owner)], +) + +#: Name of the singleton agy provider row. The seed creates it; the login flow +#: creates it on demand so a bare deployment is never stuck. +AGY_PROVIDER_NAME = "agy" + +_PROJECTED_OUT_FIELDS = ( + "id", + "provider_id", + "name", + "email", + "project_id", + "status", + "cooldown_until", + "is_active", + "last_error", + "last_used_at", + "created_at", +) + + +# --------------------------------------------------------------------------- +# Schemas — note there is no field anywhere capable of holding a token. +# --------------------------------------------------------------------------- + + +class AgyLoginStartOut(BaseModel): + login_id: str + auth_url: str + + +class AgyLoginCompleteIn(BaseModel): + login_id: str = Field(min_length=8, max_length=64) + callback_url: str = Field(min_length=20, max_length=2048) + + +class AgyAccountOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + provider_id: str + name: str + email: str + project_id: str + status: str + cooldown_until: datetime | None + is_active: bool + last_error: str + last_used_at: datetime | None + created_at: datetime + + +class AgyTestOut(BaseModel): + ok: bool + detail: str + model_internal_name: str + input_tokens: int + output_tokens: int + + +# --------------------------------------------------------------------------- +# Provider helpers +# --------------------------------------------------------------------------- + + +async def _ensure_agy_provider(repo: ProviderRepository): + provider = await repo.get_by_name(AGY_PROVIDER_NAME) + if provider is not None: + return provider + from app.domain.ai_control.models import Provider + + return await repo.create(Provider(name=AGY_PROVIDER_NAME, type=ProviderType.AGY)) + + +def _account_out(credential: ModelCredential) -> AgyAccountOut: + return AgyAccountOut( + id=credential.id, + provider_id=credential.provider_id, + name=credential.name, + email=credential.email, + project_id=credential.project_id, + status=credential.status, + cooldown_until=credential.cooldown_until, + is_active=credential.is_active, + last_error=credential.last_error, + last_used_at=credential.last_used_at, + created_at=credential.created_at, + ) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post( + "/accounts/login/start", response_model=AgyLoginStartOut, + status_code=status.HTTP_201_CREATED, +) +async def start_agy_login() -> AgyLoginStartOut: + """Open a headless login and hand back the Google URL to open.""" + from app.infrastructure.adapters.agy import begin_login + + login_id, auth_url = await begin_login() + return AgyLoginStartOut(login_id=login_id, auth_url=auth_url) + + +@router.post( + "/accounts/login/complete", response_model=AgyAccountOut, + status_code=status.HTTP_201_CREATED, +) +async def complete_agy_login( + payload: AgyLoginCompleteIn, + request: Request, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> AgyAccountOut: + """Redeem a started login with the callback URL the owner pasted.""" + from app.core.errors import ValidationError + from app.infrastructure.adapters.agy import redeem_login + + try: + result = await redeem_login(payload.login_id, payload.callback_url) + except Exception as exc: + # Login failures are user-fixable input problems, never 500s. + raise ValidationError(str(exc)) from exc + if not result.email: + raise ValidationError("Google نشانی ایمیل اکانت را برنگرداند.") + + providers = ProviderRepository(session) + credentials = ModelCredentialRepository(session) + provider = await _ensure_agy_provider(providers) + + envelope = json.dumps( + { + "refresh_token": result.refresh_token, + "project_id": result.project_id, + "email": result.email, + } + ) + existing = await credentials.find_by_email(provider.id, result.email) + if existing is not None: + # Same Google account re-login: rotate the key, keep the row. + credential = await credentials.replace_material( + existing.id, encrypted_api_key=encrypt(envelope), + project_id=result.project_id, + ) + else: + credential = await credentials.create( + ModelCredential( + provider_id=provider.id, + name=result.email, + encrypted_api_key=encrypt(envelope), + email=result.email, + project_id=result.project_id, + ) + ) + assert credential is not None # narrowed for type checkers; create/replace return it + + ip, ua = client_meta(request) + await record_audit( + session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id, + target_type="agy_account", target_id=credential.id, + metadata={"email": result.email, "provider_id": provider.id}, + ip=ip, user_agent=ua, + ) + return _account_out(credential) + + +@router.get("/accounts", response_model=list[AgyAccountOut]) +async def list_agy_accounts( + session: AsyncSession = Depends(get_session), +) -> list[AgyAccountOut]: + providers = ProviderRepository(session) + provider = await providers.get_by_name(AGY_PROVIDER_NAME) + if provider is None: + return [] + rows = await ModelCredentialRepository(session).list_for_provider(provider.id) + return [_account_out(c) for c in rows] + + +@router.post("/accounts/{credential_id}/deactivate", response_model=AgyAccountOut) +async def deactivate_agy_account( + credential_id: str, + request: Request, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> AgyAccountOut: + return await _set_account_active( + credential_id, active=False, request=request, owner=owner, session=session + ) + + +@router.post("/accounts/{credential_id}/activate", response_model=AgyAccountOut) +async def activate_agy_account( + credential_id: str, + request: Request, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> AgyAccountOut: + return await _set_account_active( + credential_id, active=True, request=request, owner=owner, session=session + ) + + +async def _set_account_active( + credential_id: str, *, active: bool, request: Request, owner: User, + session: AsyncSession, +) -> AgyAccountOut: + repo = ModelCredentialRepository(session) + credential = await repo.get(credential_id) + if credential is None: + raise NotFoundError("اکانت agy یافت نشد.") + await repo.set_active(credential_id, active) + credential = await repo.get(credential_id) + assert credential is not None + ip, ua = client_meta(request) + await record_audit( + session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id, + target_type="agy_account", target_id=credential_id, + metadata={"active": active}, + ip=ip, user_agent=ua, + ) + return _account_out(credential) + + +@router.delete("/accounts/{credential_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_agy_account( + credential_id: str, + request: Request, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> None: + repo = ModelCredentialRepository(session) + credential = await repo.get(credential_id) + if credential is None: + raise NotFoundError("اکانت agy یافت نشد.") + await repo.delete(credential_id) + ip, ua = client_meta(request) + await record_audit( + session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id, + target_type="agy_account", target_id=credential_id, + metadata={"deleted": True, "email": credential.email}, + ip=ip, user_agent=ua, + ) + + +@router.post("/accounts/{credential_id}/test", response_model=AgyTestOut) +async def test_agy_account( + credential_id: str, + session: AsyncSession = Depends(get_session), +) -> AgyTestOut: + """Run a one-shot completion through exactly this account.""" + from app.application.ports import ChatMessage + from app.infrastructure.adapters.agy import AgyModelAdapter + + repo = ModelCredentialRepository(session) + credential = await repo.get(credential_id) + if credential is None: + raise NotFoundError("اکانت agy یافت نشد.") + + adapter = AgyModelAdapter() # stateless for a single forced account + model = AiModel( + provider_id=credential.provider_id, + internal_name="gemini-3-flash", + display_name_for_admin="تست اکانت", + ) + try: + response = await adapter.complete( + model=model, + credentials=[credential], + messages=[ChatMessage(role="user", content="سلام، یک کلمه پاسخ بده.")], + max_tokens=64, + ) + except Exception as exc: # noqa: BLE001 - the console wants the reason, not a 500 + return AgyTestOut( + ok=False, detail=str(exc)[:300], model_internal_name="", + input_tokens=0, output_tokens=0, + ) + return AgyTestOut( + ok=True, detail=response.content[:300], + model_internal_name=response.model_internal_name, + input_tokens=response.input_tokens, output_tokens=response.output_tokens, + ) + + +@router.get("/models", response_model=list[str]) +async def list_agy_models() -> list[str]: + """Model ids the logged-in agy subscription exposes (registry helper).""" + from app.infrastructure.adapters.registry import get_model_provider + + provider = get_model_provider() + lister = getattr(provider, "available_models", None) + if lister is None: + return [] + return await lister() diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 83ee93c..94fe661 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -17,6 +17,7 @@ from app.interfaces.http.errors import register_exception_handlers from app.interfaces.http.request_context import RequestContextMiddleware from app.interfaces.http.routers import ( admin, + agy, auth, billing, health, @@ -121,6 +122,7 @@ def create_app() -> FastAPI: application.include_router(knowledge.router, prefix="/api") application.include_router(sandbox.router, prefix="/api") application.include_router(admin.router, prefix="/api") + application.include_router(agy.router, prefix="/api") @application.get("/", tags=["meta"]) async def root() -> dict: diff --git a/apps/api/app/seed.py b/apps/api/app/seed.py index 8f369fc..8f69bbc 100644 --- a/apps/api/app/seed.py +++ b/apps/api/app/seed.py @@ -2,7 +2,9 @@ Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample organization that both of them own, the system agent templates, routing -policies, rate cards and a mock provider with two models. +policies, rate cards and a mock provider with two models. The agy (Google +Antigravity) provider and its model catalog are seeded too; agy models become +routable only when ``AI_DEFAULT_PROVIDER=agy``. Safe to run repeatedly: every step checks for existing rows first. @@ -18,6 +20,7 @@ import logging from sqlalchemy import select +from app.core.config import settings from app.core.database import Base, SessionFactory, engine from app.core.security import hash_password from app.domain.ai_control.models import ( @@ -60,6 +63,42 @@ DEMO_PASSWORD = "demo12345" DEMO_ORG_NAME = "سازمان نمونه" DEMO_ORG_SLUG = "sample-org" +#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id +#: the Antigravity backend expects; the display name is admin-only. Seeded for +#: every deployment, activated for routing only when agy is the default +#: provider — see the seed step that consumes this table. +AGY_MODELS: list[tuple[str, str, list[Capability]]] = [ + ( + "gemini-3-flash", + "Gemini 3 Flash", + [Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT], + ), + ( + "gemini-3-pro-high", + "Gemini 3 Pro", + [ + Capability.TEXT, Capability.REASONING, Capability.CODE, + Capability.VISION, Capability.LONG_CONTEXT, + ], + ), + ( + "claude-sonnet-4-5", + "Claude Sonnet 4.5", + [Capability.TEXT, Capability.CODE, Capability.TOOLS], + ), + ( + "claude-sonnet-4-5-thinking", + "Claude Sonnet 4.5 (Thinking)", + [Capability.TEXT, Capability.CODE, Capability.REASONING], + ), + ( + "claude-opus-4-5-thinking", + "Claude Opus 4.5 (Thinking)", + [Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT], + ), + ("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE]), +] + AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [ ( "مدیر سازمان", RoleKey.MANAGER, @@ -269,6 +308,92 @@ async def seed() -> None: ) logger.info("created mock provider + 2 models") + # The agy catalog (Google Antigravity). It is seeded for every + # deployment — the admin console needs the provider and its models to + # exist before any account is logged in. Routing activation is + # reconciled on *every* seed run below, not only at insert time, so + # flipping AI_DEFAULT_PROVIDER and re-seeding reshapes routing without + # a fresh database. + agy_is_default = settings.ai_default_provider.strip().lower() == "agy" + agy_provider_row = ( + await session.execute( + select(AiProviderRow).where(AiProviderRow.name == "agy") + ) + ).scalars().first() + if agy_provider_row is None: + agy_provider = Provider(name="agy", type=ProviderType.AGY, base_url="") + agy_provider_row = AiProviderRow( + id=agy_provider.id, name=agy_provider.name, + type=agy_provider.type.value, base_url="", is_active=True, + ) + session.add(agy_provider_row) + await session.flush() + for internal, display, caps in AGY_MODELS: + existing = ( + await session.execute( + select(AiModelRow).where( + AiModelRow.provider_id == agy_provider_row.id, + AiModelRow.internal_name == internal, + ) + ) + ).scalars().first() + if existing is not None: + continue + model = AiModel( + provider_id=agy_provider_row.id, + internal_name=internal, + display_name_for_admin=display, + capabilities=[str(c) for c in caps], + # agy is billed through the Google AI subscription, so the + # internal token cost estimate is zero; the operator sets the + # customer-facing price on the rate card. + input_price_per_1m=0.0, + output_price_per_1m=0.0, + ) + session.add( + AiModelRow( + id=model.id, provider_id=model.provider_id, + internal_name=model.internal_name, + display_name_for_admin=model.display_name_for_admin, + capabilities=model.capabilities, + input_price_per_1m=model.input_price_per_1m, + output_price_per_1m=model.output_price_per_1m, + is_active=False, + ) + ) + # Reconcile routing with the configured default on every run: agy models + # are routable only when agy is the default, and the mock catalog must + # not win when it is not — a model without accounts fails at call time. + agy_model_rows = ( + await session.execute( + select(AiModelRow).where( + AiModelRow.provider_id == agy_provider_row.id + ) + ) + ).scalars() + for row in agy_model_rows: + row.is_active = agy_is_default + if agy_is_default: + mock_provider_row = ( + await session.execute( + select(AiProviderRow).where(AiProviderRow.name == "mock") + ) + ).scalars().first() + if mock_provider_row is not None: + mock_model_rows = ( + await session.execute( + select(AiModelRow).where( + AiModelRow.provider_id == mock_provider_row.id + ) + ) + ).scalars() + for row in mock_model_rows: + row.is_active = False + logger.info( + "agy is the default provider; agy catalog activated " + "(accounts are added from the admin console)" + ) + if not (await session.execute(select(AgentTemplateRow))).scalars().first(): for name, role_key, description, tools in AGENT_TEMPLATES: template = AgentTemplate( diff --git a/apps/api/scripts/agy_login.py b/apps/api/scripts/agy_login.py new file mode 100644 index 0000000..f91c90d --- /dev/null +++ b/apps/api/scripts/agy_login.py @@ -0,0 +1,196 @@ +"""Add an agy (Google Antigravity) account through headless OAuth. + +The admin console does this over HTTP; this script is the same flow for an +operator sitting at the machine that runs the API — useful because the login +needs a browser redirect to a loopback URL that deliberately never loads. + + # 1) start a login; prints the Google URL to open + .venv/Scripts/python scripts/agy_login.py start + + # 2) after signing in, paste the browser's full address-bar URL back + .venv/Scripts/python scripts/agy_login.py finish "http://localhost:51121/oauth-callback?code=...&state=..." + +Run ``scripts/agy_login.py status`` to list stored accounts. The pending +challenge lives in ``.agy-login-state.json`` next to this script (gitignored) +for the ten minutes it is valid — never commit it. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +# The script lives in+ agy کلید API ندارد: هر اکانت یک هویت Google است که از طریق OAuth وارد + میشود. هر تعداد اکانت اضافه کنید؛ پول چرخشی بار را بین آنها پخش + میکند و اکانت محدودشده را تا پایان پنجرهٔ سهمیه کنار میگذارد. +
++ {account.email || account.name} +
++ {account.last_used_at + ? `آخرین استفاده: ${new Date(account.last_used_at).toLocaleString("fa-IR")}` + : "هنوز استفاده نشده"} + {account.last_error ? ` · ${account.last_error}` : ""} +
+ {testResult &&{testResult}
} ++ ۱. روی «دریافت لینک لاگین» بزنید. یک آدرس احراز هویت گوگل ساخته + میشود که فقط برای این درخواست معتبر است (۱۰ دقیقه). +
++ ۲. آن آدرس را در مرورگر باز کنید و با اکانت Google مورد نظر وارد + شوید. +
++ ۳. بعد از تأیید، مرورگر به یک آدرس محلی منتقل میشود که عمداً + بارگذاری نمیشود. نوار آدرس را همانجا کپی کنید (آدرس کامل با + ?code=…&state=…) + و در گام بعدی Paste کنید. +
++ این آدرس را در مرورگر باز کنید و با Google وارد شوید: +
+ + {authUrl} + ++ بعد از تأیید در گوگل، آدرس کامل نوار آدرس مرورگر (که با + oauth-callback?… شروع + میشود) را در کادر زیر بچسبانید. +
+