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

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 collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse from urllib.parse import parse_qs, urlencode, urlparse
import httpx import httpx
@@ -209,6 +210,18 @@ class AgyLoginResult:
refresh_token: str 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): class AgyOAuthError(ValidationError):
"""The pasted URL or the token exchange was rejected by Google.""" """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] accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
if not accounts: if not accounts:
return [] 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. """Live model catalog of exactly one account.
Called per account selection in the admin console, so the list is Called per account selection in the admin console, so the list is
fetched fresh from Google every time (the access token is cached, the fetched fresh from Google every time (the access token is cached, the
``fetchAvailableModels`` round-trip is not) and reflects what *this* ``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) account = _account_of(credential)
if not account.refresh_token: if not account.refresh_token:
@@ -647,7 +663,7 @@ class AgyModelAdapter:
) )
return await self._fetch_models(account) 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) token = await self._access_token(account)
for base in BASE_URLS: for base in BASE_URLS:
try: try:
@@ -663,11 +679,87 @@ class AgyModelAdapter:
) )
if response.is_success: if response.is_success:
models = response.json().get("models") or {} models = response.json().get("models") or {}
return sorted(models) return self._parse_model_catalog(models)
except httpx.HTTPError: except httpx.HTTPError:
continue continue
return [] 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 ---------------------------------------------------------- # -- internals ----------------------------------------------------------
async def _load(self) -> list[ModelCredential]: async def _load(self) -> list[ModelCredential]:
+25 -5
View File
@@ -105,6 +105,15 @@ class AgyTestOut(BaseModel):
output_tokens: int 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 # 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( async def list_account_models(
credential_id: str, credential_id: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[str]: ) -> list[AgyAccountModelOut]:
"""Live model catalog of one account — fetched from Google on every call. """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 The admin console calls this when an account is selected, so the model
list always reflects that subscription's current entitlement instead of a list always reflects that subscription's current entitlement along with
cached snapshot. the supported reasoning efforts for each model.
""" """
from app.core.errors import ValidationError from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter from app.infrastructure.adapters.agy import AgyModelAdapter
@@ -352,7 +361,18 @@ async def list_account_models(
adapter = AgyModelAdapter() # stateless: one forced account adapter = AgyModelAdapter() # stateless: one forced account
try: 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: except ValidationError:
raise raise
except Exception as exc: # noqa: BLE001 - the console wants the reason 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( models = await adapter.available_models_for(
_agy_credential(email="one@gmail.com") _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. # The list is fetched from Google every call — no cached catalog.
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")] fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
assert len(fetches) == 1 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), headers=auth_header(platform_owner_token),
) )
assert response.status_code == 200, response.text 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( missing = await client.get(
"/api/admin/agy/accounts/does-not-exist/models", "/api/admin/agy/accounts/does-not-exist/models",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@agy/web", "name": "@agy/web",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.", "description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
"scripts": { "scripts": {
+50 -17
View File
@@ -6,6 +6,7 @@ import {
effortLabel, effortLabel,
type AdminModel, type AdminModel,
type AgyAccount, type AgyAccount,
type AgyAccountModel,
type Capability, type Capability,
type ReasoningEffort, type ReasoningEffort,
} from "@agy/contracts"; } 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 * 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 * 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 * model and a reasoning effort supported by that model, and the rest is metadata.
* never leaves the console — a customer only ever sees routing choices. * 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"]; 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. // The add-model cascade: provider -> account -> model -> effort.
const [providerId, setProviderId] = React.useState("agy"); const [providerId, setProviderId] = React.useState("agy");
const [accountId, setAccountId] = React.useState(""); 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 [catalogLoading, setCatalogLoading] = React.useState(false);
const [catalogError, setCatalogError] = React.useState<string | null>(null); const [catalogError, setCatalogError] = React.useState<string | null>(null);
const [modelId, setModelId] = React.useState(""); const [modelId, setModelId] = React.useState("");
@@ -135,14 +136,27 @@ export default function AdminModelsPage() {
const onModelChange = (value: string) => { const onModelChange = (value: string) => {
setModelId(value); 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(""); setEffort("");
// Sensible defaults the owner can still adjust: everything answers text, }
// and a thinking-capable model is a reasoning model.
setCaps( // Auto-detect capabilities based on agy model metadata
effortsForModel(value).length > 0 const nextCaps: Capability[] = ["text"];
? ["text", "reasoning"] if (selected?.supports_thinking || supported.length > 0) {
: ["text"], nextCaps.push("reasoning");
); }
if (selected?.supports_images) {
nextCaps.push("vision");
}
setCaps(nextCaps);
}; };
const toggleCap = (cap: Capability) => { const toggleCap = (cap: Capability) => {
@@ -162,10 +176,12 @@ export default function AdminModelsPage() {
setFormError("حداقل یک توانایی انتخاب کنید."); setFormError("حداقل یک توانایی انتخاب کنید.");
return; return;
} }
const selectedModel = (catalog ?? []).find((m) => m.id === modelId);
setCreating(true); setCreating(true);
try { try {
const created = await api.adminCreateModel({ const created = await api.adminCreateModel({
internal_name: modelId, internal_name: modelId,
display_name_for_admin: selectedModel?.display_name || modelId,
capabilities: caps, capabilities: caps,
input_price_per_1m: Number(inputPrice) || 0, input_price_per_1m: Number(inputPrice) || 0,
output_price_per_1m: Number(outputPrice) || 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) => ({ const accountOptions = accounts.map((a) => ({
value: a.id, value: a.id,
label: a.email || a.name || a.id, label: a.email || a.name || a.id,
})); }));
const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id })); const modelOptions = (catalog ?? []).map((m) => ({
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) })); 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 ( return (
<div className="anim-stagger flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
@@ -251,12 +278,18 @@ export default function AdminModelsPage() {
label="افورت استدلال" label="افورت استدلال"
value={effort} value={effort}
onChange={(e) => setEffort(e.target.value)} onChange={(e) => setEffort(e.target.value)}
options={effortOptions} options={
disabled={efforts.length === 0} !modelId
? [{ value: "", label: "ابتدا یک مدل انتخاب کنید" }]
: efforts.length === 0
? [{ value: "", label: "این مدل از افورت استدلال پشتیبانی نمی‌کند" }]
: effortOptions
}
disabled={!modelId || efforts.length === 0}
hint={ hint={
efforts.length === 0 efforts.length === 0
? "این مدل افورت استدلال ندارد." ? "این مدل افورت استدلال ندارد (مدل غیراستدلالی یا با استدلال ثابت)."
: "بودجهٔ تفکر مدل؛ روی مدل‌های غیراستدلالی نادیده گرفته می‌شود." : "افورت‌های استدلال پشتیبانی‌شدهٔ همین مدل در agy."
} }
/> />
</div> </div>
+18 -3
View File
@@ -447,9 +447,24 @@ export const api = {
output_tokens: number; output_tokens: number;
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }), }>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
// Live model catalog of one account — fetched from Google on every call, so // Live model catalog of one account — fetched from Google on every call, so
// the model dropdown always matches that subscription's entitlement. // the model dropdown always matches that subscription's entitlement along with
agyAccountModels: (accountId: string) => // its supported reasoning efforts.
apiRequest<string[]>(`/api/admin/agy/accounts/${accountId}/models`), 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"), adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
adminCreateModel: (body: { adminCreateModel: (body: {
internal_name: string; 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 * "the app is on the wrong version" bug into a failing test, which is the
* whole point of keeping them separate. * 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. */ /** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
export const APP_VERSION_LABEL = `v${APP_VERSION}`; 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 () => { 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"); const models = await api.agyAccountModels("a1");
expect(calls[0].method).toBe("GET"); expect(calls[0].method).toBe("GET");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models"); 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 () => { it("creates a model from the cascade — no manual names", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smart-org", "name": "smart-org",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.", "description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.",
"packageManager": "pnpm@9.12.0", "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. * model cannot think — the backend rejects a thinking config everywhere else.
* Same marker rule as the agy adapter: thinking-capable ids contain one of * 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 * `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[] { export function effortsForModel(modelId: string): ReasoningEffort[] {
const lower = modelId.toLowerCase(); const lower = modelId.toLowerCase();
if (lower.includes("image")) return []; 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 = const canThink =
lower.includes("thinking") || lower.includes("thinking") ||
lower.includes("gemini-3") || lower.includes("gemini-3") ||
@@ -57,6 +60,16 @@ export function effortLabel(effort: string): string {
return EFFORT_LABELS[effort as ReasoningEffort] ?? effort; 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. */ /** Rotation-pool lifecycle of one agy account. */
export type AgyAccountStatus = "active" | "cooldown" | "error"; export type AgyAccountStatus = "active" | "cooldown" | "error";