agy تنها ارائهدهندهٔ مدل؛ حذف بخش ارائهدهندهها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
This commit is contained in:
@@ -91,6 +91,33 @@ SAFETY_SETTINGS: list[dict[str, str]] = [
|
||||
#: manual paste must fit inside it comfortably.
|
||||
CHALLENGE_TTL_SECONDS = 600
|
||||
|
||||
#: agy ``reasoning_effort`` → thinking budget (tokens), mirroring the Antigravity
|
||||
#: client. An unknown value falls back to ``medium`` server-side, and the UI
|
||||
#: only ever offers these five.
|
||||
EFFORT_THINKING_BUDGETS: dict[str, int] = {
|
||||
"minimal": 1024,
|
||||
"low": 4096,
|
||||
"medium": 8192,
|
||||
"high": 16384,
|
||||
"xhigh": 32768,
|
||||
}
|
||||
|
||||
#: Where thinking is honoured. A request must never carry ``thinkingConfig`` to
|
||||
#: a model that does not support it — that is a hard 400 from the backend.
|
||||
_THINKING_MARKERS: tuple[str, ...] = (
|
||||
"thinking",
|
||||
"gemini-3",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
)
|
||||
|
||||
|
||||
def _supports_thinking(model_name: str) -> bool:
|
||||
lowered = model_name.lower()
|
||||
if "image" in lowered:
|
||||
return False
|
||||
return any(marker in lowered for marker in _THINKING_MARKERS)
|
||||
|
||||
_StatusFn = Callable[..., Awaitable[None]]
|
||||
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
|
||||
|
||||
@@ -603,7 +630,25 @@ class AgyModelAdapter:
|
||||
accounts = [a for a in (_account_of(c) for c in await self._load()) if a.refresh_token]
|
||||
if not accounts:
|
||||
return []
|
||||
token = await self._access_token(accounts[0])
|
||||
return await self._fetch_models(accounts[0])
|
||||
|
||||
async def available_models_for(self, credential: ModelCredential) -> list[str]:
|
||||
"""Live model catalog of exactly one account.
|
||||
|
||||
Called per account selection in the admin console, so the list is
|
||||
fetched fresh from Google every time (the access token is cached, the
|
||||
``fetchAvailableModels`` round-trip is not) and reflects what *this*
|
||||
subscription is entitled to.
|
||||
"""
|
||||
account = _account_of(credential)
|
||||
if not account.refresh_token:
|
||||
raise ProviderUnavailableError(
|
||||
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
|
||||
)
|
||||
return await self._fetch_models(account)
|
||||
|
||||
async def _fetch_models(self, account: AgyAccount) -> list[str]:
|
||||
token = await self._access_token(account)
|
||||
for base in BASE_URLS:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
@@ -713,7 +758,8 @@ class AgyModelAdapter:
|
||||
) -> ModelResponse:
|
||||
token = await self._access_token(account)
|
||||
payload = _build_generate_payload(
|
||||
account, model.internal_name, messages, system, temperature, max_tokens
|
||||
account, model.internal_name, messages, system, temperature, max_tokens,
|
||||
reasoning_effort=model.reasoning_effort,
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
@@ -775,6 +821,7 @@ def _build_generate_payload(
|
||||
system: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
reasoning_effort: str = "",
|
||||
) -> dict:
|
||||
"""Translate the port's OpenAI-shaped call into the Antigravity envelope."""
|
||||
system_parts: list[str] = [system] if system else []
|
||||
@@ -786,13 +833,27 @@ def _build_generate_payload(
|
||||
role = "model" if message.role == "assistant" else "user"
|
||||
contents.append({"role": role, "parts": [{"text": message.content}]})
|
||||
|
||||
generation: dict = {
|
||||
"temperature": temperature,
|
||||
"maxOutputTokens": max_tokens,
|
||||
}
|
||||
# Reasoning effort is only sent to models that can think; the backend
|
||||
# rejects ``thinkingConfig`` everywhere else with a 400.
|
||||
if reasoning_effort and _supports_thinking(model_name):
|
||||
budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192)
|
||||
generation["thinkingConfig"] = {
|
||||
"thinkingBudget": budget,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
if max_tokens <= budget:
|
||||
# The budget must fit inside the answer window or the thinking
|
||||
# model has nowhere to emit its final answer.
|
||||
generation["maxOutputTokens"] = budget + 8000
|
||||
|
||||
request: dict = {
|
||||
"contents": contents,
|
||||
"sessionId": _random_session_id(),
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
"maxOutputTokens": max_tokens,
|
||||
},
|
||||
"generationConfig": generation,
|
||||
"toolConfig": {"functionCallingConfig": {"mode": "VALIDATED"}},
|
||||
"safetySettings": SAFETY_SETTINGS,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||
"""Mock adapters for the non-AI ports, so the stack runs with zero credentials.
|
||||
|
||||
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||
change application behaviour.
|
||||
change application behaviour. The model port has no mock — ``agy`` is the only
|
||||
model provider (see ``adapters/agy.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
@@ -17,14 +17,11 @@ from typing import Any
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
ChatMessage,
|
||||
CheckoutSession,
|
||||
CommandResult,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
ModelHealth,
|
||||
ModelResponse,
|
||||
Notification,
|
||||
PaymentResult,
|
||||
PreviewDomainInfo,
|
||||
@@ -38,7 +35,6 @@ from app.application.ports import (
|
||||
VectorHit,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.ai_control.models import AiModel
|
||||
from app.domain.platform.rules import (
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
@@ -46,57 +42,6 @@ from app.domain.platform.rules import (
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockModelProviderAdapter:
|
||||
"""Deterministic, offline stand-in for an LLM.
|
||||
|
||||
Returns a canned Persian acknowledgement that names the task so tests can
|
||||
assert on it without a network call.
|
||||
"""
|
||||
|
||||
def __init__(self, latency_ms: int = 0) -> None:
|
||||
self._latency_ms = latency_ms
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential=None,
|
||||
credentials=None, # accepted for port parity; the mock has no pool
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
started = time.perf_counter()
|
||||
if self._latency_ms:
|
||||
await asyncio.sleep(self._latency_ms / 1000)
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# The canned text must read like model output, not like a debug tag: the
|
||||
# provider/model identity is platform-internal and must never surface in
|
||||
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
return ModelHealth(ok=True, detail="mock provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Real model adapter for any OpenAI-compatible endpoint.
|
||||
|
||||
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
|
||||
API key is read from the credential record (decrypted by the caller), never from
|
||||
the environment at request time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||
|
||||
|
||||
class OpenAICompatibleModelAdapter:
|
||||
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.default_api_key = default_api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
|
||||
if credential is not None and credential.encrypted_api_key:
|
||||
try:
|
||||
return decrypt(credential.encrypted_api_key)
|
||||
except Exception: # noqa: BLE001 - fall through to the env default
|
||||
pass
|
||||
return self.default_api_key
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential: ModelCredential | None = None,
|
||||
credentials: list[ModelCredential] | None = None, # no pool here
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
if not self.base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
|
||||
)
|
||||
|
||||
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
|
||||
{"role": m.role, "content": m.content} for m in messages
|
||||
]
|
||||
payload = {
|
||||
"model": model.internal_name,
|
||||
"messages": payload_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.default_api_key
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"ارائهدهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("اتصال به ارائهدهندهٔ مدل برقرار نشد.") from exc
|
||||
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
content = (choice.get("message") or {}).get("content", "") or ""
|
||||
usage = data.get("usage") or {}
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=data.get("model", model.internal_name),
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"id": data.get("id", "")},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
if not self.base_url:
|
||||
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(f"{self.base_url}/models")
|
||||
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
|
||||
except httpx.HTTPError as exc:
|
||||
return ModelHealth(ok=False, detail=str(exc))
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
One place decides which concrete adapter backs each port, driven purely by the
|
||||
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
||||
clone runs with no credentials.
|
||||
clone runs with no credentials — *except* the model provider: ``agy`` (Google
|
||||
Antigravity) is the only one the platform talks to, and its accounts are added
|
||||
from the admin console.
|
||||
|
||||
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||
cached as singletons so behaviour is consistent within a process.
|
||||
@@ -10,37 +12,36 @@ cached as singletons so behaviour is consistent within a process.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.infrastructure.adapters import mock
|
||||
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
||||
|
||||
logger = logging.getLogger("agy.registry")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_model_provider():
|
||||
provider = settings.ai_default_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockModelProviderAdapter()
|
||||
if provider == "openai_compatible":
|
||||
return OpenAICompatibleModelAdapter(
|
||||
base_url=settings.openai_compatible_base_url,
|
||||
default_api_key=settings.openai_compatible_api_key,
|
||||
"""The one model adapter: agy over its logged-in account pool."""
|
||||
configured = settings.ai_default_provider.strip().lower()
|
||||
if configured != "agy":
|
||||
# Tolerate a stale ``AI_DEFAULT_PROVIDER`` from before agy became the
|
||||
# only provider: warn loudly, keep serving with agy either way.
|
||||
logger.warning(
|
||||
"AI_DEFAULT_PROVIDER=%r is obsolete — agy is the only supported "
|
||||
"model provider; using it",
|
||||
settings.ai_default_provider,
|
||||
)
|
||||
if provider == "agy":
|
||||
from app.infrastructure.adapters.agy import (
|
||||
AgyModelAdapter,
|
||||
load_agy_accounts,
|
||||
report_agy_state,
|
||||
)
|
||||
|
||||
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
|
||||
raise ProviderUnavailableError(
|
||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||
"مقادیر مجاز: mock | openai_compatible | agy"
|
||||
from app.infrastructure.adapters.agy import (
|
||||
AgyModelAdapter,
|
||||
load_agy_accounts,
|
||||
report_agy_state,
|
||||
)
|
||||
|
||||
return AgyModelAdapter(loader=load_agy_accounts, report=report_agy_state)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_git_provider():
|
||||
|
||||
@@ -115,7 +115,10 @@ class AiProviderRow(Base):
|
||||
|
||||
id: Mapped[str] = _pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(32), default="mock", nullable=False)
|
||||
#: agy is the only provider type; the column stays a string so old rows
|
||||
#: (mock / openai_compatible, deleted by migration c2d3e4f5b6a7) cannot
|
||||
#: break the enum mapping.
|
||||
type: Mapped[str] = mapped_column(String(32), default="agy", nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(512), default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
@@ -160,6 +163,8 @@ class AiModelRow(Base):
|
||||
capabilities: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
input_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
output_price_per_1m: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
#: agy reasoning effort ("" = provider default); see ``AiModel``.
|
||||
reasoning_effort: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = _created()
|
||||
|
||||
|
||||
@@ -33,8 +33,14 @@ from app.infrastructure.db.models import (
|
||||
|
||||
|
||||
def _provider_to_domain(row: AiProviderRow) -> Provider:
|
||||
# agy is the only provider kind; a legacy row (mock / openai_compatible,
|
||||
# deleted by migration) still maps to the one enum member rather than
|
||||
# raising, so a half-migrated database reads cleanly.
|
||||
provider_type = (
|
||||
ProviderType(row.type) if row.type == ProviderType.AGY.value else ProviderType.AGY
|
||||
)
|
||||
return Provider(
|
||||
id=row.id, name=row.name, type=ProviderType(row.type),
|
||||
id=row.id, name=row.name, type=provider_type,
|
||||
base_url=row.base_url, is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
@@ -57,6 +63,7 @@ def _model_to_domain(row: AiModelRow) -> AiModel:
|
||||
capabilities=list(row.capabilities or []),
|
||||
input_price_per_1m=row.input_price_per_1m,
|
||||
output_price_per_1m=row.output_price_per_1m,
|
||||
reasoning_effort=row.reasoning_effort or "",
|
||||
is_active=row.is_active, created_at=row.created_at,
|
||||
)
|
||||
|
||||
@@ -267,6 +274,7 @@ class AiModelRepository:
|
||||
capabilities=list(model.capabilities),
|
||||
input_price_per_1m=model.input_price_per_1m,
|
||||
output_price_per_1m=model.output_price_per_1m,
|
||||
reasoning_effort=model.reasoning_effort,
|
||||
is_active=model.is_active, created_at=model.created_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
|
||||
Reference in New Issue
Block a user