diff --git a/.env.example b/.env.example index 23132f8..14657a6 100644 --- a/.env.example +++ b/.env.example @@ -27,15 +27,14 @@ JWT_SECRET=change-me ENCRYPTION_KEY=change-me-32-bytes # --- AI models (hidden from customers) ------------------------------------- -# mock | openai_compatible | agy -AI_DEFAULT_PROVIDER=mock -OPENAI_COMPATIBLE_BASE_URL= -OPENAI_COMPATIBLE_API_KEY= +# agy (Google Antigravity) is the only model provider. The setting is kept for +# compatibility with older .env files; any value is ignored (with a warning). +AI_DEFAULT_PROVIDER=agy # --- agy (Google Antigravity) ---------------------------------------------- -# Only used when AI_DEFAULT_PROVIDER=agy. There are no API keys: accounts are -# Google identities logged in via OAuth from the admin console -# (/admin/accounts → افزودن اکانت agy). Any number of accounts can be added — +# The only model provider. There are no API keys: accounts are Google +# identities logged in via OAuth from the admin console +# (/admin/agy-accounts → افزودن اکانت agy). Any number of accounts can be added — # the rotation pool spreads load across them and benches a rate-limited one # until its quota window resets. AGY_CLIENT_ID/SECRET default to the public # Antigravity CLI application; override them to use your own Google OAuth diff --git a/README.md b/README.md index 1d5234e..5f2a807 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ provider. این‌ها فقط در **Admin Console** و فقط برای مال پلتفرم سه لایهٔ نقش دارد: -1. **مالک سامانه (platform owner)** — providerها، مدل‌ها، routing policy، تعرفه‌ها، - sandbox templateها، مشاهدهٔ کل مصرف. +1. **مالک سامانه (platform owner)** — اکانت‌های agy، مدل‌ها، routing policy، + تعرفه‌ها، sandbox templateها، مشاهدهٔ کل مصرف. agy تنها ارائه‌دهندهٔ مدل + سامانه است و بخش جداگانهٔ «ارائه‌دهنده‌ها» وجود ندارد. 2. **مدیر سازمان مشتری** — ساخت سازمان، دعوت عضو، پروژه، فعال‌سازی کارمند، مصرف. 3. **عضو سازمان** — گفتگو با مدیر/کارمند، ساخت task، آپلود فایل، دیدن خروجی. @@ -217,7 +218,6 @@ web/ | قابلیت | env | مقادیر | |---|---|---| -| مدل | `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` \| `agy` | | Git | `GIT_PROVIDER` | `mock` \| `gitea` | | دامنهٔ تست | `DOMAIN_ROUTER` | `mock` \| `caddy` | | Sandbox | `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | @@ -226,24 +226,25 @@ web/ اگر credential واقعی نداریم، **قابلیت حذف نمی‌شود**: مدل دیتابیس، interface، UI و adapter mock کامل ساخته می‌شود و adapter واقعی پشت همین flag آماده می‌ماند. -### ارائه‌دهندهٔ agy (Google Antigravity) +### ارائه‌دهندهٔ agy (Google Antigravity) — تنها ارائه‌دهندهٔ مدل agy کلید API ندارد؛ **اکانت**‌ها هویت Google هستند و از طریق OAuth وارد -می‌شوند. برای فعال‌سازی: +می‌شوند. کاتالوگ مدل‌ها در seed ساخته می‌شود و همان اول فعال است. برای شروع: -1. `AI_DEFAULT_PROVIDER=agy` را در `.env` بگذارید و `pnpm db:seed` را دوباره - اجرا کنید (کتالوگ مدل‌های agy فعال و کاتالوگ mock غیرفعال می‌شود). -2. با حساب مالک سامانه وارد کنسول مدیریت شوید و از - `/admin/accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل +1. با حساب مالک سامانه وارد کنسول مدیریت شوید و از + `/admin/agy-accounts` → «افزودن اکانت agy» شروع کنید: لینک احراز هویت گوگل باز می‌شود، بعد از تأیید، URL بازگشت را از نوار آدرس مرورگر کپی و Paste کنید (صفحهٔ مقصد عمداً بارگذاری نمی‌شود؛ پارامترها همان‌جا هستند). -3. هر تعداد اکانت Google که بخواهید اضافه کنید. مدل‌ها را در - `/admin/models` در صورت نیاز فعال کنید. +2. در `/admin/models` زنجیرهٔ افزودن مدل کاملاً انتخابی است: ارائه‌دهنده + (agy) → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت) → افورت استدلال + (فقط برای مدل‌های استدلالی). هیچ نام مدلی دستی تایپ نمی‌شود و مشتری نام مدل + را هرگز نمی‌بیند. -پول چرخشی، بار را بین اکانت‌ها پخش می‌کند و اکانتی که ۴۲۹ گرفته تا پایان -پنجرهٔ سهمیهٔ آن کنار گذاشته می‌شود (هم در حافظه و هم در دیتابیس، تا workerها -هم همان تصویر را ببینند). refresh tokenها فقط encrypted at rest ذخیره -می‌شوند و هیچ‌وقت از API برنمی‌گردند. +هر تعداد اکانت Google که بخواهید اضافه کنید. پول چرخشی، بار را بین اکانت‌ها +پخش می‌کند و اکانتی که ۴۲۹ گرفته تا پایان پنجرهٔ سهمیهٔ آن کنار گذاشته +می‌شود (هم در حافظه و هم در دیتابیس، تا workerها همان تصویر را ببینند). +refresh tokenها فقط encrypted at rest ذخیره می‌شوند و هیچ‌وقت از API +برنمی‌گردند. --- diff --git a/apps/api/alembic/versions/c2d3e4f5b6a7_agy_only_and_model_effort.py b/apps/api/alembic/versions/c2d3e4f5b6a7_agy_only_and_model_effort.py new file mode 100644 index 0000000..1791da2 --- /dev/null +++ b/apps/api/alembic/versions/c2d3e4f5b6a7_agy_only_and_model_effort.py @@ -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") diff --git a/apps/api/app/application/ports/model_provider.py b/apps/api/app/application/ports/model_provider.py index d51523b..9a9cc9d 100644 --- a/apps/api/app/application/ports/model_provider.py +++ b/apps/api/app/application/ports/model_provider.py @@ -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, diff --git a/apps/api/app/application/services/workforce_service.py b/apps/api/app/application/services/workforce_service.py index c6441f9..e30ff7d 100644 --- a/apps/api/app/application/services/workforce_service.py +++ b/apps/api/app/application/services/workforce_service.py @@ -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 diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 703d531..b41b612 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -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 diff --git a/apps/api/app/core/redact.py b/apps/api/app/core/redact.py index 8919573..b2c502b 100644 --- a/apps/api/app/core/redact.py +++ b/apps/api/app/core/redact.py @@ -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, diff --git a/apps/api/app/domain/ai_control/models.py b/apps/api/app/domain/ai_control/models.py index 9088b5f..fb2a990 100644 --- a/apps/api/app/domain/ai_control/models.py +++ b/apps/api/app/domain/ai_control/models.py @@ -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) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index ee61b13..d21bab3 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -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, } diff --git a/apps/api/app/infrastructure/adapters/mock.py b/apps/api/app/infrastructure/adapters/mock.py index 263cac4..165060c 100644 --- a/apps/api/app/infrastructure/adapters/mock.py +++ b/apps/api/app/infrastructure/adapters/mock.py @@ -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 # --------------------------------------------------------------------------- diff --git a/apps/api/app/infrastructure/adapters/openai_compatible.py b/apps/api/app/infrastructure/adapters/openai_compatible.py deleted file mode 100644 index c587ddc..0000000 --- a/apps/api/app/infrastructure/adapters/openai_compatible.py +++ /dev/null @@ -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)) diff --git a/apps/api/app/infrastructure/adapters/registry.py b/apps/api/app/infrastructure/adapters/registry.py index 65b28bc..15470e6 100644 --- a/apps/api/app/infrastructure/adapters/registry.py +++ b/apps/api/app/infrastructure/adapters/registry.py @@ -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(): diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py index 410ce35..9b56fbc 100644 --- a/apps/api/app/infrastructure/db/models.py +++ b/apps/api/app/infrastructure/db/models.py @@ -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() diff --git a/apps/api/app/infrastructure/repositories/ai_control.py b/apps/api/app/infrastructure/repositories/ai_control.py index 7afa60d..fcd2d62 100644 --- a/apps/api/app/infrastructure/repositories/ai_control.py +++ b/apps/api/app/infrastructure/repositories/ai_control.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/admin.py b/apps/api/app/interfaces/http/routers/admin.py index c63df51..ab1db48 100644 --- a/apps/api/app/interfaces/http/routers/admin.py +++ b/apps/api/app/interfaces/http/routers/admin.py @@ -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) # --------------------------------------------------------------------------- diff --git a/apps/api/app/interfaces/http/routers/agy.py b/apps/api/app/interfaces/http/routers/agy.py index edf5fc0..dfe8eb5 100644 --- a/apps/api/app/interfaces/http/routers/agy.py +++ b/apps/api/app/interfaces/http/routers/agy.py @@ -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 diff --git a/apps/api/app/seed.py b/apps/api/app/seed.py index 8f69bbc..c2e74dd 100644 --- a/apps/api/app/seed.py +++ b/apps/api/app/seed.py @@ -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: diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 10f4578..f8b54c6 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -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, ) ) diff --git a/apps/api/tests/test_admin_control_plane.py b/apps/api/tests/test_admin_control_plane.py index 3052204..1163523 100644 --- a/apps/api/tests/test_admin_control_plane.py +++ b/apps/api/tests/test_admin_control_plane.py @@ -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( diff --git a/apps/api/tests/test_agy.py b/apps/api/tests/test_agy.py index bc3465c..bf0c745 100644 --- a/apps/api/tests/test_agy.py +++ b/apps/api/tests/test_agy.py @@ -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, + } diff --git a/apps/api/tests/test_e2e_smoke.py b/apps/api/tests/test_e2e_smoke.py index cea0171..295cb71 100644 --- a/apps/api/tests/test_e2e_smoke.py +++ b/apps/api/tests/test_e2e_smoke.py @@ -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 diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx index e146473..5e770ca 100644 --- a/apps/web/src/app/(app)/admin/layout.tsx +++ b/apps/web/src/app/(app)/admin/layout.tsx @@ -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: "تعرفه‌ها" }, diff --git a/apps/web/src/app/(app)/admin/models/page.tsx b/apps/web/src/app/(app)/admin/models/page.tsx index 57876b3..875a41e 100644 --- a/apps/web/src/app/(app)/admin/models/page.tsx +++ b/apps/web/src/app/(app)/admin/models/page.tsx @@ -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([]); - const [providers, setProviders] = React.useState([]); + const [accounts, setAccounts] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(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(null); + const [catalogLoading, setCatalogLoading] = React.useState(false); + const [catalogError, setCatalogError] = React.useState(null); + const [modelId, setModelId] = React.useState(""); + const [effort, setEffort] = React.useState(""); const [caps, setCaps] = React.useState(["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 (

مدل‌ها

- نام داخلی مدل‌ها فقط در این کنسول دیده می‌شود. مشتری هرگز نام مدل یا ارائه‌دهنده را - نمی‌بیند. + agy تنها ارائه‌دهندهٔ سامانه است. برای افزودن مدل، اکانت را انتخاب کنید؛ + کاتالوگ مدل‌ها لحظه‌ای و مستقیم از همان اکانت خوانده می‌شود. نام مدل فقط + در این کنسول دیده می‌شود و مشتری هرگز آن را نمی‌بیند.

افزودن مدل - قیمت‌ها به «واحد پول به ازای هر یک‌میلیون توکن» وارد می‌شوند و مبنای محاسبهٔ هزینهٔ - داخلی هستند. + قیمت‌ها به «واحد پول به ازای هر یک‌میلیون توکن» وارد می‌شوند و مبنای + محاسبهٔ هزینهٔ داخلی هستند.
-
+
onModelChange(e.target.value)} + options={ + !accountId + ? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }] + : modelOptions.length === 0 + ? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }] + : modelOptions + } + disabled={!accountId || catalogLoading || modelOptions.length === 0} + /> + setType(e.target.value as ProviderType)} - options={PROVIDER_TYPES} - /> - setBaseUrl(e.target.value)} - placeholder="https://api.example.com/v1" - className="ltr-inline" - /> -
-
- -
- - {formError && ( -
- -
- )} - - - {loading ? ( - - ) : error ? ( - - ) : providers.length === 0 ? ( - - ) : ( -
- {providers.map((provider) => ( - - ))} -
- )} -
- ); -} - -function ProviderCard({ provider }: { provider: AdminProvider }) { - const [open, setOpen] = React.useState(false); - const [credentials, setCredentials] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); - - const [credName, setCredName] = React.useState(""); - const [apiKey, setApiKey] = React.useState(""); - const [saving, setSaving] = React.useState(false); - const [formError, setFormError] = React.useState(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 ( - -
-
-
-

{provider.name}

- - {provider.is_active ? "فعال" : "غیرفعال"} - -
-

- {provider.type} - {provider.base_url ? ` · ${provider.base_url}` : ""} -

-
- -
- - {open && ( -
- {loading ? ( - - ) : error ? ( - - ) : credentials.length === 0 ? ( -

کلیدی ثبت نشده است.

- ) : ( -
    - {credentials.map((credential) => ( -
  • - {credential.name} - - {credential.masked_key} - -
  • - ))} -
- )} - -
-
- setCredName(e.target.value)} - placeholder="production" - className="ltr-inline" - /> -
-
- setApiKey(e.target.value)} - hint="پس از ذخیره دیگر قابل بازیابی نیست." - className="ltr-inline" - /> -
- -
- {formError && } -
- )} -
- ); -} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f6ae2f5..56ee745 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -418,26 +418,6 @@ export const api = { ), // --- platform owner ------------------------------------------------------- - adminProviders: () => - apiRequest("/api/admin/providers"), - adminCreateProvider: (body: { - name: string; - type: import("@agy/contracts").ProviderType; - base_url?: string; - }) => - apiRequest("/api/admin/providers", { - method: "POST", - body, - }), - adminCredentials: (providerId: string) => - apiRequest( - `/api/admin/providers/${providerId}/credentials`, - ), - adminCreateCredential: (providerId: string, body: { name: string; api_key: string }) => - apiRequest( - `/api/admin/providers/${providerId}/credentials`, - { method: "POST", body }, - ), // agy accounts: OAuth login instead of API keys. agyAccounts: () => apiRequest("/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("/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(`/api/admin/agy/accounts/${accountId}/models`), adminModels: () => apiRequest("/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("/api/admin/models", { method: "POST", diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts index 2a95c7e..481ab54 100644 --- a/apps/web/src/lib/nav.ts +++ b/apps/web/src/lib/nav.ts @@ -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: "قیمت واحدها" }, diff --git a/apps/web/src/test/api.test.ts b/apps/web/src/test/api.test.ts index b627c92..09c5da8 100644 --- a/apps/web/src/test/api.test.ts +++ b/apps/web/src/test/api.test.ts @@ -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"); + }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3ccc467..eca6513 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,14 +10,52 @@ export type Role = "owner" | "admin" | "manager" | "member" | "viewer"; export type MembershipStatus = "active" | "invited" | "suspended"; -/** Provider kinds the AI Control Plane can talk to. */ -export type ProviderType = - | "mock" - | "openai_compatible" - | "anthropic" - | "gemini" - | "openrouter" - | "agy"; +/** + * Provider kinds the AI Control Plane can talk to. agy (Google Antigravity) + * is the only one — the platform no longer has a provider picker. + */ +export type ProviderType = "agy"; + +/** agy reasoning efforts, cheapest first. Mirrors the backend's list. */ +export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; + +export const REASONING_EFFORTS: ReasoningEffort[] = [ + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +const EFFORT_LABELS: Record = { + minimal: "حداقل", + low: "کم", + medium: "متوسط", + high: "زیاد", + xhigh: "بسیار زیاد", +}; + +/** + * Reasoning efforts a model may be configured with, or an empty list when the + * model cannot think — the backend rejects a thinking config everywhere else. + * Same marker rule as the agy adapter: thinking-capable ids contain one of + * `thinking` / `gemini-3` / `gemini-2.5-pro` / `gemini-2.5-flash`, image + * models are always excluded. + */ +export function effortsForModel(modelId: string): ReasoningEffort[] { + const lower = modelId.toLowerCase(); + if (lower.includes("image")) return []; + const canThink = + lower.includes("thinking") || + lower.includes("gemini-3") || + lower.includes("gemini-2.5-pro") || + lower.includes("gemini-2.5-flash"); + return canThink ? [...REASONING_EFFORTS] : []; +} + +export function effortLabel(effort: string): string { + return EFFORT_LABELS[effort as ReasoningEffort] ?? effort; +} /** Rotation-pool lifecycle of one agy account. */ export type AgyAccountStatus = "active" | "cooldown" | "error"; @@ -182,15 +220,6 @@ export interface Repository { private: boolean; } -export interface AdminProvider { - id: string; - name: string; - type: string; - base_url: string; - is_active: boolean; - created_at: string; -} - export interface AdminModel { id: string; provider_id: string; @@ -199,6 +228,8 @@ export interface AdminModel { capabilities: string[]; input_price_per_1m: number; output_price_per_1m: number; + /** agy reasoning effort ("" = provider default). Owner-only metadata. */ + reasoning_effort: string; is_active: boolean; } @@ -228,21 +259,6 @@ export interface AdminOrganization { created_at: string; } -/** - * A stored provider credential. - * - * There is no field here that can carry a plaintext key — the API only ever - * returns a masked form, and on list it returns a fixed mask because the - * plaintext is unrecoverable by design. - */ -export interface AdminCredential { - id: string; - provider_id: string; - name: string; - masked_key: string; - is_active: boolean; -} - /** Result of a routing dry-run: which model a mode would pick, and why. */ export interface ModelChoice { mode: string;