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

- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائه‌دهنده‌ها» از کنسول حذف شد؛ ورود اکانت‌ها فقط از صفحهٔ اکانت‌های agy
- صفحهٔ مدل‌ها بازطراحی شد: ارائه‌دهنده → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدل‌ها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه می‌کند و فقط به مدل‌های استدلالی می‌فرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیف‌های providerهای قدیمی
- تست‌ها: ۲۴۶ تست بک‌اند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
mjalmoudy94
2026-10-13 10:01:11 +03:30
parent a2284db4f4
commit da5a2a5d1a
29 changed files with 906 additions and 995 deletions
+41 -162
View File
@@ -1,11 +1,12 @@
"""Platform owner console: providers, models, routing policies and rate cards.
"""Platform owner console: agy models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced
here and tested:
1. Only a platform owner may call any of it.
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
a nested object. Only ``masked_key`` leaves this module.
2. Model identity stays inside the console: a customer never receives a raw
model or account name, and no credential material (a Google refresh token)
is ever returned — only the agy account metadata is, from the agy router.
"""
from __future__ import annotations
@@ -17,15 +18,12 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.crypto import encrypt, mask
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
REASONING_EFFORTS,
AiModel,
Capability,
ModelCredential,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
@@ -36,7 +34,6 @@ from app.domain.base import utcnow
from app.domain.identity.models import User
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
ProviderRepository,
RateCardRepository,
RoutingPolicyRepository,
@@ -73,38 +70,6 @@ router = APIRouter(
# ---------------------------------------------------------------------------
class ProviderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
type: str
base_url: str
is_active: bool
created_at: datetime
class CreateProviderRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
type: ProviderType = ProviderType.MOCK
base_url: str = Field(default="", max_length=512)
class CredentialOut(BaseModel):
"""Deliberately has no field capable of holding the plaintext key."""
id: str
provider_id: str
name: str
masked_key: str
is_active: bool
class CreateCredentialRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
api_key: str = Field(min_length=4, max_length=512)
class ModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -115,16 +80,19 @@ class ModelOut(BaseModel):
capabilities: list[str]
input_price_per_1m: float
output_price_per_1m: float
reasoning_effort: str
is_active: bool
class CreateModelRequest(BaseModel):
provider_id: str
#: The provider is no longer a client choice — agy is the only one, resolved
#: server-side. The model id must come from the account's live catalog.
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(default="", max_length=160)
capabilities: list[Capability] = Field(default_factory=list)
input_price_per_1m: float = Field(default=0.0, ge=0)
output_price_per_1m: float = Field(default=0.0, ge=0)
reasoning_effort: str = Field(default="", max_length=16)
class RoutingPolicyOut(BaseModel):
@@ -180,124 +148,29 @@ class AdminUsageRow(BaseModel):
total_price: float
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
providers = await ProviderRepository(session).list()
return [
ProviderOut(
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
is_active=p.is_active, created_at=p.created_at,
)
for p in providers
]
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
async def create_provider(
payload: CreateProviderRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ProviderOut:
repo = ProviderRepository(session)
if await repo.get_by_name(payload.name) is not None:
raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.")
provider = await repo.create(
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="provider", target_id=provider.id,
metadata={"name": provider.name, "type": provider.type.value},
ip=ip, user_agent=ua,
)
return ProviderOut(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=provider.is_active,
created_at=provider.created_at,
)
@router.post(
"/providers/{provider_id}/credentials",
response_model=CredentialOut,
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
provider_id: str,
payload: CreateCredentialRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CredentialOut:
provider = await ProviderRepository(session).get(provider_id)
if provider is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
credential = await ModelCredentialRepository(session).create(
ModelCredential(
provider_id=provider_id,
name=payload.name,
# Encrypted at rest; the plaintext exists only inside this request.
encrypted_api_key=encrypt(payload.api_key),
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="credential", target_id=credential.id,
metadata={"provider_id": provider_id, "name": credential.name},
ip=ip, user_agent=ua,
)
return CredentialOut(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
masked_key=mask(payload.api_key), is_active=credential.is_active,
)
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
async def list_credentials(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> list[CredentialOut]:
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
return [
CredentialOut(
id=c.id, provider_id=c.provider_id, name=c.name,
# We cannot recover the plaintext to mask it — and we must not try.
masked_key="••••••",
is_active=c.is_active,
)
for c in credentials
]
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
def _model_out(model: AiModel) -> ModelOut:
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort, is_active=model.is_active,
)
@router.get("/models", response_model=list[ModelOut])
async def list_models(
provider_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[ModelOut]:
models = await AiModelRepository(session).list(provider_id=provider_id)
return [
ModelOut(
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
display_name_for_admin=m.display_name_for_admin,
capabilities=list(m.capabilities),
input_price_per_1m=m.input_price_per_1m,
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
)
for m in models
]
return [_model_out(m) for m in models]
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
@@ -307,33 +180,39 @@ async def create_model(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ModelOut:
if await ProviderRepository(session).get(payload.provider_id) is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
from app.interfaces.http.routers.agy import ensure_agy_provider
model = await AiModelRepository(session).create(
effort = payload.reasoning_effort.strip().lower()
if effort and effort not in REASONING_EFFORTS:
raise ValidationError(
"افورت نامعتبر است. مقادیر مجاز: " + " | ".join(REASONING_EFFORTS)
)
provider = await ensure_agy_provider(ProviderRepository(session))
models = AiModelRepository(session)
existing = await models.list(provider_id=provider.id)
if any(m.internal_name == payload.internal_name for m in existing):
raise ConflictError("مدلی با این نام داخلی از قبل ثبت شده است.")
model = await models.create(
AiModel(
provider_id=payload.provider_id,
provider_id=provider.id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin,
display_name_for_admin=payload.display_name_for_admin or payload.internal_name,
capabilities=[str(c) for c in payload.capabilities],
input_price_per_1m=payload.input_price_per_1m,
output_price_per_1m=payload.output_price_per_1m,
reasoning_effort=effort,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model.id,
metadata={"internal_name": model.internal_name},
metadata={"internal_name": model.internal_name, "reasoning_effort": effort},
ip=ip, user_agent=ua,
)
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
)
return _model_out(model)
# ---------------------------------------------------------------------------
+27 -11
View File
@@ -110,7 +110,7 @@ class AgyTestOut(BaseModel):
# ---------------------------------------------------------------------------
async def _ensure_agy_provider(repo: ProviderRepository):
async def ensure_agy_provider(repo: ProviderRepository):
provider = await repo.get_by_name(AGY_PROVIDER_NAME)
if provider is not None:
return provider
@@ -176,7 +176,7 @@ async def complete_agy_login(
providers = ProviderRepository(session)
credentials = ModelCredentialRepository(session)
provider = await _ensure_agy_provider(providers)
provider = await ensure_agy_provider(providers)
envelope = json.dumps(
{
@@ -331,13 +331,29 @@ async def test_agy_account(
)
@router.get("/models", response_model=list[str])
async def list_agy_models() -> list[str]:
"""Model ids the logged-in agy subscription exposes (registry helper)."""
from app.infrastructure.adapters.registry import get_model_provider
@router.get("/accounts/{credential_id}/models", response_model=list[str])
async def list_account_models(
credential_id: str,
session: AsyncSession = Depends(get_session),
) -> list[str]:
"""Live model catalog of one account — fetched from Google on every call.
provider = get_model_provider()
lister = getattr(provider, "available_models", None)
if lister is None:
return []
return await lister()
The admin console calls this when an account is selected, so the model
list always reflects that subscription's current entitlement instead of a
cached snapshot.
"""
from app.core.errors import ValidationError
from app.infrastructure.adapters.agy import AgyModelAdapter
repo = ModelCredentialRepository(session)
credential = await repo.get(credential_id)
if credential is None:
raise NotFoundError("اکانت agy یافت نشد.")
adapter = AgyModelAdapter() # stateless: one forced account
try:
return await adapter.available_models_for(credential)
except ValidationError:
raise
except Exception as exc: # noqa: BLE001 - the console wants the reason
raise ValidationError(str(exc)) from exc