agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""agy becomes the only model provider; models gain a reasoning effort
|
||||
|
||||
Two changes locked in together:
|
||||
|
||||
1. ``ai_models`` gains ``reasoning_effort`` (the agy thinking-budget selector
|
||||
the admin console sets per model). NOT NULL with an empty-string default so
|
||||
existing rows backfill to "provider default" without a rewrite.
|
||||
2. Every provider row that is not ``agy`` — and the models and credentials that
|
||||
hang off it — is deleted. agy is the only provider the platform talks to;
|
||||
a leftover ``mock``/``openai_compatible`` provider would keep dead rows
|
||||
selectable by ``pick_for_mode`` and confuse the console.
|
||||
|
||||
The seed reconciles this on every run, so a database that was already re-seeded
|
||||
finds nothing left to do here.
|
||||
|
||||
Revision ID: c2d3e4f5b6a7
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-10-13 09:05:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "c2d3e4f5b6a7"
|
||||
down_revision: str | None = "b1c2d3e4f5a6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ai_models",
|
||||
sa.Column(
|
||||
"reasoning_effort",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
# Children first: the FKs carry ON DELETE CASCADE, but spelling the deletes
|
||||
# out keeps the intent readable and works on every backend/driver combo
|
||||
# Alembic supports (including SQLite without PRAGMA introspection).
|
||||
op.execute(
|
||||
"DELETE FROM ai_models WHERE provider_id IN "
|
||||
"(SELECT id FROM ai_providers WHERE type <> 'agy')"
|
||||
)
|
||||
op.execute(
|
||||
"DELETE FROM ai_model_credentials WHERE provider_id IN "
|
||||
"(SELECT id FROM ai_providers WHERE type <> 'agy')"
|
||||
)
|
||||
op.execute("DELETE FROM ai_providers WHERE type <> 'agy'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The deleted rows are unrecoverable by design: their content was mock
|
||||
# fixtures and API keys the operator would have had to paste again.
|
||||
op.drop_column("ai_models", "reasoning_effort")
|
||||
@@ -37,8 +37,7 @@ class ModelHealth:
|
||||
|
||||
@runtime_checkable
|
||||
class ModelProviderPort(Protocol):
|
||||
"""Driven port. Implemented by ``MockModelProviderAdapter`` and
|
||||
``OpenAICompatibleModelAdapter``."""
|
||||
"""Driven port. Implemented by ``AgyModelAdapter`` (the only provider)."""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
|
||||
@@ -714,8 +714,8 @@ class WorkforceService:
|
||||
) -> str:
|
||||
"""Run one completion and record its usage. The single model entry point."""
|
||||
model = await self._resolve_model(employee)
|
||||
# Pool-aware: rotation providers (agy) fan out across every usable
|
||||
# account; key-based providers simply get their one active credential.
|
||||
# Pool-aware: the agy adapter fans out across every usable account, so
|
||||
# one account's rate limit never becomes everyone's outage.
|
||||
pool = await self.credentials.list_active_for_provider(model.provider_id)
|
||||
credential = pool[0] if pool else None
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+110
-13
@@ -1,7 +1,10 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
|
||||
suite needs no Postgres, no Redis and no credentials.
|
||||
Tests run against an in-memory SQLite database with the real agy-shaped adapter
|
||||
wiring and a fake model provider, so the suite needs no Postgres, no Redis, no
|
||||
Google and no accounts. agy is the only model provider the platform talks to
|
||||
(see ``app/infrastructure/adapters/registry.py``); the fake keeps completions
|
||||
deterministic and offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -96,6 +99,76 @@ async def client(engine, session_factory):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake model provider — the only sanctioned stand-in for agy in tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeModelProvider:
|
||||
"""Offline stand-in for the agy adapter.
|
||||
|
||||
The canned text must read like model output, not like a debug tag: the
|
||||
provider/model identity is platform-internal and must never surface in
|
||||
anything a customer can read (see the e2e smoke test).
|
||||
"""
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model,
|
||||
messages,
|
||||
system="",
|
||||
temperature=0.2,
|
||||
max_tokens=2048,
|
||||
credential=None,
|
||||
credentials=None,
|
||||
):
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from app.application.ports import ModelResponse
|
||||
|
||||
started = time.perf_counter()
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# A tiny sleep so latency bookkeeping runs a real code path.
|
||||
await asyncio.sleep(0)
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={},
|
||||
)
|
||||
|
||||
async def health(self):
|
||||
from app.application.ports import ModelHealth
|
||||
|
||||
return ModelHealth(ok=True, detail="fake agy pool")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_model_provider(monkeypatch):
|
||||
"""Swap the real model adapter out of every request path.
|
||||
|
||||
``container`` and the health router bind ``get_model_provider`` at import
|
||||
time, so both import sites are patched; without this a completion test
|
||||
would open the developer's real database and reach the real Google API.
|
||||
"""
|
||||
from app.application import container as di_container
|
||||
from app.interfaces.http.routers import health as health_router
|
||||
|
||||
provider = _FakeModelProvider()
|
||||
monkeypatch.setattr(di_container, "get_model_provider", lambda: provider)
|
||||
monkeypatch.setattr(health_router, "get_model_provider", lambda: provider)
|
||||
return provider
|
||||
|
||||
|
||||
async def signup(
|
||||
client: AsyncClient, email: str, password: str = "password123", name: str = ""
|
||||
) -> dict:
|
||||
@@ -149,10 +222,31 @@ SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
|
||||
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
|
||||
]
|
||||
|
||||
#: The mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
|
||||
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
|
||||
("mock-fast", "Mock Fast", ["text"]),
|
||||
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "code"]),
|
||||
#: The agy models ``app/seed.py`` installs: (internal_name, display,
|
||||
#: capabilities, reasoning_effort). agy is the only provider, so this is the
|
||||
#: whole catalogue.
|
||||
SYSTEM_MODELS: list[tuple[str, str, list[str], str]] = [
|
||||
("gemini-3-flash", "Gemini 3 Flash", ["text", "code", "long_context"], ""),
|
||||
(
|
||||
"gemini-3-pro-high",
|
||||
"Gemini 3 Pro",
|
||||
["text", "reasoning", "code", "vision", "long_context"],
|
||||
"high",
|
||||
),
|
||||
("claude-sonnet-4-5", "Claude Sonnet 4.5", ["text", "code", "tools"], ""),
|
||||
(
|
||||
"claude-sonnet-4-5-thinking",
|
||||
"Claude Sonnet 4.5 (Thinking)",
|
||||
["text", "code", "reasoning"],
|
||||
"high",
|
||||
),
|
||||
(
|
||||
"claude-opus-4-5-thinking",
|
||||
"Claude Opus 4.5 (Thinking)",
|
||||
["text", "reasoning", "code", "long_context"],
|
||||
"high",
|
||||
),
|
||||
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", ["text", "code"], ""),
|
||||
]
|
||||
|
||||
#: The rate cards ``app/seed.py`` installs: (unit, customer_price, internal_cost).
|
||||
@@ -174,11 +268,11 @@ SYSTEM_SANDBOX_TEMPLATES: list[tuple[str, str, str]] = [
|
||||
async def seeded(session_factory):
|
||||
"""Install the platform-level seed rows a fresh install would have.
|
||||
|
||||
Agent templates, routing policies, the mock provider/models, the rate cards
|
||||
and the sandbox templates are all *platform* data (no organization owns
|
||||
them), so without this an employee cannot be created, a run cannot resolve a
|
||||
model to route to, usage cannot be priced and no sandbox can be started —
|
||||
none of which the real seeded install lacks.
|
||||
Agent templates, routing policies, the agy provider with its model catalog,
|
||||
the rate cards and the sandbox templates are all *platform* data (no
|
||||
organization owns them), so without this an employee cannot be created, a
|
||||
run cannot resolve a model to route to, usage cannot be priced and no
|
||||
sandbox can be started — none of which the real seeded install lacks.
|
||||
"""
|
||||
from app.infrastructure.db.models import (
|
||||
AgentTemplateRow,
|
||||
@@ -214,9 +308,11 @@ async def seeded(session_factory):
|
||||
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
|
||||
provider_id = new_id()
|
||||
session.add(
|
||||
AiProviderRow(id=provider_id, name="mock", type="mock", base_url="", is_active=True)
|
||||
AiProviderRow(
|
||||
id=provider_id, name="agy", type="agy", base_url="", is_active=True
|
||||
)
|
||||
)
|
||||
for internal, display, capabilities in SYSTEM_MODELS:
|
||||
for internal, display, capabilities, effort in SYSTEM_MODELS:
|
||||
session.add(
|
||||
AiModelRow(
|
||||
provider_id=provider_id,
|
||||
@@ -225,6 +321,7 @@ async def seeded(session_factory):
|
||||
capabilities=capabilities,
|
||||
input_price_per_1m=0.3,
|
||||
output_price_per_1m=1.2,
|
||||
reasoning_effort=effort,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Two properties matter more than any feature here:
|
||||
|
||||
1. A normal customer can never reach these endpoints.
|
||||
2. A provider API key can never come back out of the API.
|
||||
2. Model identity and account material stay inside the console — a customer
|
||||
never sees a raw model, and nothing that could act as a credential (a Google
|
||||
refresh token, an API key) ever comes back out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,146 +14,92 @@ from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
SECRET_KEY = "sk-super-secret-value-do-not-leak-1234567890"
|
||||
|
||||
|
||||
async def test_normal_user_cannot_list_providers(client: AsyncClient):
|
||||
async def test_normal_user_cannot_list_models(client: AsyncClient):
|
||||
token = (await signup(client, "peon@example.com"))["access_token"]
|
||||
response = await client.get("/api/admin/providers", headers=auth_header(token))
|
||||
response = await client.get("/api/admin/models", headers=auth_header(token))
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"]["code"] == "permission_denied"
|
||||
|
||||
|
||||
async def test_normal_user_cannot_create_provider(client: AsyncClient):
|
||||
async def test_normal_user_cannot_create_model(client: AsyncClient):
|
||||
token = (await signup(client, "peon2@example.com"))["access_token"]
|
||||
response = await client.post(
|
||||
"/api/admin/providers",
|
||||
json={"name": "evil", "type": "mock", "base_url": ""},
|
||||
"/api/admin/models",
|
||||
json={"internal_name": "evil-model"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_endpoints_require_authentication(client: AsyncClient):
|
||||
response = await client.get("/api/admin/providers")
|
||||
response = await client.get("/api/admin/models")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_platform_owner_can_create_and_list_provider(
|
||||
async def test_model_creation_resolves_the_agy_provider_itself(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
"""The provider is not a client choice: agy is the only one."""
|
||||
created = await client.post(
|
||||
"/api/admin/providers",
|
||||
json={"name": "openrouter", "type": "openrouter", "base_url": "https://openrouter.ai/api/v1"},
|
||||
"/api/admin/models",
|
||||
json={
|
||||
"internal_name": "gemini-3-pro-high",
|
||||
"capabilities": ["text", "reasoning"],
|
||||
"input_price_per_1m": 0.3,
|
||||
"output_price_per_1m": 1.2,
|
||||
"reasoning_effort": "high",
|
||||
},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["name"] == "openrouter"
|
||||
body = created.json()
|
||||
assert body["internal_name"] == "gemini-3-pro-high"
|
||||
# The display name falls back to the model id; nothing is typed twice.
|
||||
assert body["display_name_for_admin"] == "gemini-3-pro-high"
|
||||
assert body["reasoning_effort"] == "high"
|
||||
assert body["provider_id"], "the agy provider must be resolved server-side"
|
||||
|
||||
listing = await client.get("/api/admin/providers", headers=auth_header(platform_owner_token))
|
||||
listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token))
|
||||
assert listing.status_code == 200
|
||||
assert [p["name"] for p in listing.json()] == ["openrouter"]
|
||||
assert [m["internal_name"] for m in listing.json()] == ["gemini-3-pro-high"]
|
||||
|
||||
|
||||
async def test_duplicate_provider_name_is_rejected(client: AsyncClient, platform_owner_token: str):
|
||||
payload = {"name": "dupe", "type": "mock", "base_url": ""}
|
||||
async def test_duplicate_model_name_is_rejected(client: AsyncClient, platform_owner_token: str):
|
||||
payload = {"internal_name": "claude-sonnet-4-5-thinking", "reasoning_effort": "high"}
|
||||
first = await client.post(
|
||||
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
|
||||
"/api/admin/models", json=payload, headers=auth_header(platform_owner_token)
|
||||
)
|
||||
assert first.status_code == 201
|
||||
second = await client.post(
|
||||
"/api/admin/providers", json=payload, headers=auth_header(platform_owner_token)
|
||||
"/api/admin/models", json=payload, headers=auth_header(platform_owner_token)
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_credential_creation_returns_only_a_masked_key(
|
||||
async def test_unknown_reasoning_effort_is_rejected(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
provider = (
|
||||
await client.post(
|
||||
"/api/admin/providers",
|
||||
json={"name": "p1", "type": "openai_compatible", "base_url": "https://x.test/v1"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
).json()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/admin/providers/{provider['id']}/credentials",
|
||||
json={"name": "primary", "api_key": SECRET_KEY},
|
||||
"/api/admin/models",
|
||||
json={"internal_name": "gemini-3-flash", "reasoning_effort": "ultra"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert SECRET_KEY not in str(body)
|
||||
assert body["masked_key"].startswith("•")
|
||||
assert body["masked_key"].endswith(SECRET_KEY[-4:])
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_listing_credentials_never_exposes_the_key(
|
||||
async def test_agy_account_and_model_endpoints_are_owner_only(
|
||||
client: AsyncClient, platform_owner_token: str
|
||||
):
|
||||
provider = (
|
||||
await client.post(
|
||||
"/api/admin/providers",
|
||||
json={"name": "p2", "type": "openai_compatible", "base_url": "https://y.test/v1"},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
).json()
|
||||
await client.post(
|
||||
f"/api/admin/providers/{provider['id']}/credentials",
|
||||
json={"name": "primary", "api_key": SECRET_KEY},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
|
||||
"""The account catalog is owner-only and never carries token material."""
|
||||
token = (await signup(client, "peon3@example.com"))["access_token"]
|
||||
response = await client.get(
|
||||
f"/api/admin/providers/{provider['id']}/credentials",
|
||||
headers=auth_header(platform_owner_token),
|
||||
"/api/admin/agy/accounts/whatever/models", headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert SECRET_KEY not in response.text
|
||||
assert response.json()[0]["masked_key"] == "••••••"
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_model_creation_and_listing(client: AsyncClient, platform_owner_token: str):
|
||||
provider = (
|
||||
await client.post(
|
||||
"/api/admin/providers",
|
||||
json={"name": "p3", "type": "mock", "base_url": ""},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
).json()
|
||||
|
||||
created = await client.post(
|
||||
"/api/admin/models",
|
||||
json={
|
||||
"provider_id": provider["id"],
|
||||
"internal_name": "internal-coder-v1",
|
||||
"display_name_for_admin": "Coder V1",
|
||||
"capabilities": ["text", "code"],
|
||||
"input_price_per_1m": 0.3,
|
||||
"output_price_per_1m": 1.2,
|
||||
},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
listing = await client.get("/api/admin/models", headers=auth_header(platform_owner_token))
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()[0]["internal_name"] == "internal-coder-v1"
|
||||
|
||||
|
||||
async def test_model_requires_existing_provider(client: AsyncClient, platform_owner_token: str):
|
||||
response = await client.post(
|
||||
"/api/admin/models",
|
||||
json={
|
||||
"provider_id": "does-not-exist",
|
||||
"internal_name": "x",
|
||||
"display_name_for_admin": "X",
|
||||
},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
unauthenticated = await client.get("/api/admin/agy/accounts/whatever/models")
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
async def test_routing_policy_creation_and_default_swap(
|
||||
|
||||
+173
-21
@@ -344,41 +344,42 @@ async def test_seed_creates_agy_catalog(client, seeded_install, session_factory)
|
||||
"claude-opus-4-5-thinking",
|
||||
"gpt-oss-120b-medium",
|
||||
}
|
||||
# Default deployment keeps routing on mock: agy models wait inactive.
|
||||
assert all(active is False for _, active in models)
|
||||
|
||||
|
||||
async def test_seed_activates_agy_when_it_is_default(
|
||||
monkeypatch, client, seeded_install, session_factory
|
||||
):
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "ai_default_provider", "agy")
|
||||
await seeded_install.seed()
|
||||
models = await _agy_models(session_factory)
|
||||
# agy is the only provider: the catalog is routable from the first run.
|
||||
assert all(active is True for _, active in models)
|
||||
|
||||
|
||||
async def test_seed_deactivates_mock_when_agy_is_default(
|
||||
monkeypatch, client, seeded_install, session_factory
|
||||
):
|
||||
async def test_seed_round_trips_efforts(client, seeded_install, session_factory):
|
||||
"""The seed's effort column is what the adapter later sends to Google."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.infrastructure.db.models import AiModelRow, AiProviderRow
|
||||
|
||||
monkeypatch.setattr(settings, "ai_default_provider", "agy")
|
||||
await seeded_install.seed()
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(AiModelRow)
|
||||
.join(AiProviderRow, AiModelRow.provider_id == AiProviderRow.id)
|
||||
.where(AiProviderRow.name == "mock")
|
||||
.where(AiProviderRow.name == "agy")
|
||||
)
|
||||
).scalars()
|
||||
assert all(row.is_active is False for row in rows)
|
||||
efforts = {row.internal_name: row.reasoning_effort for row in rows}
|
||||
assert efforts["gemini-3-pro-high"] == "high"
|
||||
assert efforts["claude-sonnet-4-5-thinking"] == "high"
|
||||
assert efforts["gemini-3-flash"] == ""
|
||||
|
||||
|
||||
async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_factory):
|
||||
"""agy is the only provider row a seeded install may carry."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.infrastructure.db.models import AiProviderRow
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(select(AiProviderRow))).scalars()
|
||||
types = {row.type for row in rows}
|
||||
names = {row.name for row in rows}
|
||||
assert types == {"agy"}
|
||||
assert "mock" not in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -519,6 +520,64 @@ def test_payload_translation(decrypted):
|
||||
assert all(s["threshold"] == "OFF" for s in payload["request"]["safetySettings"])
|
||||
|
||||
|
||||
def test_effort_becomes_a_thinking_config_on_a_thinking_model():
|
||||
"""Effort is translated, not forwarded: high → a 16384-token budget."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
|
||||
)
|
||||
payload = agy._build_generate_payload(
|
||||
account,
|
||||
"claude-sonnet-4-5-thinking",
|
||||
[ChatMessage(role="user", content="سلام")],
|
||||
system="",
|
||||
temperature=0.4,
|
||||
max_tokens=512,
|
||||
reasoning_effort="high",
|
||||
)
|
||||
generation = payload["request"]["generationConfig"]
|
||||
assert generation["thinkingConfig"] == {
|
||||
"thinkingBudget": 16384,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
# The budget must fit inside the answer window.
|
||||
assert generation["maxOutputTokens"] == 16384 + 8000
|
||||
|
||||
|
||||
def test_effort_is_not_sent_to_a_non_thinking_model():
|
||||
"""``thinkingConfig`` on a model that cannot think is a hard 400."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
|
||||
)
|
||||
payload = agy._build_generate_payload(
|
||||
account,
|
||||
"claude-sonnet-4-5",
|
||||
[ChatMessage(role="user", content="سلام")],
|
||||
system="",
|
||||
temperature=0.4,
|
||||
max_tokens=512,
|
||||
reasoning_effort="high",
|
||||
)
|
||||
assert "thinkingConfig" not in payload["request"]["generationConfig"]
|
||||
assert payload["request"]["generationConfig"]["maxOutputTokens"] == 512
|
||||
|
||||
|
||||
def test_unknown_effort_falls_back_to_medium():
|
||||
account = agy.AgyAccount(
|
||||
credential_id="c1", email="a@b.c", project_id="proj-1", refresh_token="refresh-1"
|
||||
)
|
||||
payload = agy._build_generate_payload(
|
||||
account,
|
||||
"claude-opus-4-5-thinking",
|
||||
[ChatMessage(role="user", content="سلام")],
|
||||
system="",
|
||||
temperature=0.4,
|
||||
max_tokens=512,
|
||||
reasoning_effort="turbo",
|
||||
)
|
||||
budget = payload["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"]
|
||||
assert budget == 8192
|
||||
|
||||
|
||||
def test_response_parsing_skips_thoughts():
|
||||
model = AiModel(provider_id="p", internal_name="m", display_name_for_admin="m")
|
||||
response = agy._parse_generate_response(
|
||||
@@ -713,3 +772,96 @@ async def test_available_models(google, decrypted):
|
||||
adapter = agy.AgyModelAdapter(loader=loader)
|
||||
models = await adapter.available_models()
|
||||
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live per-account model catalog (the console's model dropdown source)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_available_models_for_one_account(google, decrypted):
|
||||
"""The console asks for a specific account, so the fetch is per-account."""
|
||||
adapter = agy.AgyModelAdapter()
|
||||
models = await adapter.available_models_for(
|
||||
_agy_credential(email="one@gmail.com")
|
||||
)
|
||||
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
# The list is fetched from Google every call — no cached catalog.
|
||||
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
|
||||
assert len(fetches) == 1
|
||||
|
||||
|
||||
async def test_available_models_for_account_without_material():
|
||||
"""A credential with no refresh token can never list models."""
|
||||
adapter = agy.AgyModelAdapter()
|
||||
credential = ModelCredential(
|
||||
provider_id="p",
|
||||
name="empty@gmail.com",
|
||||
encrypted_api_key="PLAINTEXT::{}",
|
||||
email="empty@gmail.com",
|
||||
)
|
||||
with pytest.raises(ProviderUnavailableError):
|
||||
await adapter.available_models_for(credential)
|
||||
|
||||
|
||||
async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
"""GET /accounts/{id}/models returns that account's live catalog."""
|
||||
started = await client.post(
|
||||
"/api/admin/agy/accounts/login/start",
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
state = parse_qs(urlparse(started.json()["auth_url"]).query)["state"][0]
|
||||
account_id = (
|
||||
await client.post(
|
||||
"/api/admin/agy/accounts/login/complete",
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
},
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/admin/agy/accounts/{account_id}/models",
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
|
||||
missing = await client.get(
|
||||
"/api/admin/agy/accounts/does-not-exist/models",
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||
"""The end-to-end path: effort on the model row reaches the payload."""
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
if url.endswith("/v1internal:generateContent"):
|
||||
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
calls = patch_google(monkeypatch, handler)
|
||||
model = AiModel(
|
||||
provider_id="p",
|
||||
internal_name="claude-sonnet-4-5-thinking",
|
||||
display_name_for_admin="f",
|
||||
reasoning_effort="low",
|
||||
)
|
||||
adapter = agy.AgyModelAdapter()
|
||||
await adapter.complete(
|
||||
model=model,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
generate = [c for c in calls if c["url"].endswith("generateContent")]
|
||||
assert generate[0]["json"]["request"]["generationConfig"]["thinkingConfig"] == {
|
||||
"thinkingBudget": 4096,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""§18.3 — the minimal end-to-end smoke path.
|
||||
|
||||
One test walks the whole product in order, through the real HTTP API and the
|
||||
real application services, on the mock adapters:
|
||||
real application services, against the agy-shaped catalogue (the seeded fixture
|
||||
installs the agy provider and its models) with the fake model provider from
|
||||
conftest standing in for the Google round-trip:
|
||||
|
||||
signup -> organization -> project -> repo -> preview domain -> sandbox
|
||||
-> employee -> manager message -> task -> run -> usage -> audit
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useSession } from "@/lib/session";
|
||||
const SECTIONS = [
|
||||
{ href: "/admin", label: "نمای کلی" },
|
||||
{ href: "/admin/agy-accounts", label: "اکانتهای agy" },
|
||||
{ href: "/admin/providers", label: "ارائهدهندهها" },
|
||||
{ href: "/admin/models", label: "مدلها" },
|
||||
{ href: "/admin/routing", label: "مسیریابی" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminModel, AdminProvider, Capability } from "@agy/contracts";
|
||||
import {
|
||||
effortsForModel,
|
||||
effortLabel,
|
||||
type AdminModel,
|
||||
type AgyAccount,
|
||||
type Capability,
|
||||
type ReasoningEffort,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -17,17 +24,34 @@ import {
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Model catalogue (platform owner only).
|
||||
*
|
||||
* agy is the only provider, and nothing about a model is typed by hand: the
|
||||
* console asks the selected *account* for its live catalogue, the owner picks a
|
||||
* model and a reasoning effort, and the rest is metadata. The model id itself
|
||||
* never leaves the console — a customer only ever sees routing choices.
|
||||
*/
|
||||
|
||||
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
|
||||
|
||||
/** The single provider the platform talks to. */
|
||||
const PROVIDER_OPTIONS = [{ value: "agy", label: "agy (Google Antigravity)" }];
|
||||
|
||||
export default function AdminModelsPage() {
|
||||
const [models, setModels] = React.useState<AdminModel[]>([]);
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [accounts, setAccounts] = React.useState<AgyAccount[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [providerId, setProviderId] = React.useState("");
|
||||
const [internalName, setInternalName] = React.useState("");
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
// The add-model cascade: provider -> account -> model -> effort.
|
||||
const [providerId, setProviderId] = React.useState("agy");
|
||||
const [accountId, setAccountId] = React.useState("");
|
||||
const [catalog, setCatalog] = React.useState<string[] | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = React.useState(false);
|
||||
const [catalogError, setCatalogError] = React.useState<string | null>(null);
|
||||
const [modelId, setModelId] = React.useState("");
|
||||
const [effort, setEffort] = React.useState("");
|
||||
const [caps, setCaps] = React.useState<Capability[]>(["text"]);
|
||||
const [inputPrice, setInputPrice] = React.useState("");
|
||||
const [outputPrice, setOutputPrice] = React.useState("");
|
||||
@@ -38,13 +62,12 @@ export default function AdminModelsPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextModels, nextProviders] = await Promise.all([
|
||||
const [nextModels, nextAccounts] = await Promise.all([
|
||||
api.adminModels(),
|
||||
api.adminProviders(),
|
||||
api.agyAccounts(),
|
||||
]);
|
||||
setModels(nextModels);
|
||||
setProviders(nextProviders);
|
||||
setProviderId((current) => current || nextProviders[0]?.id || "");
|
||||
setAccounts(nextAccounts);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت مدلها ناموفق بود.");
|
||||
} finally {
|
||||
@@ -56,6 +79,72 @@ export default function AdminModelsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** Reload only the accounts — the provider step of the cascade. */
|
||||
const loadAccounts = React.useCallback(async () => {
|
||||
try {
|
||||
setAccounts(await api.agyAccounts());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت اکانتها ناموفق بود.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Live catalogue of the selected account — fetched from Google on every
|
||||
* account change (and on explicit refresh), never from a cached list.
|
||||
*/
|
||||
const loadCatalog = React.useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setCatalog(null);
|
||||
setCatalogError(null);
|
||||
return;
|
||||
}
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(null);
|
||||
try {
|
||||
setCatalog(await api.agyAccountModels(id));
|
||||
} catch (err) {
|
||||
setCatalog(null);
|
||||
setCatalogError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "دریافت کاتالوگ مدلها از agy ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onProviderChange = (value: string) => {
|
||||
setProviderId(value);
|
||||
// One provider exists, but the flow is wired for real: picking it reloads
|
||||
// the account list, which is what the next step depends on.
|
||||
setAccountId("");
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
setCatalog(null);
|
||||
setCatalogError(null);
|
||||
void loadAccounts();
|
||||
};
|
||||
|
||||
const onAccountChange = (value: string) => {
|
||||
setAccountId(value);
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
void loadCatalog(value);
|
||||
};
|
||||
|
||||
const onModelChange = (value: string) => {
|
||||
setModelId(value);
|
||||
setEffort("");
|
||||
// Sensible defaults the owner can still adjust: everything answers text,
|
||||
// and a thinking-capable model is a reasoning model.
|
||||
setCaps(
|
||||
effortsForModel(value).length > 0
|
||||
? ["text", "reasoning"]
|
||||
: ["text"],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCap = (cap: Capability) => {
|
||||
setCaps((current) =>
|
||||
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
|
||||
@@ -65,8 +154,8 @@ export default function AdminModelsPage() {
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
if (!providerId) {
|
||||
setFormError("ابتدا یک ارائهدهنده بسازید.");
|
||||
if (!modelId) {
|
||||
setFormError("ابتدا یک مدل از کاتالوگ اکانت انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
if (caps.length === 0) {
|
||||
@@ -76,16 +165,15 @@ export default function AdminModelsPage() {
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateModel({
|
||||
provider_id: providerId,
|
||||
internal_name: internalName.trim(),
|
||||
display_name_for_admin: displayName.trim() || internalName.trim(),
|
||||
internal_name: modelId,
|
||||
capabilities: caps,
|
||||
input_price_per_1m: Number(inputPrice) || 0,
|
||||
output_price_per_1m: Number(outputPrice) || 0,
|
||||
reasoning_effort: effort || undefined,
|
||||
});
|
||||
setModels((current) => [created, ...current]);
|
||||
setInternalName("");
|
||||
setDisplayName("");
|
||||
setModelId("");
|
||||
setEffort("");
|
||||
setInputPrice("");
|
||||
setOutputPrice("");
|
||||
setCaps(["text"]);
|
||||
@@ -96,53 +184,93 @@ export default function AdminModelsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
|
||||
const accountOptions = accounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.email || a.name || a.id,
|
||||
}));
|
||||
const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id }));
|
||||
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) }));
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">مدلها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
نام داخلی مدلها فقط در این کنسول دیده میشود. مشتری هرگز نام مدل یا ارائهدهنده را
|
||||
نمیبیند.
|
||||
agy تنها ارائهدهندهٔ سامانه است. برای افزودن مدل، اکانت را انتخاب کنید؛
|
||||
کاتالوگ مدلها لحظهای و مستقیم از همان اکانت خوانده میشود. نام مدل فقط
|
||||
در این کنسول دیده میشود و مشتری هرگز آن را نمیبیند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن مدل</CardTitle>
|
||||
<CardHint>
|
||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای محاسبهٔ هزینهٔ
|
||||
داخلی هستند.
|
||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای
|
||||
محاسبهٔ هزینهٔ داخلی هستند.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="ارائهدهنده"
|
||||
required
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
options={
|
||||
providers.length === 0
|
||||
? [{ value: "", label: "ارائهدهندهای وجود ندارد" }]
|
||||
: providers.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
onChange={(e) => onProviderChange(e.target.value)}
|
||||
options={PROVIDER_OPTIONS}
|
||||
/>
|
||||
<Field
|
||||
label="نام داخلی"
|
||||
<Select
|
||||
label="اکانت agy"
|
||||
required
|
||||
value={internalName}
|
||||
onChange={(e) => setInternalName(e.target.value)}
|
||||
placeholder="gpt-4o-mini"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label="نام نمایشی برای مالک"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="مدل سریع و ارزان"
|
||||
value={accountId}
|
||||
onChange={(e) => onAccountChange(e.target.value)}
|
||||
options={
|
||||
accountOptions.length === 0
|
||||
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
|
||||
: accountOptions
|
||||
}
|
||||
hint="کاتالوگ مدلها از همین اکانت خوانده میشود."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="مدل"
|
||||
required
|
||||
value={modelId}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
options={
|
||||
!accountId
|
||||
? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }]
|
||||
: modelOptions.length === 0
|
||||
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
|
||||
: modelOptions
|
||||
}
|
||||
disabled={!accountId || catalogLoading || modelOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
label="افورت استدلال"
|
||||
value={effort}
|
||||
onChange={(e) => setEffort(e.target.value)}
|
||||
options={effortOptions}
|
||||
disabled={efforts.length === 0}
|
||||
hint={
|
||||
efforts.length === 0
|
||||
? "این مدل افورت استدلال ندارد."
|
||||
: "بودجهٔ تفکر مدل؛ روی مدلهای غیراستدلالی نادیده گرفته میشود."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(accountId && catalogLoading) || (accountId && catalog === null && !catalogError) ? (
|
||||
<LoadingState label="در حال دریافت کاتالوگ مدلها از اکانت…" />
|
||||
) : catalogError ? (
|
||||
<ErrorState
|
||||
title="دریافت کاتالوگ ناموفق"
|
||||
message={catalogError}
|
||||
onRetry={() => void loadCatalog(accountId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="قیمت ورودی (به ازای ۱M توکن)"
|
||||
@@ -178,7 +306,7 @@ export default function AdminModelsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
<Button type="submit" loading={creating} disabled={!modelId}>
|
||||
افزودن مدل
|
||||
</Button>
|
||||
</div>
|
||||
@@ -207,7 +335,7 @@ export default function AdminModelsPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام داخلی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ارائهدهنده</th>
|
||||
<th className="px-2 py-2 text-right font-medium">افورت</th>
|
||||
<th className="px-2 py-2 text-right font-medium">تواناییها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ورودی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">خروجی</th>
|
||||
@@ -219,10 +347,16 @@ export default function AdminModelsPage() {
|
||||
<tr key={model.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2">
|
||||
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
|
||||
<p className="text-xs text-slate-500">{model.display_name_for_admin}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{model.display_name_for_admin}
|
||||
</p>
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
||||
{providerName(model.provider_id)}
|
||||
<td className="px-2 py-2">
|
||||
{model.reasoning_effort ? (
|
||||
<Badge tone="info">{effortLabel(model.reasoning_effort)}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">پیشفرض</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function AdminOverviewPage() {
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
مدیریت ارائهدهندهها، مدلها، سیاست مسیریابی و تعرفهها.
|
||||
مدیریت اکانتهای agy، مدلها، سیاست مسیریابی و تعرفهها.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -69,8 +69,8 @@ export default function AdminOverviewPage() {
|
||||
<Card>
|
||||
<CardTitle>آداپتورهای فعال</CardTitle>
|
||||
<CardHint>
|
||||
این مقادیر از محیط خوانده میشوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
|
||||
متصل نشده است.
|
||||
وضعیت سرویسهای بیرونی. هر مقدار «mock» یعنی آن سرویس هنوز متصل نشده
|
||||
است؛ مدلها فقط از agy (Google Antigravity) سرویس میشوند.
|
||||
</CardHint>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
|
||||
@@ -84,13 +84,13 @@ export default function AdminOverviewPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
title: "ارائهدهندهها و کلیدها",
|
||||
hint: "ساخت ارائهدهنده و ذخیرهٔ کلید رمزنگاریشده. کلید خام هرگز نمایش داده نمیشود.",
|
||||
path: "/admin/providers",
|
||||
title: "اکانتهای agy",
|
||||
hint: "ورود با Google و پول چرخشی اکانتها. agy تنها ارائهدهندهٔ سامانه است.",
|
||||
path: "/admin/agy-accounts",
|
||||
},
|
||||
{
|
||||
title: "مدلها",
|
||||
hint: "نام داخلی، تواناییها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
|
||||
hint: "انتخاب مدل از کاتالوگ زندهٔ اکانت، افورت استدلال و قیمت — فقط برای مالک سامانه.",
|
||||
path: "/admin/models",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminCredential, AdminProvider, ProviderType } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
|
||||
{ value: "mock", label: "آزمایشی (mock)" },
|
||||
{ value: "openai_compatible", label: "سازگار با OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "openrouter", label: "OpenRouter" },
|
||||
{ value: "agy", label: "agy (Google Antigravity)" },
|
||||
];
|
||||
|
||||
export default function AdminProvidersPage() {
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [type, setType] = React.useState<ProviderType>("openai_compatible");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProviders(await api.adminProviders());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت ارائهدهندهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateProvider({
|
||||
name: name.trim(),
|
||||
type,
|
||||
base_url: baseUrl.trim(),
|
||||
});
|
||||
setProviders((current) => [created, ...current]);
|
||||
setName("");
|
||||
setBaseUrl("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت ارائهدهنده ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">ارائهدهندهها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر ارائهدهنده میتواند چند کلید داشته باشد. کلیدها رمزنگاریشده ذخیره میشوند و فقط
|
||||
بهصورت ماسکشده برگردانده میشوند.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن ارائهدهنده</CardTitle>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="نام"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً openai-main"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Select
|
||||
label="نوع"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as ProviderType)}
|
||||
options={PROVIDER_TYPES}
|
||||
/>
|
||||
<Field
|
||||
label="آدرس پایه"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت ارائهدهنده ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت ارائهدهندهها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : providers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="ارائهدهندهای ثبت نشده است"
|
||||
description="با فرم بالا اولین ارائهدهنده را اضافه کنید."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ provider }: { provider: AdminProvider }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [credentials, setCredentials] = React.useState<AdminCredential[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [credName, setCredName] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const loadCredentials = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setCredentials(await api.adminCredentials(provider.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کلیدها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [provider.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) void loadCredentials();
|
||||
}, [open, loadCredentials]);
|
||||
|
||||
const onAddCredential = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await api.adminCreateCredential(provider.id, {
|
||||
name: credName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
});
|
||||
// The response carries the masked form only; the plaintext is gone.
|
||||
setCredentials((current) => [...current, created]);
|
||||
setCredName("");
|
||||
setApiKey("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ کلید ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="ltr-inline text-sm font-medium text-slate-100">{provider.name}</p>
|
||||
<Badge tone={provider.is_active ? "success" : "neutral"}>
|
||||
{provider.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="ltr-inline text-xs text-slate-500">
|
||||
{provider.type}
|
||||
{provider.base_url ? ` · ${provider.base_url}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "بستن کلیدها" : "مدیریت کلیدها"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 flex flex-col gap-4 border-t border-surface-border pt-4">
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کلیدها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadCredentials} />
|
||||
) : credentials.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">کلیدی ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{credentials.map((credential) => (
|
||||
<li
|
||||
key={credential.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{credential.name}</span>
|
||||
<span className="ltr-inline font-mono text-xs text-slate-500">
|
||||
{credential.masked_key}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={onAddCredential} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="نام کلید"
|
||||
required
|
||||
value={credName}
|
||||
onChange={(e) => setCredName(e.target.value)}
|
||||
placeholder="production"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="کلید API"
|
||||
required
|
||||
type="password"
|
||||
minLength={4}
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
hint="پس از ذخیره دیگر قابل بازیابی نیست."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</form>
|
||||
{formError && <ErrorState title="ذخیرهٔ کلید ناموفق" message={formError} />}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+6
-24
@@ -418,26 +418,6 @@ export const api = {
|
||||
),
|
||||
|
||||
// --- platform owner -------------------------------------------------------
|
||||
adminProviders: () =>
|
||||
apiRequest<import("@agy/contracts").AdminProvider[]>("/api/admin/providers"),
|
||||
adminCreateProvider: (body: {
|
||||
name: string;
|
||||
type: import("@agy/contracts").ProviderType;
|
||||
base_url?: string;
|
||||
}) =>
|
||||
apiRequest<import("@agy/contracts").AdminProvider>("/api/admin/providers", {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
adminCredentials: (providerId: string) =>
|
||||
apiRequest<import("@agy/contracts").AdminCredential[]>(
|
||||
`/api/admin/providers/${providerId}/credentials`,
|
||||
),
|
||||
adminCreateCredential: (providerId: string, body: { name: string; api_key: string }) =>
|
||||
apiRequest<import("@agy/contracts").AdminCredential>(
|
||||
`/api/admin/providers/${providerId}/credentials`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
// agy accounts: OAuth login instead of API keys.
|
||||
agyAccounts: () =>
|
||||
apiRequest<import("@agy/contracts").AgyAccount[]>("/api/admin/agy/accounts"),
|
||||
@@ -466,16 +446,18 @@ export const api = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
|
||||
agyModels: () =>
|
||||
apiRequest<string[]>("/api/admin/agy/models"),
|
||||
// Live model catalog of one account — fetched from Google on every call, so
|
||||
// the model dropdown always matches that subscription's entitlement.
|
||||
agyAccountModels: (accountId: string) =>
|
||||
apiRequest<string[]>(`/api/admin/agy/accounts/${accountId}/models`),
|
||||
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
|
||||
adminCreateModel: (body: {
|
||||
provider_id: string;
|
||||
internal_name: string;
|
||||
display_name_for_admin: string;
|
||||
display_name_for_admin?: string;
|
||||
capabilities: import("@agy/contracts").Capability[];
|
||||
input_price_per_1m?: number;
|
||||
output_price_per_1m?: number;
|
||||
reasoning_effort?: string;
|
||||
}) =>
|
||||
apiRequest<import("@agy/contracts").AdminModel>("/api/admin/models", {
|
||||
method: "POST",
|
||||
|
||||
@@ -27,10 +27,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
/**
|
||||
* Platform-owner-only destinations. `admin/layout.tsx` mirrors this list and
|
||||
* renders it as a tab strip; the API is the real security boundary either way.
|
||||
* agy is the only model provider, so there is no providers section anymore —
|
||||
* accounts live at `/admin/agy-accounts`.
|
||||
*/
|
||||
export const ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
|
||||
{ href: "/admin/providers", label: "ارائهدهندهها", hint: "مدلها و کلیدها" },
|
||||
{ href: "/admin/agy-accounts", label: "اکانتهای agy", hint: "ورود و پول چرخشی" },
|
||||
{ href: "/admin/models", label: "مدلها", hint: "قابلیت و قیمت" },
|
||||
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
||||
|
||||
@@ -167,4 +167,24 @@ describe("agy account client", () => {
|
||||
expect(calls[0].method).toBe("DELETE");
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
|
||||
});
|
||||
|
||||
it("fetches the live model catalog for one account", async () => {
|
||||
const calls = stubJson(["gemini-3-flash", "gemini-3-pro-high"]);
|
||||
const models = await api.agyAccountModels("a1");
|
||||
expect(calls[0].method).toBe("GET");
|
||||
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
|
||||
expect(models).toContain("gemini-3-pro-high");
|
||||
});
|
||||
|
||||
it("creates a model from the cascade — no manual names", async () => {
|
||||
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
|
||||
await api.adminCreateModel({
|
||||
internal_name: "gemini-3-pro-high",
|
||||
capabilities: ["text", "reasoning"],
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
expect(calls[0].url).toContain("/api/admin/models");
|
||||
expect(calls[0].body).toContain("gemini-3-pro-high");
|
||||
expect(calls[0].body).toContain("reasoning_effort");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user