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
+6 -7
View File
@@ -27,15 +27,14 @@ JWT_SECRET=change-me
ENCRYPTION_KEY=change-me-32-bytes
# --- AI models (hidden from customers) -------------------------------------
# mock | openai_compatible | agy
AI_DEFAULT_PROVIDER=mock
OPENAI_COMPATIBLE_BASE_URL=
OPENAI_COMPATIBLE_API_KEY=
# agy (Google Antigravity) is the only model provider. The setting is kept for
# compatibility with older .env files; any value is ignored (with a warning).
AI_DEFAULT_PROVIDER=agy
# --- agy (Google Antigravity) ----------------------------------------------
# Only used when AI_DEFAULT_PROVIDER=agy. There are no API keys: accounts are
# Google identities logged in via OAuth from the admin console
# (/admin/accounts → افزودن اکانت agy). Any number of accounts can be added —
# The only model provider. There are no API keys: accounts are Google
# identities logged in via OAuth from the admin console
# (/admin/agy-accounts → افزودن اکانت agy). Any number of accounts can be added —
# the rotation pool spreads load across them and benches a rate-limited one
# until its quota window resets. AGY_CLIENT_ID/SECRET default to the public
# Antigravity CLI application; override them to use your own Google OAuth
+16 -15
View File
@@ -21,8 +21,9 @@ provider. این‌ها فقط در **Admin Console** و فقط برای مال
پلتفرم سه لایهٔ نقش دارد:
1. **مالک سامانه (platform owner)** — providerها، مدل‌ها، routing policy، تعرفه‌ها،
sandbox templateها، مشاهدهٔ کل مصرف.
1. **مالک سامانه (platform owner)** — اکانت‌های agy، مدل‌ها، routing policy،
تعرفه‌ها، sandbox templateها، مشاهدهٔ کل مصرف. agy تنها ارائه‌دهندهٔ مدل
سامانه است و بخش جداگانهٔ «ارائه‌دهنده‌ها» وجود ندارد.
2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعال‌سازی کارمند، مصرف.
3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی.
@@ -217,7 +218,6 @@ web/
| قابلیت | env | مقادیر |
|---|---|---|
| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` \| `agy` |
| Git | `GIT_PROVIDER` | `mock` \| `gitea` |
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` |
@@ -226,24 +226,25 @@ web/
اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و
adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند.
### ارائه‌دهندهٔ agy (Google Antigravity)
### ارائه‌دهندهٔ agy (Google Antigravity) — تنها ارائه‌دهندهٔ مدل
agy کلید API ندارد؛ **اکانت**‌ها هویت Google هستند و از طریق OAuth وارد
می‌شوند. برای فعال‌سازی:
می‌شوند. کاتالوگ مدل‌ها در seed ساخته می‌شود و همان اول فعال است. برای شروع:
1. `AI_DEFAULT_PROVIDER=agy` را در `.env` بگذارید و `pnpm db:seed` را دوباره
اجرا کنید (کتالوگ مدل‌های agy فعال و کاتالوگ mock غیرفعال می‌شود).
2. با حساب مالک سامانه وارد کنسول مدیریت شوید و از
`/admin/accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل
1. با حساب مالک سامانه وارد کنسول مدیریت شوید و از
`/admin/agy-accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل
باز می‌شود، بعد از تأیید، URL بازگشت را از نوار آدرس مرورگر کپی و Paste
کنید (صفحهٔ مقصد عمداً بارگذاری نمی‌شود؛ پارامترها همان‌جا هستند).
3. هر تعداد اکانت Google که بخواهید اضافه کنید. مدل‌ها را در
`/admin/models` در صورت نیاز فعال کنید.
2. در `/admin/models` زنجیرهٔ افزودن مدل کاملاً انتخابی است: ارائه‌دهنده
(agy) → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت) → افورت استدلال
(فقط برای مدل‌های استدلالی). هیچ نام مدلی دستی تایپ نمی‌شود و مشتری نام مدل
را هرگز نمی‌بیند.
پول چرخشی، بار را بین اکانت‌ها پخش می‌کند و اکانتی که ۴۲۹ گرفته تا پایان
پنجرهٔ سهمیهٔ آن کنار گذاشته می‌شود (هم در حافظه و هم در دیتابیس، تا workerها
هم همان تصویر را ببینند). refresh tokenها فقط encrypted at rest ذخیره
می‌شوند و هیچ‌وقت از API برنمی‌گردند.
هر تعداد اکانت Google که بخواهید اضافه کنید. پول چرخشی، بار را بین اکانت‌ها
پخش می‌کند و اکانتی که ۴۲۹ گرفته تا پایان پنجرهٔ سهمیهٔ آن کنار گذاشته
می‌شود (هم در حافظه و هم در دیتابیس، تا workerها همان تصویر را ببینند).
refresh tokenها فقط encrypted at rest ذخیره می‌شوند و هیچ‌وقت از API
برنمی‌گردند.
---
@@ -0,0 +1,62 @@
"""agy becomes the only model provider; models gain a reasoning effort
Two changes locked in together:
1. ``ai_models`` gains ``reasoning_effort`` (the agy thinking-budget selector
the admin console sets per model). NOT NULL with an empty-string default so
existing rows backfill to "provider default" without a rewrite.
2. Every provider row that is not ``agy`` — and the models and credentials that
hang off it — is deleted. agy is the only provider the platform talks to;
a leftover ``mock``/``openai_compatible`` provider would keep dead rows
selectable by ``pick_for_mode`` and confuse the console.
The seed reconciles this on every run, so a database that was already re-seeded
finds nothing left to do here.
Revision ID: c2d3e4f5b6a7
Revises: b1c2d3e4f5a6
Create Date: 2026-10-13 09:05:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c2d3e4f5b6a7"
down_revision: str | None = "b1c2d3e4f5a6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"ai_models",
sa.Column(
"reasoning_effort",
sa.String(length=16),
nullable=False,
server_default="",
),
)
# Children first: the FKs carry ON DELETE CASCADE, but spelling the deletes
# out keeps the intent readable and works on every backend/driver combo
# Alembic supports (including SQLite without PRAGMA introspection).
op.execute(
"DELETE FROM ai_models WHERE provider_id IN "
"(SELECT id FROM ai_providers WHERE type <> 'agy')"
)
op.execute(
"DELETE FROM ai_model_credentials WHERE provider_id IN "
"(SELECT id FROM ai_providers WHERE type <> 'agy')"
)
op.execute("DELETE FROM ai_providers WHERE type <> 'agy'")
def downgrade() -> None:
# The deleted rows are unrecoverable by design: their content was mock
# fixtures and API keys the operator would have had to paste again.
op.drop_column("ai_models", "reasoning_effort")
@@ -37,8 +37,7 @@ class ModelHealth:
@runtime_checkable
class ModelProviderPort(Protocol):
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
``OpenAICompatibleModelAdapter``."""
"""Driven port. Implemented by ``AgyModelAdapter`` (the only provider)."""
async def complete(
self,
@@ -714,8 +714,8 @@ class WorkforceService:
) -> str:
"""Run one completion and record its usage. The single model entry point."""
model = await self._resolve_model(employee)
# Pool-aware: rotation providers (agy) fan out across every usable
# account; key-based providers simply get their one active credential.
# Pool-aware: the agy adapter fans out across every usable account, so
# one account's rate limit never becomes everyone's outage.
pool = await self.credentials.list_active_for_provider(model.provider_id)
credential = pool[0] if pool else None
+4 -3
View File
@@ -60,9 +60,10 @@ class Settings(BaseSettings):
encryption_key: str = ""
# --- AI models (hidden from customers) -----------------------------------
ai_default_provider: str = "mock"
openai_compatible_base_url: str = ""
openai_compatible_api_key: str = ""
#: agy (Google Antigravity) is the only model provider the platform talks
#: to. The setting is kept so an old ``AI_DEFAULT_PROVIDER`` value in a
#: ``.env`` keeps booting: the registry warns and uses agy regardless.
ai_default_provider: str = "agy"
# --- agy (Google Antigravity) --------------------------------------------
#: OAuth client of the public Antigravity CLI application. Overridable so a
-1
View File
@@ -205,7 +205,6 @@ def _configured_secret_values() -> tuple[str, ...]:
candidates = (
settings.jwt_secret,
settings.encryption_key,
settings.openai_compatible_api_key,
settings.gitea_admin_token,
settings.daytona_api_key,
settings.e2b_api_key,
+16 -12
View File
@@ -14,13 +14,10 @@ from app.domain.base import new_id, utcnow
class ProviderType(StrEnum):
MOCK = "mock"
OPENAI_COMPATIBLE = "openai_compatible"
ANTHROPIC = "anthropic"
GEMINI = "gemini"
OPENROUTER = "openrouter"
#: Google Antigravity (``agy``) — OAuth accounts billed through a Google AI
#: subscription. Credentials of this type are *accounts*, not API keys.
#: subscription. It is the *only* provider the platform talks to; the
#: previous ``mock`` / ``openai_compatible`` kinds were removed with the
#: single-provider decision, and the migration drops their rows.
AGY = "agy"
@@ -34,6 +31,11 @@ class CredentialStatus(StrEnum):
ERROR = "error"
#: agy reasoning efforts, cheapest first. The admin console offers exactly
#: these; the adapter maps each to a thinking budget (see adapters/agy.py).
REASONING_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh")
class RoutingMode(StrEnum):
FAST = "fast"
CHEAP = "cheap"
@@ -65,7 +67,7 @@ class RateUnit(StrEnum):
@dataclass(slots=True, kw_only=True)
class Provider:
name: str
type: ProviderType = ProviderType.MOCK
type: ProviderType = ProviderType.AGY
base_url: str = ""
id: str = field(default_factory=new_id)
is_active: bool = True
@@ -74,12 +76,10 @@ class Provider:
@dataclass(slots=True, kw_only=True)
class ModelCredential:
"""One credential for a provider.
"""One credential for the agy provider — i.e. one Google *account*.
For key-based providers ``encrypted_api_key`` holds the encrypted API key and
the agy fields stay empty. For the agy provider the credential *is an
account*: ``encrypted_api_key`` holds the encrypted JSON blob
``{"refresh_token": ..., "project_id": ..., "email": ...}`` while ``email`` /
``encrypted_api_key`` holds the encrypted JSON blob
``{"refresh_token": ..., "project_id": ..., "email": ...}``, while ``email`` /
``project_id`` duplicate the identity columns so the admin console can show
and rotate accounts without touching the ciphertext.
"""
@@ -107,6 +107,10 @@ class AiModel:
capabilities: list[str] = field(default_factory=list)
input_price_per_1m: float = 0.0
output_price_per_1m: float = 0.0
#: agy reasoning effort: ``minimal | low | medium | high | xhigh`` (empty =
#: provider default). Only thinking-capable models receive it; the adapter
#: maps it to a ``thinkingConfig`` budget.
reasoning_effort: str = ""
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
+67 -6
View File
@@ -91,6 +91,33 @@ SAFETY_SETTINGS: list[dict[str, str]] = [
#: manual paste must fit inside it comfortably.
CHALLENGE_TTL_SECONDS = 600
#: agy ``reasoning_effort`` → thinking budget (tokens), mirroring the Antigravity
#: client. An unknown value falls back to ``medium`` server-side, and the UI
#: only ever offers these five.
EFFORT_THINKING_BUDGETS: dict[str, int] = {
"minimal": 1024,
"low": 4096,
"medium": 8192,
"high": 16384,
"xhigh": 32768,
}
#: Where thinking is honoured. A request must never carry ``thinkingConfig`` to
#: a model that does not support it — that is a hard 400 from the backend.
_THINKING_MARKERS: tuple[str, ...] = (
"thinking",
"gemini-3",
"gemini-2.5-pro",
"gemini-2.5-flash",
)
def _supports_thinking(model_name: str) -> bool:
lowered = model_name.lower()
if "image" in lowered:
return False
return any(marker in lowered for marker in _THINKING_MARKERS)
_StatusFn = Callable[..., Awaitable[None]]
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
@@ -603,7 +630,25 @@ class AgyModelAdapter:
accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
if not accounts:
return []
token = await self._access_token(accounts[0])
return await self._fetch_models(accounts[0])
async def available_models_for(self, credential: ModelCredential) -> list[str]:
"""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.
"""
account = _account_of(credential)
if not account.refresh_token:
raise ProviderUnavailableError(
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
)
return await self._fetch_models(account)
async def _fetch_models(self, account: AgyAccount) -> list[str]:
token = await self._access_token(account)
for base in BASE_URLS:
try:
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -713,7 +758,8 @@ class AgyModelAdapter:
) -> ModelResponse:
token = await self._access_token(account)
payload = _build_generate_payload(
account, model.internal_name, messages, system, temperature, max_tokens
account, model.internal_name, messages, system, temperature, max_tokens,
reasoning_effort=model.reasoning_effort,
)
headers = {
"Authorization": f"Bearer {token}",
@@ -775,6 +821,7 @@ def _build_generate_payload(
system: str,
temperature: float,
max_tokens: int,
reasoning_effort: str = "",
) -> dict:
"""Translate the port's OpenAI-shaped call into the Antigravity envelope."""
system_parts: list[str] = [system] if system else []
@@ -786,13 +833,27 @@ def _build_generate_payload(
role = "model" if message.role == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": message.content}]})
generation: dict = {
"temperature": temperature,
"maxOutputTokens": max_tokens,
}
# Reasoning effort is only sent to models that can think; the backend
# rejects ``thinkingConfig`` everywhere else with a 400.
if reasoning_effort and _supports_thinking(model_name):
budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192)
generation["thinkingConfig"] = {
"thinkingBudget": budget,
"includeThoughts": True,
}
if max_tokens <= budget:
# The budget must fit inside the answer window or the thinking
# model has nowhere to emit its final answer.
generation["maxOutputTokens"] = budget + 8000
request: dict = {
"contents": contents,
"sessionId": _random_session_id(),
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens,
},
"generationConfig": generation,
"toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}},
"safetySettings": SAFETY_SETTINGS,
}
+3 -58
View File
@@ -1,13 +1,13 @@
"""Mock adapters — every port has one so the stack runs with zero credentials.
"""Mock adapters for the non-AI ports, so the stack runs with zero credentials.
These are not toys: they enforce the same contracts (timeouts, output caps,
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
change application behaviour.
change application behaviour. The model port has no mock — ``agy`` is the only
model provider (see ``adapters/agy.py``).
"""
from __future__ import annotations
import asyncio
import hashlib
import secrets
import time
@@ -17,14 +17,11 @@ from typing import Any
from app.application.ports import (
BranchInfo,
ChatMessage,
CheckoutSession,
CommandResult,
CommitInfo,
GitCredentials,
MergeResult,
ModelHealth,
ModelResponse,
Notification,
PaymentResult,
PreviewDomainInfo,
@@ -38,7 +35,6 @@ from app.application.ports import (
VectorHit,
WorkspaceInfo,
)
from app.domain.ai_control.models import AiModel
from app.domain.platform.rules import (
assert_port_allocatable,
build_host,
@@ -46,57 +42,6 @@ from app.domain.platform.rules import (
validate_proxy_target,
)
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class MockModelProviderAdapter:
"""Deterministic, offline stand-in for an LLM.
Returns a canned Persian acknowledgement that names the task so tests can
assert on it without a network call.
"""
def __init__(self, latency_ms: int = 0) -> None:
self._latency_ms = latency_ms
async def complete(
self,
*,
model: AiModel,
credential=None,
credentials=None, # accepted for port parity; the mock has no pool
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
started = time.perf_counter()
if self._latency_ms:
await asyncio.sleep(self._latency_ms / 1000)
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# The canned text must read like model output, not like a debug tag: the
# provider/model identity is platform-internal and must never surface in
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"mock": True},
)
async def health(self) -> ModelHealth:
return ModelHealth(ok=True, detail="mock provider")
# ---------------------------------------------------------------------------
# Git
# ---------------------------------------------------------------------------
@@ -1,98 +0,0 @@
"""Real model adapter for any OpenAI-compatible endpoint.
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
API key is read from the credential record (decrypted by the caller), never from
the environment at request time.
"""
from __future__ import annotations
import time
import httpx
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel, ModelCredential
class OpenAICompatibleModelAdapter:
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
self.base_url = (base_url or "").rstrip("/")
self.default_api_key = default_api_key
self.timeout = timeout
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
if credential is not None and credential.encrypted_api_key:
try:
return decrypt(credential.encrypted_api_key)
except Exception: # noqa: BLE001 - fall through to the env default
pass
return self.default_api_key
async def complete(
self,
*,
model: AiModel,
credential: ModelCredential | None = None,
credentials: list[ModelCredential] | None = None, # no pool here
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
if not self.base_url:
raise ProviderUnavailableError(
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
)
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
{"role": m.role, "content": m.content} for m in messages
]
payload = {
"model": model.internal_name,
"messages": payload_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {"Content-Type": "application/json"}
api_key = self.default_api_key
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/chat/completions", json=payload, headers=headers
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise ProviderUnavailableError(
f"ارائه‌دهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
) from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("اتصال به ارائه‌دهندهٔ مدل برقرار نشد.") from exc
choice = (data.get("choices") or [{}])[0]
content = (choice.get("message") or {}).get("content", "") or ""
usage = data.get("usage") or {}
return ModelResponse(
content=content,
model_internal_name=data.get("model", model.internal_name),
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"id": data.get("id", "")},
)
async def health(self) -> ModelHealth:
if not self.base_url:
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{self.base_url}/models")
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
except httpx.HTTPError as exc:
return ModelHealth(ok=False, detail=str(exc))
@@ -2,7 +2,9 @@
One place decides which concrete adapter backs each port, driven purely by the
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
clone runs with no credentials.
clone runs with no credentials — *except* the model provider: ``agy`` (Google
Antigravity) is the only one the platform talks to, and its accounts are added
from the admin console.
Adapters that hold process state (the mock Git forge, the mock port ledger) are
cached as singletons so behaviour is consistent within a process.
@@ -10,25 +12,28 @@ cached as singletons so behaviour is consistent within a process.
from __future__ import annotations
import logging
from functools import lru_cache
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.infrastructure.adapters import mock
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
logger = logging.getLogger("agy.registry")
@lru_cache
def get_model_provider():
provider = settings.ai_default_provider.lower()
if provider == "mock":
return mock.MockModelProviderAdapter()
if provider == "openai_compatible":
return OpenAICompatibleModelAdapter(
base_url=settings.openai_compatible_base_url,
default_api_key=settings.openai_compatible_api_key,
"""The one model adapter: agy over its logged-in account pool."""
configured = settings.ai_default_provider.strip().lower()
if configured != "agy":
# Tolerate a stale ``AI_DEFAULT_PROVIDER`` from before agy became the
# only provider: warn loudly, keep serving with agy either way.
logger.warning(
"AI_DEFAULT_PROVIDER=%r is obsolete — agy is the only supported "
"model provider; using it",
settings.ai_default_provider,
)
if provider == "agy":
from app.infrastructure.adapters.agy import (
AgyModelAdapter,
load_agy_accounts,
@@ -36,10 +41,6 @@ def get_model_provider():
)
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
raise ProviderUnavailableError(
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | openai_compatible | agy"
)
@lru_cache
+6 -1
View File
@@ -115,7 +115,10 @@ class AiProviderRow(Base):
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
#: agy is the only provider type; the column stays a string so old rows
#: (mock / openai_compatible, deleted by migration c2d3e4f5b6a7) cannot
#: break the enum mapping.
type: Mapped[str] = mapped_column(String(32), default="agy", nullable=False)
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
@@ -160,6 +163,8 @@ class AiModelRow(Base):
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
#: agy reasoning effort ("" = provider default); see ``AiModel``.
reasoning_effort: Mapped[str] = mapped_column(String(16), default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
@@ -33,8 +33,14 @@ from app.infrastructure.db.models import (
def _provider_to_domain(row: AiProviderRow) -> Provider:
# agy is the only provider kind; a legacy row (mock / openai_compatible,
# deleted by migration) still maps to the one enum member rather than
# raising, so a half-migrated database reads cleanly.
provider_type = (
ProviderType(row.type) if row.type == ProviderType.AGY.value else ProviderType.AGY
)
return Provider(
id=row.id, name=row.name, type=ProviderType(row.type),
id=row.id, name=row.name, type=provider_type,
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
)
@@ -57,6 +63,7 @@ def _model_to_domain(row: AiModelRow) -> AiModel:
capabilities=list(row.capabilities or []),
input_price_per_1m=row.input_price_per_1m,
output_price_per_1m=row.output_price_per_1m,
reasoning_effort=row.reasoning_effort or "",
is_active=row.is_active, created_at=row.created_at,
)
@@ -267,6 +274,7 @@ class AiModelRepository:
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort,
is_active=model.is_active, created_at=model.created_at,
)
self.session.add(row)
+41 -162
View File
@@ -1,11 +1,12 @@
"""Platform owner console: providers, models, routing policies and rate cards.
"""Platform owner console: agy models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced
here and tested:
1. Only a platform owner may call any of it.
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
a nested object. Only ``masked_key`` leaves this module.
2. Model identity stays inside the console: a customer never receives a raw
model or account name, and no credential material (a Google refresh token)
is ever returned — only the agy account metadata is, from the agy router.
"""
from __future__ import annotations
@@ -17,15 +18,12 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.crypto import encrypt, mask
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
REASONING_EFFORTS,
AiModel,
Capability,
ModelCredential,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
@@ -36,7 +34,6 @@ from app.domain.base import utcnow
from app.domain.identity.models import User
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
ProviderRepository,
RateCardRepository,
RoutingPolicyRepository,
@@ -73,38 +70,6 @@ router = APIRouter(
# ---------------------------------------------------------------------------
class ProviderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
type: str
base_url: str
is_active: bool
created_at: datetime
class CreateProviderRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
type: ProviderType = ProviderType.MOCK
base_url: str = Field(default="", max_length=512)
class CredentialOut(BaseModel):
"""Deliberately has no field capable of holding the plaintext key."""
id: str
provider_id: str
name: str
masked_key: str
is_active: bool
class CreateCredentialRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
api_key: str = Field(min_length=4, max_length=512)
class ModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -115,16 +80,19 @@ class ModelOut(BaseModel):
capabilities: list[str]
input_price_per_1m: float
output_price_per_1m: float
reasoning_effort: str
is_active: bool
class CreateModelRequest(BaseModel):
provider_id: str
#: The provider is no longer a client choice — agy is the only one, resolved
#: server-side. The model id must come from the account's live catalog.
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(default="", max_length=160)
capabilities: list[Capability] = Field(default_factory=list)
input_price_per_1m: float = Field(default=0.0, ge=0)
output_price_per_1m: float = Field(default=0.0, ge=0)
reasoning_effort: str = Field(default="", max_length=16)
class RoutingPolicyOut(BaseModel):
@@ -180,124 +148,29 @@ class AdminUsageRow(BaseModel):
total_price: float
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
providers = await ProviderRepository(session).list()
return [
ProviderOut(
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
is_active=p.is_active, created_at=p.created_at,
)
for p in providers
]
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
async def create_provider(
payload: CreateProviderRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ProviderOut:
repo = ProviderRepository(session)
if await repo.get_by_name(payload.name) is not None:
raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.")
provider = await repo.create(
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="provider", target_id=provider.id,
metadata={"name": provider.name, "type": provider.type.value},
ip=ip, user_agent=ua,
)
return ProviderOut(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=provider.is_active,
created_at=provider.created_at,
)
@router.post(
"/providers/{provider_id}/credentials",
response_model=CredentialOut,
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
provider_id: str,
payload: CreateCredentialRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CredentialOut:
provider = await ProviderRepository(session).get(provider_id)
if provider is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
credential = await ModelCredentialRepository(session).create(
ModelCredential(
provider_id=provider_id,
name=payload.name,
# Encrypted at rest; the plaintext exists only inside this request.
encrypted_api_key=encrypt(payload.api_key),
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="credential", target_id=credential.id,
metadata={"provider_id": provider_id, "name": credential.name},
ip=ip, user_agent=ua,
)
return CredentialOut(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
masked_key=mask(payload.api_key), is_active=credential.is_active,
)
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
async def list_credentials(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> list[CredentialOut]:
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
return [
CredentialOut(
id=c.id, provider_id=c.provider_id, name=c.name,
# We cannot recover the plaintext to mask it — and we must not try.
masked_key="••••••",
is_active=c.is_active,
)
for c in credentials
]
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
def _model_out(model: AiModel) -> ModelOut:
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort, is_active=model.is_active,
)
@router.get("/models", response_model=list[ModelOut])
async def list_models(
provider_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[ModelOut]:
models = await AiModelRepository(session).list(provider_id=provider_id)
return [
ModelOut(
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
display_name_for_admin=m.display_name_for_admin,
capabilities=list(m.capabilities),
input_price_per_1m=m.input_price_per_1m,
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
)
for m in models
]
return [_model_out(m) for m in models]
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
@@ -307,33 +180,39 @@ async def create_model(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ModelOut:
if await ProviderRepository(session).get(payload.provider_id) is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
from app.interfaces.http.routers.agy import ensure_agy_provider
model = await AiModelRepository(session).create(
effort = payload.reasoning_effort.strip().lower()
if effort and effort not in REASONING_EFFORTS:
raise ValidationError(
"افورت نامعتبر است. مقادیر مجاز: " + " | ".join(REASONING_EFFORTS)
)
provider = await ensure_agy_provider(ProviderRepository(session))
models = AiModelRepository(session)
existing = await models.list(provider_id=provider.id)
if any(m.internal_name == payload.internal_name for m in existing):
raise ConflictError("مدلی با این نام داخلی از قبل ثبت شده است.")
model = await models.create(
AiModel(
provider_id=payload.provider_id,
provider_id=provider.id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin,
display_name_for_admin=payload.display_name_for_admin or payload.internal_name,
capabilities=[str(c) for c in payload.capabilities],
input_price_per_1m=payload.input_price_per_1m,
output_price_per_1m=payload.output_price_per_1m,
reasoning_effort=effort,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model.id,
metadata={"internal_name": model.internal_name},
metadata={"internal_name": model.internal_name, "reasoning_effort": effort},
ip=ip, user_agent=ua,
)
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
)
return _model_out(model)
# ---------------------------------------------------------------------------
+27 -11
View File
@@ -110,7 +110,7 @@ class AgyTestOut(BaseModel):
# ---------------------------------------------------------------------------
async def _ensure_agy_provider(repo: ProviderRepository):
async def ensure_agy_provider(repo: ProviderRepository):
provider = await repo.get_by_name(AGY_PROVIDER_NAME)
if provider is not None:
return provider
@@ -176,7 +176,7 @@ async def complete_agy_login(
providers = ProviderRepository(session)
credentials = ModelCredentialRepository(session)
provider = await _ensure_agy_provider(providers)
provider = await ensure_agy_provider(providers)
envelope = json.dumps(
{
@@ -331,13 +331,29 @@ async def test_agy_account(
)
@router.get("/models", response_model=list[str])
async def list_agy_models() -> list[str]:
"""Model ids the logged-in agy subscription exposes (registry helper)."""
from app.infrastructure.adapters.registry import get_model_provider
@router.get("/accounts/{credential_id}/models", response_model=list[str])
async def list_account_models(
credential_id: str,
session: AsyncSession = Depends(get_session),
) -> list[str]:
"""Live model catalog of one account — fetched from Google on every call.
provider = get_model_provider()
lister = getattr(provider, "available_models", None)
if lister is None:
return []
return await lister()
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.
"""
from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter
repo = ModelCredentialRepository(session)
credential = await repo.get(credential_id)
if credential is None:
raise NotFoundError("اکانت agy یافت نشد.")
adapter = AgyModelAdapter() # stateless: one forced account
try:
return await adapter.available_models_for(credential)
except ValidationError:
raise
except Exception as exc: # noqa: BLE001 - the console wants the reason
raise ValidationError(str(exc)) from exc
+31 -85
View File
@@ -2,9 +2,9 @@
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
organization that both of them own, the system agent templates, routing
policies, rate cards and a mock provider with two models. The agy (Google
Antigravity) provider and its model catalog are seeded too; agy models become
routable only when ``AI_DEFAULT_PROVIDER=agy``.
policies, rate cards and the agy (Google Antigravity) provider with its model
catalog. agy is the only model provider the platform talks to; accounts are
added from the admin console.
Safe to run repeatedly: every step checks for existing rows first.
@@ -20,7 +20,6 @@ import logging
from sqlalchemy import select
from app.core.config import settings
from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password
from app.domain.ai_control.models import (
@@ -64,14 +63,17 @@ DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org"
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
#: the Antigravity backend expects; the display name is admin-only. Seeded for
#: every deployment, activated for routing only when agy is the default
#: provider — see the seed step that consumes this table.
AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
#: the Antigravity backend expects; the display name is admin-only. The 4th
#: element is the reasoning effort (empty = provider default) — the console can
#: change it per model, and the adapter only sends it to thinking-capable
#: models. Seeded for every deployment and reconciled on *every* run, so a
#: database created by an older version converges without being rebuilt.
AGY_MODELS: list[tuple[str, str, list[Capability], str]] = [
(
"gemini-3-flash",
"Gemini 3 Flash",
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
"",
),
(
"gemini-3-pro-high",
@@ -80,23 +82,27 @@ AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
Capability.TEXT, Capability.REASONING, Capability.CODE,
Capability.VISION, Capability.LONG_CONTEXT,
],
"high",
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
"",
),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
[Capability.TEXT, Capability.CODE, Capability.REASONING],
"high",
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
[Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT],
"high",
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE]),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE], ""),
]
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
@@ -270,51 +276,11 @@ async def seed() -> None:
# as either one lands on a populated dashboard rather than an empty one.
await _ensure_org(session, members=[owner, demo])
if not (await session.execute(select(AiProviderRow))).scalars().first():
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
session.add(
AiProviderRow(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=True,
)
)
await session.flush()
for internal, display, caps in (
("mock-fast", "Mock Fast", [Capability.TEXT]),
(
"mock-reasoning",
"Mock Reasoning",
[Capability.TEXT, Capability.REASONING, Capability.CODE],
),
):
model = AiModel(
provider_id=provider.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
input_price_per_1m=0.3,
output_price_per_1m=1.2,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=True,
)
)
logger.info("created mock provider + 2 models")
# The agy catalog (Google Antigravity). It is seeded for every
# deployment — the admin console needs the provider and its models to
# exist before any account is logged in. Routing activation is
# reconciled on *every* seed run below, not only at insert time, so
# flipping AI_DEFAULT_PROVIDER and re-seeding reshapes routing without
# a fresh database.
agy_is_default = settings.ai_default_provider.strip().lower() == "agy"
# The agy catalog (Google Antigravity). agy is the only model provider,
# so the provider row and its models are created on a first run and
# reconciled on every later one: display names, capabilities, efforts
# and activation follow this table, and a model removed here stays in
# the database (neither table nor row deletion is safe for a seed).
agy_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "agy")
@@ -328,7 +294,7 @@ async def seed() -> None:
)
session.add(agy_provider_row)
await session.flush()
for internal, display, caps in AGY_MODELS:
for internal, display, caps, effort in AGY_MODELS:
existing = (
await session.execute(
select(AiModelRow).where(
@@ -338,6 +304,12 @@ async def seed() -> None:
)
).scalars().first()
if existing is not None:
# Reconcile: an older database has these rows with different
# metadata (efforts did not exist, capabilities moved).
existing.display_name_for_admin = display
existing.capabilities = [str(c) for c in caps]
existing.reasoning_effort = effort
existing.is_active = True
continue
model = AiModel(
provider_id=agy_provider_row.id,
@@ -349,6 +321,7 @@ async def seed() -> None:
# customer-facing price on the rate card.
input_price_per_1m=0.0,
output_price_per_1m=0.0,
reasoning_effort=effort,
)
session.add(
AiModelRow(
@@ -358,39 +331,12 @@ async def seed() -> None:
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=False,
reasoning_effort=model.reasoning_effort,
is_active=True,
)
)
# Reconcile routing with the configured default on every run: agy models
# are routable only when agy is the default, and the mock catalog must
# not win when it is not — a model without accounts fails at call time.
agy_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id
)
)
).scalars()
for row in agy_model_rows:
row.is_active = agy_is_default
if agy_is_default:
mock_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "mock")
)
).scalars().first()
if mock_provider_row is not None:
mock_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == mock_provider_row.id
)
)
).scalars()
for row in mock_model_rows:
row.is_active = False
logger.info(
"agy is the default provider; agy catalog activated "
"agy is the only model provider; catalog active "
"(accounts are added from the admin console)"
)
+110 -13
View File
@@ -1,7 +1,10 @@
"""Shared test fixtures.
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
suite needs no Postgres, no Redis and no credentials.
Tests run against an in-memory SQLite database with the real agy-shaped adapter
wiring and a fake model provider, so the suite needs no Postgres, no Redis, no
Google and no accounts. agy is the only model provider the platform talks to
(see ``app/infrastructure/adapters/registry.py``); the fake keeps completions
deterministic and offline.
"""
from __future__ import annotations
@@ -96,6 +99,76 @@ async def client(engine, session_factory):
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Fake model provider — the only sanctioned stand-in for agy in tests
# ---------------------------------------------------------------------------
class _FakeModelProvider:
"""Offline stand-in for the agy adapter.
The canned text must read like model output, not like a debug tag: the
provider/model identity is platform-internal and must never surface in
anything a customer can read (see the e2e smoke test).
"""
async def complete(
self,
*,
model,
messages,
system="",
temperature=0.2,
max_tokens=2048,
credential=None,
credentials=None,
):
import asyncio
import time
from app.application.ports import ModelResponse
started = time.perf_counter()
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# A tiny sleep so latency bookkeeping runs a real code path.
await asyncio.sleep(0)
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={},
)
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=True, detail="fake agy pool")
@pytest.fixture(autouse=True)
def _fake_model_provider(monkeypatch):
"""Swap the real model adapter out of every request path.
``container`` and the health router bind ``get_model_provider`` at import
time, so both import sites are patched; without this a completion test
would open the developer's real database and reach the real Google API.
"""
from app.application import container as di_container
from app.interfaces.http.routers import health as health_router
provider = _FakeModelProvider()
monkeypatch.setattr(di_container, "get_model_provider", lambda: provider)
monkeypatch.setattr(health_router, "get_model_provider", lambda: provider)
return provider
async def signup(
client: AsyncClient, email: str, password: str = "password123", name: str = ""
) -> dict:
@@ -149,10 +222,31 @@ SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
]
#: The mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
("mock-fast", "Mock Fast", ["text"]),
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "code"]),
#: The agy models ``app/seed.py`` installs: (internal_name, display,
#: capabilities, reasoning_effort). agy is the only provider, so this is the
#: whole catalogue.
SYSTEM_MODELS: list[tuple[str, str, list[str], str]] = [
("gemini-3-flash", "Gemini 3 Flash", ["text", "code", "long_context"], ""),
(
"gemini-3-pro-high",
"Gemini 3 Pro",
["text", "reasoning", "code", "vision", "long_context"],
"high",
),
("claude-sonnet-4-5", "Claude Sonnet 4.5", ["text", "code", "tools"], ""),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
["text", "code", "reasoning"],
"high",
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
["text", "reasoning", "code", "long_context"],
"high",
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", ["text", "code"], ""),
]
#: The rate cards ``app/seed.py`` installs: (unit, customer_price, internal_cost).
@@ -174,11 +268,11 @@ SYSTEM_SANDBOX_TEMPLATES: list[tuple[str, str, str]] = [
async def seeded(session_factory):
"""Install the platform-level seed rows a fresh install would have.
Agent templates, routing policies, the mock provider/models, the rate cards
and the sandbox templates are all *platform* data (no organization owns
them), so without this an employee cannot be created, a run cannot resolve a
model to route to, usage cannot be priced and no sandbox can be started —
none of which the real seeded install lacks.
Agent templates, routing policies, the agy provider with its model catalog,
the rate cards and the sandbox templates are all *platform* data (no
organization owns them), so without this an employee cannot be created, a
run cannot resolve a model to route to, usage cannot be priced and no
sandbox can be started — none of which the real seeded install lacks.
"""
from app.infrastructure.db.models import (
AgentTemplateRow,
@@ -214,9 +308,11 @@ async def seeded(session_factory):
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
provider_id = new_id()
session.add(
AiProviderRow(id=provider_id, name="mock", type="mock", base_url="", is_active=True)
AiProviderRow(
id=provider_id, name="agy", type="agy", base_url="", is_active=True
)
for internal, display, capabilities in SYSTEM_MODELS:
)
for internal, display, capabilities, effort in SYSTEM_MODELS:
session.add(
AiModelRow(
provider_id=provider_id,
@@ -225,6 +321,7 @@ async def seeded(session_factory):
capabilities=capabilities,
input_price_per_1m=0.3,
output_price_per_1m=1.2,
reasoning_effort=effort,
is_active=True,
)
)
+42 -94
View File
@@ -3,7 +3,9 @@
Two properties matter more than any feature here:
1. A normal customer can never reach these endpoints.
2. A provider API key can never come back out of the API.
2. Model identity and account material stay inside the console — a customer
never sees a raw model, and nothing that could act as a credential (a Google
refresh token, an API key) ever comes back out.
"""
from __future__ import annotations
@@ -12,146 +14,92 @@ from httpx import AsyncClient
from tests.conftest import auth_header, signup
SECRET_KEY = "sk-super-secret-value-do-not-leak-1234567890"
async def test_normal_user_cannot_list_providers(client: AsyncClient):
async def test_normal_user_cannot_list_models(client: AsyncClient):
token = (await signup(client, "peon@example.com"))["access_token"]
response = await client.get("/api/admin/providers", headers=auth_header(token))
response = await client.get("/api/admin/models", headers=auth_header(token))
assert response.status_code == 403
assert response.json()["error"]["code"] == "permission_denied"
async def test_normal_user_cannot_create_provider(client: AsyncClient):
async def test_normal_user_cannot_create_model(client: AsyncClient):
token = (await signup(client, "peon2@example.com"))["access_token"]
response = await client.post(
"/api/admin/providers",
json={"name": "evil", "type": "mock", "base_url": ""},
"/api/admin/models",
json={"internal_name": "evil-model"},
headers=auth_header(token),
)
assert response.status_code == 403
async def test_admin_endpoints_require_authentication(client: AsyncClient):
response = await client.get("/api/admin/providers")
response = await client.get("/api/admin/models")
assert response.status_code == 401
async def test_platform_owner_can_create_and_list_provider(
async def test_model_creation_resolves_the_agy_provider_itself(
client: AsyncClient, platform_owner_token: str
):
"""The provider is not a client choice: agy is the only one."""
created = await client.post(
"/api/admin/providers",
json={"name": "openrouter", "type": "openrouter", "base_url": "https://openrouter.ai/api/v1"},
"/api/admin/models",
json={
"internal_name": "gemini-3-pro-high",
"capabilities": ["text", "reasoning"],
"input_price_per_1m": 0.3,
"output_price_per_1m": 1.2,
"reasoning_effort": "high",
},
headers=auth_header(platform_owner_token),
)
assert created.status_code == 201, created.text
assert created.json()["name"] == "openrouter"
body = created.json()
assert body["internal_name"] == "gemini-3-pro-high"
# The display name falls back to the model id; nothing is typed twice.
assert body["display_name_for_admin"] == "gemini-3-pro-high"
assert body["reasoning_effort"] == "high"
assert body["provider_id"], "the agy provider must be resolved server-side"
listing = await client.get("/api/admin/providers", headers=auth_header(platform_owner_token))
listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
assert [p["name"] for p in listing.json()] == ["openrouter"]
assert [m["internal_name"] for m in listing.json()] == ["gemini-3-pro-high"]
async def test_duplicate_provider_name_is_rejected(client: AsyncClient, platform_owner_token: str):
payload = {"name": "dupe", "type": "mock", "base_url": ""}
async def test_duplicate_model_name_is_rejected(client: AsyncClient, platform_owner_token: str):
payload = {"internal_name": "claude-sonnet-4-5-thinking", "reasoning_effort": "high"}
first = await client.post(
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
"/api/admin/models", json=payload, headers=auth_header(platform_owner_token)
)
assert first.status_code == 201
second = await client.post(
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
"/api/admin/models", json=payload, headers=auth_header(platform_owner_token)
)
assert second.status_code == 409
async def test_credential_creation_returns_only_a_masked_key(
async def test_unknown_reasoning_effort_is_rejected(
client: AsyncClient, platform_owner_token: str
):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p1", "type": "openai_compatible", "base_url": "https://x.test/v1"},
headers=auth_header(platform_owner_token),
)
).json()
response = await client.post(
f"/api/admin/providers/{provider['id']}/credentials",
json={"name": "primary", "api_key": SECRET_KEY},
"/api/admin/models",
json={"internal_name": "gemini-3-flash", "reasoning_effort": "ultra"},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 201, response.text
body = response.json()
assert SECRET_KEY not in str(body)
assert body["masked_key"].startswith("•")
assert body["masked_key"].endswith(SECRET_KEY[-4:])
assert response.status_code == 422
async def test_listing_credentials_never_exposes_the_key(
async def test_agy_account_and_model_endpoints_are_owner_only(
client: AsyncClient, platform_owner_token: str
):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p2", "type": "openai_compatible", "base_url": "https://y.test/v1"},
headers=auth_header(platform_owner_token),
)
).json()
await client.post(
f"/api/admin/providers/{provider['id']}/credentials",
json={"name": "primary", "api_key": SECRET_KEY},
headers=auth_header(platform_owner_token),
)
"""The account catalog is owner-only and never carries token material."""
token = (await signup(client, "peon3@example.com"))["access_token"]
response = await client.get(
f"/api/admin/providers/{provider['id']}/credentials",
headers=auth_header(platform_owner_token),
"/api/admin/agy/accounts/whatever/models", headers=auth_header(token)
)
assert response.status_code == 200
assert SECRET_KEY not in response.text
assert response.json()[0]["masked_key"] == "••••••"
assert response.status_code == 403
async def test_model_creation_and_listing(client: AsyncClient, platform_owner_token: str):
provider = (
await client.post(
"/api/admin/providers",
json={"name": "p3", "type": "mock", "base_url": ""},
headers=auth_header(platform_owner_token),
)
).json()
created = await client.post(
"/api/admin/models",
json={
"provider_id": provider["id"],
"internal_name": "internal-coder-v1",
"display_name_for_admin": "Coder V1",
"capabilities": ["text", "code"],
"input_price_per_1m": 0.3,
"output_price_per_1m": 1.2,
},
headers=auth_header(platform_owner_token),
)
assert created.status_code == 201, created.text
listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token))
assert listing.status_code == 200
assert listing.json()[0]["internal_name"] == "internal-coder-v1"
async def test_model_requires_existing_provider(client: AsyncClient, platform_owner_token: str):
response = await client.post(
"/api/admin/models",
json={
"provider_id": "does-not-exist",
"internal_name": "x",
"display_name_for_admin": "X",
},
headers=auth_header(platform_owner_token),
)
assert response.status_code == 404
unauthenticated = await client.get("/api/admin/agy/accounts/whatever/models")
assert unauthenticated.status_code == 401
async def test_routing_policy_creation_and_default_swap(
+173 -21
View File
@@ -344,41 +344,42 @@ async def test_seed_creates_agy_catalog(client, seeded_install, session_factory)
"claude-opus-4-5-thinking",
"gpt-oss-120b-medium",
}
# Default deployment keeps routing on mock: agy models wait inactive.
assert all(active is False for _, active in models)
async def test_seed_activates_agy_when_it_is_default(
monkeypatch, client, seeded_install, session_factory
):
from app.core.config import settings
monkeypatch.setattr(settings, "ai_default_provider", "agy")
await seeded_install.seed()
models = await _agy_models(session_factory)
# agy is the only provider: the catalog is routable from the first run.
assert all(active is True for _, active in models)
async def test_seed_deactivates_mock_when_agy_is_default(
monkeypatch, client, seeded_install, session_factory
):
async def test_seed_round_trips_efforts(client, seeded_install, session_factory):
"""The seed's effort column is what the adapter later sends to Google."""
from sqlalchemy import select
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AiProviderRow
monkeypatch.setattr(settings, "ai_default_provider", "agy")
await seeded_install.seed()
async with session_factory() as session:
rows = (
await session.execute(
select(AiModelRow)
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
.where(AiProviderRow.name == "mock")
.where(AiProviderRow.name == "agy")
)
).scalars()
assert all(row.is_active is False for row in rows)
efforts = {row.internal_name: row.reasoning_effort for row in rows}
assert efforts["gemini-3-pro-high"] == "high"
assert efforts["claude-sonnet-4-5-thinking"] == "high"
assert efforts["gemini-3-flash"] == ""
async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_factory):
"""agy is the only provider row a seeded install may carry."""
from sqlalchemy import select
from app.infrastructure.db.models import AiProviderRow
async with session_factory() as session:
rows = (await session.execute(select(AiProviderRow))).scalars()
types = {row.type for row in rows}
names = {row.name for row in rows}
assert types == {"agy"}
assert "mock" not in names
# ---------------------------------------------------------------------------
@@ -519,6 +520,64 @@ def test_payload_translation(decrypted):
assert all(s["threshold"] == "OFF" for s in payload["request"]["safetySettings"])
def test_effort_becomes_a_thinking_config_on_a_thinking_model():
"""Effort is translated, not forwarded: high → a 16384-token budget."""
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-sonnet-4-5-thinking",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="high",
)
generation = payload["request"]["generationConfig"]
assert generation["thinkingConfig"] == {
"thinkingBudget": 16384,
"includeThoughts": True,
}
# The budget must fit inside the answer window.
assert generation["maxOutputTokens"] == 16384 + 8000
def test_effort_is_not_sent_to_a_non_thinking_model():
"""``thinkingConfig`` on a model that cannot think is a hard 400."""
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-sonnet-4-5",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="high",
)
assert "thinkingConfig" not in payload["request"]["generationConfig"]
assert payload["request"]["generationConfig"]["maxOutputTokens"] == 512
def test_unknown_effort_falls_back_to_medium():
account = agy.AgyAccount(
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
)
payload = agy._build_generate_payload(
account,
"claude-opus-4-5-thinking",
[ChatMessage(role="user", content="سلام")],
system="",
temperature=0.4,
max_tokens=512,
reasoning_effort="turbo",
)
budget = payload["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
assert budget == 8192
def test_response_parsing_skips_thoughts():
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
response = agy._parse_generate_response(
@@ -713,3 +772,96 @@ async def test_available_models(google, decrypted):
adapter = agy.AgyModelAdapter(loader=loader)
models = await adapter.available_models()
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
# ---------------------------------------------------------------------------
# Live per-account model catalog (the console's model dropdown source)
# ---------------------------------------------------------------------------
async def test_available_models_for_one_account(google, decrypted):
"""The console asks for a specific account, so the fetch is per-account."""
adapter = agy.AgyModelAdapter()
models = await adapter.available_models_for(
_agy_credential(email="one@gmail.com")
)
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
# 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
async def test_available_models_for_account_without_material():
"""A credential with no refresh token can never list models."""
adapter = agy.AgyModelAdapter()
credential = ModelCredential(
provider_id="p",
name="empty@gmail.com",
encrypted_api_key="PLAINTEXT::{}",
email="empty@gmail.com",
)
with pytest.raises(ProviderUnavailableError):
await adapter.available_models_for(credential)
async def test_account_models_endpoint(client, google, platform_owner_token):
"""GET /accounts/{id}/models returns that account's live catalog."""
started = await client.post(
"/api/admin/agy/accounts/login/start",
headers=auth_header(platform_owner_token),
)
state = parse_qs(urlparse(started.json()["auth_url"]).query)["state"][0]
account_id = (
await client.post(
"/api/admin/agy/accounts/login/complete",
headers=auth_header(platform_owner_token),
json={
"login_id": started.json()["login_id"],
"callback_url": (
f"http://localhost:51121/oauth-callback?code=c&state={state}"
),
},
)
).json()["id"]
response = await client.get(
f"/api/admin/agy/accounts/{account_id}/models",
headers=auth_header(platform_owner_token),
)
assert response.status_code == 200, response.text
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
missing = await client.get(
"/api/admin/agy/accounts/does-not-exist/models",
headers=auth_header(platform_owner_token),
)
assert missing.status_code == 404
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
"""The end-to-end path: effort on the model row reaches the payload."""
async def handler(method, url, json_body, headers, data):
if url.endswith("/token"):
return _token_response()
if url.endswith("/v1internal:generateContent"):
return _FakeResponse(json_data=GOOD_GENERATE)
return await default_handler(method, url, json_body, headers, data)
calls = patch_google(monkeypatch, handler)
model = AiModel(
provider_id="p",
internal_name="claude-sonnet-4-5-thinking",
display_name_for_admin="f",
reasoning_effort="low",
)
adapter = agy.AgyModelAdapter()
await adapter.complete(
model=model,
credentials=[_agy_credential(email="one@gmail.com")],
messages=_MESSAGES,
)
generate = [c for c in calls if c["url"].endswith("generateContent")]
assert generate[0]["json"]["request"]["generationConfig"]["thinkingConfig"] == {
"thinkingBudget": 4096,
"includeThoughts": True,
}
+3 -1
View File
@@ -1,7 +1,9 @@
"""§18.3 — the minimal end-to-end smoke path.
One test walks the whole product in order, through the real HTTP API and the
real application services, on the mock adapters:
real application services, against the agy-shaped catalogue (the seeded fixture
installs the agy provider and its models) with the fake model provider from
conftest standing in for the Google round-trip:
signup -> organization -> project -> repo -> preview domain -> sandbox
-> employee -> manager message -> task -> run -> usage -> audit
-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");
});
});
+48 -32
View File
@@ -10,14 +10,52 @@ export type Role = "owner" | "admin" | "manager" | "member" | "viewer";
export type MembershipStatus = "active" | "invited" | "suspended";
/** Provider kinds the AI Control Plane can talk to. */
export type ProviderType =
| "mock"
| "openai_compatible"
| "anthropic"
| "gemini"
| "openrouter"
| "agy";
/**
* Provider kinds the AI Control Plane can talk to. agy (Google Antigravity)
* is the only one — the platform no longer has a provider picker.
*/
export type ProviderType = "agy";
/** agy reasoning efforts, cheapest first. Mirrors the backend's list. */
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
export const REASONING_EFFORTS: ReasoningEffort[] = [
"minimal",
"low",
"medium",
"high",
"xhigh",
];
const EFFORT_LABELS: Record<ReasoningEffort, string> = {
minimal: "حداقل",
low: "کم",
medium: "متوسط",
high: "زیاد",
xhigh: "بسیار زیاد",
};
/**
* Reasoning efforts a model may be configured with, or an empty list when the
* 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.
*/
export function effortsForModel(modelId: string): ReasoningEffort[] {
const lower = modelId.toLowerCase();
if (lower.includes("image")) return [];
const canThink =
lower.includes("thinking") ||
lower.includes("gemini-3") ||
lower.includes("gemini-2.5-pro") ||
lower.includes("gemini-2.5-flash");
return canThink ? [...REASONING_EFFORTS] : [];
}
export function effortLabel(effort: string): string {
return EFFORT_LABELS[effort as ReasoningEffort] ?? effort;
}
/** Rotation-pool lifecycle of one agy account. */
export type AgyAccountStatus = "active" | "cooldown" | "error";
@@ -182,15 +220,6 @@ export interface Repository {
private: boolean;
}
export interface AdminProvider {
id: string;
name: string;
type: string;
base_url: string;
is_active: boolean;
created_at: string;
}
export interface AdminModel {
id: string;
provider_id: string;
@@ -199,6 +228,8 @@ export interface AdminModel {
capabilities: string[];
input_price_per_1m: number;
output_price_per_1m: number;
/** agy reasoning effort ("" = provider default). Owner-only metadata. */
reasoning_effort: string;
is_active: boolean;
}
@@ -228,21 +259,6 @@ export interface AdminOrganization {
created_at: string;
}
/**
* A stored provider credential.
*
* There is no field here that can carry a plaintext key — the API only ever
* returns a masked form, and on list it returns a fixed mask because the
* plaintext is unrecoverable by design.
*/
export interface AdminCredential {
id: string;
provider_id: string;
name: string;
masked_key: string;
is_active: boolean;
}
/** Result of a routing dry-run: which model a mode would pick, and why. */
export interface ModelChoice {
mode: string;