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

- Adapter اکانت‌محور با چرخش round-robin و بنچ‌شدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاری‌شدهٔ refresh token
- صفحهٔ مدیریت اکانت‌های agy در پنل ادمین به همراه تست و فعال/غیرفعال‌سازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستون‌های اکانت agy
- seed همیشه کاتالوگ agy را می‌سازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی می‌شود
This commit is contained in:
mjalmoudy94
2026-10-13 08:20:27 +03:30
parent 54074f10c3
commit 3d1e98f4fb
25 changed files with 3073 additions and 10 deletions
@@ -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>
);
}
+1
View File
@@ -16,6 +16,7 @@ import { useSession } from "@/lib/session";
const SECTIONS = [
{ href: "/admin", label: "نمای کلی" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy" },
{ href: "/admin/providers", label: "ارائه‌دهنده‌ها" },
{ href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" },
@@ -22,6 +22,7 @@ const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
{ value: "anthropic", label: "Anthropic" },
{ value: "gemini", label: "Gemini" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "agy", label: "agy (Google Antigravity)" },
];
export default function AdminProvidersPage() {
+30
View File
@@ -438,6 +438,36 @@ export const api = {
`/api/admin/providers/${providerId}/credentials`,
{ 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"),
adminCreateModel: (body: {
provider_id: string;
+57
View File
@@ -111,3 +111,60 @@ describe("error console client", () => {
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");
});
});