agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
+6
-7
@@ -27,15 +27,14 @@ JWT_SECRET=change-me
|
|||||||
ENCRYPTION_KEY=change-me-32-bytes
|
ENCRYPTION_KEY=change-me-32-bytes
|
||||||
|
|
||||||
# --- AI models (hidden from customers) -------------------------------------
|
# --- AI models (hidden from customers) -------------------------------------
|
||||||
# mock | openai_compatible | agy
|
# agy (Google Antigravity) is the only model provider. The setting is kept for
|
||||||
AI_DEFAULT_PROVIDER=mock
|
# compatibility with older .env files; any value is ignored (with a warning).
|
||||||
OPENAI_COMPATIBLE_BASE_URL=
|
AI_DEFAULT_PROVIDER=agy
|
||||||
OPENAI_COMPATIBLE_API_KEY=
|
|
||||||
|
|
||||||
# --- agy (Google Antigravity) ----------------------------------------------
|
# --- agy (Google Antigravity) ----------------------------------------------
|
||||||
# Only used when AI_DEFAULT_PROVIDER=agy. There are no API keys: accounts are
|
# The only model provider. There are no API keys: accounts are Google
|
||||||
# Google identities logged in via OAuth from the admin console
|
# identities logged in via OAuth from the admin console
|
||||||
# (/admin/accounts → افزودن اکانت agy). Any number of accounts can be added —
|
# (/admin/agy-accounts → افزودن اکانت agy). Any number of accounts can be added —
|
||||||
# the rotation pool spreads load across them and benches a rate-limited one
|
# 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
|
# until its quota window resets. AGY_CLIENT_ID/SECRET default to the public
|
||||||
# Antigravity CLI application; override them to use your own Google OAuth
|
# Antigravity CLI application; override them to use your own Google OAuth
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ provider. اینها فقط در **Admin Console** و فقط برای مال
|
|||||||
|
|
||||||
پلتفرم سه لایهٔ نقش دارد:
|
پلتفرم سه لایهٔ نقش دارد:
|
||||||
|
|
||||||
1. **مالک سامانه (platform owner)** — providerها، مدلها، routing policy، تعرفهها،
|
1. **مالک سامانه (platform owner)** — اکانتهای agy، مدلها، routing policy،
|
||||||
sandbox templateها، مشاهدهٔ کل مصرف.
|
تعرفهها، sandbox templateها، مشاهدهٔ کل مصرف. agy تنها ارائهدهندهٔ مدل
|
||||||
|
سامانه است و بخش جداگانهٔ «ارائهدهندهها» وجود ندارد.
|
||||||
2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعالسازی کارمند، مصرف.
|
2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعالسازی کارمند، مصرف.
|
||||||
3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی.
|
3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی.
|
||||||
|
|
||||||
@@ -217,7 +218,6 @@ web/
|
|||||||
|
|
||||||
| قابلیت | env | مقادیر |
|
| قابلیت | env | مقادیر |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` \| `agy` |
|
|
||||||
| Git | `GIT_PROVIDER` | `mock` \| `gitea` |
|
| Git | `GIT_PROVIDER` | `mock` \| `gitea` |
|
||||||
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
|
| دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` |
|
||||||
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` |
|
| Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` |
|
||||||
@@ -226,24 +226,25 @@ web/
|
|||||||
اگر credential واقعی نداریم، **قابلیت حذف نمیشود**: مدل دیتابیس، interface، UI و
|
اگر credential واقعی نداریم، **قابلیت حذف نمیشود**: مدل دیتابیس، interface، UI و
|
||||||
adapter mock کامل ساخته میشود و adapter واقعی پشت همین flag آماده میماند.
|
adapter mock کامل ساخته میشود و adapter واقعی پشت همین flag آماده میماند.
|
||||||
|
|
||||||
### ارائهدهندهٔ agy (Google Antigravity)
|
### ارائهدهندهٔ agy (Google Antigravity) — تنها ارائهدهندهٔ مدل
|
||||||
|
|
||||||
agy کلید API ندارد؛ **اکانت**ها هویت Google هستند و از طریق OAuth وارد
|
agy کلید API ندارد؛ **اکانت**ها هویت Google هستند و از طریق OAuth وارد
|
||||||
میشوند. برای فعالسازی:
|
میشوند. کاتالوگ مدلها در seed ساخته میشود و همان اول فعال است. برای شروع:
|
||||||
|
|
||||||
1. `AI_DEFAULT_PROVIDER=agy` را در `.env` بگذارید و `pnpm db:seed` را دوباره
|
1. با حساب مالک سامانه وارد کنسول مدیریت شوید و از
|
||||||
اجرا کنید (کتالوگ مدلهای agy فعال و کاتالوگ mock غیرفعال میشود).
|
`/admin/agy-accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل
|
||||||
2. با حساب مالک سامانه وارد کنسول مدیریت شوید و از
|
|
||||||
`/admin/accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل
|
|
||||||
باز میشود، بعد از تأیید، URL بازگشت را از نوار آدرس مرورگر کپی و Paste
|
باز میشود، بعد از تأیید، URL بازگشت را از نوار آدرس مرورگر کپی و Paste
|
||||||
کنید (صفحهٔ مقصد عمداً بارگذاری نمیشود؛ پارامترها همانجا هستند).
|
کنید (صفحهٔ مقصد عمداً بارگذاری نمیشود؛ پارامترها همانجا هستند).
|
||||||
3. هر تعداد اکانت Google که بخواهید اضافه کنید. مدلها را در
|
2. در `/admin/models` زنجیرهٔ افزودن مدل کاملاً انتخابی است: ارائهدهنده
|
||||||
`/admin/models` در صورت نیاز فعال کنید.
|
(agy) → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت) → افورت استدلال
|
||||||
|
(فقط برای مدلهای استدلالی). هیچ نام مدلی دستی تایپ نمیشود و مشتری نام مدل
|
||||||
|
را هرگز نمیبیند.
|
||||||
|
|
||||||
پول چرخشی، بار را بین اکانتها پخش میکند و اکانتی که ۴۲۹ گرفته تا پایان
|
هر تعداد اکانت Google که بخواهید اضافه کنید. پول چرخشی، بار را بین اکانتها
|
||||||
پنجرهٔ سهمیهٔ آن کنار گذاشته میشود (هم در حافظه و هم در دیتابیس، تا workerها
|
پخش میکند و اکانتی که ۴۲۹ گرفته تا پایان پنجرهٔ سهمیهٔ آن کنار گذاشته
|
||||||
هم همان تصویر را ببینند). refresh tokenها فقط encrypted at rest ذخیره
|
میشود (هم در حافظه و هم در دیتابیس، تا workerها همان تصویر را ببینند).
|
||||||
میشوند و هیچوقت از API برنمیگردند.
|
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
|
@runtime_checkable
|
||||||
class ModelProviderPort(Protocol):
|
class ModelProviderPort(Protocol):
|
||||||
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
|
"""Driven port. Implemented by ``AgyModelAdapter`` (the only provider)."""
|
||||||
``OpenAICompatibleModelAdapter``."""
|
|
||||||
|
|
||||||
async def complete(
|
async def complete(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -714,8 +714,8 @@ class WorkforceService:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Run one completion and record its usage. The single model entry point."""
|
"""Run one completion and record its usage. The single model entry point."""
|
||||||
model = await self._resolve_model(employee)
|
model = await self._resolve_model(employee)
|
||||||
# Pool-aware: rotation providers (agy) fan out across every usable
|
# Pool-aware: the agy adapter fans out across every usable account, so
|
||||||
# account; key-based providers simply get their one active credential.
|
# one account's rate limit never becomes everyone's outage.
|
||||||
pool = await self.credentials.list_active_for_provider(model.provider_id)
|
pool = await self.credentials.list_active_for_provider(model.provider_id)
|
||||||
credential = pool[0] if pool else None
|
credential = pool[0] if pool else None
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ class Settings(BaseSettings):
|
|||||||
encryption_key: str = ""
|
encryption_key: str = ""
|
||||||
|
|
||||||
# --- AI models (hidden from customers) -----------------------------------
|
# --- AI models (hidden from customers) -----------------------------------
|
||||||
ai_default_provider: str = "mock"
|
#: agy (Google Antigravity) is the only model provider the platform talks
|
||||||
openai_compatible_base_url: str = ""
|
#: to. The setting is kept so an old ``AI_DEFAULT_PROVIDER`` value in a
|
||||||
openai_compatible_api_key: str = ""
|
#: ``.env`` keeps booting: the registry warns and uses agy regardless.
|
||||||
|
ai_default_provider: str = "agy"
|
||||||
|
|
||||||
# --- agy (Google Antigravity) --------------------------------------------
|
# --- agy (Google Antigravity) --------------------------------------------
|
||||||
#: OAuth client of the public Antigravity CLI application. Overridable so a
|
#: OAuth client of the public Antigravity CLI application. Overridable so a
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ def _configured_secret_values() -> tuple[str, ...]:
|
|||||||
candidates = (
|
candidates = (
|
||||||
settings.jwt_secret,
|
settings.jwt_secret,
|
||||||
settings.encryption_key,
|
settings.encryption_key,
|
||||||
settings.openai_compatible_api_key,
|
|
||||||
settings.gitea_admin_token,
|
settings.gitea_admin_token,
|
||||||
settings.daytona_api_key,
|
settings.daytona_api_key,
|
||||||
settings.e2b_api_key,
|
settings.e2b_api_key,
|
||||||
|
|||||||
@@ -14,13 +14,10 @@ from app.domain.base import new_id, utcnow
|
|||||||
|
|
||||||
|
|
||||||
class ProviderType(StrEnum):
|
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
|
#: 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"
|
AGY = "agy"
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +31,11 @@ class CredentialStatus(StrEnum):
|
|||||||
ERROR = "error"
|
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):
|
class RoutingMode(StrEnum):
|
||||||
FAST = "fast"
|
FAST = "fast"
|
||||||
CHEAP = "cheap"
|
CHEAP = "cheap"
|
||||||
@@ -65,7 +67,7 @@ class RateUnit(StrEnum):
|
|||||||
@dataclass(slots=True, kw_only=True)
|
@dataclass(slots=True, kw_only=True)
|
||||||
class Provider:
|
class Provider:
|
||||||
name: str
|
name: str
|
||||||
type: ProviderType = ProviderType.MOCK
|
type: ProviderType = ProviderType.AGY
|
||||||
base_url: str = ""
|
base_url: str = ""
|
||||||
id: str = field(default_factory=new_id)
|
id: str = field(default_factory=new_id)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@@ -74,12 +76,10 @@ class Provider:
|
|||||||
|
|
||||||
@dataclass(slots=True, kw_only=True)
|
@dataclass(slots=True, kw_only=True)
|
||||||
class ModelCredential:
|
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
|
``encrypted_api_key`` holds the encrypted JSON blob
|
||||||
the agy fields stay empty. For the agy provider the credential *is an
|
``{"refresh_token": ..., "project_id": ..., "email": ...}``, while ``email`` /
|
||||||
account*: ``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
|
``project_id`` duplicate the identity columns so the admin console can show
|
||||||
and rotate accounts without touching the ciphertext.
|
and rotate accounts without touching the ciphertext.
|
||||||
"""
|
"""
|
||||||
@@ -107,6 +107,10 @@ class AiModel:
|
|||||||
capabilities: list[str] = field(default_factory=list)
|
capabilities: list[str] = field(default_factory=list)
|
||||||
input_price_per_1m: float = 0.0
|
input_price_per_1m: float = 0.0
|
||||||
output_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)
|
id: str = field(default_factory=new_id)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = field(default_factory=utcnow)
|
created_at: datetime = field(default_factory=utcnow)
|
||||||
|
|||||||
@@ -91,6 +91,33 @@ SAFETY_SETTINGS: list[dict[str, str]] = [
|
|||||||
#: manual paste must fit inside it comfortably.
|
#: manual paste must fit inside it comfortably.
|
||||||
CHALLENGE_TTL_SECONDS = 600
|
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]]
|
_StatusFn = Callable[..., Awaitable[None]]
|
||||||
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
|
_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]
|
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 []
|
||||||
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:
|
for base in BASE_URLS:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
@@ -713,7 +758,8 @@ class AgyModelAdapter:
|
|||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
token = await self._access_token(account)
|
token = await self._access_token(account)
|
||||||
payload = _build_generate_payload(
|
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 = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -775,6 +821,7 @@ def _build_generate_payload(
|
|||||||
system: str,
|
system: str,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
|
reasoning_effort: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Translate the port's OpenAI-shaped call into the Antigravity envelope."""
|
"""Translate the port's OpenAI-shaped call into the Antigravity envelope."""
|
||||||
system_parts: list[str] = [system] if system else []
|
system_parts: list[str] = [system] if system else []
|
||||||
@@ -786,13 +833,27 @@ def _build_generate_payload(
|
|||||||
role = "model" if message.role == "assistant" else "user"
|
role = "model" if message.role == "assistant" else "user"
|
||||||
contents.append({"role": role, "parts": [{"text": message.content}]})
|
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 = {
|
request: dict = {
|
||||||
"contents": contents,
|
"contents": contents,
|
||||||
"sessionId": _random_session_id(),
|
"sessionId": _random_session_id(),
|
||||||
"generationConfig": {
|
"generationConfig": generation,
|
||||||
"temperature": temperature,
|
|
||||||
"maxOutputTokens": max_tokens,
|
|
||||||
},
|
|
||||||
"toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}},
|
"toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}},
|
||||||
"safetySettings": SAFETY_SETTINGS,
|
"safetySettings": SAFETY_SETTINGS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
@@ -17,14 +17,11 @@ from typing import Any
|
|||||||
|
|
||||||
from app.application.ports import (
|
from app.application.ports import (
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
ChatMessage,
|
|
||||||
CheckoutSession,
|
CheckoutSession,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
GitCredentials,
|
GitCredentials,
|
||||||
MergeResult,
|
MergeResult,
|
||||||
ModelHealth,
|
|
||||||
ModelResponse,
|
|
||||||
Notification,
|
Notification,
|
||||||
PaymentResult,
|
PaymentResult,
|
||||||
PreviewDomainInfo,
|
PreviewDomainInfo,
|
||||||
@@ -38,7 +35,6 @@ from app.application.ports import (
|
|||||||
VectorHit,
|
VectorHit,
|
||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
)
|
)
|
||||||
from app.domain.ai_control.models import AiModel
|
|
||||||
from app.domain.platform.rules import (
|
from app.domain.platform.rules import (
|
||||||
assert_port_allocatable,
|
assert_port_allocatable,
|
||||||
build_host,
|
build_host,
|
||||||
@@ -46,57 +42,6 @@ from app.domain.platform.rules import (
|
|||||||
validate_proxy_target,
|
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
|
# 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
|
One place decides which concrete adapter backs each port, driven purely by the
|
||||||
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
``*_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
|
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||||
cached as singletons so behaviour is consistent within a process.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import ProviderUnavailableError
|
from app.core.errors import ProviderUnavailableError
|
||||||
from app.infrastructure.adapters import mock
|
from app.infrastructure.adapters import mock
|
||||||
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
|
||||||
|
logger = logging.getLogger("agy.registry")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_model_provider():
|
def get_model_provider():
|
||||||
provider = settings.ai_default_provider.lower()
|
"""The one model adapter: agy over its logged-in account pool."""
|
||||||
if provider == "mock":
|
configured = settings.ai_default_provider.strip().lower()
|
||||||
return mock.MockModelProviderAdapter()
|
if configured != "agy":
|
||||||
if provider == "openai_compatible":
|
# Tolerate a stale ``AI_DEFAULT_PROVIDER`` from before agy became the
|
||||||
return OpenAICompatibleModelAdapter(
|
# only provider: warn loudly, keep serving with agy either way.
|
||||||
base_url=settings.openai_compatible_base_url,
|
logger.warning(
|
||||||
default_api_key=settings.openai_compatible_api_key,
|
"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 (
|
from app.infrastructure.adapters.agy import (
|
||||||
AgyModelAdapter,
|
AgyModelAdapter,
|
||||||
load_agy_accounts,
|
load_agy_accounts,
|
||||||
@@ -36,10 +41,6 @@ def get_model_provider():
|
|||||||
)
|
)
|
||||||
|
|
||||||
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
|
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
|
||||||
raise ProviderUnavailableError(
|
|
||||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
|
||||||
"مقادیر مجاز: mock | openai_compatible | agy"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ class AiProviderRow(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = _pk()
|
id: Mapped[str] = _pk()
|
||||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
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)
|
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
created_at: Mapped[datetime] = _created()
|
created_at: Mapped[datetime] = _created()
|
||||||
@@ -160,6 +163,8 @@ class AiModelRow(Base):
|
|||||||
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||||
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, 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)
|
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)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
created_at: Mapped[datetime] = _created()
|
created_at: Mapped[datetime] = _created()
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,14 @@ from app.infrastructure.db.models import (
|
|||||||
|
|
||||||
|
|
||||||
def _provider_to_domain(row: AiProviderRow) -> Provider:
|
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(
|
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,
|
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 []),
|
capabilities=list(row.capabilities or []),
|
||||||
input_price_per_1m=row.input_price_per_1m,
|
input_price_per_1m=row.input_price_per_1m,
|
||||||
output_price_per_1m=row.output_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,
|
is_active=row.is_active, created_at=row.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,6 +274,7 @@ class AiModelRepository:
|
|||||||
capabilities=list(model.capabilities),
|
capabilities=list(model.capabilities),
|
||||||
input_price_per_1m=model.input_price_per_1m,
|
input_price_per_1m=model.input_price_per_1m,
|
||||||
output_price_per_1m=model.output_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,
|
is_active=model.is_active, created_at=model.created_at,
|
||||||
)
|
)
|
||||||
self.session.add(row)
|
self.session.add(row)
|
||||||
|
|||||||
@@ -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
|
Everything in this router is invisible to customers. Two rules are enforced
|
||||||
here and tested:
|
here and tested:
|
||||||
|
|
||||||
1. Only a platform owner may call any of it.
|
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
|
2. Model identity stays inside the console: a customer never receives a raw
|
||||||
a nested object. Only ``masked_key`` leaves this module.
|
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
|
from __future__ import annotations
|
||||||
@@ -17,15 +18,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.services.audit_service import record_audit
|
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.database import get_session
|
||||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||||
from app.domain.ai_control.models import (
|
from app.domain.ai_control.models import (
|
||||||
|
REASONING_EFFORTS,
|
||||||
AiModel,
|
AiModel,
|
||||||
Capability,
|
Capability,
|
||||||
ModelCredential,
|
|
||||||
Provider,
|
|
||||||
ProviderType,
|
|
||||||
RateCard,
|
RateCard,
|
||||||
RateUnit,
|
RateUnit,
|
||||||
RoutingMode,
|
RoutingMode,
|
||||||
@@ -36,7 +34,6 @@ from app.domain.base import utcnow
|
|||||||
from app.domain.identity.models import User
|
from app.domain.identity.models import User
|
||||||
from app.infrastructure.repositories.ai_control import (
|
from app.infrastructure.repositories.ai_control import (
|
||||||
AiModelRepository,
|
AiModelRepository,
|
||||||
ModelCredentialRepository,
|
|
||||||
ProviderRepository,
|
ProviderRepository,
|
||||||
RateCardRepository,
|
RateCardRepository,
|
||||||
RoutingPolicyRepository,
|
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):
|
class ModelOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -115,16 +80,19 @@ class ModelOut(BaseModel):
|
|||||||
capabilities: list[str]
|
capabilities: list[str]
|
||||||
input_price_per_1m: float
|
input_price_per_1m: float
|
||||||
output_price_per_1m: float
|
output_price_per_1m: float
|
||||||
|
reasoning_effort: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
class CreateModelRequest(BaseModel):
|
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)
|
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)
|
capabilities: list[Capability] = Field(default_factory=list)
|
||||||
input_price_per_1m: float = Field(default=0.0, ge=0)
|
input_price_per_1m: float = Field(default=0.0, ge=0)
|
||||||
output_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):
|
class RoutingPolicyOut(BaseModel):
|
||||||
@@ -180,124 +148,29 @@ class AdminUsageRow(BaseModel):
|
|||||||
total_price: float
|
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
|
# 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])
|
@router.get("/models", response_model=list[ModelOut])
|
||||||
async def list_models(
|
async def list_models(
|
||||||
provider_id: str | None = Query(default=None),
|
provider_id: str | None = Query(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[ModelOut]:
|
) -> list[ModelOut]:
|
||||||
models = await AiModelRepository(session).list(provider_id=provider_id)
|
models = await AiModelRepository(session).list(provider_id=provider_id)
|
||||||
return [
|
return [_model_out(m) for m in models]
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
|
@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),
|
owner: User = Depends(require_platform_owner),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> ModelOut:
|
) -> ModelOut:
|
||||||
if await ProviderRepository(session).get(payload.provider_id) is None:
|
from app.interfaces.http.routers.agy import ensure_agy_provider
|
||||||
raise NotFoundError("ارائهدهنده یافت نشد.")
|
|
||||||
|
|
||||||
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(
|
AiModel(
|
||||||
provider_id=payload.provider_id,
|
provider_id=provider.id,
|
||||||
internal_name=payload.internal_name,
|
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],
|
capabilities=[str(c) for c in payload.capabilities],
|
||||||
input_price_per_1m=payload.input_price_per_1m,
|
input_price_per_1m=payload.input_price_per_1m,
|
||||||
output_price_per_1m=payload.output_price_per_1m,
|
output_price_per_1m=payload.output_price_per_1m,
|
||||||
|
reasoning_effort=effort,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ip, ua = client_meta(request)
|
ip, ua = client_meta(request)
|
||||||
await record_audit(
|
await record_audit(
|
||||||
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
||||||
target_type="model", target_id=model.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,
|
ip=ip, user_agent=ua,
|
||||||
)
|
)
|
||||||
return ModelOut(
|
return _model_out(model)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
provider = await repo.get_by_name(AGY_PROVIDER_NAME)
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
return provider
|
return provider
|
||||||
@@ -176,7 +176,7 @@ async def complete_agy_login(
|
|||||||
|
|
||||||
providers = ProviderRepository(session)
|
providers = ProviderRepository(session)
|
||||||
credentials = ModelCredentialRepository(session)
|
credentials = ModelCredentialRepository(session)
|
||||||
provider = await _ensure_agy_provider(providers)
|
provider = await ensure_agy_provider(providers)
|
||||||
|
|
||||||
envelope = json.dumps(
|
envelope = json.dumps(
|
||||||
{
|
{
|
||||||
@@ -331,13 +331,29 @@ async def test_agy_account(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=list[str])
|
@router.get("/accounts/{credential_id}/models", response_model=list[str])
|
||||||
async def list_agy_models() -> list[str]:
|
async def list_account_models(
|
||||||
"""Model ids the logged-in agy subscription exposes (registry helper)."""
|
credential_id: str,
|
||||||
from app.infrastructure.adapters.registry import get_model_provider
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[str]:
|
||||||
|
"""Live model catalog of one account — fetched from Google on every call.
|
||||||
|
|
||||||
provider = get_model_provider()
|
The admin console calls this when an account is selected, so the model
|
||||||
lister = getattr(provider, "available_models", None)
|
list always reflects that subscription's current entitlement instead of a
|
||||||
if lister is None:
|
cached snapshot.
|
||||||
return []
|
"""
|
||||||
return await lister()
|
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
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
|
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
|
organization that both of them own, the system agent templates, routing
|
||||||
policies, rate cards and a mock provider with two models. The agy (Google
|
policies, rate cards and the agy (Google Antigravity) provider with its model
|
||||||
Antigravity) provider and its model catalog are seeded too; agy models become
|
catalog. agy is the only model provider the platform talks to; accounts are
|
||||||
routable only when ``AI_DEFAULT_PROVIDER=agy``.
|
added from the admin console.
|
||||||
|
|
||||||
Safe to run repeatedly: every step checks for existing rows first.
|
Safe to run repeatedly: every step checks for existing rows first.
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import Base, SessionFactory, engine
|
from app.core.database import Base, SessionFactory, engine
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.domain.ai_control.models import (
|
from app.domain.ai_control.models import (
|
||||||
@@ -64,14 +63,17 @@ DEMO_ORG_NAME = "سازمان نمونه"
|
|||||||
DEMO_ORG_SLUG = "sample-org"
|
DEMO_ORG_SLUG = "sample-org"
|
||||||
|
|
||||||
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
|
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
|
||||||
#: the Antigravity backend expects; the display name is admin-only. Seeded for
|
#: the Antigravity backend expects; the display name is admin-only. The 4th
|
||||||
#: every deployment, activated for routing only when agy is the default
|
#: element is the reasoning effort (empty = provider default) — the console can
|
||||||
#: provider — see the seed step that consumes this table.
|
#: change it per model, and the adapter only sends it to thinking-capable
|
||||||
AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
|
#: 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",
|
||||||
"Gemini 3 Flash",
|
"Gemini 3 Flash",
|
||||||
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
|
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
|
||||||
|
"",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"gemini-3-pro-high",
|
"gemini-3-pro-high",
|
||||||
@@ -80,23 +82,27 @@ AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
|
|||||||
Capability.TEXT, Capability.REASONING, Capability.CODE,
|
Capability.TEXT, Capability.REASONING, Capability.CODE,
|
||||||
Capability.VISION, Capability.LONG_CONTEXT,
|
Capability.VISION, Capability.LONG_CONTEXT,
|
||||||
],
|
],
|
||||||
|
"high",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"claude-sonnet-4-5",
|
"claude-sonnet-4-5",
|
||||||
"Claude Sonnet 4.5",
|
"Claude Sonnet 4.5",
|
||||||
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
|
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
|
||||||
|
"",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"claude-sonnet-4-5-thinking",
|
"claude-sonnet-4-5-thinking",
|
||||||
"Claude Sonnet 4.5 (Thinking)",
|
"Claude Sonnet 4.5 (Thinking)",
|
||||||
[Capability.TEXT, Capability.CODE, Capability.REASONING],
|
[Capability.TEXT, Capability.CODE, Capability.REASONING],
|
||||||
|
"high",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"claude-opus-4-5-thinking",
|
"claude-opus-4-5-thinking",
|
||||||
"Claude Opus 4.5 (Thinking)",
|
"Claude Opus 4.5 (Thinking)",
|
||||||
[Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT],
|
[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]]] = [
|
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.
|
# as either one lands on a populated dashboard rather than an empty one.
|
||||||
await _ensure_org(session, members=[owner, demo])
|
await _ensure_org(session, members=[owner, demo])
|
||||||
|
|
||||||
if not (await session.execute(select(AiProviderRow))).scalars().first():
|
# The agy catalog (Google Antigravity). agy is the only model provider,
|
||||||
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
|
# so the provider row and its models are created on a first run and
|
||||||
session.add(
|
# reconciled on every later one: display names, capabilities, efforts
|
||||||
AiProviderRow(
|
# and activation follow this table, and a model removed here stays in
|
||||||
id=provider.id, name=provider.name, type=provider.type.value,
|
# the database (neither table nor row deletion is safe for a seed).
|
||||||
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"
|
|
||||||
agy_provider_row = (
|
agy_provider_row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(AiProviderRow).where(AiProviderRow.name == "agy")
|
select(AiProviderRow).where(AiProviderRow.name == "agy")
|
||||||
@@ -328,7 +294,7 @@ async def seed() -> None:
|
|||||||
)
|
)
|
||||||
session.add(agy_provider_row)
|
session.add(agy_provider_row)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
for internal, display, caps in AGY_MODELS:
|
for internal, display, caps, effort in AGY_MODELS:
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(AiModelRow).where(
|
select(AiModelRow).where(
|
||||||
@@ -338,6 +304,12 @@ async def seed() -> None:
|
|||||||
)
|
)
|
||||||
).scalars().first()
|
).scalars().first()
|
||||||
if existing is not None:
|
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
|
continue
|
||||||
model = AiModel(
|
model = AiModel(
|
||||||
provider_id=agy_provider_row.id,
|
provider_id=agy_provider_row.id,
|
||||||
@@ -349,6 +321,7 @@ async def seed() -> None:
|
|||||||
# customer-facing price on the rate card.
|
# customer-facing price on the rate card.
|
||||||
input_price_per_1m=0.0,
|
input_price_per_1m=0.0,
|
||||||
output_price_per_1m=0.0,
|
output_price_per_1m=0.0,
|
||||||
|
reasoning_effort=effort,
|
||||||
)
|
)
|
||||||
session.add(
|
session.add(
|
||||||
AiModelRow(
|
AiModelRow(
|
||||||
@@ -358,39 +331,12 @@ async def seed() -> None:
|
|||||||
capabilities=model.capabilities,
|
capabilities=model.capabilities,
|
||||||
input_price_per_1m=model.input_price_per_1m,
|
input_price_per_1m=model.input_price_per_1m,
|
||||||
output_price_per_1m=model.output_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(
|
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)"
|
"(accounts are added from the admin console)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+110
-13
@@ -1,7 +1,10 @@
|
|||||||
"""Shared test fixtures.
|
"""Shared test fixtures.
|
||||||
|
|
||||||
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
|
Tests run against an in-memory SQLite database with the real agy-shaped adapter
|
||||||
suite needs no Postgres, no Redis and no credentials.
|
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
|
from __future__ import annotations
|
||||||
@@ -96,6 +99,76 @@ async def client(engine, session_factory):
|
|||||||
app.dependency_overrides.clear()
|
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(
|
async def signup(
|
||||||
client: AsyncClient, email: str, password: str = "password123", name: str = ""
|
client: AsyncClient, email: str, password: str = "password123", name: str = ""
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -149,10 +222,31 @@ SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
|
|||||||
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
|
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
|
||||||
]
|
]
|
||||||
|
|
||||||
#: The mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
|
#: The agy models ``app/seed.py`` installs: (internal_name, display,
|
||||||
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
|
#: capabilities, reasoning_effort). agy is the only provider, so this is the
|
||||||
("mock-fast", "Mock Fast", ["text"]),
|
#: whole catalogue.
|
||||||
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "code"]),
|
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).
|
#: 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):
|
async def seeded(session_factory):
|
||||||
"""Install the platform-level seed rows a fresh install would have.
|
"""Install the platform-level seed rows a fresh install would have.
|
||||||
|
|
||||||
Agent templates, routing policies, the mock provider/models, the rate cards
|
Agent templates, routing policies, the agy provider with its model catalog,
|
||||||
and the sandbox templates are all *platform* data (no organization owns
|
the rate cards and the sandbox templates are all *platform* data (no
|
||||||
them), so without this an employee cannot be created, a run cannot resolve a
|
organization owns them), so without this an employee cannot be created, a
|
||||||
model to route to, usage cannot be priced and no sandbox can be started —
|
run cannot resolve a model to route to, usage cannot be priced and no
|
||||||
none of which the real seeded install lacks.
|
sandbox can be started — none of which the real seeded install lacks.
|
||||||
"""
|
"""
|
||||||
from app.infrastructure.db.models import (
|
from app.infrastructure.db.models import (
|
||||||
AgentTemplateRow,
|
AgentTemplateRow,
|
||||||
@@ -214,9 +308,11 @@ async def seeded(session_factory):
|
|||||||
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
|
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
|
||||||
provider_id = new_id()
|
provider_id = new_id()
|
||||||
session.add(
|
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(
|
session.add(
|
||||||
AiModelRow(
|
AiModelRow(
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
@@ -225,6 +321,7 @@ async def seeded(session_factory):
|
|||||||
capabilities=capabilities,
|
capabilities=capabilities,
|
||||||
input_price_per_1m=0.3,
|
input_price_per_1m=0.3,
|
||||||
output_price_per_1m=1.2,
|
output_price_per_1m=1.2,
|
||||||
|
reasoning_effort=effort,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
Two properties matter more than any feature here:
|
Two properties matter more than any feature here:
|
||||||
|
|
||||||
1. A normal customer can never reach these endpoints.
|
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
|
from __future__ import annotations
|
||||||
@@ -12,146 +14,92 @@ from httpx import AsyncClient
|
|||||||
|
|
||||||
from tests.conftest import auth_header, signup
|
from tests.conftest import auth_header, signup
|
||||||
|
|
||||||
SECRET_KEY = "sk-super-secret-value-do-not-leak-1234567890"
|
|
||||||
|
|
||||||
|
async def test_normal_user_cannot_list_models(client: AsyncClient):
|
||||||
async def test_normal_user_cannot_list_providers(client: AsyncClient):
|
|
||||||
token = (await signup(client, "peon@example.com"))["access_token"]
|
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.status_code == 403
|
||||||
assert response.json()["error"]["code"] == "permission_denied"
|
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"]
|
token = (await signup(client, "peon2@example.com"))["access_token"]
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/admin/providers",
|
"/api/admin/models",
|
||||||
json={"name": "evil", "type": "mock", "base_url": ""},
|
json={"internal_name": "evil-model"},
|
||||||
headers=auth_header(token),
|
headers=auth_header(token),
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_endpoints_require_authentication(client: AsyncClient):
|
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
|
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
|
client: AsyncClient, platform_owner_token: str
|
||||||
):
|
):
|
||||||
|
"""The provider is not a client choice: agy is the only one."""
|
||||||
created = await client.post(
|
created = await client.post(
|
||||||
"/api/admin/providers",
|
"/api/admin/models",
|
||||||
json={"name": "openrouter", "type": "openrouter", "base_url": "https://openrouter.ai/api/v1"},
|
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),
|
headers=auth_header(platform_owner_token),
|
||||||
)
|
)
|
||||||
assert created.status_code == 201, created.text
|
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 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):
|
async def test_duplicate_model_name_is_rejected(client: AsyncClient, platform_owner_token: str):
|
||||||
payload = {"name": "dupe", "type": "mock", "base_url": ""}
|
payload = {"internal_name": "claude-sonnet-4-5-thinking", "reasoning_effort": "high"}
|
||||||
first = await client.post(
|
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
|
assert first.status_code == 201
|
||||||
second = await client.post(
|
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
|
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
|
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(
|
response = await client.post(
|
||||||
f"/api/admin/providers/{provider['id']}/credentials",
|
"/api/admin/models",
|
||||||
json={"name": "primary", "api_key": SECRET_KEY},
|
json={"internal_name": "gemini-3-flash", "reasoning_effort": "ultra"},
|
||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
)
|
)
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 422
|
||||||
body = response.json()
|
|
||||||
assert SECRET_KEY not in str(body)
|
|
||||||
assert body["masked_key"].startswith("•")
|
|
||||||
assert body["masked_key"].endswith(SECRET_KEY[-4:])
|
|
||||||
|
|
||||||
|
|
||||||
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
|
client: AsyncClient, platform_owner_token: str
|
||||||
):
|
):
|
||||||
provider = (
|
"""The account catalog is owner-only and never carries token material."""
|
||||||
await client.post(
|
token = (await signup(client, "peon3@example.com"))["access_token"]
|
||||||
"/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),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/api/admin/providers/{provider['id']}/credentials",
|
"/api/admin/agy/accounts/whatever/models", headers=auth_header(token)
|
||||||
headers=auth_header(platform_owner_token),
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 403
|
||||||
assert SECRET_KEY not in response.text
|
|
||||||
assert response.json()[0]["masked_key"] == "••••••"
|
|
||||||
|
|
||||||
|
unauthenticated = await client.get("/api/admin/agy/accounts/whatever/models")
|
||||||
async def test_model_creation_and_listing(client: AsyncClient, platform_owner_token: str):
|
assert unauthenticated.status_code == 401
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_routing_policy_creation_and_default_swap(
|
async def test_routing_policy_creation_and_default_swap(
|
||||||
|
|||||||
+173
-21
@@ -344,41 +344,42 @@ async def test_seed_creates_agy_catalog(client, seeded_install, session_factory)
|
|||||||
"claude-opus-4-5-thinking",
|
"claude-opus-4-5-thinking",
|
||||||
"gpt-oss-120b-medium",
|
"gpt-oss-120b-medium",
|
||||||
}
|
}
|
||||||
# Default deployment keeps routing on mock: agy models wait inactive.
|
# agy is the only provider: the catalog is routable from the first run.
|
||||||
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)
|
|
||||||
assert all(active is True for _, active in models)
|
assert all(active is True for _, active in models)
|
||||||
|
|
||||||
|
|
||||||
async def test_seed_deactivates_mock_when_agy_is_default(
|
async def test_seed_round_trips_efforts(client, seeded_install, session_factory):
|
||||||
monkeypatch, client, seeded_install, session_factory
|
"""The seed's effort column is what the adapter later sends to Google."""
|
||||||
):
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.infrastructure.db.models import AiModelRow, AiProviderRow
|
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:
|
async with session_factory() as session:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(AiModelRow)
|
select(AiModelRow)
|
||||||
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
|
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
|
||||||
.where(AiProviderRow.name == "mock")
|
.where(AiProviderRow.name == "agy")
|
||||||
)
|
)
|
||||||
).scalars()
|
).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"])
|
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():
|
def test_response_parsing_skips_thoughts():
|
||||||
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
|
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
|
||||||
response = agy._parse_generate_response(
|
response = agy._parse_generate_response(
|
||||||
@@ -713,3 +772,96 @@ async def test_available_models(google, decrypted):
|
|||||||
adapter = agy.AgyModelAdapter(loader=loader)
|
adapter = agy.AgyModelAdapter(loader=loader)
|
||||||
models = await adapter.available_models()
|
models = await adapter.available_models()
|
||||||
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""§18.3 — the minimal end-to-end smoke path.
|
"""§18.3 — the minimal end-to-end smoke path.
|
||||||
|
|
||||||
One test walks the whole product in order, through the real HTTP API and the
|
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
|
signup -> organization -> project -> repo -> preview domain -> sandbox
|
||||||
-> employee -> manager message -> task -> run -> usage -> audit
|
-> employee -> manager message -> task -> run -> usage -> audit
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useSession } from "@/lib/session";
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ href: "/admin", label: "نمای کلی" },
|
{ href: "/admin", label: "نمای کلی" },
|
||||||
{ href: "/admin/agy-accounts", label: "اکانتهای agy" },
|
{ href: "/admin/agy-accounts", label: "اکانتهای agy" },
|
||||||
{ href: "/admin/providers", label: "ارائهدهندهها" },
|
|
||||||
{ href: "/admin/models", label: "مدلها" },
|
{ href: "/admin/models", label: "مدلها" },
|
||||||
{ href: "/admin/routing", label: "مسیریابی" },
|
{ href: "/admin/routing", label: "مسیریابی" },
|
||||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,17 +24,34 @@ import {
|
|||||||
} from "@agy/ui";
|
} from "@agy/ui";
|
||||||
import { ApiError, api } from "@/lib/api";
|
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"];
|
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() {
|
export default function AdminModelsPage() {
|
||||||
const [models, setModels] = React.useState<AdminModel[]>([]);
|
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 [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [providerId, setProviderId] = React.useState("");
|
// The add-model cascade: provider -> account -> model -> effort.
|
||||||
const [internalName, setInternalName] = React.useState("");
|
const [providerId, setProviderId] = React.useState("agy");
|
||||||
const [displayName, setDisplayName] = React.useState("");
|
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 [caps, setCaps] = React.useState<Capability[]>(["text"]);
|
||||||
const [inputPrice, setInputPrice] = React.useState("");
|
const [inputPrice, setInputPrice] = React.useState("");
|
||||||
const [outputPrice, setOutputPrice] = React.useState("");
|
const [outputPrice, setOutputPrice] = React.useState("");
|
||||||
@@ -38,13 +62,12 @@ export default function AdminModelsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [nextModels, nextProviders] = await Promise.all([
|
const [nextModels, nextAccounts] = await Promise.all([
|
||||||
api.adminModels(),
|
api.adminModels(),
|
||||||
api.adminProviders(),
|
api.agyAccounts(),
|
||||||
]);
|
]);
|
||||||
setModels(nextModels);
|
setModels(nextModels);
|
||||||
setProviders(nextProviders);
|
setAccounts(nextAccounts);
|
||||||
setProviderId((current) => current || nextProviders[0]?.id || "");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "دریافت مدلها ناموفق بود.");
|
setError(err instanceof ApiError ? err.message : "دریافت مدلها ناموفق بود.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,6 +79,72 @@ export default function AdminModelsPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [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) => {
|
const toggleCap = (cap: Capability) => {
|
||||||
setCaps((current) =>
|
setCaps((current) =>
|
||||||
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
|
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
|
||||||
@@ -65,8 +154,8 @@ export default function AdminModelsPage() {
|
|||||||
const onCreate = async (event: React.FormEvent) => {
|
const onCreate = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
if (!providerId) {
|
if (!modelId) {
|
||||||
setFormError("ابتدا یک ارائهدهنده بسازید.");
|
setFormError("ابتدا یک مدل از کاتالوگ اکانت انتخاب کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (caps.length === 0) {
|
if (caps.length === 0) {
|
||||||
@@ -76,16 +165,15 @@ export default function AdminModelsPage() {
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const created = await api.adminCreateModel({
|
const created = await api.adminCreateModel({
|
||||||
provider_id: providerId,
|
internal_name: modelId,
|
||||||
internal_name: internalName.trim(),
|
|
||||||
display_name_for_admin: displayName.trim() || internalName.trim(),
|
|
||||||
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,
|
||||||
|
reasoning_effort: effort || undefined,
|
||||||
});
|
});
|
||||||
setModels((current) => [created, ...current]);
|
setModels((current) => [created, ...current]);
|
||||||
setInternalName("");
|
setModelId("");
|
||||||
setDisplayName("");
|
setEffort("");
|
||||||
setInputPrice("");
|
setInputPrice("");
|
||||||
setOutputPrice("");
|
setOutputPrice("");
|
||||||
setCaps(["text"]);
|
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 (
|
return (
|
||||||
<div className="anim-stagger flex flex-col gap-5">
|
<div className="anim-stagger flex flex-col gap-5">
|
||||||
<header className="flex flex-col gap-1">
|
<header className="flex flex-col gap-1">
|
||||||
<h1 className="text-lg font-medium text-slate-100">مدلها</h1>
|
<h1 className="text-lg font-medium text-slate-100">مدلها</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
نام داخلی مدلها فقط در این کنسول دیده میشود. مشتری هرگز نام مدل یا ارائهدهنده را
|
agy تنها ارائهدهندهٔ سامانه است. برای افزودن مدل، اکانت را انتخاب کنید؛
|
||||||
نمیبیند.
|
کاتالوگ مدلها لحظهای و مستقیم از همان اکانت خوانده میشود. نام مدل فقط
|
||||||
|
در این کنسول دیده میشود و مشتری هرگز آن را نمیبیند.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardTitle>افزودن مدل</CardTitle>
|
<CardTitle>افزودن مدل</CardTitle>
|
||||||
<CardHint>
|
<CardHint>
|
||||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای محاسبهٔ هزینهٔ
|
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای
|
||||||
داخلی هستند.
|
محاسبهٔ هزینهٔ داخلی هستند.
|
||||||
</CardHint>
|
</CardHint>
|
||||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
<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
|
<Select
|
||||||
label="ارائهدهنده"
|
label="ارائهدهنده"
|
||||||
required
|
required
|
||||||
value={providerId}
|
value={providerId}
|
||||||
onChange={(e) => setProviderId(e.target.value)}
|
onChange={(e) => onProviderChange(e.target.value)}
|
||||||
options={
|
options={PROVIDER_OPTIONS}
|
||||||
providers.length === 0
|
|
||||||
? [{ value: "", label: "ارائهدهندهای وجود ندارد" }]
|
|
||||||
: providers.map((p) => ({ value: p.id, label: p.name }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Field
|
<Select
|
||||||
label="نام داخلی"
|
label="اکانت agy"
|
||||||
required
|
required
|
||||||
value={internalName}
|
value={accountId}
|
||||||
onChange={(e) => setInternalName(e.target.value)}
|
onChange={(e) => onAccountChange(e.target.value)}
|
||||||
placeholder="gpt-4o-mini"
|
options={
|
||||||
className="ltr-inline"
|
accountOptions.length === 0
|
||||||
/>
|
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
|
||||||
<Field
|
: accountOptions
|
||||||
label="نام نمایشی برای مالک"
|
}
|
||||||
value={displayName}
|
hint="کاتالوگ مدلها از همین اکانت خوانده میشود."
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="مدل سریع و ارزان"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<Field
|
||||||
label="قیمت ورودی (به ازای ۱M توکن)"
|
label="قیمت ورودی (به ازای ۱M توکن)"
|
||||||
@@ -178,7 +306,7 @@ export default function AdminModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" loading={creating}>
|
<Button type="submit" loading={creating} disabled={!modelId}>
|
||||||
افزودن مدل
|
افزودن مدل
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +335,7 @@ export default function AdminModelsPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
<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>
|
||||||
<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">
|
<tr key={model.id} className="border-b border-surface-border/60">
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
|
<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>
|
||||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
<td className="px-2 py-2">
|
||||||
{providerName(model.provider_id)}
|
{model.reasoning_effort ? (
|
||||||
|
<Badge tone="info">{effortLabel(model.reasoning_effort)}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">پیشفرض</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function AdminOverviewPage() {
|
|||||||
<header className="flex flex-col gap-1">
|
<header className="flex flex-col gap-1">
|
||||||
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
|
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
مدیریت ارائهدهندهها، مدلها، سیاست مسیریابی و تعرفهها.
|
مدیریت اکانتهای agy، مدلها، سیاست مسیریابی و تعرفهها.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ export default function AdminOverviewPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardTitle>آداپتورهای فعال</CardTitle>
|
<CardTitle>آداپتورهای فعال</CardTitle>
|
||||||
<CardHint>
|
<CardHint>
|
||||||
این مقادیر از محیط خوانده میشوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
|
وضعیت سرویسهای بیرونی. هر مقدار «mock» یعنی آن سرویس هنوز متصل نشده
|
||||||
متصل نشده است.
|
است؛ مدلها فقط از agy (Google Antigravity) سرویس میشوند.
|
||||||
</CardHint>
|
</CardHint>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
|
{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">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "ارائهدهندهها و کلیدها",
|
title: "اکانتهای agy",
|
||||||
hint: "ساخت ارائهدهنده و ذخیرهٔ کلید رمزنگاریشده. کلید خام هرگز نمایش داده نمیشود.",
|
hint: "ورود با Google و پول چرخشی اکانتها. agy تنها ارائهدهندهٔ سامانه است.",
|
||||||
path: "/admin/providers",
|
path: "/admin/agy-accounts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "مدلها",
|
title: "مدلها",
|
||||||
hint: "نام داخلی، تواناییها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
|
hint: "انتخاب مدل از کاتالوگ زندهٔ اکانت، افورت استدلال و قیمت — فقط برای مالک سامانه.",
|
||||||
path: "/admin/models",
|
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
@@ -418,26 +418,6 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// --- platform owner -------------------------------------------------------
|
// --- 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.
|
// agy accounts: OAuth login instead of API keys.
|
||||||
agyAccounts: () =>
|
agyAccounts: () =>
|
||||||
apiRequest<import("@agy/contracts").AgyAccount[]>("/api/admin/agy/accounts"),
|
apiRequest<import("@agy/contracts").AgyAccount[]>("/api/admin/agy/accounts"),
|
||||||
@@ -466,16 +446,18 @@ export const api = {
|
|||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
|
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
|
||||||
agyModels: () =>
|
// Live model catalog of one account — fetched from Google on every call, so
|
||||||
apiRequest<string[]>("/api/admin/agy/models"),
|
// 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"),
|
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
||||||
adminCreateModel: (body: {
|
adminCreateModel: (body: {
|
||||||
provider_id: string;
|
|
||||||
internal_name: string;
|
internal_name: string;
|
||||||
display_name_for_admin: string;
|
display_name_for_admin?: string;
|
||||||
capabilities: import("@agy/contracts").Capability[];
|
capabilities: import("@agy/contracts").Capability[];
|
||||||
input_price_per_1m?: number;
|
input_price_per_1m?: number;
|
||||||
output_price_per_1m?: number;
|
output_price_per_1m?: number;
|
||||||
|
reasoning_effort?: string;
|
||||||
}) =>
|
}) =>
|
||||||
apiRequest<import("@agy/contracts").AdminModel>("/api/admin/models", {
|
apiRequest<import("@agy/contracts").AdminModel>("/api/admin/models", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
/**
|
/**
|
||||||
* Platform-owner-only destinations. `admin/layout.tsx` mirrors this list and
|
* 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.
|
* 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[] = [
|
export const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
|
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
|
||||||
{ href: "/admin/providers", label: "ارائهدهندهها", hint: "مدلها و کلیدها" },
|
{ href: "/admin/agy-accounts", label: "اکانتهای agy", hint: "ورود و پول چرخشی" },
|
||||||
{ href: "/admin/models", label: "مدلها", hint: "قابلیت و قیمت" },
|
{ href: "/admin/models", label: "مدلها", hint: "قابلیت و قیمت" },
|
||||||
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
|
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
|
||||||
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
||||||
|
|||||||
@@ -167,4 +167,24 @@ describe("agy account client", () => {
|
|||||||
expect(calls[0].method).toBe("DELETE");
|
expect(calls[0].method).toBe("DELETE");
|
||||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,52 @@ export type Role = "owner" | "admin" | "manager" | "member" | "viewer";
|
|||||||
|
|
||||||
export type MembershipStatus = "active" | "invited" | "suspended";
|
export type MembershipStatus = "active" | "invited" | "suspended";
|
||||||
|
|
||||||
/** Provider kinds the AI Control Plane can talk to. */
|
/**
|
||||||
export type ProviderType =
|
* Provider kinds the AI Control Plane can talk to. agy (Google Antigravity)
|
||||||
| "mock"
|
* is the only one — the platform no longer has a provider picker.
|
||||||
| "openai_compatible"
|
*/
|
||||||
| "anthropic"
|
export type ProviderType = "agy";
|
||||||
| "gemini"
|
|
||||||
| "openrouter"
|
/** agy reasoning efforts, cheapest first. Mirrors the backend's list. */
|
||||||
| "agy";
|
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. */
|
/** Rotation-pool lifecycle of one agy account. */
|
||||||
export type AgyAccountStatus = "active" | "cooldown" | "error";
|
export type AgyAccountStatus = "active" | "cooldown" | "error";
|
||||||
@@ -182,15 +220,6 @@ export interface Repository {
|
|||||||
private: boolean;
|
private: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
base_url: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminModel {
|
export interface AdminModel {
|
||||||
id: string;
|
id: string;
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
@@ -199,6 +228,8 @@ export interface AdminModel {
|
|||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
input_price_per_1m: number;
|
input_price_per_1m: number;
|
||||||
output_price_per_1m: number;
|
output_price_per_1m: number;
|
||||||
|
/** agy reasoning effort ("" = provider default). Owner-only metadata. */
|
||||||
|
reasoning_effort: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,21 +259,6 @@ export interface AdminOrganization {
|
|||||||
created_at: string;
|
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. */
|
/** Result of a routing dry-run: which model a mode would pick, and why. */
|
||||||
export interface ModelChoice {
|
export interface ModelChoice {
|
||||||
mode: string;
|
mode: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user