به‌روزرسانی: دریافت زندهٔ کاتالوگ مدل‌ها و افورت‌های استدلال پشتیبانی‌شده بر اساس اکانت انتخابی

This commit is contained in:
mjalmoudy94
2026-10-13 07:41:00 +00:00
parent da5a2a5d1a
commit 0b72c4ec6d
10 changed files with 229 additions and 39 deletions
+97 -5
View File
@@ -37,6 +37,7 @@ import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
@@ -209,6 +210,18 @@ class AgyLoginResult:
refresh_token: str
@dataclass(slots=True, kw_only=True)
class AgyAccountModelInfo:
"""Model catalogue item as reported by an agy account."""
id: str
display_name: str
supports_thinking: bool = False
supported_efforts: list[str] = field(default_factory=list)
default_effort: str | None = None
supports_images: bool = False
class AgyOAuthError(ValidationError):
"""The pasted URL or the token exchange was rejected by Google."""
@@ -630,15 +643,18 @@ class AgyModelAdapter:
accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
if not accounts:
return []
return await self._fetch_models(accounts[0])
items = await self._fetch_models(accounts[0])
return [m.id for m in items]
async def available_models_for(self, credential: ModelCredential) -> list[str]:
async def available_models_for(
self, credential: ModelCredential
) -> list[AgyAccountModelInfo]:
"""Live model catalog of exactly one account.
Called per account selection in the admin console, so the list is
fetched fresh from Google every time (the access token is cached, the
``fetchAvailableModels`` round-trip is not) and reflects what *this*
subscription is entitled to.
subscription is entitled to along with each model's supported efforts.
"""
account = _account_of(credential)
if not account.refresh_token:
@@ -647,7 +663,7 @@ class AgyModelAdapter:
)
return await self._fetch_models(account)
async def _fetch_models(self, account: AgyAccount) -> list[str]:
async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]:
token = await self._access_token(account)
for base in BASE_URLS:
try:
@@ -663,11 +679,87 @@ class AgyModelAdapter:
)
if response.is_success:
models = response.json().get("models") or {}
return sorted(models)
return self._parse_model_catalog(models)
except httpx.HTTPError:
continue
return []
@classmethod
def _parse_model_catalog(
cls, raw_models: dict[str, Any]
) -> list[AgyAccountModelInfo]:
items: list[AgyAccountModelInfo] = []
for model_id, details in sorted(raw_models.items()):
if not isinstance(details, dict):
details = {}
# Omit internal non-generative / tab-completion preview models
if details.get("isInternal") and not details.get("displayName"):
continue
if model_id.startswith("tab_") or model_id.startswith("chat_"):
continue
display_name = str(details.get("displayName") or model_id)
supports_images = bool(details.get("supportsImages"))
supports_thinking = bool(details.get("supportsThinking"))
if not supports_thinking and _supports_thinking(model_id):
supports_thinking = True
supported_efforts = cls._supported_efforts_for(model_id, details)
default_effort = cls._default_effort_for(supported_efforts)
items.append(
AgyAccountModelInfo(
id=model_id,
display_name=display_name,
supports_thinking=supports_thinking,
supported_efforts=supported_efforts,
default_effort=default_effort,
supports_images=supports_images,
)
)
return items
@classmethod
def _supported_efforts_for(
cls, model_id: str, details: dict[str, Any]
) -> list[str]:
lowered = model_id.lower()
if "image" in lowered:
return []
if "claude" in lowered:
return []
supports_thinking = details.get("supportsThinking")
if supports_thinking is False:
return []
if supports_thinking is None and not _supports_thinking(model_id):
return []
if "gpt" in lowered:
return ["medium"]
if "pro" in lowered and "3.1" in lowered:
return ["low", "high"]
budget = details.get("thinkingBudget")
if isinstance(budget, int) and budget > 0:
if budget <= 4096:
return ["low"]
if budget <= 8192:
return ["medium"]
return ["high"]
return ["low", "medium", "high"]
@staticmethod
def _default_effort_for(supported: list[str]) -> str | None:
if not supported:
return None
if "high" in supported:
return "high"
if "medium" in supported:
return "medium"
return supported[0]
# -- internals ----------------------------------------------------------
async def _load(self) -> list[ModelCredential]:
+25 -5
View File
@@ -105,6 +105,15 @@ class AgyTestOut(BaseModel):
output_tokens: int
class AgyAccountModelOut(BaseModel):
id: str
display_name: str
supports_thinking: bool = False
supported_efforts: list[str] = Field(default_factory=list)
default_effort: str | None = None
supports_images: bool = False
# ---------------------------------------------------------------------------
# Provider helpers
# ---------------------------------------------------------------------------
@@ -331,16 +340,16 @@ async def test_agy_account(
)
@router.get("/accounts/{credential_id}/models", response_model=list[str])
@router.get("/accounts/{credential_id}/models", response_model=list[AgyAccountModelOut])
async def list_account_models(
credential_id: str,
session: AsyncSession = Depends(get_session),
) -> list[str]:
) -> list[AgyAccountModelOut]:
"""Live model catalog of one account — fetched from Google on every call.
The admin console calls this when an account is selected, so the model
list always reflects that subscription's current entitlement instead of a
cached snapshot.
list always reflects that subscription's current entitlement along with
the supported reasoning efforts for each model.
"""
from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter
@@ -352,7 +361,18 @@ async def list_account_models(
adapter = AgyModelAdapter() # stateless: one forced account
try:
return await adapter.available_models_for(credential)
models = await adapter.available_models_for(credential)
return [
AgyAccountModelOut(
id=m.id,
display_name=m.display_name,
supports_thinking=m.supports_thinking,
supported_efforts=m.supported_efforts,
default_effort=m.default_effort,
supports_images=m.supports_images,
)
for m in models
]
except ValidationError:
raise
except Exception as exc: # noqa: BLE001 - the console wants the reason
+5 -2
View File
@@ -785,7 +785,8 @@ async def test_available_models_for_one_account(google, decrypted):
models = await adapter.available_models_for(
_agy_credential(email="one@gmail.com")
)
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
assert models[1].supported_efforts == ["low", "medium", "high"]
# The list is fetched from Google every call — no cached catalog.
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
assert len(fetches) == 1
@@ -829,7 +830,9 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
data = response.json()
assert [m["id"] for m in data] == ["claude-sonnet-4-5", "gemini-3-flash"]
assert data[1]["supported_efforts"] == ["low", "medium", "high"]
missing = await client.get(
"/api/admin/agy/accounts/does-not-exist/models",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@agy/web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
"scripts": {
+50 -17
View File
@@ -6,6 +6,7 @@ import {
effortLabel,
type AdminModel,
type AgyAccount,
type AgyAccountModel,
type Capability,
type ReasoningEffort,
} from "@agy/contracts";
@@ -29,8 +30,8 @@ import { ApiError, api } from "@/lib/api";
*
* 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.
* model and a reasoning effort supported by that model, 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"];
@@ -47,7 +48,7 @@ export default function AdminModelsPage() {
// 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 [catalog, setCatalog] = React.useState<AgyAccountModel[] | null>(null);
const [catalogLoading, setCatalogLoading] = React.useState(false);
const [catalogError, setCatalogError] = React.useState<string | null>(null);
const [modelId, setModelId] = React.useState("");
@@ -135,14 +136,27 @@ export default function AdminModelsPage() {
const onModelChange = (value: string) => {
setModelId(value);
const selected = (catalog ?? []).find((m) => m.id === value);
const supported = selected ? selected.supported_efforts : effortsForModel(value);
// Pick default effort if supported by this model
if (selected?.default_effort && supported.includes(selected.default_effort)) {
setEffort(selected.default_effort);
} else if (supported.length > 0) {
setEffort(supported.includes("high") ? "high" : supported[0]);
} else {
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"],
);
}
// Auto-detect capabilities based on agy model metadata
const nextCaps: Capability[] = ["text"];
if (selected?.supports_thinking || supported.length > 0) {
nextCaps.push("reasoning");
}
if (selected?.supports_images) {
nextCaps.push("vision");
}
setCaps(nextCaps);
};
const toggleCap = (cap: Capability) => {
@@ -162,10 +176,12 @@ export default function AdminModelsPage() {
setFormError("حداقل یک توانایی انتخاب کنید.");
return;
}
const selectedModel = (catalog ?? []).find((m) => m.id === modelId);
setCreating(true);
try {
const created = await api.adminCreateModel({
internal_name: modelId,
display_name_for_admin: selectedModel?.display_name || modelId,
capabilities: caps,
input_price_per_1m: Number(inputPrice) || 0,
output_price_per_1m: Number(outputPrice) || 0,
@@ -184,13 +200,24 @@ export default function AdminModelsPage() {
}
};
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
const selectedModel = (catalog ?? []).find((m) => m.id === modelId);
const efforts: ReasoningEffort[] = selectedModel
? selectedModel.supported_efforts
: 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) }));
const modelOptions = (catalog ?? []).map((m) => ({
value: m.id,
label: m.display_name && m.display_name !== m.id ? `${m.display_name} (${m.id})` : m.id,
}));
const effortOptions = [
{ value: "", label: "پیش‌فرض سامانه" },
...efforts.map((e) => ({ value: e, label: effortLabel(e) })),
];
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -251,12 +278,18 @@ export default function AdminModelsPage() {
label="افورت استدلال"
value={effort}
onChange={(e) => setEffort(e.target.value)}
options={effortOptions}
disabled={efforts.length === 0}
options={
!modelId
? [{ value: "", label: "ابتدا یک مدل انتخاب کنید" }]
: efforts.length === 0
? [{ value: "", label: "این مدل از افورت استدلال پشتیبانی نمی‌کند" }]
: effortOptions
}
disabled={!modelId || efforts.length === 0}
hint={
efforts.length === 0
? "این مدل افورت استدلال ندارد."
: "بودجهٔ تفکر مدل؛ روی مدل‌های غیراستدلالی نادیده گرفته می‌شود."
? "این مدل افورت استدلال ندارد (مدل غیراستدلالی یا با استدلال ثابت)."
: "افورت‌های استدلال پشتیبانی‌شدهٔ همین مدل در agy."
}
/>
</div>
+18 -3
View File
@@ -447,9 +447,24 @@ export const api = {
output_tokens: number;
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
// 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`),
// the model dropdown always matches that subscription's entitlement along with
// its supported reasoning efforts.
agyAccountModels: async (accountId: string) => {
const raw = await apiRequest<(import("@agy/contracts").AgyAccountModel | string)[]>(
`/api/admin/agy/accounts/${accountId}/models`,
);
return raw.map((item): import("@agy/contracts").AgyAccountModel => {
if (typeof item === "string") {
return {
id: item,
display_name: item,
supports_thinking: false,
supported_efforts: [],
};
}
return item;
});
},
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
adminCreateModel: (body: {
internal_name: string;
+1 -1
View File
@@ -12,7 +12,7 @@
* "the app is on the wrong version" bug into a failing test, which is the
* whole point of keeping them separate.
*/
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.1.1";
/** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+16 -2
View File
@@ -169,11 +169,25 @@ describe("agy account client", () => {
});
it("fetches the live model catalog for one account", async () => {
const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]);
const calls = stubJson([
{
id: "gemini-3-flash",
display_name: "Gemini 3 Flash",
supports_thinking: true,
supported_efforts: ["low", "medium", "high"],
},
{
id: "gemini-3-pro-high",
display_name: "Gemini 3 Pro High",
supports_thinking: true,
supported_efforts: ["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");
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
});
it("creates a model from the cascade — no manual names", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smart-org",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.",
"packageManager": "pnpm@9.12.0",
+14 -1
View File
@@ -40,11 +40,14 @@ const EFFORT_LABELS: Record<ReasoningEffort, string> = {
* model cannot think — the backend rejects a thinking config everywhere else.
* Same marker rule as the agy adapter: thinking-capable ids contain one of
* `thinking` / `gemini-3` / `gemini-2.5-pro` / `gemini-2.5-flash`, image
* models are always excluded.
* and claude models are excluded from explicit effort selection.
*/
export function effortsForModel(modelId: string): ReasoningEffort[] {
const lower = modelId.toLowerCase();
if (lower.includes("image")) return [];
if (lower.includes("claude")) return [];
if (lower.includes("gpt")) return ["medium"];
if (lower.includes("gemini-3.1-pro")) return ["low", "high"];
const canThink =
lower.includes("thinking") ||
lower.includes("gemini-3") ||
@@ -57,6 +60,16 @@ export function effortLabel(effort: string): string {
return EFFORT_LABELS[effort as ReasoningEffort] ?? effort;
}
/** A live model reported by an agy account's catalogue. */
export interface AgyAccountModel {
id: string;
display_name: string;
supports_thinking: boolean;
supported_efforts: ReasoningEffort[];
default_effort?: ReasoningEffort | null;
supports_images?: boolean;
}
/** Rotation-pool lifecycle of one agy account. */
export type AgyAccountStatus = "active" | "cooldown" | "error";