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
@@ -37,8 +37,7 @@ class ModelHealth:
@runtime_checkable
class ModelProviderPort(Protocol):
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
``OpenAICompatibleModelAdapter``."""
"""Driven port. Implemented by ``AgyModelAdapter`` (the only provider)."""
async def complete(
self,
@@ -714,8 +714,8 @@ class WorkforceService:
) -> str:
"""Run one completion and record its usage. The single model entry point."""
model = await self._resolve_model(employee)
# Pool-aware: rotation providers (agy) fan out across every usable
# account; key-based providers simply get their one active credential.
# Pool-aware: the agy adapter fans out across every usable account, so
# one account's rate limit never becomes everyone's outage.
pool = await self.credentials.list_active_for_provider(model.provider_id)
credential = pool[0] if pool else None
+4 -3
View File
@@ -60,9 +60,10 @@ class Settings(BaseSettings):
encryption_key: str = ""
# --- AI models (hidden from customers) -----------------------------------
ai_default_provider: str = "mock"
openai_compatible_base_url: str = ""
openai_compatible_api_key: str = ""
#: agy (Google Antigravity) is the only model provider the platform talks
#: to. The setting is kept so an old ``AI_DEFAULT_PROVIDER`` value in a
#: ``.env`` keeps booting: the registry warns and uses agy regardless.
ai_default_provider: str = "agy"
# --- agy (Google Antigravity) --------------------------------------------
#: OAuth client of the public Antigravity CLI application. Overridable so a
-1
View File
@@ -205,7 +205,6 @@ def _configured_secret_values() -> tuple[str, ...]:
candidates = (
settings.jwt_secret,
settings.encryption_key,
settings.openai_compatible_api_key,
settings.gitea_admin_token,
settings.daytona_api_key,
settings.e2b_api_key,
+16 -12
View File
@@ -14,13 +14,10 @@ from app.domain.base import new_id, utcnow
class ProviderType(StrEnum):
MOCK = "mock"
OPENAI_COMPATIBLE = "openai_compatible"
ANTHROPIC = "anthropic"
GEMINI = "gemini"
OPENROUTER = "openrouter"
#: Google Antigravity (``agy``) — OAuth accounts billed through a Google AI
#: subscription. Credentials of this type are *accounts*, not API keys.
#: subscription. It is the *only* provider the platform talks to; the
#: previous ``mock`` / ``openai_compatible`` kinds were removed with the
#: single-provider decision, and the migration drops their rows.
AGY = "agy"
@@ -34,6 +31,11 @@ class CredentialStatus(StrEnum):
ERROR = "error"
#: agy reasoning efforts, cheapest first. The admin console offers exactly
#: these; the adapter maps each to a thinking budget (see adapters/agy.py).
REASONING_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh")
class RoutingMode(StrEnum):
FAST = "fast"
CHEAP = "cheap"
@@ -65,7 +67,7 @@ class RateUnit(StrEnum):
@dataclass(slots=True, kw_only=True)
class Provider:
name: str
type: ProviderType = ProviderType.MOCK
type: ProviderType = ProviderType.AGY
base_url: str = ""
id: str = field(default_factory=new_id)
is_active: bool = True
@@ -74,12 +76,10 @@ class Provider:
@dataclass(slots=True, kw_only=True)
class ModelCredential:
"""One credential for a provider.
"""One credential for the agy provider — i.e. one Google *account*.
For key-based providers ``encrypted_api_key`` holds the encrypted API key and
the agy fields stay empty. For the agy provider the credential *is an
account*: ``encrypted_api_key`` holds the encrypted JSON blob
``{"refresh_token": ..., "project_id": ..., "email": ...}`` while ``email`` /
``encrypted_api_key`` holds the encrypted JSON blob
``{"refresh_token": ..., "project_id": ..., "email": ...}``, while ``email`` /
``project_id`` duplicate the identity columns so the admin console can show
and rotate accounts without touching the ciphertext.
"""
@@ -107,6 +107,10 @@ class AiModel:
capabilities: list[str] = field(default_factory=list)
input_price_per_1m: float = 0.0
output_price_per_1m: float = 0.0
#: agy reasoning effort: ``minimal | low | medium | high | xhigh`` (empty =
#: provider default). Only thinking-capable models receive it; the adapter
#: maps it to a ``thinkingConfig`` budget.
reasoning_effort: str = ""
id: str = field(default_factory=new_id)
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
+67 -6
View File
@@ -91,6 +91,33 @@ SAFETY_SETTINGS: list[dict[str, str]] = [
#: manual paste must fit inside it comfortably.
CHALLENGE_TTL_SECONDS = 600
#: agy ``reasoning_effort`` → thinking budget (tokens), mirroring the Antigravity
#: client. An unknown value falls back to ``medium`` server-side, and the UI
#: only ever offers these five.
EFFORT_THINKING_BUDGETS: dict[str, int] = {
"minimal": 1024,
"low": 4096,
"medium": 8192,
"high": 16384,
"xhigh": 32768,
}
#: Where thinking is honoured. A request must never carry ``thinkingConfig`` to
#: a model that does not support it — that is a hard 400 from the backend.
_THINKING_MARKERS: tuple[str, ...] = (
"thinking",
"gemini-3",
"gemini-2.5-pro",
"gemini-2.5-flash",
)
def _supports_thinking(model_name: str) -> bool:
lowered = model_name.lower()
if "image" in lowered:
return False
return any(marker in lowered for marker in _THINKING_MARKERS)
_StatusFn = Callable[..., Awaitable[None]]
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
@@ -603,7 +630,25 @@ class AgyModelAdapter:
accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
if not accounts:
return []
token = await self._access_token(accounts[0])
return await self._fetch_models(accounts[0])
async def available_models_for(self, credential: ModelCredential) -> list[str]:
"""Live model catalog of exactly one account.
Called per account selection in the admin console, so the list is
fetched fresh from Google every time (the access token is cached, the
``fetchAvailableModels`` round-trip is not) and reflects what *this*
subscription is entitled to.
"""
account = _account_of(credential)
if not account.refresh_token:
raise ProviderUnavailableError(
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
)
return await self._fetch_models(account)
async def _fetch_models(self, account: AgyAccount) -> list[str]:
token = await self._access_token(account)
for base in BASE_URLS:
try:
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -713,7 +758,8 @@ class AgyModelAdapter:
) -> ModelResponse:
token = await self._access_token(account)
payload = _build_generate_payload(
account, model.internal_name, messages, system, temperature, max_tokens
account, model.internal_name, messages, system, temperature, max_tokens,
reasoning_effort=model.reasoning_effort,
)
headers = {
"Authorization": f"Bearer {token}",
@@ -775,6 +821,7 @@ def _build_generate_payload(
system: str,
temperature: float,
max_tokens: int,
reasoning_effort: str = "",
) -> dict:
"""Translate the port's OpenAI-shaped call into the Antigravity envelope."""
system_parts: list[str] = [system] if system else []
@@ -786,13 +833,27 @@ def _build_generate_payload(
role = "model" if message.role == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": message.content}]})
generation: dict = {
"temperature": temperature,
"maxOutputTokens": max_tokens,
}
# Reasoning effort is only sent to models that can think; the backend
# rejects ``thinkingConfig`` everywhere else with a 400.
if reasoning_effort and _supports_thinking(model_name):
budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192)
generation["thinkingConfig"] = {
"thinkingBudget": budget,
"includeThoughts": True,
}
if max_tokens <= budget:
# The budget must fit inside the answer window or the thinking
# model has nowhere to emit its final answer.
generation["maxOutputTokens"] = budget + 8000
request: dict = {
"contents": contents,
"sessionId": _random_session_id(),
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens,
},
"generationConfig": generation,
"toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}},
"safetySettings": SAFETY_SETTINGS,
}
+3 -58
View File
@@ -1,13 +1,13 @@
"""Mock adapters — every port has one so the stack runs with zero credentials.
"""Mock adapters for the non-AI ports, so the stack runs with zero credentials.
These are not toys: they enforce the same contracts (timeouts, output caps,
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
change application behaviour.
change application behaviour. The model port has no mock — ``agy`` is the only
model provider (see ``adapters/agy.py``).
"""
from __future__ import annotations
import asyncio
import hashlib
import secrets
import time
@@ -17,14 +17,11 @@ from typing import Any
from app.application.ports import (
BranchInfo,
ChatMessage,
CheckoutSession,
CommandResult,
CommitInfo,
GitCredentials,
MergeResult,
ModelHealth,
ModelResponse,
Notification,
PaymentResult,
PreviewDomainInfo,
@@ -38,7 +35,6 @@ from app.application.ports import (
VectorHit,
WorkspaceInfo,
)
from app.domain.ai_control.models import AiModel
from app.domain.platform.rules import (
assert_port_allocatable,
build_host,
@@ -46,57 +42,6 @@ from app.domain.platform.rules import (
validate_proxy_target,
)
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class MockModelProviderAdapter:
"""Deterministic, offline stand-in for an LLM.
Returns a canned Persian acknowledgement that names the task so tests can
assert on it without a network call.
"""
def __init__(self, latency_ms: int = 0) -> None:
self._latency_ms = latency_ms
async def complete(
self,
*,
model: AiModel,
credential=None,
credentials=None, # accepted for port parity; the mock has no pool
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
started = time.perf_counter()
if self._latency_ms:
await asyncio.sleep(self._latency_ms / 1000)
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# The canned text must read like model output, not like a debug tag: the
# provider/model identity is platform-internal and must never surface in
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"mock": True},
)
async def health(self) -> ModelHealth:
return ModelHealth(ok=True, detail="mock provider")
# ---------------------------------------------------------------------------
# Git
# ---------------------------------------------------------------------------
@@ -1,98 +0,0 @@
"""Real model adapter for any OpenAI-compatible endpoint.
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
API key is read from the credential record (decrypted by the caller), never from
the environment at request time.
"""
from __future__ import annotations
import time
import httpx
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel, ModelCredential
class OpenAICompatibleModelAdapter:
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
self.base_url = (base_url or "").rstrip("/")
self.default_api_key = default_api_key
self.timeout = timeout
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
if credential is not None and credential.encrypted_api_key:
try:
return decrypt(credential.encrypted_api_key)
except Exception: # noqa: BLE001 - fall through to the env default
pass
return self.default_api_key
async def complete(
self,
*,
model: AiModel,
credential: ModelCredential | None = None,
credentials: list[ModelCredential] | None = None, # no pool here
messages: list[ChatMessage],
system: str = "",
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
if not self.base_url:
raise ProviderUnavailableError(
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
)
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
{"role": m.role, "content": m.content} for m in messages
]
payload = {
"model": model.internal_name,
"messages": payload_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {"Content-Type": "application/json"}
api_key = self.default_api_key
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/chat/completions", json=payload, headers=headers
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
raise ProviderUnavailableError(
f"ارائه‌دهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
) from exc
except httpx.HTTPError as exc:
raise ProviderUnavailableError("اتصال به ارائه‌دهندهٔ مدل برقرار نشد.") from exc
choice = (data.get("choices") or [{}])[0]
content = (choice.get("message") or {}).get("content", "") or ""
usage = data.get("usage") or {}
return ModelResponse(
content=content,
model_internal_name=data.get("model", model.internal_name),
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={"id": data.get("id", "")},
)
async def health(self) -> ModelHealth:
if not self.base_url:
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{self.base_url}/models")
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
except httpx.HTTPError as exc:
return ModelHealth(ok=False, detail=str(exc))
@@ -2,7 +2,9 @@
One place decides which concrete adapter backs each port, driven purely by the
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
clone runs with no credentials.
clone runs with no credentials — *except* the model provider: ``agy`` (Google
Antigravity) is the only one the platform talks to, and its accounts are added
from the admin console.
Adapters that hold process state (the mock Git forge, the mock port ledger) are
cached as singletons so behaviour is consistent within a process.
@@ -10,37 +12,36 @@ cached as singletons so behaviour is consistent within a process.
from __future__ import annotations
import logging
from functools import lru_cache
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.infrastructure.adapters import mock
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
logger = logging.getLogger("agy.registry")
@lru_cache
def get_model_provider():
provider = settings.ai_default_provider.lower()
if provider == "mock":
return mock.MockModelProviderAdapter()
if provider == "openai_compatible":
return OpenAICompatibleModelAdapter(
base_url=settings.openai_compatible_base_url,
default_api_key=settings.openai_compatible_api_key,
"""The one model adapter: agy over its logged-in account pool."""
configured = settings.ai_default_provider.strip().lower()
if configured != "agy":
# Tolerate a stale ``AI_DEFAULT_PROVIDER`` from before agy became the
# only provider: warn loudly, keep serving with agy either way.
logger.warning(
"AI_DEFAULT_PROVIDER=%r is obsolete — agy is the only supported "
"model provider; using it",
settings.ai_default_provider,
)
if provider == "agy":
from app.infrastructure.adapters.agy import (
AgyModelAdapter,
load_agy_accounts,
report_agy_state,
)
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
raise ProviderUnavailableError(
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمی‌شود. "
"مقادیر مجاز: mock | openai_compatible | agy"
from app.infrastructure.adapters.agy import (
AgyModelAdapter,
load_agy_accounts,
report_agy_state,
)
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
@lru_cache
def get_git_provider():
+6 -1
View File
@@ -115,7 +115,10 @@ class AiProviderRow(Base):
id: Mapped[str] = _pk()
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
#: agy is the only provider type; the column stays a string so old rows
#: (mock / openai_compatible, deleted by migration c2d3e4f5b6a7) cannot
#: break the enum mapping.
type: Mapped[str] = mapped_column(String(32), default="agy", nullable=False)
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
@@ -160,6 +163,8 @@ class AiModelRow(Base):
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
#: agy reasoning effort ("" = provider default); see ``AiModel``.
reasoning_effort: Mapped[str] = mapped_column(String(16), default="", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = _created()
@@ -33,8 +33,14 @@ from app.infrastructure.db.models import (
def _provider_to_domain(row: AiProviderRow) -> Provider:
# agy is the only provider kind; a legacy row (mock / openai_compatible,
# deleted by migration) still maps to the one enum member rather than
# raising, so a half-migrated database reads cleanly.
provider_type = (
ProviderType(row.type) if row.type == ProviderType.AGY.value else ProviderType.AGY
)
return Provider(
id=row.id, name=row.name, type=ProviderType(row.type),
id=row.id, name=row.name, type=provider_type,
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
)
@@ -57,6 +63,7 @@ def _model_to_domain(row: AiModelRow) -> AiModel:
capabilities=list(row.capabilities or []),
input_price_per_1m=row.input_price_per_1m,
output_price_per_1m=row.output_price_per_1m,
reasoning_effort=row.reasoning_effort or "",
is_active=row.is_active, created_at=row.created_at,
)
@@ -267,6 +274,7 @@ class AiModelRepository:
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort,
is_active=model.is_active, created_at=model.created_at,
)
self.session.add(row)
+41 -162
View File
@@ -1,11 +1,12 @@
"""Platform owner console: providers, models, routing policies and rate cards.
"""Platform owner console: agy models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced
here and tested:
1. Only a platform owner may call any of it.
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
a nested object. Only ``masked_key`` leaves this module.
2. Model identity stays inside the console: a customer never receives a raw
model or account name, and no credential material (a Google refresh token)
is ever returned — only the agy account metadata is, from the agy router.
"""
from __future__ import annotations
@@ -17,15 +18,12 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.crypto import encrypt, mask
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
REASONING_EFFORTS,
AiModel,
Capability,
ModelCredential,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
@@ -36,7 +34,6 @@ from app.domain.base import utcnow
from app.domain.identity.models import User
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
ProviderRepository,
RateCardRepository,
RoutingPolicyRepository,
@@ -73,38 +70,6 @@ router = APIRouter(
# ---------------------------------------------------------------------------
class ProviderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
type: str
base_url: str
is_active: bool
created_at: datetime
class CreateProviderRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
type: ProviderType = ProviderType.MOCK
base_url: str = Field(default="", max_length=512)
class CredentialOut(BaseModel):
"""Deliberately has no field capable of holding the plaintext key."""
id: str
provider_id: str
name: str
masked_key: str
is_active: bool
class CreateCredentialRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
api_key: str = Field(min_length=4, max_length=512)
class ModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -115,16 +80,19 @@ class ModelOut(BaseModel):
capabilities: list[str]
input_price_per_1m: float
output_price_per_1m: float
reasoning_effort: str
is_active: bool
class CreateModelRequest(BaseModel):
provider_id: str
#: The provider is no longer a client choice — agy is the only one, resolved
#: server-side. The model id must come from the account's live catalog.
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(default="", max_length=160)
capabilities: list[Capability] = Field(default_factory=list)
input_price_per_1m: float = Field(default=0.0, ge=0)
output_price_per_1m: float = Field(default=0.0, ge=0)
reasoning_effort: str = Field(default="", max_length=16)
class RoutingPolicyOut(BaseModel):
@@ -180,124 +148,29 @@ class AdminUsageRow(BaseModel):
total_price: float
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
providers = await ProviderRepository(session).list()
return [
ProviderOut(
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
is_active=p.is_active, created_at=p.created_at,
)
for p in providers
]
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
async def create_provider(
payload: CreateProviderRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ProviderOut:
repo = ProviderRepository(session)
if await repo.get_by_name(payload.name) is not None:
raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.")
provider = await repo.create(
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="provider", target_id=provider.id,
metadata={"name": provider.name, "type": provider.type.value},
ip=ip, user_agent=ua,
)
return ProviderOut(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=provider.is_active,
created_at=provider.created_at,
)
@router.post(
"/providers/{provider_id}/credentials",
response_model=CredentialOut,
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
provider_id: str,
payload: CreateCredentialRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CredentialOut:
provider = await ProviderRepository(session).get(provider_id)
if provider is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
credential = await ModelCredentialRepository(session).create(
ModelCredential(
provider_id=provider_id,
name=payload.name,
# Encrypted at rest; the plaintext exists only inside this request.
encrypted_api_key=encrypt(payload.api_key),
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="credential", target_id=credential.id,
metadata={"provider_id": provider_id, "name": credential.name},
ip=ip, user_agent=ua,
)
return CredentialOut(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
masked_key=mask(payload.api_key), is_active=credential.is_active,
)
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
async def list_credentials(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> list[CredentialOut]:
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
return [
CredentialOut(
id=c.id, provider_id=c.provider_id, name=c.name,
# We cannot recover the plaintext to mask it — and we must not try.
masked_key="••••••",
is_active=c.is_active,
)
for c in credentials
]
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
def _model_out(model: AiModel) -> ModelOut:
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort, is_active=model.is_active,
)
@router.get("/models", response_model=list[ModelOut])
async def list_models(
provider_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[ModelOut]:
models = await AiModelRepository(session).list(provider_id=provider_id)
return [
ModelOut(
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
display_name_for_admin=m.display_name_for_admin,
capabilities=list(m.capabilities),
input_price_per_1m=m.input_price_per_1m,
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
)
for m in models
]
return [_model_out(m) for m in models]
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
@@ -307,33 +180,39 @@ async def create_model(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ModelOut:
if await ProviderRepository(session).get(payload.provider_id) is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
from app.interfaces.http.routers.agy import ensure_agy_provider
model = await AiModelRepository(session).create(
effort = payload.reasoning_effort.strip().lower()
if effort and effort not in REASONING_EFFORTS:
raise ValidationError(
"افورت نامعتبر است. مقادیر مجاز: " + " | ".join(REASONING_EFFORTS)
)
provider = await ensure_agy_provider(ProviderRepository(session))
models = AiModelRepository(session)
existing = await models.list(provider_id=provider.id)
if any(m.internal_name == payload.internal_name for m in existing):
raise ConflictError("مدلی با این نام داخلی از قبل ثبت شده است.")
model = await models.create(
AiModel(
provider_id=payload.provider_id,
provider_id=provider.id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin,
display_name_for_admin=payload.display_name_for_admin or payload.internal_name,
capabilities=[str(c) for c in payload.capabilities],
input_price_per_1m=payload.input_price_per_1m,
output_price_per_1m=payload.output_price_per_1m,
reasoning_effort=effort,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model.id,
metadata={"internal_name": model.internal_name},
metadata={"internal_name": model.internal_name, "reasoning_effort": effort},
ip=ip, user_agent=ua,
)
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
)
return _model_out(model)
# ---------------------------------------------------------------------------
+27 -11
View File
@@ -110,7 +110,7 @@ class AgyTestOut(BaseModel):
# ---------------------------------------------------------------------------
async def _ensure_agy_provider(repo: ProviderRepository):
async def ensure_agy_provider(repo: ProviderRepository):
provider = await repo.get_by_name(AGY_PROVIDER_NAME)
if provider is not None:
return provider
@@ -176,7 +176,7 @@ async def complete_agy_login(
providers = ProviderRepository(session)
credentials = ModelCredentialRepository(session)
provider = await _ensure_agy_provider(providers)
provider = await ensure_agy_provider(providers)
envelope = json.dumps(
{
@@ -331,13 +331,29 @@ async def test_agy_account(
)
@router.get("/models", response_model=list[str])
async def list_agy_models() -> list[str]:
"""Model ids the logged-in agy subscription exposes (registry helper)."""
from app.infrastructure.adapters.registry import get_model_provider
@router.get("/accounts/{credential_id}/models", response_model=list[str])
async def list_account_models(
credential_id: str,
session: AsyncSession = Depends(get_session),
) -> list[str]:
"""Live model catalog of one account — fetched from Google on every call.
provider = get_model_provider()
lister = getattr(provider, "available_models", None)
if lister is None:
return []
return await lister()
The admin console calls this when an account is selected, so the model
list always reflects that subscription's current entitlement instead of a
cached snapshot.
"""
from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter
repo = ModelCredentialRepository(session)
credential = await repo.get(credential_id)
if credential is None:
raise NotFoundError("اکانت agy یافت نشد.")
adapter = AgyModelAdapter() # stateless: one forced account
try:
return await adapter.available_models_for(credential)
except ValidationError:
raise
except Exception as exc: # noqa: BLE001 - the console wants the reason
raise ValidationError(str(exc)) from exc
+34 -88
View File
@@ -2,9 +2,9 @@
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
organization that both of them own, the system agent templates, routing
policies, rate cards and a mock provider with two models. The agy (Google
Antigravity) provider and its model catalog are seeded too; agy models become
routable only when ``AI_DEFAULT_PROVIDER=agy``.
policies, rate cards and the agy (Google Antigravity) provider with its model
catalog. agy is the only model provider the platform talks to; accounts are
added from the admin console.
Safe to run repeatedly: every step checks for existing rows first.
@@ -20,7 +20,6 @@ import logging
from sqlalchemy import select
from app.core.config import settings
from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password
from app.domain.ai_control.models import (
@@ -64,14 +63,17 @@ DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org"
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
#: the Antigravity backend expects; the display name is admin-only. Seeded for
#: every deployment, activated for routing only when agy is the default
#: provider — see the seed step that consumes this table.
AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
#: the Antigravity backend expects; the display name is admin-only. The 4th
#: element is the reasoning effort (empty = provider default) — the console can
#: change it per model, and the adapter only sends it to thinking-capable
#: models. Seeded for every deployment and reconciled on *every* run, so a
#: database created by an older version converges without being rebuilt.
AGY_MODELS: list[tuple[str, str, list[Capability], str]] = [
(
"gemini-3-flash",
"Gemini 3 Flash",
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
"",
),
(
"gemini-3-pro-high",
@@ -80,23 +82,27 @@ AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
Capability.TEXT, Capability.REASONING, Capability.CODE,
Capability.VISION, Capability.LONG_CONTEXT,
],
"high",
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
"",
),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
[Capability.TEXT, Capability.CODE, Capability.REASONING],
"high",
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
[Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT],
"high",
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE]),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE], ""),
]
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
@@ -270,51 +276,11 @@ async def seed() -> None:
# as either one lands on a populated dashboard rather than an empty one.
await _ensure_org(session, members=[owner, demo])
if not (await session.execute(select(AiProviderRow))).scalars().first():
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
session.add(
AiProviderRow(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=True,
)
)
await session.flush()
for internal, display, caps in (
("mock-fast", "Mock Fast", [Capability.TEXT]),
(
"mock-reasoning",
"Mock Reasoning",
[Capability.TEXT, Capability.REASONING, Capability.CODE],
),
):
model = AiModel(
provider_id=provider.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
input_price_per_1m=0.3,
output_price_per_1m=1.2,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=True,
)
)
logger.info("created mock provider + 2 models")
# The agy catalog (Google Antigravity). It is seeded for every
# deployment — the admin console needs the provider and its models to
# exist before any account is logged in. Routing activation is
# reconciled on *every* seed run below, not only at insert time, so
# flipping AI_DEFAULT_PROVIDER and re-seeding reshapes routing without
# a fresh database.
agy_is_default = settings.ai_default_provider.strip().lower() == "agy"
# The agy catalog (Google Antigravity). agy is the only model provider,
# so the provider row and its models are created on a first run and
# reconciled on every later one: display names, capabilities, efforts
# and activation follow this table, and a model removed here stays in
# the database (neither table nor row deletion is safe for a seed).
agy_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "agy")
@@ -328,7 +294,7 @@ async def seed() -> None:
)
session.add(agy_provider_row)
await session.flush()
for internal, display, caps in AGY_MODELS:
for internal, display, caps, effort in AGY_MODELS:
existing = (
await session.execute(
select(AiModelRow).where(
@@ -338,6 +304,12 @@ async def seed() -> None:
)
).scalars().first()
if existing is not None:
# Reconcile: an older database has these rows with different
# metadata (efforts did not exist, capabilities moved).
existing.display_name_for_admin = display
existing.capabilities = [str(c) for c in caps]
existing.reasoning_effort = effort
existing.is_active = True
continue
model = AiModel(
provider_id=agy_provider_row.id,
@@ -349,6 +321,7 @@ async def seed() -> None:
# customer-facing price on the rate card.
input_price_per_1m=0.0,
output_price_per_1m=0.0,
reasoning_effort=effort,
)
session.add(
AiModelRow(
@@ -358,41 +331,14 @@ async def seed() -> None:
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=False,
reasoning_effort=model.reasoning_effort,
is_active=True,
)
)
# Reconcile routing with the configured default on every run: agy models
# are routable only when agy is the default, and the mock catalog must
# not win when it is not — a model without accounts fails at call time.
agy_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id
)
)
).scalars()
for row in agy_model_rows:
row.is_active = agy_is_default
if agy_is_default:
mock_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "mock")
)
).scalars().first()
if mock_provider_row is not None:
mock_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == mock_provider_row.id
)
)
).scalars()
for row in mock_model_rows:
row.is_active = False
logger.info(
"agy is the default provider; agy catalog activated "
"(accounts are added from the admin console)"
)
logger.info(
"agy is the only model provider; catalog active "
"(accounts are added from the admin console)"
)
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
for name, role_key, description, tools in AGENT_TEMPLATES: