افزودن ارائهدهندهٔ 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:
+15
-1
@@ -27,11 +27,25 @@ JWT_SECRET=change-me
|
|||||||
ENCRYPTION_KEY=change-me-32-bytes
|
ENCRYPTION_KEY=change-me-32-bytes
|
||||||
|
|
||||||
# --- AI models (hidden from customers) -------------------------------------
|
# --- AI models (hidden from customers) -------------------------------------
|
||||||
# mock | openai_compatible
|
# mock | openai_compatible | agy
|
||||||
AI_DEFAULT_PROVIDER=mock
|
AI_DEFAULT_PROVIDER=mock
|
||||||
OPENAI_COMPATIBLE_BASE_URL=
|
OPENAI_COMPATIBLE_BASE_URL=
|
||||||
OPENAI_COMPATIBLE_API_KEY=
|
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 ----------------------------------------------------------
|
# --- Git provider ----------------------------------------------------------
|
||||||
# mock | gitea
|
# mock | gitea
|
||||||
GIT_PROVIDER=mock
|
GIT_PROVIDER=mock
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ venv/
|
|||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# pending agy OAuth login challenge (contains a PKCE verifier — never commit)
|
||||||
|
.agy-login-state.json
|
||||||
|
apps/api/.agy-login-state.json
|
||||||
|
|
||||||
# editor / os
|
# editor / os
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ web/
|
|||||||
|
|
||||||
| قابلیت | env | مقادیر |
|
| قابلیت | env | مقادیر |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` |
|
| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` \| `agy` |
|
||||||
| Git | `GIT_PROVIDER` | `mock` \| `gitea` |
|
| Git | `GIT_PROVIDER` | `mock` \| `gitea` |
|
||||||
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
|
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
|
||||||
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` |
|
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` |
|
||||||
@@ -226,6 +226,25 @@ web/
|
|||||||
اگر credential واقعی نداریم، **قابلیت حذف نمیشود**: مدل دیتابیس، interface، UI و
|
اگر credential واقعی نداریم، **قابلیت حذف نمیشود**: مدل دیتابیس، interface، UI و
|
||||||
adapter mock کامل ساخته میشود و adapter واقعی پشت همین flag آماده میماند.
|
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های پسزمینه
|
## ۹. Workerهای پسزمینه
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -44,13 +44,21 @@ class ModelProviderPort(Protocol):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
model: AiModel,
|
model: AiModel,
|
||||||
credential: ModelCredential | None,
|
credential: ModelCredential | None = None,
|
||||||
|
credentials: list[ModelCredential] | None = None,
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
system: str = "",
|
system: str = "",
|
||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
max_tokens: int = 2048,
|
max_tokens: int = 2048,
|
||||||
) -> ModelResponse:
|
) -> 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:
|
async def health(self) -> ModelHealth:
|
||||||
|
|||||||
@@ -714,10 +714,17 @@ class WorkforceService:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Run one completion and record its usage. The single model entry point."""
|
"""Run one completion and record its usage. The single model entry point."""
|
||||||
model = await self._resolve_model(employee)
|
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(
|
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(
|
await self._record_usage(
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ class Settings(BaseSettings):
|
|||||||
openai_compatible_base_url: str = ""
|
openai_compatible_base_url: str = ""
|
||||||
openai_compatible_api_key: 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 -----------------------------------------------------------------
|
||||||
git_provider: str = "mock"
|
git_provider: str = "mock"
|
||||||
gitea_url: str = ""
|
gitea_url: str = ""
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ class ProviderType(StrEnum):
|
|||||||
ANTHROPIC = "anthropic"
|
ANTHROPIC = "anthropic"
|
||||||
GEMINI = "gemini"
|
GEMINI = "gemini"
|
||||||
OPENROUTER = "openrouter"
|
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):
|
class RoutingMode(StrEnum):
|
||||||
@@ -61,12 +74,29 @@ class Provider:
|
|||||||
|
|
||||||
@dataclass(slots=True, kw_only=True)
|
@dataclass(slots=True, kw_only=True)
|
||||||
class ModelCredential:
|
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
|
provider_id: str
|
||||||
name: str
|
name: str
|
||||||
encrypted_api_key: str
|
encrypted_api_key: str
|
||||||
id: str = field(default_factory=new_id)
|
id: str = field(default_factory=new_id)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = field(default_factory=utcnow)
|
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)
|
@dataclass(slots=True, kw_only=True)
|
||||||
|
|||||||
@@ -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"<html><body>OK - you may close this tab.</body></html>"
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -66,6 +66,7 @@ class MockModelProviderAdapter:
|
|||||||
*,
|
*,
|
||||||
model: AiModel,
|
model: AiModel,
|
||||||
credential=None,
|
credential=None,
|
||||||
|
credentials=None, # accepted for port parity; the mock has no pool
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
system: str = "",
|
system: str = "",
|
||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ class OpenAICompatibleModelAdapter:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
model: AiModel,
|
model: AiModel,
|
||||||
credential: ModelCredential | None,
|
credential: ModelCredential | None = None,
|
||||||
|
credentials: list[ModelCredential] | None = None, # no pool here
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
system: str = "",
|
system: str = "",
|
||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ def get_model_provider():
|
|||||||
base_url=settings.openai_compatible_base_url,
|
base_url=settings.openai_compatible_base_url,
|
||||||
default_api_key=settings.openai_compatible_api_key,
|
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(
|
raise ProviderUnavailableError(
|
||||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||||
"مقادیر مجاز: mock | openai_compatible"
|
"مقادیر مجاز: mock | openai_compatible | agy"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ class AiModelCredentialRow(Base):
|
|||||||
encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
encrypted_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
created_at: Mapped[datetime] = _created()
|
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):
|
class AiModelRow(Base):
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ move rows.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ from app.domain.ai_control.models import (
|
|||||||
RoutingMode,
|
RoutingMode,
|
||||||
RoutingPolicy,
|
RoutingPolicy,
|
||||||
)
|
)
|
||||||
|
from app.domain.base import utcnow
|
||||||
from app.infrastructure.db.models import (
|
from app.infrastructure.db.models import (
|
||||||
AiModelCredentialRow,
|
AiModelCredentialRow,
|
||||||
AiModelRow,
|
AiModelRow,
|
||||||
@@ -41,6 +44,9 @@ def _credential_to_domain(row: AiModelCredentialRow) -> ModelCredential:
|
|||||||
id=row.id, provider_id=row.provider_id, name=row.name,
|
id=row.id, provider_id=row.provider_id, name=row.name,
|
||||||
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
|
encrypted_api_key=row.encrypted_api_key, is_active=row.is_active,
|
||||||
created_at=row.created_at,
|
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(
|
.where(
|
||||||
AiModelCredentialRow.provider_id == provider_id,
|
AiModelCredentialRow.provider_id == provider_id,
|
||||||
AiModelCredentialRow.is_active.is_(True),
|
AiModelCredentialRow.is_active.is_(True),
|
||||||
|
AiModelCredentialRow.status == "active",
|
||||||
)
|
)
|
||||||
.order_by(AiModelCredentialRow.created_at.desc())
|
.order_by(AiModelCredentialRow.created_at.desc())
|
||||||
)
|
)
|
||||||
row = (await self.session.execute(stmt)).scalars().first()
|
row = (await self.session.execute(stmt)).scalars().first()
|
||||||
return _credential_to_domain(row) if row else None
|
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:
|
async def create(self, credential: ModelCredential) -> ModelCredential:
|
||||||
row = AiModelCredentialRow(
|
row = AiModelCredentialRow(
|
||||||
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||||
encrypted_api_key=credential.encrypted_api_key,
|
encrypted_api_key=credential.encrypted_api_key,
|
||||||
is_active=credential.is_active, created_at=credential.created_at,
|
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)
|
self.session.add(row)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return _credential_to_domain(row)
|
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:
|
class AiModelRepository:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.request_context import RequestContextMiddleware
|
||||||
from app.interfaces.http.routers import (
|
from app.interfaces.http.routers import (
|
||||||
admin,
|
admin,
|
||||||
|
agy,
|
||||||
auth,
|
auth,
|
||||||
billing,
|
billing,
|
||||||
health,
|
health,
|
||||||
@@ -121,6 +122,7 @@ def create_app() -> FastAPI:
|
|||||||
application.include_router(knowledge.router, prefix="/api")
|
application.include_router(knowledge.router, prefix="/api")
|
||||||
application.include_router(sandbox.router, prefix="/api")
|
application.include_router(sandbox.router, prefix="/api")
|
||||||
application.include_router(admin.router, prefix="/api")
|
application.include_router(admin.router, prefix="/api")
|
||||||
|
application.include_router(agy.router, prefix="/api")
|
||||||
|
|
||||||
@application.get("/", tags=["meta"])
|
@application.get("/", tags=["meta"])
|
||||||
async def root() -> dict:
|
async def root() -> dict:
|
||||||
|
|||||||
+126
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
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
|
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.
|
Safe to run repeatedly: every step checks for existing rows first.
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import Base, SessionFactory, engine
|
from app.core.database import Base, SessionFactory, engine
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.domain.ai_control.models import (
|
from app.domain.ai_control.models import (
|
||||||
@@ -60,6 +63,42 @@ DEMO_PASSWORD = "demo12345"
|
|||||||
DEMO_ORG_NAME = "سازمان نمونه"
|
DEMO_ORG_NAME = "سازمان نمونه"
|
||||||
DEMO_ORG_SLUG = "sample-org"
|
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]]] = [
|
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
|
||||||
(
|
(
|
||||||
"مدیر سازمان", RoleKey.MANAGER,
|
"مدیر سازمان", RoleKey.MANAGER,
|
||||||
@@ -269,6 +308,92 @@ async def seed() -> None:
|
|||||||
)
|
)
|
||||||
logger.info("created mock provider + 2 models")
|
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():
|
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
|
||||||
for name, role_key, description, tools in AGENT_TEMPLATES:
|
for name, role_key, description, tools in AGENT_TEMPLATES:
|
||||||
template = AgentTemplate(
|
template = AgentTemplate(
|
||||||
|
|||||||
@@ -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 <repo>/apps/api/scripts/; app/ is one level up.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.core.config import settings # noqa: E402
|
||||||
|
from app.core.crypto import encrypt # noqa: E402
|
||||||
|
from app.core.database import SessionFactory # noqa: E402
|
||||||
|
from app.domain.ai_control.models import ( # noqa: E402
|
||||||
|
ModelCredential,
|
||||||
|
Provider,
|
||||||
|
ProviderType,
|
||||||
|
)
|
||||||
|
from app.infrastructure.adapters.agy import ( # noqa: E402
|
||||||
|
CHALLENGE_TTL_SECONDS,
|
||||||
|
AgyAuthClient,
|
||||||
|
AgyLoginChallenge,
|
||||||
|
AgyOAuthError,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.ai_control import ( # noqa: E402
|
||||||
|
ModelCredentialRepository,
|
||||||
|
ProviderRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_PATH = Path(__file__).resolve().parents[1] / ".agy-login-state.json"
|
||||||
|
AGY_PROVIDER_NAME = "agy"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_challenge(challenge: AgyLoginChallenge, auth_url: str) -> None:
|
||||||
|
STATE_PATH.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"login_id": challenge.login_id,
|
||||||
|
"auth_url": auth_url,
|
||||||
|
"redirect_uri": challenge.redirect_uri,
|
||||||
|
"verifier": challenge.verifier,
|
||||||
|
"state": challenge.state,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_challenge() -> tuple[AgyLoginChallenge, str]:
|
||||||
|
if not STATE_PATH.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
"هیچ لاگین در انتظاری نیست. اول «start» را اجرا کنید.\n"
|
||||||
|
"No pending login. Run `start` first."
|
||||||
|
)
|
||||||
|
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||||
|
challenge = AgyLoginChallenge(
|
||||||
|
login_id=data["login_id"],
|
||||||
|
auth_url=data["auth_url"],
|
||||||
|
redirect_uri=data["redirect_uri"],
|
||||||
|
verifier=data["verifier"],
|
||||||
|
state=data["state"],
|
||||||
|
created_at=0.0,
|
||||||
|
)
|
||||||
|
return challenge, data["auth_url"]
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_start(_: argparse.Namespace) -> None:
|
||||||
|
challenge = AgyAuthClient().begin()
|
||||||
|
_save_challenge(challenge, challenge.auth_url)
|
||||||
|
print(challenge.auth_url)
|
||||||
|
print()
|
||||||
|
print("این لینک را در مرورگر باز کنید و با اکانت Google وارد شوید.")
|
||||||
|
print("Open this URL in a browser and sign in with the Google account.")
|
||||||
|
print(
|
||||||
|
f"بعد از تأیید، آدرس کامل نوار آدرس مرورگر را کپی کنید "
|
||||||
|
f"(با {challenge.redirect_uri} شروع میشود) و با «finish <URL>» بدهید."
|
||||||
|
)
|
||||||
|
print(f"این درخواست {CHALLENGE_TTL_SECONDS // 60} دقیقه معتبر است.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_finish(args: argparse.Namespace) -> None:
|
||||||
|
challenge, _auth_url = _load_challenge()
|
||||||
|
try:
|
||||||
|
result = await AgyAuthClient().exchange(challenge, args.callback_url)
|
||||||
|
except AgyOAuthError as exc:
|
||||||
|
raise SystemExit(f"لاگین ناموفق بود: {exc}") from exc
|
||||||
|
if not result.email:
|
||||||
|
raise SystemExit("Google نشانی ایمیل اکانت را برنگرداند؛ لاگین لغو شد.")
|
||||||
|
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
providers = ProviderRepository(session)
|
||||||
|
credentials = ModelCredentialRepository(session)
|
||||||
|
provider = await providers.get_by_name(AGY_PROVIDER_NAME)
|
||||||
|
if provider is None:
|
||||||
|
provider = await providers.create(
|
||||||
|
Provider(name=AGY_PROVIDER_NAME, type=ProviderType.AGY, base_url="")
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
await credentials.replace_material(
|
||||||
|
existing.id, encrypted_api_key=encrypt(envelope),
|
||||||
|
project_id=result.project_id,
|
||||||
|
)
|
||||||
|
action = "بهروزرسانی شد"
|
||||||
|
else:
|
||||||
|
await credentials.create(
|
||||||
|
ModelCredential(
|
||||||
|
provider_id=provider.id,
|
||||||
|
name=result.email,
|
||||||
|
encrypted_api_key=encrypt(envelope),
|
||||||
|
email=result.email,
|
||||||
|
project_id=result.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
action = "اضافه شد"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
STATE_PATH.unlink(missing_ok=True)
|
||||||
|
print(f"اکانت {result.email} {action}.")
|
||||||
|
print(f"project_id: {result.project_id or '(auto)'}")
|
||||||
|
print("توکن refresh بهصورت رمزنگاریشده ذخیره شد و هرگز از API برنمیگردد.")
|
||||||
|
print(
|
||||||
|
"برای استفادهٔ واقعی، AI_DEFAULT_PROVIDER=agy را تنظیم و "
|
||||||
|
"«db:seed» را دوباره اجرا کنید."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_status(_: argparse.Namespace) -> None:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
providers = ProviderRepository(session)
|
||||||
|
provider = await providers.get_by_name(AGY_PROVIDER_NAME)
|
||||||
|
if provider is None:
|
||||||
|
print("provider agy وجود ندارد. یک لاگین شروع کنید.")
|
||||||
|
return
|
||||||
|
accounts = await ModelCredentialRepository(session).list_for_provider(provider.id)
|
||||||
|
if not accounts:
|
||||||
|
print("هیچ اکانتی ثبت نشده است.")
|
||||||
|
return
|
||||||
|
for account in accounts:
|
||||||
|
state = account.status if account.is_active else "disabled"
|
||||||
|
print(f"- {account.email or account.name} [{state}] last_used={account.last_used_at}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
sub.add_parser("start", help="شروع لاگین و چاپ لینک گوگل")
|
||||||
|
finish = sub.add_parser("finish", help="تکمیل لاگین با URL بازگشت مرورگر")
|
||||||
|
finish.add_argument("callback_url", help="آدرس کامل نوار آدرس بعد از لاگین")
|
||||||
|
sub.add_parser("status", help="فهرست اکانتهای ثبتشده")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if settings.is_production and args.command in {"start", "finish"}:
|
||||||
|
print(
|
||||||
|
"هشدار: AI_DEFAULT_PROVIDER در محیط production است. "
|
||||||
|
"ادامه میدهید چون لاگین فقط credential میسازد."
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
{
|
||||||
|
"start": cmd_start,
|
||||||
|
"finish": cmd_finish,
|
||||||
|
"status": cmd_status,
|
||||||
|
}[args.command](args)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""agy provider tests — OAuth paste-URL login, translation, pool rotation.
|
||||||
|
|
||||||
|
Everything external is faked at the ``httpx.AsyncClient`` seam, so the suite
|
||||||
|
runs with no Google, no network and no accounts. What is *not* faked is the
|
||||||
|
protocol shape: the fake answers are the shapes Google answers with (token
|
||||||
|
JSON, userinfo JSON, ``v1internal:generateContent`` responses, 429s), so a
|
||||||
|
regression in our translation fails here rather than in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
import app.infrastructure.adapters.agy as agy
|
||||||
|
from app.application.ports import ChatMessage
|
||||||
|
from app.core.errors import ProviderUnavailableError
|
||||||
|
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||||
|
from tests.conftest import auth_header, signup
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake httpx seam
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GOOD_GENERATE: dict = {
|
||||||
|
"candidates": [
|
||||||
|
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
|
||||||
|
],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 7,
|
||||||
|
"candidatesTokenCount": 3,
|
||||||
|
"thoughtsTokenCount": 0,
|
||||||
|
"totalTokenCount": 10,
|
||||||
|
},
|
||||||
|
"model": "gemini-3-flash",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status_code: int = 200,
|
||||||
|
json_data: dict | None = None,
|
||||||
|
text: str = "",
|
||||||
|
headers: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json = json_data if json_data is not None else {}
|
||||||
|
self._text = text
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_success(self) -> bool:
|
||||||
|
return self.status_code < 400
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
def _token_response(refresh: str = "refresh-1", access: str = "access-1") -> _FakeResponse:
|
||||||
|
return _FakeResponse(
|
||||||
|
json_data={"access_token": access, "refresh_token": refresh, "expires_in": 3600}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_google(monkeypatch, handler) -> list[dict]:
|
||||||
|
"""Point ``httpx.AsyncClient`` at a scripted handler.
|
||||||
|
|
||||||
|
The adapter creates its own ``AsyncClient`` internally, so the seam is the
|
||||||
|
class itself. ``handler(method, url, json_body, headers, data)`` returns a
|
||||||
|
``_FakeResponse`` (sync or async); every call is recorded and returned for
|
||||||
|
assertions.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def _dispatch(method, url, json_body, headers, data):
|
||||||
|
result = handler(method, url, json_body, headers or {}, data or {})
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
result = await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _post(self, url, *, json=None, headers=None, data=None, **_):
|
||||||
|
calls.append({"method": "POST", "url": url, "json": json, "headers": headers, "data": data})
|
||||||
|
return await _dispatch("POST", url, json, headers, data)
|
||||||
|
|
||||||
|
async def _get(self, url, *, headers=None, **_):
|
||||||
|
calls.append({"method": "GET", "url": url, "json": None, "headers": headers, "data": None})
|
||||||
|
return await _dispatch("GET", url, None, headers, None)
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *_, **__):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
post = _post
|
||||||
|
get = _get
|
||||||
|
|
||||||
|
monkeypatch.setattr(agy.httpx, "AsyncClient", _Client)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def default_handler(method, url, json_body, headers, data):
|
||||||
|
"""The Google every happy-path test talks to."""
|
||||||
|
if url.endswith("/token"):
|
||||||
|
return _token_response()
|
||||||
|
if "userinfo" in url:
|
||||||
|
return _FakeResponse(json_data={"email": "person@gmail.com"})
|
||||||
|
if url.endswith("/v1internal:loadCodeAssist"):
|
||||||
|
return _FakeResponse(json_data={"cloudaicompanionProject": "agy-proj-123"})
|
||||||
|
if url.endswith("/v1internal:fetchAvailableModels"):
|
||||||
|
return _FakeResponse(json_data={"models": {"gemini-3-flash": {}, "claude-sonnet-4-5": {}}})
|
||||||
|
if url.endswith("/v1internal:generateContent"):
|
||||||
|
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||||
|
raise AssertionError(f"unexpected {method} {url}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def google(monkeypatch):
|
||||||
|
"""Async handler matching ``default_handler``; returns the call log."""
|
||||||
|
return patch_google(monkeypatch, lambda *a: default_handler(*a))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def decrypted(monkeypatch):
|
||||||
|
"""Make ``decrypt`` a passthrough so tests can build fake ciphertext."""
|
||||||
|
monkeypatch.setattr(agy, "decrypt", lambda blob: blob.split("PLAINTEXT::", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _agy_credential(
|
||||||
|
*, email: str, refresh_token: str = "refresh-1", project_id: str = "agy-proj-123"
|
||||||
|
) -> ModelCredential:
|
||||||
|
"""A credential as the adapter sees it: ciphertext already holds the blob."""
|
||||||
|
envelope = json.dumps(
|
||||||
|
{"refresh_token": refresh_token, "project_id": project_id, "email": email}
|
||||||
|
)
|
||||||
|
return ModelCredential(
|
||||||
|
provider_id="prov-1",
|
||||||
|
name=email,
|
||||||
|
encrypted_api_key=f"PLAINTEXT::{envelope}",
|
||||||
|
email=email,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MODEL = AiModel(provider_id="p", internal_name="gemini-3-flash", display_name_for_admin="f")
|
||||||
|
_MESSAGES = [ChatMessage(role="user", content="سلام")]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP endpoints (platform owner only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agy_endpoints_require_platform_owner(client, google):
|
||||||
|
body = await signup(client, "customer@example.com")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/start", headers=auth_header(body["access_token"])
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
||||||
|
# 1. start the headless login.
|
||||||
|
started = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/start",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
assert started.status_code == 201, started.text
|
||||||
|
login_id = started.json()["login_id"]
|
||||||
|
auth_url = started.json()["auth_url"]
|
||||||
|
state = parse_qs(urlparse(auth_url).query)["state"][0]
|
||||||
|
|
||||||
|
# 2. the operator pastes the browser's callback URL back.
|
||||||
|
completed = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/complete",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
json={
|
||||||
|
"login_id": login_id,
|
||||||
|
"callback_url": (
|
||||||
|
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert completed.status_code == 201, completed.text
|
||||||
|
account = completed.json()
|
||||||
|
assert account["email"] == "person@gmail.com"
|
||||||
|
assert account["status"] == "active"
|
||||||
|
assert account["is_active"] is True
|
||||||
|
|
||||||
|
# No credential material ever crosses the API surface.
|
||||||
|
assert "refresh_token" not in completed.text
|
||||||
|
assert "encrypted" not in completed.text
|
||||||
|
assert "agy-proj-123" in completed.text # project id is admin metadata
|
||||||
|
|
||||||
|
# 3. the account is listed.
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
emails = [a["email"] for a in listed.json()]
|
||||||
|
assert emails == ["person@gmail.com"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agy_complete_login_rejects_unrelated_url(client, google, platform_owner_token):
|
||||||
|
started = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/start",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
completed = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/complete",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
json={
|
||||||
|
"login_id": started.json()["login_id"],
|
||||||
|
"callback_url": "http://localhost:51121/oauth-callback?code=x&state=elsewhere",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert completed.status_code == 422
|
||||||
|
# The failed attempt must not have created an account.
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||||
|
)
|
||||||
|
assert listed.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agy_account_test_and_delete(client, google, platform_owner_token):
|
||||||
|
started = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/start",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
state = parse_qs(urlparse(started.json()["auth_url"]).query)["state"][0]
|
||||||
|
created = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/complete",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
json={
|
||||||
|
"login_id": started.json()["login_id"],
|
||||||
|
"callback_url": (
|
||||||
|
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
account_id = created.json()["id"]
|
||||||
|
|
||||||
|
# A test call runs a real (faked-Google) completion through the account.
|
||||||
|
tested = await client.post(
|
||||||
|
f"/api/admin/agy/accounts/{account_id}/test",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
assert tested.status_code == 200, tested.text
|
||||||
|
assert tested.json()["ok"] is True
|
||||||
|
assert tested.json()["detail"] == "سلام"
|
||||||
|
|
||||||
|
deleted = await client.delete(
|
||||||
|
f"/api/admin/agy/accounts/{account_id}",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||||
|
)
|
||||||
|
assert listed.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_agy_same_account_twice_replaces(client, google, platform_owner_token):
|
||||||
|
"""Re-login of a known Google account rotates the key, never duplicates."""
|
||||||
|
for _ in range(2):
|
||||||
|
started = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/start",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
)
|
||||||
|
state = parse_qs(urlparse(started.json()["auth_url"]).query)["state"][0]
|
||||||
|
done = await client.post(
|
||||||
|
"/api/admin/agy/accounts/login/complete",
|
||||||
|
headers=auth_header(platform_owner_token),
|
||||||
|
json={
|
||||||
|
"login_id": started.json()["login_id"],
|
||||||
|
"callback_url": (
|
||||||
|
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert done.status_code == 201
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||||
|
)
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed: the agy catalog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def seeded_install(monkeypatch, engine, session_factory):
|
||||||
|
"""Run the real ``app.seed`` against the in-memory test database."""
|
||||||
|
import app.seed as seed_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(seed_module, "engine", engine)
|
||||||
|
monkeypatch.setattr(seed_module, "SessionFactory", session_factory)
|
||||||
|
await seed_module.seed()
|
||||||
|
return seed_module
|
||||||
|
|
||||||
|
|
||||||
|
async def _agy_models(session_factory) -> list[tuple[str, bool]]:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.infrastructure.db.models import AiModelRow, AiProviderRow
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(AiModelRow, AiProviderRow.name)
|
||||||
|
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
|
||||||
|
.where(AiProviderRow.name == "agy")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [(row.internal_name, row.is_active) for row, _name in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_creates_agy_catalog(client, seeded_install, session_factory):
|
||||||
|
models = await _agy_models(session_factory)
|
||||||
|
names = {name for name, _ in models}
|
||||||
|
assert names == {
|
||||||
|
"gemini-3-flash",
|
||||||
|
"gemini-3-pro-high",
|
||||||
|
"claude-sonnet-4-5",
|
||||||
|
"claude-sonnet-4-5-thinking",
|
||||||
|
"claude-opus-4-5-thinking",
|
||||||
|
"gpt-oss-120b-medium",
|
||||||
|
}
|
||||||
|
# Default deployment keeps routing on mock: agy models wait inactive.
|
||||||
|
assert all(active is False for _, active in models)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_activates_agy_when_it_is_default(
|
||||||
|
monkeypatch, client, seeded_install, session_factory
|
||||||
|
):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "ai_default_provider", "agy")
|
||||||
|
await seeded_install.seed()
|
||||||
|
models = await _agy_models(session_factory)
|
||||||
|
assert all(active is True for _, active in models)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_deactivates_mock_when_agy_is_default(
|
||||||
|
monkeypatch, client, seeded_install, session_factory
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.infrastructure.db.models import AiModelRow, AiProviderRow
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "ai_default_provider", "agy")
|
||||||
|
await seeded_install.seed()
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(AiModelRow)
|
||||||
|
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
|
||||||
|
.where(AiProviderRow.name == "mock")
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
assert all(row.is_active is False for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OAuth login flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_url_shape():
|
||||||
|
challenge = agy.AgyAuthClient().begin()
|
||||||
|
query = parse_qs(urlparse(challenge.auth_url).query)
|
||||||
|
assert challenge.auth_url.startswith(
|
||||||
|
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
)
|
||||||
|
assert query["client_id"][0] == (
|
||||||
|
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||||
|
)
|
||||||
|
assert query["redirect_uri"][0] == "http://localhost:51121/oauth-callback"
|
||||||
|
assert query["code_challenge_method"][0] == "S256"
|
||||||
|
# access_type=offline + prompt=consent is what makes Google hand back a
|
||||||
|
# refresh token; without it the login completes but nothing can be stored.
|
||||||
|
assert query["access_type"][0] == "offline"
|
||||||
|
assert query["prompt"][0] == "consent"
|
||||||
|
assert "cloud-platform" in query["scope"][0]
|
||||||
|
assert challenge.verifier and challenge.state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paste_flow_exchanges_and_resolves_account(google):
|
||||||
|
"""start → paste callback URL → account material, verifier stays server-side."""
|
||||||
|
login_id, _auth_url = await agy.begin_login()
|
||||||
|
challenge = agy._CHALLENGES[login_id]
|
||||||
|
callback = (
|
||||||
|
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||||
|
)
|
||||||
|
result = await agy.redeem_login(login_id, callback)
|
||||||
|
assert result.email == "person@gmail.com"
|
||||||
|
assert result.project_id == "agy-proj-123"
|
||||||
|
assert result.refresh_token == "refresh-1"
|
||||||
|
# The challenge is single-use.
|
||||||
|
with pytest.raises(agy.AgyOAuthError):
|
||||||
|
await agy.redeem_login(login_id, callback)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_foreign_state_is_rejected(google):
|
||||||
|
login_id, _ = await agy.begin_login()
|
||||||
|
with pytest.raises(agy.AgyOAuthError):
|
||||||
|
await agy.redeem_login(
|
||||||
|
login_id, "http://localhost:51121/oauth-callback?code=x&state=not-ours"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_code_is_rejected(google):
|
||||||
|
login_id, _ = await agy.begin_login()
|
||||||
|
with pytest.raises(agy.AgyOAuthError):
|
||||||
|
await agy.redeem_login(login_id, "http://localhost:51121/oauth-callback?state=x")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_google_error_page_is_rejected(google):
|
||||||
|
login_id, _ = await agy.begin_login()
|
||||||
|
with pytest.raises(agy.AgyOAuthError):
|
||||||
|
await agy.redeem_login(
|
||||||
|
login_id, "http://localhost:51121/oauth-callback?error=access_denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_token_missing_is_actionable(monkeypatch):
|
||||||
|
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
return _FakeResponse(
|
||||||
|
json_data={"access_token": "a", "expires_in": 3600}
|
||||||
|
)
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
login_id, _ = await agy.begin_login()
|
||||||
|
challenge = agy._CHALLENGES[login_id]
|
||||||
|
with pytest.raises(agy.AgyOAuthError) as excinfo:
|
||||||
|
await agy.redeem_login(
|
||||||
|
login_id,
|
||||||
|
f"http://localhost:51121/oauth-callback?code=c&state={challenge.state}",
|
||||||
|
)
|
||||||
|
assert "refresh_token" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejected_refresh_is_an_account_error_not_an_outage(monkeypatch):
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
return _FakeResponse(
|
||||||
|
status_code=400,
|
||||||
|
text=json.dumps({"error": "invalid_grant"}),
|
||||||
|
)
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
with pytest.raises(Exception) as excinfo:
|
||||||
|
await agy.AgyAuthClient().refresh("dead-token")
|
||||||
|
# Raised as the internal taxonomy so the adapter benches the account.
|
||||||
|
assert type(excinfo.value).__name__ == "_AgyAuthError"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request translation (pure functions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_translation(decrypted):
|
||||||
|
account = agy.AgyAccount(
|
||||||
|
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
|
||||||
|
)
|
||||||
|
payload = agy._build_generate_payload(
|
||||||
|
account,
|
||||||
|
"gemini-3-pro-high",
|
||||||
|
[
|
||||||
|
ChatMessage(role="system", content="تو مدیر هستی."),
|
||||||
|
ChatMessage(role="user", content="سلام"),
|
||||||
|
ChatMessage(role="assistant", content="سلام! چیزی کمکی میخواهید؟"),
|
||||||
|
ChatMessage(role="user", content="یک گزارش بساز"),
|
||||||
|
],
|
||||||
|
system="پرامپت سیستم",
|
||||||
|
temperature=0.4,
|
||||||
|
max_tokens=512,
|
||||||
|
)
|
||||||
|
assert payload["model"] == "gemini-3-pro-high"
|
||||||
|
assert payload["project"] == "proj-1"
|
||||||
|
assert payload["requestId"].startswith("agent-")
|
||||||
|
assert payload["request"]["sessionId"].startswith("-")
|
||||||
|
# System content from both the `system` argument and system messages lands
|
||||||
|
# in systemInstruction, not as a user turn.
|
||||||
|
instruction = payload["request"]["systemInstruction"]["parts"][0]["text"]
|
||||||
|
assert "پرامپت سیستم" in instruction
|
||||||
|
assert "تو مدیر هستی." in instruction
|
||||||
|
roles = [c["role"] for c in payload["request"]["contents"]]
|
||||||
|
assert roles == ["user", "model", "user"]
|
||||||
|
assert payload["request"]["generationConfig"]["temperature"] == 0.4
|
||||||
|
assert payload["request"]["generationConfig"]["maxOutputTokens"] == 512
|
||||||
|
# Function calling must be VALIDATED or tool schemas get rejected.
|
||||||
|
assert payload["request"]["toolConfig"]["functionCallingConfig"]["mode"] == "VALIDATED"
|
||||||
|
assert all(s["threshold"] == "OFF" for s in payload["request"]["safetySettings"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_parsing_skips_thoughts():
|
||||||
|
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
|
||||||
|
response = agy._parse_generate_response(
|
||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"text": "دارم فکر میکنم", "thought": True},
|
||||||
|
{"text": "پاسخ نهایی"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 11,
|
||||||
|
"candidatesTokenCount": 5,
|
||||||
|
"thoughtsTokenCount": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model=model,
|
||||||
|
started=0.0,
|
||||||
|
)
|
||||||
|
assert response.content == "پاسخ نهایی"
|
||||||
|
assert response.input_tokens == 11
|
||||||
|
assert response.output_tokens == 5
|
||||||
|
assert response.raw["thought_tokens"] == 4
|
||||||
|
assert response.raw["finish_reason"] == "STOP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The adapter: completion + account pool rotation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_happy_path(google, decrypted):
|
||||||
|
adapter = agy.AgyModelAdapter()
|
||||||
|
response = await adapter.complete(
|
||||||
|
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||||
|
messages=_MESSAGES,
|
||||||
|
)
|
||||||
|
assert response.content == "سلام"
|
||||||
|
assert response.input_tokens == 7
|
||||||
|
assert response.output_tokens == 3
|
||||||
|
generate = [c for c in google if c["url"].endswith("generateContent")]
|
||||||
|
assert len(generate) == 1
|
||||||
|
# The account's project id is what bills the call.
|
||||||
|
assert generate[0]["json"]["project"] == "agy-proj-123"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
|
||||||
|
"""A 429 on one account must not fail the request: the pool moves on."""
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
# Each account's refresh token maps to its own access token.
|
||||||
|
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
||||||
|
data.get("refresh_token", "")
|
||||||
|
]
|
||||||
|
return _token_response(refresh=data.get("refresh_token", ""), access=access)
|
||||||
|
if url.endswith("/v1internal:generateContent"):
|
||||||
|
auth = headers.get("Authorization", "")
|
||||||
|
if auth.endswith("access-one"):
|
||||||
|
# Both base URLs hit the same quota.
|
||||||
|
return _FakeResponse(
|
||||||
|
status_code=429, text="quota", headers={"Retry-After": "120"}
|
||||||
|
)
|
||||||
|
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
reported: list[tuple] = []
|
||||||
|
|
||||||
|
async def report(credential_id, *, status=None, cooldown_until=None, error=None, used=False):
|
||||||
|
reported.append((credential_id, status, cooldown_until, error, used))
|
||||||
|
|
||||||
|
adapter = agy.AgyModelAdapter(report=report)
|
||||||
|
# Start the rotation cursor so `one` is genuinely attempted first.
|
||||||
|
adapter._state.cursor = -1
|
||||||
|
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
||||||
|
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
||||||
|
response = await adapter.complete(
|
||||||
|
model=_MODEL, credentials=[one, two], messages=_MESSAGES
|
||||||
|
)
|
||||||
|
assert response.content == "سلام"
|
||||||
|
|
||||||
|
cooldowns = [r for r in reported if r[1] == "cooldown"]
|
||||||
|
assert cooldowns, "the rate-limited account must be reported"
|
||||||
|
assert cooldowns[0][0] == one.id
|
||||||
|
# Google's Retry-After is honoured: the bench is ~120s out, not the
|
||||||
|
# configured fallback.
|
||||||
|
remaining = cooldowns[0][2].timestamp() - time.time()
|
||||||
|
assert 90 <= remaining <= 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_account_rate_limited_raises(monkeypatch, decrypted):
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
return _token_response()
|
||||||
|
if url.endswith("/v1internal:generateContent"):
|
||||||
|
return _FakeResponse(status_code=429, text="quota")
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
adapter = agy.AgyModelAdapter()
|
||||||
|
with pytest.raises(ProviderUnavailableError):
|
||||||
|
await adapter.complete(
|
||||||
|
model=_MODEL,
|
||||||
|
credentials=[
|
||||||
|
_agy_credential(email="one@gmail.com", refresh_token="r1"),
|
||||||
|
_agy_credential(email="two@gmail.com", refresh_token="r2"),
|
||||||
|
],
|
||||||
|
messages=_MESSAGES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||||
|
"""401 means the OAuth grant died — bench as error, move to next account."""
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
||||||
|
data.get("refresh_token", "")
|
||||||
|
]
|
||||||
|
return _token_response(refresh=data.get("refresh_token", ""), access=access)
|
||||||
|
if url.endswith("/v1internal:generateContent"):
|
||||||
|
auth = headers.get("Authorization", "")
|
||||||
|
if auth.endswith("access-one"):
|
||||||
|
return _FakeResponse(status_code=401, text="unauthorized")
|
||||||
|
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
reported: list[tuple] = []
|
||||||
|
|
||||||
|
async def report(credential_id, *, status=None, cooldown_until=None, error=None, used=False):
|
||||||
|
reported.append((credential_id, status))
|
||||||
|
|
||||||
|
adapter = agy.AgyModelAdapter(report=report)
|
||||||
|
adapter._state.cursor = -1
|
||||||
|
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
||||||
|
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
||||||
|
response = await adapter.complete(
|
||||||
|
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
|
||||||
|
)
|
||||||
|
assert response.content == "سلام"
|
||||||
|
errors = [r for r in reported if r[1] == "error"]
|
||||||
|
assert errors and errors[0][0] == dead.id
|
||||||
|
# The healthy account is cleared from any bench and marked used.
|
||||||
|
assert any(r[0] == alive.id and r[1] == "active" for r in reported)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
|
||||||
|
"""5xx on one backend URL is not the account's fault — try the other."""
|
||||||
|
async def handler(method, url, json_body, headers, data):
|
||||||
|
if url.endswith("/token"):
|
||||||
|
return _token_response()
|
||||||
|
if url.endswith("/v1internal:generateContent"):
|
||||||
|
if "daily-cloudcode-pa" in url:
|
||||||
|
return _FakeResponse(status_code=503, text="backend down")
|
||||||
|
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||||
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
|
patch_google(monkeypatch, handler)
|
||||||
|
adapter = agy.AgyModelAdapter()
|
||||||
|
response = await adapter.complete(
|
||||||
|
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||||
|
messages=_MESSAGES,
|
||||||
|
)
|
||||||
|
assert response.content == "سلام"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_accounts_raises_actionable_error(google, decrypted):
|
||||||
|
adapter = agy.AgyModelAdapter()
|
||||||
|
with pytest.raises(ProviderUnavailableError) as excinfo:
|
||||||
|
await adapter.complete(model=_MODEL, credentials=[], messages=_MESSAGES)
|
||||||
|
assert "اکانت agy" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_health_reports_account_count(google, decrypted):
|
||||||
|
async def loader():
|
||||||
|
return [_agy_credential(email="one@gmail.com")]
|
||||||
|
|
||||||
|
adapter = agy.AgyModelAdapter(loader=loader)
|
||||||
|
health = await adapter.health()
|
||||||
|
assert health.ok and "1" in health.detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_available_models(google, decrypted):
|
||||||
|
async def loader():
|
||||||
|
return [_agy_credential(email="one@gmail.com")]
|
||||||
|
|
||||||
|
adapter = agy.AgyModelAdapter(loader=loader)
|
||||||
|
models = await adapter.available_models()
|
||||||
|
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type { AgyAccount } from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
Modal,
|
||||||
|
ToastStack,
|
||||||
|
type ToastMessage,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* agy account management (platform owner only).
|
||||||
|
*
|
||||||
|
* agy has no API keys — an account is a Google identity. The login flow is the
|
||||||
|
* headless paste-URL shape of Google OAuth:
|
||||||
|
*
|
||||||
|
* 1. start login → the API returns an auth URL;
|
||||||
|
* 2. the owner opens it, signs in, and Google redirects to a loopback URL
|
||||||
|
* that deliberately never finishes loading — the code and state are
|
||||||
|
* already in the address bar;
|
||||||
|
* 3. the owner pastes that whole URL back and the API redeems it.
|
||||||
|
*
|
||||||
|
* The rotation pool spreads calls across the accounts listed here and benches
|
||||||
|
* a rate-limited one until its quota window resets, so adding more accounts is
|
||||||
|
* the sanctioned way to raise agy throughput.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<AgyAccount["status"], string> = {
|
||||||
|
active: "فعال",
|
||||||
|
cooldown: "استراحت (سهمیه)",
|
||||||
|
error: "خطا — نیاز به لاگین مجدد",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminAgyAccountsPage() {
|
||||||
|
const [accounts, setAccounts] = React.useState<AgyAccount[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [loginOpen, setLoginOpen] = React.useState(false);
|
||||||
|
const [toDelete, setToDelete] = React.useState<AgyAccount | null>(null);
|
||||||
|
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
const pushToast = React.useCallback(
|
||||||
|
(title: string, description?: string) => {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
setToasts((current) => [...current, { id, title, description }]);
|
||||||
|
window.setTimeout(
|
||||||
|
() => setToasts((current) => current.filter((t) => t.id !== id)),
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setAccounts(await api.agyAccounts());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت اکانتها ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const onAccountAdded = (account: AgyAccount) => {
|
||||||
|
setAccounts((current) => {
|
||||||
|
const exists = current.some((a) => a.id === account.id);
|
||||||
|
return exists
|
||||||
|
? current.map((a) => (a.id === account.id ? account : a))
|
||||||
|
: [account, ...current];
|
||||||
|
});
|
||||||
|
setLoginOpen(false);
|
||||||
|
pushToast("اکانت agy اضافه شد", account.email || undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSetActive = async (account: AgyAccount, active: boolean) => {
|
||||||
|
try {
|
||||||
|
const updated = await api.agySetAccountActive(account.id, active);
|
||||||
|
setAccounts((current) => current.map((a) => (a.id === updated.id ? updated : a)));
|
||||||
|
} catch (err) {
|
||||||
|
pushToast(
|
||||||
|
"تغییر وضعیت ناموفق",
|
||||||
|
err instanceof ApiError ? err.message : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (!toDelete) return;
|
||||||
|
try {
|
||||||
|
await api.agyDeleteAccount(toDelete.id);
|
||||||
|
setAccounts((current) => current.filter((a) => a.id !== toDelete.id));
|
||||||
|
pushToast("اکانت حذف شد", toDelete.email || undefined);
|
||||||
|
} catch (err) {
|
||||||
|
pushToast("حذف ناموفق", err instanceof ApiError ? err.message : undefined);
|
||||||
|
} finally {
|
||||||
|
setToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-lg font-medium text-slate-100">اکانتهای agy</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-slate-400">
|
||||||
|
agy کلید API ندارد: هر اکانت یک هویت Google است که از طریق OAuth وارد
|
||||||
|
میشود. هر تعداد اکانت اضافه کنید؛ پول چرخشی بار را بین آنها پخش
|
||||||
|
میکند و اکانت محدودشده را تا پایان پنجرهٔ سهمیه کنار میگذارد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setLoginOpen(true)}>افزودن اکانت agy</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoadingState label="در حال دریافت اکانتها…" />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState message={error} onRetry={load} />
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="هنوز اکانتی ثبت نشده است"
|
||||||
|
description="با دکمهٔ «افزودن اکانت agy» اولین اکانت Google را وارد کنید."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<AgyAccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onSetActive={onSetActive}
|
||||||
|
onDelete={() => setToDelete(account)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loginOpen && (
|
||||||
|
<LoginModal
|
||||||
|
onClose={() => setLoginOpen(false)}
|
||||||
|
onDone={onAccountAdded}
|
||||||
|
onError={(message) => pushToast("لاگین ناموفق", message)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toDelete !== null}
|
||||||
|
title="حذف اکانت agy"
|
||||||
|
description={
|
||||||
|
toDelete
|
||||||
|
? `اکانت «${toDelete.email || toDelete.name}» حذف شود؟ این عملیات قابل بازگشت نیست.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="حذف"
|
||||||
|
onConfirm={onDelete}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToastStack toasts={toasts} onDismiss={(id) =>
|
||||||
|
setToasts((current) => current.filter((t) => t.id !== id))
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgyAccountCard({
|
||||||
|
account,
|
||||||
|
onSetActive,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
account: AgyAccount;
|
||||||
|
onSetActive: (account: AgyAccount, active: boolean) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const [testing, setTesting] = React.useState(false);
|
||||||
|
const [testResult, setTestResult] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const onTest = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await api.agyTestAccount(account.id);
|
||||||
|
setTestResult(
|
||||||
|
result.ok ? `پاسخ دریافت شد: ${result.detail}` : `تست ناموفق: ${result.detail}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult(err instanceof ApiError ? err.message : "تست ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cooldownRemaining = React.useMemo(() => {
|
||||||
|
if (account.status !== "cooldown" || !account.cooldown_until) return null;
|
||||||
|
const ms = new Date(account.cooldown_until).getTime() - Date.now();
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
return Math.ceil(ms / 60000);
|
||||||
|
}, [account.status, account.cooldown_until]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="ltr-inline text-sm font-medium text-slate-100">
|
||||||
|
{account.email || account.name}
|
||||||
|
</p>
|
||||||
|
<Badge tone={account.is_active ? "success" : "neutral"}>
|
||||||
|
{account.is_active ? "فعال" : "غیرفعال"}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
tone={
|
||||||
|
account.status === "active"
|
||||||
|
? "info"
|
||||||
|
: account.status === "cooldown"
|
||||||
|
? "warning"
|
||||||
|
: "danger"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[account.status]}
|
||||||
|
{cooldownRemaining ? ` · ${cooldownRemaining} دقیقه` : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{account.last_used_at
|
||||||
|
? `آخرین استفاده: ${new Date(account.last_used_at).toLocaleString("fa-IR")}`
|
||||||
|
: "هنوز استفاده نشده"}
|
||||||
|
{account.last_error ? ` · ${account.last_error}` : ""}
|
||||||
|
</p>
|
||||||
|
{testResult && <p className="text-xs text-slate-400">{testResult}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" size="sm" loading={testing} onClick={onTest}>
|
||||||
|
تست
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSetActive(account, !account.is_active)}
|
||||||
|
>
|
||||||
|
{account.is_active ? "غیرفعالسازی" : "فعالسازی"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={onDelete}>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginModal({
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (account: AgyAccount) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = React.useState<"start" | "redeem">("start");
|
||||||
|
const [authUrl, setAuthUrl] = React.useState("");
|
||||||
|
const [loginId, setLoginId] = React.useState("");
|
||||||
|
const [callbackUrl, setCallbackUrl] = React.useState("");
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [formError, setFormError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const onStart = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const started = await api.agyStartLogin();
|
||||||
|
setLoginId(started.login_id);
|
||||||
|
setAuthUrl(started.auth_url);
|
||||||
|
setStep("redeem");
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof ApiError ? err.message : "شروع لاگین ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onComplete = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const account = await api.agyCompleteLogin({
|
||||||
|
login_id: loginId,
|
||||||
|
callback_url: callbackUrl.trim(),
|
||||||
|
});
|
||||||
|
onDone(account);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "تکمیل لاگین ناموفق بود.";
|
||||||
|
setFormError(message);
|
||||||
|
onError(message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="افزودن اکانت agy"
|
||||||
|
description="ورود headless با OAuth گوگل — مراحل سهگانه"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
step === "redeem" ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button form="agy-login-form" type="submit" loading={busy}>
|
||||||
|
تکمیل ورود
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onStart} loading={busy}>
|
||||||
|
دریافت لینک لاگین
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step === "start" ? (
|
||||||
|
<div className="flex flex-col gap-3 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
۱. روی «دریافت لینک لاگین» بزنید. یک آدرس احراز هویت گوگل ساخته
|
||||||
|
میشود که فقط برای این درخواست معتبر است (۱۰ دقیقه).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
۲. آن آدرس را در مرورگر باز کنید و با اکانت Google مورد نظر وارد
|
||||||
|
شوید.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
۳. بعد از تأیید، مرورگر به یک آدرس محلی منتقل میشود که عمداً
|
||||||
|
بارگذاری نمیشود. نوار آدرس را همانجا کپی کنید (آدرس کامل با
|
||||||
|
<span className="ltr-inline mx-1 font-mono text-xs">?code=…&state=…</span>)
|
||||||
|
و در گام بعدی Paste کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
این آدرس را در مرورگر باز کنید و با Google وارد شوید:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={authUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ltr-inline break-all rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 font-mono text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{authUrl}
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
بعد از تأیید در گوگل، آدرس کامل نوار آدرس مرورگر (که با
|
||||||
|
<span className="ltr-inline mx-1">oauth-callback?…</span> شروع
|
||||||
|
میشود) را در کادر زیر بچسبانید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form id="agy-login-form" onSubmit={onComplete} className="flex flex-col gap-3">
|
||||||
|
<Field
|
||||||
|
label="URL بازگشت از مرورگر"
|
||||||
|
required
|
||||||
|
value={callbackUrl}
|
||||||
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:51121/oauth-callback?code=…&state=…"
|
||||||
|
className="ltr-inline"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{formError && <ErrorState title="تکمیل لاگین ناموفق" message={formError} />}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useSession } from "@/lib/session";
|
|||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ href: "/admin", label: "نمای کلی" },
|
{ href: "/admin", label: "نمای کلی" },
|
||||||
|
{ href: "/admin/agy-accounts", label: "اکانتهای agy" },
|
||||||
{ href: "/admin/providers", label: "ارائهدهندهها" },
|
{ href: "/admin/providers", label: "ارائهدهندهها" },
|
||||||
{ href: "/admin/models", label: "مدلها" },
|
{ href: "/admin/models", label: "مدلها" },
|
||||||
{ href: "/admin/routing", label: "مسیریابی" },
|
{ href: "/admin/routing", label: "مسیریابی" },
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
|
|||||||
{ value: "anthropic", label: "Anthropic" },
|
{ value: "anthropic", label: "Anthropic" },
|
||||||
{ value: "gemini", label: "Gemini" },
|
{ value: "gemini", label: "Gemini" },
|
||||||
{ value: "openrouter", label: "OpenRouter" },
|
{ value: "openrouter", label: "OpenRouter" },
|
||||||
|
{ value: "agy", label: "agy (Google Antigravity)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminProvidersPage() {
|
export default function AdminProvidersPage() {
|
||||||
|
|||||||
@@ -438,6 +438,36 @@ export const api = {
|
|||||||
`/api/admin/providers/${providerId}/credentials`,
|
`/api/admin/providers/${providerId}/credentials`,
|
||||||
{ method: "POST", body },
|
{ method: "POST", body },
|
||||||
),
|
),
|
||||||
|
// agy accounts: OAuth login instead of API keys.
|
||||||
|
agyAccounts: () =>
|
||||||
|
apiRequest<import("@agy/contracts").AgyAccount[]>("/api/admin/agy/accounts"),
|
||||||
|
agyStartLogin: () =>
|
||||||
|
apiRequest<{ login_id: string; auth_url: string }>(
|
||||||
|
"/api/admin/agy/accounts/login/start",
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
agyCompleteLogin: (body: { login_id: string; callback_url: string }) =>
|
||||||
|
apiRequest<import("@agy/contracts").AgyAccount>(
|
||||||
|
"/api/admin/agy/accounts/login/complete",
|
||||||
|
{ method: "POST", body },
|
||||||
|
),
|
||||||
|
agySetAccountActive: (accountId: string, active: boolean) =>
|
||||||
|
apiRequest<import("@agy/contracts").AgyAccount>(
|
||||||
|
`/api/admin/agy/accounts/${accountId}/${active ? "activate" : "deactivate"}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
agyDeleteAccount: (accountId: string) =>
|
||||||
|
apiRequest<null>(`/api/admin/agy/accounts/${accountId}`, { method: "DELETE" }),
|
||||||
|
agyTestAccount: (accountId: string) =>
|
||||||
|
apiRequest<{
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
model_internal_name: string;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
|
||||||
|
agyModels: () =>
|
||||||
|
apiRequest<string[]>("/api/admin/agy/models"),
|
||||||
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
||||||
adminCreateModel: (body: {
|
adminCreateModel: (body: {
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
|
|||||||
@@ -111,3 +111,60 @@ describe("error console client", () => {
|
|||||||
expect(calls[0]).not.toContain("?");
|
expect(calls[0]).not.toContain("?");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("agy account client", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubJson(body: unknown) {
|
||||||
|
const calls: { method: string; url: string; body: string }[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
calls.push({
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
url,
|
||||||
|
body: String(init?.body ?? ""),
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("posts to the login start endpoint", async () => {
|
||||||
|
const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
|
||||||
|
await api.agyStartLogin();
|
||||||
|
expect(calls[0].method).toBe("POST");
|
||||||
|
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the pasted callback URL back verbatim", async () => {
|
||||||
|
const calls = stubJson({ id: "a1", email: "x@gmail.com" });
|
||||||
|
await api.agyCompleteLogin({
|
||||||
|
login_id: "l1",
|
||||||
|
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
|
||||||
|
});
|
||||||
|
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete");
|
||||||
|
expect(calls[0].body).toContain("callback_url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes activate/deactivate to the right verb", async () => {
|
||||||
|
const calls = stubJson({ id: "a1" });
|
||||||
|
await api.agySetAccountActive("a1", false);
|
||||||
|
await api.agySetAccountActive("a1", true);
|
||||||
|
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate");
|
||||||
|
expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes with the DELETE verb", async () => {
|
||||||
|
const calls = stubJson(null);
|
||||||
|
await api.agyDeleteAccount("a1");
|
||||||
|
expect(calls[0].method).toBe("DELETE");
|
||||||
|
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,29 @@ export type ProviderType =
|
|||||||
| "openai_compatible"
|
| "openai_compatible"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "gemini"
|
| "gemini"
|
||||||
| "openrouter";
|
| "openrouter"
|
||||||
|
| "agy";
|
||||||
|
|
||||||
|
/** Rotation-pool lifecycle of one agy account. */
|
||||||
|
export type AgyAccountStatus = "active" | "cooldown" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agy account (Google identity). Deliberately credential-free: the console
|
||||||
|
* manages accounts, never tokens.
|
||||||
|
*/
|
||||||
|
export interface AgyAccount {
|
||||||
|
id: string;
|
||||||
|
provider_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
project_id: string;
|
||||||
|
status: AgyAccountStatus;
|
||||||
|
cooldown_until: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
last_error: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** What a model can do. Routing picks models by required capability. */
|
/** What a model can do. Routing picks models by required capability. */
|
||||||
export type Capability = "text" | "vision" | "tools" | "reasoning" | "long_context" | "code";
|
export type Capability = "text" | "vision" | "tools" | "reasoning" | "long_context" | "code";
|
||||||
|
|||||||
Reference in New Issue
Block a user