افزودن ارائهدهندهٔ 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,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 = [
|
||||
{ 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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user