agy تنها ارائه‌دهندهٔ مدل؛ حذف بخش ارائه‌دهنده‌ها و افزودن زنجیرهٔ انتخاب مدل

- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائه‌دهنده‌ها» از کنسول حذف شد؛ ورود اکانت‌ها فقط از صفحهٔ اکانت‌های agy
- صفحهٔ مدل‌ها بازطراحی شد: ارائه‌دهنده → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدل‌ها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه می‌کند و فقط به مدل‌های استدلالی می‌فرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیف‌های providerهای قدیمی
- تست‌ها: ۲۴۶ تست بک‌اند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
mjalmoudy94
2026-10-13 10:01:11 +03:30
parent a2284db4f4
commit da5a2a5d1a
29 changed files with 906 additions and 995 deletions
+6 -7
View File
@@ -27,15 +27,14 @@ JWT_SECRET=change-me
ENCRYPTION_KEY=change-me-32-bytes 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
+16 -15
View File
@@ -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
+4 -3
View File
@@ -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
-1
View File
@@ -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,
+16 -12
View File
@@ -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)
+67 -6
View File
@@ -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,
} }
+3 -58
View File
@@ -1,13 +1,13 @@
"""Mock adapters — every port has one so the stack runs with zero credentials. """Mock adapters for the non-AI ports, so the stack runs with zero credentials.
These are not toys: they enforce the same contracts (timeouts, output caps, 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,37 +12,36 @@ 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, report_agy_state,
report_agy_state,
)
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
raise ProviderUnavailableError(
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | openai_compatible | agy"
) )
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
@lru_cache @lru_cache
def get_git_provider(): def get_git_provider():
+6 -1
View File
@@ -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)
+41 -162
View File
@@ -1,11 +1,12 @@
"""Platform owner console: providers, models, routing policies and rate cards. """Platform owner console: agy models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced 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,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+27 -11
View File
@@ -110,7 +110,7 @@ class AgyTestOut(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _ensure_agy_provider(repo: ProviderRepository): async def ensure_agy_provider(repo: ProviderRepository):
provider = await repo.get_by_name(AGY_PROVIDER_NAME) 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
+34 -88
View File
@@ -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,41 +331,14 @@ 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 logger.info(
# are routable only when agy is the default, and the mock catalog must "agy is the only model provider; catalog active "
# not win when it is not — a model without accounts fails at call time. "(accounts are added from the admin console)"
agy_model_rows = ( )
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id
)
)
).scalars()
for row in agy_model_rows:
row.is_active = agy_is_default
if agy_is_default:
mock_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "mock")
)
).scalars().first()
if mock_provider_row is not None:
mock_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == mock_provider_row.id
)
)
).scalars()
for row in mock_model_rows:
row.is_active = False
logger.info(
"agy is the default provider; agy catalog activated "
"(accounts are added from the admin console)"
)
if not (await session.execute(select(AgentTemplateRow))).scalars().first(): if not (await session.execute(select(AgentTemplateRow))).scalars().first():
for name, role_key, description, tools in AGENT_TEMPLATES: for name, role_key, description, tools in AGENT_TEMPLATES:
+110 -13
View File
@@ -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,
) )
) )
+42 -94
View File
@@ -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
View File
@@ -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,
}
+3 -1
View File
@@ -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
-1
View File
@@ -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: "تعرفه‌ها" },
+179 -45
View File
@@ -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">
+7 -7
View File
@@ -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
View File
@@ -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",
+3 -1
View File
@@ -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: "قیمت واحدها" },
+20
View File
@@ -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");
});
}); });
+48 -32
View File
@@ -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;