agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user