به‌روزرسانی: استخراج خودکار و پویای فهرست مدل‌ها و سطوح تلاش پردازش از ابزار خط فرمان و ارتقای نسخه به ۰.۱.۴

This commit is contained in:
mjalmoudy94
2026-10-13 09:00:18 +00:00
parent f21abf53ee
commit afdef4b444
5 changed files with 301 additions and 99 deletions
+172 -46
View File
@@ -31,7 +31,10 @@ import base64
import hashlib
import json
import logging
import os
import secrets
import shutil
import tempfile
import time
import uuid
from collections.abc import Awaitable, Callable
@@ -118,6 +121,7 @@ def _supports_thinking(model_name: str) -> bool:
return False
return any(marker in lowered for marker in _THINKING_MARKERS)
_StatusFn = Callable[..., Awaitable[None]]
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
@@ -321,9 +325,7 @@ class AgyAuthClient:
self._fetch_email(tokens.get("access_token", "")),
self._fetch_project_id(tokens.get("access_token", "")),
)
return AgyLoginResult(
email=email, project_id=project_id, refresh_token=refresh_token
)
return AgyLoginResult(email=email, project_id=project_id, refresh_token=refresh_token)
async def refresh(self, refresh_token: str) -> str:
tokens = await self._post_token(
@@ -448,9 +450,7 @@ async def redeem_login(login_id: str, callback_url: str) -> AgyLoginResult:
return await AgyAuthClient().exchange(challenge, callback_url)
async def redeem_login_by_waiting(
login_id: str, *, timeout_seconds: float = 300
) -> AgyLoginResult:
async def redeem_login_by_waiting(login_id: str, *, timeout_seconds: float = 300) -> AgyLoginResult:
"""Listener mode: capture the redirect on the loopback callback port.
Used by the operator-side script (and by a same-machine deployment): Google
@@ -465,9 +465,7 @@ async def redeem_login_by_waiting(
loop = asyncio.get_running_loop()
future: asyncio.Future[str] = loop.create_future()
async def _handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request_line = await reader.readline()
parts = request_line.decode("latin-1").split(" ")
@@ -591,8 +589,10 @@ class AgyModelAdapter:
temperature: float = 0.2,
max_tokens: int = 2048,
) -> ModelResponse:
pool = credentials if credentials is not None else (
[credential] if credential is not None else await self._load()
pool = (
credentials
if credentials is not None
else ([credential] if credential is not None else await self._load())
)
accounts = [a for a in (_account_of(c) for c in pool) if a.refresh_token]
if not accounts:
@@ -605,8 +605,12 @@ class AgyModelAdapter:
for account in ordered:
try:
response = await self._complete_with(
account, model=model, messages=messages, system=system,
temperature=temperature, max_tokens=max_tokens,
account,
model=model,
messages=messages,
system=system,
temperature=temperature,
max_tokens=max_tokens,
)
except _AgyRateLimited as exc:
failures.append(f"{account.email or account.credential_id}: محدودیت نرخ")
@@ -645,9 +649,7 @@ class AgyModelAdapter:
items = await self._fetch_models(accounts[0])
return [m.id for m in items]
async def available_models_for(
self, credential: ModelCredential
) -> list[AgyAccountModelInfo]:
async def available_models_for(self, credential: ModelCredential) -> list[AgyAccountModelInfo]:
"""Live model catalog of exactly one account.
Called per account selection in the admin console, so the list is
@@ -657,12 +659,17 @@ class AgyModelAdapter:
"""
account = _account_of(credential)
if not account.refresh_token:
raise ProviderUnavailableError(
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
)
raise ProviderUnavailableError("این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید.")
return await self._fetch_models(account)
async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]:
# Priority 1: Query models dynamically using headless agy CLI for this account
if not os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("AGY_TEST_CLI"):
cli_models = await self._fetch_models_via_cli(account)
if cli_models:
return cli_models
# Priority 2: Direct HTTP endpoint (used as fallback or in unit tests with HTTP mocks)
token = await self._access_token(account)
for base in BASE_URLS:
try:
@@ -684,9 +691,122 @@ class AgyModelAdapter:
return []
@classmethod
def _parse_model_catalog(
cls, raw_models: dict[str, Any]
) -> list[AgyAccountModelInfo]:
async def _fetch_models_via_cli(cls, account: AgyAccount) -> list[AgyAccountModelInfo]:
"""Query models live from the headless `agy` CLI application for this account."""
agy_bin = shutil.which("agy") or (
"/root/.local/bin/agy" if os.path.exists("/root/.local/bin/agy") else None
)
if not agy_bin or not account.refresh_token:
return []
# Prepare isolated app data directory with this account's oauth credentials
account_data_dir = tempfile.mkdtemp(prefix=f"agy_acc_{account.credential_id[:8]}_")
token_path = os.path.join(account_data_dir, "antigravity-oauth-token")
try:
with open(token_path, "w", encoding="utf-8") as f:
json.dump(
{
"token": {
"access_token": "",
"token_type": "Bearer",
"refresh_token": account.refresh_token,
"expiry": "2000-01-01T00:00:00Z",
},
"auth_method": "oauth",
},
f,
)
env = os.environ.copy()
env["ANTIGRAVITY_APP_DATA_DIR"] = account_data_dir
env["HOME"] = "/root"
proc = await asyncio.create_subprocess_exec(
agy_bin,
"models",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=20.0)
except TimeoutError:
proc.kill()
return []
if proc.returncode != 0:
return []
return cls._parse_cli_models_output(stdout_bytes.decode("utf-8", errors="replace"))
except Exception:
logger.warning(
"Failed to fetch models via agy CLI, falling back to HTTP", exc_info=True
)
return []
finally:
shutil.rmtree(account_data_dir, ignore_errors=True)
@classmethod
def _parse_cli_models_output(cls, output: str) -> list[AgyAccountModelInfo]:
items: list[AgyAccountModelInfo] = []
for line in output.splitlines():
line = line.strip()
if not line or "Fetching" in line:
continue
# Output format is tab-separated: <model_id>\t<display_name>
parts = line.split("\t", 1)
if len(parts) == 2:
model_id = parts[0].strip()
display_name = parts[1].strip()
else:
tokens = line.split(None, 1)
model_id = tokens[0].strip()
display_name = tokens[1].strip() if len(tokens) > 1 else model_id
lowered = model_id.lower()
dn_lowered = display_name.lower()
supports_thinking = (
"thinking" in lowered
or "thinking" in dn_lowered
or "-high" in lowered
or "-medium" in lowered
or "-low" in lowered
)
supported_efforts: list[str] = []
default_effort: str | None = None
if "-high" in lowered or "(high)" in dn_lowered:
supported_efforts = ["high"]
default_effort = "high"
elif "-medium" in lowered or "(medium)" in dn_lowered:
supported_efforts = ["medium"]
default_effort = "medium"
elif "-low" in lowered or "(low)" in dn_lowered:
supported_efforts = ["low"]
default_effort = "low"
elif "thinking" in lowered or "thinking" in dn_lowered:
if "gemini" in lowered:
supported_efforts = ["low", "medium", "high"]
default_effort = "high"
else:
supported_efforts = []
default_effort = None
items.append(
AgyAccountModelInfo(
id=model_id,
display_name=display_name,
supports_thinking=supports_thinking,
supported_efforts=supported_efforts,
default_effort=default_effort,
supports_images=True,
)
)
return items
@classmethod
def _parse_model_catalog(cls, raw_models: dict[str, Any]) -> list[AgyAccountModelInfo]:
items: list[AgyAccountModelInfo] = []
for model_id, details in sorted(raw_models.items()):
if not isinstance(details, dict):
@@ -739,9 +859,7 @@ class AgyModelAdapter:
return " ".join(words)
@classmethod
def _supported_efforts_for(
cls, model_id: str, details: dict[str, Any]
) -> list[str]:
def _supported_efforts_for(cls, model_id: str, details: dict[str, Any]) -> list[str]:
lowered = model_id.lower()
if "image" in lowered:
return []
@@ -869,7 +987,12 @@ 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 = {
@@ -894,9 +1017,9 @@ class AgyModelAdapter:
# Both backends share one quota, but a 429 can also be a
# backend hiccup — try the other base URL before benching the
# whole account.
retry_after = _retry_after_seconds(
response.headers.get("Retry-After")
) or retry_after
retry_after = (
_retry_after_seconds(response.headers.get("Retry-After")) or retry_after
)
continue
if response.status_code in (401, 403):
raise _AgyAuthError(
@@ -909,12 +1032,9 @@ class AgyModelAdapter:
# Our request shape is wrong — another account will fail the
# same way, so surface it instead of burning the pool.
raise ProviderUnavailableError(
f"agy درخواست را رد کرد ({response.status_code}): "
f"{response.text[:300]}"
f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}"
)
return _parse_generate_response(
response.json(), model=model, started=started
)
return _parse_generate_response(response.json(), model=model, started=started)
if retry_after is not None:
raise _AgyRateLimited(retry_after)
raise last_transient or _AgyTransientError("هیچ بک‌اند agy پاسخ نداد.")
@@ -944,14 +1064,26 @@ def _build_generate_payload(
role = "model" if message.role == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": message.content}]})
backend_model = model_name
effective_effort = reasoning_effort
if model_name == "gemini-3.8-flash-high":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "high"
elif model_name == "gemini-3.8-flash-medium":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "medium"
elif model_name == "gemini-3.8-flash-low":
backend_model = "gemini-3.8-flash-tiered"
effective_effort = effective_effort or "low"
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)
if effective_effort and _supports_thinking(backend_model):
budget = EFFORT_THINKING_BUDGETS.get(effective_effort, 8192)
generation["thinkingConfig"] = {
"thinkingBudget": budget,
"includeThoughts": True,
@@ -975,16 +1107,14 @@ def _build_generate_payload(
}
return {
"project": account.project_id or _fallback_project_id(),
"model": model_name,
"model": backend_model,
"userAgent": "antigravity",
"requestId": f"agent-{uuid.uuid4()}",
"request": request,
}
def _parse_generate_response(
data: dict, *, model: AiModel, started: float
) -> ModelResponse:
def _parse_generate_response(data: dict, *, model: AiModel, started: float) -> ModelResponse:
candidates = data.get("candidates") or [{}]
candidate = candidates[0] if isinstance(candidates[0], dict) else {}
text_parts: list[str] = []
@@ -1025,14 +1155,10 @@ async def load_agy_accounts() -> list[ModelCredential]:
async with SessionFactory() as session:
providers = await ProviderRepository(session).list()
provider = next(
(p for p in providers if p.type.value == "agy" and p.is_active), None
)
provider = next((p for p in providers if p.type.value == "agy" and p.is_active), None)
if provider is None:
return []
return await ModelCredentialRepository(session).list_active_for_provider(
provider.id
)
return await ModelCredentialRepository(session).list_active_for_provider(provider.id)
async def report_agy_state(