"""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 class AgyAccountModelOut(BaseModel): 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 # --------------------------------------------------------------------------- # 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("/accounts/{credential_id}/models", response_model=list[AgyAccountModelOut]) async def list_account_models( credential_id: str, session: AsyncSession = Depends(get_session), ) -> list[AgyAccountModelOut]: """Live model catalog of one account — fetched from Google on every call. The admin console calls this when an account is selected, so the model list always reflects that subscription's current entitlement along with the supported reasoning efforts for each model. """ from app.core.errors import ValidationError 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: one forced account try: models = await adapter.available_models_for(credential) return [ AgyAccountModelOut( id=m.id, display_name=m.display_name, supports_thinking=m.supports_thinking, supported_efforts=m.supported_efforts, default_effort=m.default_effort, supports_images=m.supports_images, ) for m in models ] except ValidationError: raise except Exception as exc: # noqa: BLE001 - the console wants the reason raise ValidationError(str(exc)) from exc