agy تنها ارائه‌دهندهٔ مدل؛ حذف بخش ارائه‌دهنده‌ها و افزودن زنجیرهٔ انتخاب مدل

- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائه‌دهنده‌ها» از کنسول حذف شد؛ ورود اکانت‌ها فقط از صفحهٔ اکانت‌های agy
- صفحهٔ مدل‌ها بازطراحی شد: ارائه‌دهنده → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدل‌ها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه می‌کند و فقط به مدل‌های استدلالی می‌فرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیف‌های providerهای قدیمی
- تست‌ها: ۲۴۶ تست بک‌اند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
mjalmoudy94
2026-10-13 10:01:11 +03:30
parent a2284db4f4
commit da5a2a5d1a
29 changed files with 906 additions and 995 deletions
-1
View File
@@ -17,7 +17,6 @@ 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: "مسیریابی" },
{ href: "/admin/rate-cards", label: "تعرفه‌ها" },
+179 -45
View File
@@ -1,7 +1,14 @@
"use client";
import * as React from "react";
import type { AdminModel, AdminProvider, Capability } from "@agy/contracts";
import {
effortsForModel,
effortLabel,
type AdminModel,
type AgyAccount,
type Capability,
type ReasoningEffort,
} from "@agy/contracts";
import {
Badge,
Button,
@@ -17,17 +24,34 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
/**
* Model catalogue (platform owner only).
*
* agy is the only provider, and nothing about a model is typed by hand: the
* console asks the selected *account* for its live catalogue, the owner picks a
* model and a reasoning effort, and the rest is metadata. The model id itself
* never leaves the console — a customer only ever sees routing choices.
*/
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
/** The single provider the platform talks to. */
const PROVIDER_OPTIONS = [{ value: "agy", label: "agy (Google Antigravity)" }];
export default function AdminModelsPage() {
const [models, setModels] = React.useState<AdminModel[]>([]);
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
const [accounts, setAccounts] = React.useState<AgyAccount[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [providerId, setProviderId] = React.useState("");
const [internalName, setInternalName] = React.useState("");
const [displayName, setDisplayName] = React.useState("");
// The add-model cascade: provider -> account -> model -> effort.
const [providerId, setProviderId] = React.useState("agy");
const [accountId, setAccountId] = React.useState("");
const [catalog, setCatalog] = React.useState<string[] | null>(null);
const [catalogLoading, setCatalogLoading] = React.useState(false);
const [catalogError, setCatalogError] = React.useState<string | null>(null);
const [modelId, setModelId] = React.useState("");
const [effort, setEffort] = React.useState("");
const [caps, setCaps] = React.useState<Capability[]>(["text"]);
const [inputPrice, setInputPrice] = React.useState("");
const [outputPrice, setOutputPrice] = React.useState("");
@@ -38,13 +62,12 @@ export default function AdminModelsPage() {
setLoading(true);
setError(null);
try {
const [nextModels, nextProviders] = await Promise.all([
const [nextModels, nextAccounts] = await Promise.all([
api.adminModels(),
api.adminProviders(),
api.agyAccounts(),
]);
setModels(nextModels);
setProviders(nextProviders);
setProviderId((current) => current || nextProviders[0]?.id || "");
setAccounts(nextAccounts);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت مدل‌ها ناموفق بود.");
} finally {
@@ -56,6 +79,72 @@ export default function AdminModelsPage() {
void load();
}, [load]);
/** Reload only the accounts — the provider step of the cascade. */
const loadAccounts = React.useCallback(async () => {
try {
setAccounts(await api.agyAccounts());
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اکانت‌ها ناموفق بود.");
}
}, []);
/**
* Live catalogue of the selected account — fetched from Google on every
* account change (and on explicit refresh), never from a cached list.
*/
const loadCatalog = React.useCallback(async (id: string) => {
if (!id) {
setCatalog(null);
setCatalogError(null);
return;
}
setCatalogLoading(true);
setCatalogError(null);
try {
setCatalog(await api.agyAccountModels(id));
} catch (err) {
setCatalog(null);
setCatalogError(
err instanceof ApiError
? err.message
: "دریافت کاتالوگ مدل‌ها از agy ناموفق بود.",
);
} finally {
setCatalogLoading(false);
}
}, []);
const onProviderChange = (value: string) => {
setProviderId(value);
// One provider exists, but the flow is wired for real: picking it reloads
// the account list, which is what the next step depends on.
setAccountId("");
setModelId("");
setEffort("");
setCatalog(null);
setCatalogError(null);
void loadAccounts();
};
const onAccountChange = (value: string) => {
setAccountId(value);
setModelId("");
setEffort("");
void loadCatalog(value);
};
const onModelChange = (value: string) => {
setModelId(value);
setEffort("");
// Sensible defaults the owner can still adjust: everything answers text,
// and a thinking-capable model is a reasoning model.
setCaps(
effortsForModel(value).length > 0
? ["text", "reasoning"]
: ["text"],
);
};
const toggleCap = (cap: Capability) => {
setCaps((current) =>
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
@@ -65,8 +154,8 @@ export default function AdminModelsPage() {
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
if (!providerId) {
setFormError("ابتدا یک ارائه‌دهنده بسازید.");
if (!modelId) {
setFormError("ابتدا یک مدل از کاتالوگ اکانت انتخاب کنید.");
return;
}
if (caps.length === 0) {
@@ -76,16 +165,15 @@ export default function AdminModelsPage() {
setCreating(true);
try {
const created = await api.adminCreateModel({
provider_id: providerId,
internal_name: internalName.trim(),
display_name_for_admin: displayName.trim() || internalName.trim(),
internal_name: modelId,
capabilities: caps,
input_price_per_1m: Number(inputPrice) || 0,
output_price_per_1m: Number(outputPrice) || 0,
reasoning_effort: effort || undefined,
});
setModels((current) => [created, ...current]);
setInternalName("");
setDisplayName("");
setModelId("");
setEffort("");
setInputPrice("");
setOutputPrice("");
setCaps(["text"]);
@@ -96,53 +184,93 @@ export default function AdminModelsPage() {
}
};
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
const accountOptions = accounts.map((a) => ({
value: a.id,
label: a.email || a.name || a.id,
}));
const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id }));
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) }));
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مدل‌ها</h1>
<p className="text-sm text-slate-400">
نام داخلی مدل‌ها فقط در این کنسول دیده می‌شود. مشتری هرگز نام مدل یا ارائه‌دهنده را
نمی‌بیند.
agy تنها ارائه‌دهندهٔ سامانه است. برای افزودن مدل، اکانت را انتخاب کنید؛
کاتالوگ مدل‌ها لحظه‌ای و مستقیم از همان اکانت خوانده می‌شود. نام مدل فقط
در این کنسول دیده می‌شود و مشتری هرگز آن را نمی‌بیند.
</p>
</header>
<Card>
<CardTitle>افزودن مدل</CardTitle>
<CardHint>
قیمت‌ها به «واحد پول به ازای هر یک‌میلیون توکن» وارد می‌شوند و مبنای محاسبهٔ هزینهٔ
داخلی هستند.
قیمت‌ها به «واحد پول به ازای هر یک‌میلیون توکن» وارد می‌شوند و مبنای
محاسبهٔ هزینهٔ داخلی هستند.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="ارائه‌دهنده"
required
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
options={
providers.length === 0
? [{ value: "", label: "ارائه‌دهنده‌ای وجود ندارد" }]
: providers.map((p) => ({ value: p.id, label: p.name }))
}
onChange={(e) => onProviderChange(e.target.value)}
options={PROVIDER_OPTIONS}
/>
<Field
label="نام داخلی"
<Select
label="اکانت agy"
required
value={internalName}
onChange={(e) => setInternalName(e.target.value)}
placeholder="gpt-4o-mini"
className="ltr-inline"
/>
<Field
label="نام نمایشی برای مالک"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="مدل سریع و ارزان"
value={accountId}
onChange={(e) => onAccountChange(e.target.value)}
options={
accountOptions.length === 0
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
: accountOptions
}
hint="کاتالوگ مدل‌ها از همین اکانت خوانده می‌شود."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="مدل"
required
value={modelId}
onChange={(e) => onModelChange(e.target.value)}
options={
!accountId
? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }]
: modelOptions.length === 0
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
: modelOptions
}
disabled={!accountId || catalogLoading || modelOptions.length === 0}
/>
<Select
label="افورت استدلال"
value={effort}
onChange={(e) => setEffort(e.target.value)}
options={effortOptions}
disabled={efforts.length === 0}
hint={
efforts.length === 0
? "این مدل افورت استدلال ندارد."
: "بودجهٔ تفکر مدل؛ روی مدل‌های غیراستدلالی نادیده گرفته می‌شود."
}
/>
</div>
{(accountId && catalogLoading) || (accountId && catalog === null && !catalogError) ? (
<LoadingState label="در حال دریافت کاتالوگ مدل‌ها از اکانت…" />
) : catalogError ? (
<ErrorState
title="دریافت کاتالوگ ناموفق"
message={catalogError}
onRetry={() => void loadCatalog(accountId)}
/>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="قیمت ورودی (به ازای ۱M توکن)"
@@ -178,7 +306,7 @@ export default function AdminModelsPage() {
</div>
<div className="flex justify-end">
<Button type="submit" loading={creating}>
<Button type="submit" loading={creating} disabled={!modelId}>
افزودن مدل
</Button>
</div>
@@ -207,7 +335,7 @@ export default function AdminModelsPage() {
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">نام داخلی</th>
<th className="px-2 py-2 text-right font-medium">ارائه‌دهنده</th>
<th className="px-2 py-2 text-right font-medium">افورت</th>
<th className="px-2 py-2 text-right font-medium">توانایی‌ها</th>
<th className="px-2 py-2 text-right font-medium">ورودی</th>
<th className="px-2 py-2 text-right font-medium">خروجی</th>
@@ -219,10 +347,16 @@ export default function AdminModelsPage() {
<tr key={model.id} className="border-b border-surface-border/60">
<td className="px-2 py-2">
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
<p className="text-xs text-slate-500">{model.display_name_for_admin}</p>
<p className="text-xs text-slate-500">
{model.display_name_for_admin}
</p>
</td>
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
{providerName(model.provider_id)}
<td className="px-2 py-2">
{model.reasoning_effort ? (
<Badge tone="info">{effortLabel(model.reasoning_effort)}</Badge>
) : (
<span className="text-xs text-slate-500">پیش‌فرض</span>
)}
</td>
<td className="px-2 py-2">
<div className="flex flex-wrap gap-1">
+7 -7
View File
@@ -52,7 +52,7 @@ export default function AdminOverviewPage() {
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
<p className="text-sm text-slate-400">
مدیریت ارائه‌دهنده‌ها، مدل‌ها، سیاست مسیریابی و تعرفه‌ها.
مدیریت اکانت‌های agy، مدل‌ها، سیاست مسیریابی و تعرفه‌ها.
</p>
</header>
@@ -69,8 +69,8 @@ export default function AdminOverviewPage() {
<Card>
<CardTitle>آداپتورهای فعال</CardTitle>
<CardHint>
این مقادیر از محیط خوانده می‌شوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
متصل نشده است.
وضعیت سرویس‌های بیرونی. هر مقدار «mock» یعنی آن سرویس هنوز متصل نشده
است؛ مدل‌ها فقط از agy (Google Antigravity) سرویس می‌شوند.
</CardHint>
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
@@ -84,13 +84,13 @@ export default function AdminOverviewPage() {
<div className="grid gap-4 lg:grid-cols-2">
{[
{
title: "ارائه‌دهنده‌ها و کلیدها",
hint: "ساخت ارائه‌دهنده و ذخیرهٔ کلید رمزنگاری‌شده. کلید خام هرگز نمایش داده نمی‌شود.",
path: "/admin/providers",
title: "اکانت‌های agy",
hint: "ورود با Google و پول چرخشی اکانت‌ها. agy تنها ارائه‌دهندهٔ سامانه است.",
path: "/admin/agy-accounts",
},
{
title: "مدل‌ها",
hint: "نام داخلی، توانایی‌ها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
hint: "انتخاب مدل از کاتالوگ زندهٔ اکانت، افورت استدلال و قیمت — فقط برای مالک سامانه.",
path: "/admin/models",
},
{
@@ -1,269 +0,0 @@
"use client";
import * as React from "react";
import type { AdminCredential, AdminProvider, ProviderType } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
{ value: "mock", label: "آزمایشی (mock)" },
{ value: "openai_compatible", label: "سازگار با OpenAI" },
{ value: "anthropic", label: "Anthropic" },
{ value: "gemini", label: "Gemini" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "agy", label: "agy (Google Antigravity)" },
];
export default function AdminProvidersPage() {
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [type, setType] = React.useState<ProviderType>("openai_compatible");
const [baseUrl, setBaseUrl] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setProviders(await api.adminProviders());
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت ارائه‌دهنده‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
setCreating(true);
try {
const created = await api.adminCreateProvider({
name: name.trim(),
type,
base_url: baseUrl.trim(),
});
setProviders((current) => [created, ...current]);
setName("");
setBaseUrl("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت ارائه‌دهنده ناموفق بود.");
} finally {
setCreating(false);
}
};
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">ارائه‌دهنده‌ها</h1>
<p className="text-sm text-slate-400">
هر ارائه‌دهنده می‌تواند چند کلید داشته باشد. کلیدها رمزنگاری‌شده ذخیره می‌شوند و فقط
به‌صورت ماسک‌شده برگردانده می‌شوند.
</p>
</header>
<Card>
<CardTitle>افزودن ارائه‌دهنده</CardTitle>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Field
label="نام"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً openai-main"
className="ltr-inline"
/>
<Select
label="نوع"
value={type}
onChange={(e) => setType(e.target.value as ProviderType)}
options={PROVIDER_TYPES}
/>
<Field
label="آدرس پایه"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
className="ltr-inline"
/>
</div>
<div className="flex justify-end">
<Button type="submit" loading={creating}>
افزودن
</Button>
</div>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت ارائه‌دهنده ناموفق" message={formError} />
</div>
)}
</Card>
{loading ? (
<LoadingState label="در حال دریافت ارائه‌دهنده‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : providers.length === 0 ? (
<EmptyState
title="ارائه‌دهنده‌ای ثبت نشده است"
description="با فرم بالا اولین ارائه‌دهنده را اضافه کنید."
/>
) : (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
</div>
)}
</div>
);
}
function ProviderCard({ provider }: { provider: AdminProvider }) {
const [open, setOpen] = React.useState(false);
const [credentials, setCredentials] = React.useState<AdminCredential[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [credName, setCredName] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const loadCredentials = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setCredentials(await api.adminCredentials(provider.id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کلیدها ناموفق بود.");
} finally {
setLoading(false);
}
}, [provider.id]);
React.useEffect(() => {
if (open) void loadCredentials();
}, [open, loadCredentials]);
const onAddCredential = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
setSaving(true);
try {
const created = await api.adminCreateCredential(provider.id, {
name: credName.trim(),
api_key: apiKey.trim(),
});
// The response carries the masked form only; the plaintext is gone.
setCredentials((current) => [...current, created]);
setCredName("");
setApiKey("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ کلید ناموفق بود.");
} finally {
setSaving(false);
}
};
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">{provider.name}</p>
<Badge tone={provider.is_active ? "success" : "neutral"}>
{provider.is_active ? "فعال" : "غیرفعال"}
</Badge>
</div>
<p className="ltr-inline text-xs text-slate-500">
{provider.type}
{provider.base_url ? ` · ${provider.base_url}` : ""}
</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "بستن کلیدها" : "مدیریت کلیدها"}
</Button>
</div>
{open && (
<div className="mt-4 flex flex-col gap-4 border-t border-surface-border pt-4">
{loading ? (
<LoadingState label="در حال دریافت کلیدها…" />
) : error ? (
<ErrorState message={error} onRetry={loadCredentials} />
) : credentials.length === 0 ? (
<p className="text-xs text-slate-500">کلیدی ثبت نشده است.</p>
) : (
<ul className="flex flex-col gap-2">
{credentials.map((credential) => (
<li
key={credential.id}
className="flex items-center justify-between gap-3 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
>
<span className="text-xs text-slate-300">{credential.name}</span>
<span className="ltr-inline font-mono text-xs text-slate-500">
{credential.masked_key}
</span>
</li>
))}
</ul>
)}
<form onSubmit={onAddCredential} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="نام کلید"
required
value={credName}
onChange={(e) => setCredName(e.target.value)}
placeholder="production"
className="ltr-inline"
/>
</div>
<div className="flex-1">
<Field
label="کلید API"
required
type="password"
minLength={4}
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
hint="پس از ذخیره دیگر قابل بازیابی نیست."
className="ltr-inline"
/>
</div>
<Button type="submit" loading={saving}>
ذخیره
</Button>
</form>
{formError && <ErrorState title="ذخیرهٔ کلید ناموفق" message={formError} />}
</div>
)}
</Card>
);
}
+6 -24
View File
@@ -418,26 +418,6 @@ export const api = {
),
// --- platform owner -------------------------------------------------------
adminProviders: () =>
apiRequest<import("@agy/contracts").AdminProvider[]>("/api/admin/providers"),
adminCreateProvider: (body: {
name: string;
type: import("@agy/contracts").ProviderType;
base_url?: string;
}) =>
apiRequest<import("@agy/contracts").AdminProvider>("/api/admin/providers", {
method: "POST",
body,
}),
adminCredentials: (providerId: string) =>
apiRequest<import("@agy/contracts").AdminCredential[]>(
`/api/admin/providers/${providerId}/credentials`,
),
adminCreateCredential: (providerId: string, body: { name: string; api_key: string }) =>
apiRequest<import("@agy/contracts").AdminCredential>(
`/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"),
@@ -466,16 +446,18 @@ export const api = {
input_tokens: number;
output_tokens: number;
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
agyModels: () =>
apiRequest<string[]>("/api/admin/agy/models"),
// Live model catalog of one account — fetched from Google on every call, so
// the model dropdown always matches that subscription's entitlement.
agyAccountModels: (accountId: string) =>
apiRequest<string[]>(`/api/admin/agy/accounts/${accountId}/models`),
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
adminCreateModel: (body: {
provider_id: string;
internal_name: string;
display_name_for_admin: string;
display_name_for_admin?: string;
capabilities: import("@agy/contracts").Capability[];
input_price_per_1m?: number;
output_price_per_1m?: number;
reasoning_effort?: string;
}) =>
apiRequest<import("@agy/contracts").AdminModel>("/api/admin/models", {
method: "POST",
+3 -1
View File
@@ -27,10 +27,12 @@ export const NAV_ITEMS: NavItem[] = [
/**
* Platform-owner-only destinations. `admin/layout.tsx` mirrors this list and
* renders it as a tab strip; the API is the real security boundary either way.
* agy is the only model provider, so there is no providers section anymore —
* accounts live at `/admin/agy-accounts`.
*/
export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
{ href: "/admin/providers", label: "ارائه‌دهنده‌ها", hint: "مدل‌ها و کلیدها" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy", hint: "ورود و پول چرخشی" },
{ href: "/admin/models", label: "مدل‌ها", hint: "قابلیت و قیمت" },
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
{ href: "/admin/rate-cards", label: "تعرفه‌ها", hint: "قیمت واحدها" },
+20
View File
@@ -167,4 +167,24 @@ describe("agy account client", () => {
expect(calls[0].method).toBe("DELETE");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
});
it("fetches the live model catalog for one account", async () => {
const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]);
const models = await api.agyAccountModels("a1");
expect(calls[0].method).toBe("GET");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
expect(models).toContain("gemini-3-pro-high");
});
it("creates a model from the cascade — no manual names", async () => {
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
await api.adminCreateModel({
internal_name: "gemini-3-pro-high",
capabilities: ["text", "reasoning"],
reasoning_effort: "high",
});
expect(calls[0].url).toContain("/api/admin/models");
expect(calls[0].body).toContain("gemini-3-pro-high");
expect(calls[0].body).toContain("reasoning_effort");
});
});