بهروزرسانی: دریافت زندهٔ کاتالوگ مدلها و افورتهای استدلال پشتیبانیشده بر اساس اکانت انتخابی
This commit is contained in:
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
setEffort("");
|
const selected = (catalog ?? []).find((m) => m.id === value);
|
||||||
// Sensible defaults the owner can still adjust: everything answers text,
|
const supported = selected ? selected.supported_efforts : effortsForModel(value);
|
||||||
// and a thinking-capable model is a reasoning model.
|
|
||||||
setCaps(
|
// Pick default effort if supported by this model
|
||||||
effortsForModel(value).length > 0
|
if (selected?.default_effort && supported.includes(selected.default_effort)) {
|
||||||
? ["text", "reasoning"]
|
setEffort(selected.default_effort);
|
||||||
: ["text"],
|
} 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) => {
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user