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