From 0b72c4ec6d47fe7c79b1c5c0738f733f2ed79f3b Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 07:41:00 +0000 Subject: [PATCH] =?UTF-8?q?=D8=A8=D9=87=E2=80=8C=D8=B1=D9=88=D8=B2=D8=B1?= =?UTF-8?q?=D8=B3=D8=A7=D9=86=DB=8C:=20=D8=AF=D8=B1=DB=8C=D8=A7=D9=81?= =?UTF-8?q?=D8=AA=20=D8=B2=D9=86=D8=AF=D9=87=D9=94=20=DA=A9=D8=A7=D8=AA?= =?UTF-8?q?=D8=A7=D9=84=D9=88=DA=AF=20=D9=85=D8=AF=D9=84=E2=80=8C=D9=87?= =?UTF-8?q?=D8=A7=20=D9=88=20=D8=A7=D9=81=D9=88=D8=B1=D8=AA=E2=80=8C=D9=87?= =?UTF-8?q?=D8=A7=DB=8C=20=D8=A7=D8=B3=D8=AA=D8=AF=D9=84=D8=A7=D9=84=20?= =?UTF-8?q?=D9=BE=D8=B4=D8=AA=DB=8C=D8=A8=D8=A7=D9=86=DB=8C=E2=80=8C=D8=B4?= =?UTF-8?q?=D8=AF=D9=87=20=D8=A8=D8=B1=20=D8=A7=D8=B3=D8=A7=D8=B3=20=D8=A7?= =?UTF-8?q?=DA=A9=D8=A7=D9=86=D8=AA=20=D8=A7=D9=86=D8=AA=D8=AE=D8=A7=D8=A8?= =?UTF-8?q?=DB=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/infrastructure/adapters/agy.py | 102 ++++++++++++++++++- apps/api/app/interfaces/http/routers/agy.py | 30 +++++- apps/api/tests/test_agy.py | 7 +- apps/web/package.json | 2 +- apps/web/src/app/(app)/admin/models/page.tsx | 69 +++++++++---- apps/web/src/lib/api.ts | 21 +++- apps/web/src/lib/version.ts | 2 +- apps/web/src/test/api.test.ts | 18 +++- package.json | 2 +- packages/contracts/src/index.ts | 15 ++- 10 files changed, 229 insertions(+), 39 deletions(-) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index d21bab3..649d0a6 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -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]: diff --git a/apps/api/app/interfaces/http/routers/agy.py b/apps/api/app/interfaces/http/routers/agy.py index dfe8eb5..05718b7 100644 --- a/apps/api/app/interfaces/http/routers/agy.py +++ b/apps/api/app/interfaces/http/routers/agy.py @@ -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 diff --git a/apps/api/tests/test_agy.py b/apps/api/tests/test_agy.py index bf0c745..7137adf 100644 --- a/apps/api/tests/test_agy.py +++ b/apps/api/tests/test_agy.py @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index bd359ad..17577ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index 875a41e..87cb417 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -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(null); + const [catalog, setCatalog] = React.useState(null); const [catalogLoading, setCatalogLoading] = React.useState(false); const [catalogError, setCatalogError] = React.useState(null); const [modelId, setModelId] = React.useState(""); @@ -135,14 +136,27 @@ export default function AdminModelsPage() { 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 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(""); + } + + // 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 (
@@ -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." } />
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 56ee745..4b9ea47 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(`/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("/api/admin/models"), adminCreateModel: (body: { internal_name: string; diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index a3a64e5..fc4be2f 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -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}`; diff --git a/apps/web/src/test/api.test.ts b/apps/web/src/test/api.test.ts index 09c5da8..ded234f 100644 --- a/apps/web/src/test/api.test.ts +++ b/apps/web/src/test/api.test.ts @@ -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 () => { diff --git a/package.json b/package.json index cd25ea1..a343adb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index eca6513..fdad3a0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -40,11 +40,14 @@ const EFFORT_LABELS: Record = { * 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";