افزودن ارائه‌دهندهٔ agy (Google Antigravity) با لاگین headless و پول چرخشی اکانت

- Adapter اکانت‌محور با چرخش round-robin و بنچ‌شدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاری‌شدهٔ refresh token
- صفحهٔ مدیریت اکانت‌های agy در پنل ادمین به همراه تست و فعال/غیرفعال‌سازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستون‌های اکانت agy
- seed همیشه کاتالوگ agy را می‌سازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی می‌شود
This commit is contained in:
mjalmoudy94
2026-10-13 08:20:27 +03:30
parent 54074f10c3
commit 3d1e98f4fb
25 changed files with 3073 additions and 10 deletions
@@ -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: