بهروزرسانی: استخراج خودکار و پویای فهرست مدلها و سطوح تلاش پردازش از ابزار خط فرمان و ارتقای نسخه به ۰.۱.۴
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user