- Adapter اکانتمحور با چرخش round-robin و بنچشدن خودکار روی ۴۲۹/۴۰۱ - ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاریشدهٔ refresh token - صفحهٔ مدیریت اکانتهای agy در پنل ادمین به همراه تست و فعال/غیرفعالسازی - مهاجرت Alembic b1c2d3e4f5a6 برای ستونهای اکانت agy - seed همیشه کاتالوگ agy را میسازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی میشود
344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""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()
|