افزودن ارائهدهندهٔ 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user