بهروزرسانی: استخراج خودکار و پویای فهرست مدلها و سطوح تلاش پردازش از ابزار خط فرمان و ارتقای نسخه به ۰.۱.۴
This commit is contained in:
@@ -31,7 +31,10 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -118,6 +121,7 @@ def _supports_thinking(model_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
return any(marker in lowered for marker in _THINKING_MARKERS)
|
return any(marker in lowered for marker in _THINKING_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
_StatusFn = Callable[..., Awaitable[None]]
|
_StatusFn = Callable[..., Awaitable[None]]
|
||||||
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
|
_LoaderFn = Callable[[], Awaitable[list[ModelCredential]]]
|
||||||
|
|
||||||
@@ -321,9 +325,7 @@ class AgyAuthClient:
|
|||||||
self._fetch_email(tokens.get("access_token", "")),
|
self._fetch_email(tokens.get("access_token", "")),
|
||||||
self._fetch_project_id(tokens.get("access_token", "")),
|
self._fetch_project_id(tokens.get("access_token", "")),
|
||||||
)
|
)
|
||||||
return AgyLoginResult(
|
return AgyLoginResult(email=email, project_id=project_id, refresh_token=refresh_token)
|
||||||
email=email, project_id=project_id, refresh_token=refresh_token
|
|
||||||
)
|
|
||||||
|
|
||||||
async def refresh(self, refresh_token: str) -> str:
|
async def refresh(self, refresh_token: str) -> str:
|
||||||
tokens = await self._post_token(
|
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)
|
return await AgyAuthClient().exchange(challenge, callback_url)
|
||||||
|
|
||||||
|
|
||||||
async def redeem_login_by_waiting(
|
async def redeem_login_by_waiting(login_id: str, *, timeout_seconds: float = 300) -> AgyLoginResult:
|
||||||
login_id: str, *, timeout_seconds: float = 300
|
|
||||||
) -> AgyLoginResult:
|
|
||||||
"""Listener mode: capture the redirect on the loopback callback port.
|
"""Listener mode: capture the redirect on the loopback callback port.
|
||||||
|
|
||||||
Used by the operator-side script (and by a same-machine deployment): Google
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
future: asyncio.Future[str] = loop.create_future()
|
future: asyncio.Future[str] = loop.create_future()
|
||||||
|
|
||||||
async def _handle(
|
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
||||||
) -> None:
|
|
||||||
try:
|
try:
|
||||||
request_line = await reader.readline()
|
request_line = await reader.readline()
|
||||||
parts = request_line.decode("latin-1").split(" ")
|
parts = request_line.decode("latin-1").split(" ")
|
||||||
@@ -591,8 +589,10 @@ class AgyModelAdapter:
|
|||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
max_tokens: int = 2048,
|
max_tokens: int = 2048,
|
||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
pool = credentials if credentials is not None else (
|
pool = (
|
||||||
[credential] if credential is not None else await self._load()
|
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]
|
accounts = [a for a in (_account_of(c) for c in pool) if a.refresh_token]
|
||||||
if not accounts:
|
if not accounts:
|
||||||
@@ -605,8 +605,12 @@ class AgyModelAdapter:
|
|||||||
for account in ordered:
|
for account in ordered:
|
||||||
try:
|
try:
|
||||||
response = await self._complete_with(
|
response = await self._complete_with(
|
||||||
account, model=model, messages=messages, system=system,
|
account,
|
||||||
temperature=temperature, max_tokens=max_tokens,
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
system=system,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
except _AgyRateLimited as exc:
|
except _AgyRateLimited as exc:
|
||||||
failures.append(f"{account.email or account.credential_id}: محدودیت نرخ")
|
failures.append(f"{account.email or account.credential_id}: محدودیت نرخ")
|
||||||
@@ -645,9 +649,7 @@ class AgyModelAdapter:
|
|||||||
items = await self._fetch_models(accounts[0])
|
items = await self._fetch_models(accounts[0])
|
||||||
return [m.id for m in items]
|
return [m.id for m in items]
|
||||||
|
|
||||||
async def available_models_for(
|
async def available_models_for(self, credential: ModelCredential) -> list[AgyAccountModelInfo]:
|
||||||
self, credential: ModelCredential
|
|
||||||
) -> list[AgyAccountModelInfo]:
|
|
||||||
"""Live model catalog of exactly one account.
|
"""Live model catalog of exactly one account.
|
||||||
|
|
||||||
Called per account selection in the admin console, so the list is
|
Called per account selection in the admin console, so the list is
|
||||||
@@ -657,12 +659,17 @@ class AgyModelAdapter:
|
|||||||
"""
|
"""
|
||||||
account = _account_of(credential)
|
account = _account_of(credential)
|
||||||
if not account.refresh_token:
|
if not account.refresh_token:
|
||||||
raise ProviderUnavailableError(
|
raise ProviderUnavailableError("این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید.")
|
||||||
"این اکانت اطلاعات ورود ندارد؛ یک بار دیگر لاگین کنید."
|
|
||||||
)
|
|
||||||
return await self._fetch_models(account)
|
return await self._fetch_models(account)
|
||||||
|
|
||||||
async def _fetch_models(self, account: AgyAccount) -> list[AgyAccountModelInfo]:
|
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)
|
token = await self._access_token(account)
|
||||||
for base in BASE_URLS:
|
for base in BASE_URLS:
|
||||||
try:
|
try:
|
||||||
@@ -684,9 +691,122 @@ class AgyModelAdapter:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_model_catalog(
|
async def _fetch_models_via_cli(cls, account: AgyAccount) -> list[AgyAccountModelInfo]:
|
||||||
cls, raw_models: dict[str, Any]
|
"""Query models live from the headless `agy` CLI application for this account."""
|
||||||
) -> list[AgyAccountModelInfo]:
|
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] = []
|
items: list[AgyAccountModelInfo] = []
|
||||||
for model_id, details in sorted(raw_models.items()):
|
for model_id, details in sorted(raw_models.items()):
|
||||||
if not isinstance(details, dict):
|
if not isinstance(details, dict):
|
||||||
@@ -739,9 +859,7 @@ class AgyModelAdapter:
|
|||||||
return " ".join(words)
|
return " ".join(words)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _supported_efforts_for(
|
def _supported_efforts_for(cls, model_id: str, details: dict[str, Any]) -> list[str]:
|
||||||
cls, model_id: str, details: dict[str, Any]
|
|
||||||
) -> list[str]:
|
|
||||||
lowered = model_id.lower()
|
lowered = model_id.lower()
|
||||||
if "image" in lowered:
|
if "image" in lowered:
|
||||||
return []
|
return []
|
||||||
@@ -869,7 +987,12 @@ class AgyModelAdapter:
|
|||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
token = await self._access_token(account)
|
token = await self._access_token(account)
|
||||||
payload = _build_generate_payload(
|
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,
|
reasoning_effort=model.reasoning_effort,
|
||||||
)
|
)
|
||||||
headers = {
|
headers = {
|
||||||
@@ -894,9 +1017,9 @@ class AgyModelAdapter:
|
|||||||
# Both backends share one quota, but a 429 can also be a
|
# Both backends share one quota, but a 429 can also be a
|
||||||
# backend hiccup — try the other base URL before benching the
|
# backend hiccup — try the other base URL before benching the
|
||||||
# whole account.
|
# whole account.
|
||||||
retry_after = _retry_after_seconds(
|
retry_after = (
|
||||||
response.headers.get("Retry-After")
|
_retry_after_seconds(response.headers.get("Retry-After")) or retry_after
|
||||||
) or retry_after
|
)
|
||||||
continue
|
continue
|
||||||
if response.status_code in (401, 403):
|
if response.status_code in (401, 403):
|
||||||
raise _AgyAuthError(
|
raise _AgyAuthError(
|
||||||
@@ -909,12 +1032,9 @@ class AgyModelAdapter:
|
|||||||
# Our request shape is wrong — another account will fail the
|
# Our request shape is wrong — another account will fail the
|
||||||
# same way, so surface it instead of burning the pool.
|
# same way, so surface it instead of burning the pool.
|
||||||
raise ProviderUnavailableError(
|
raise ProviderUnavailableError(
|
||||||
f"agy درخواست را رد کرد ({response.status_code}): "
|
f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}"
|
||||||
f"{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:
|
if retry_after is not None:
|
||||||
raise _AgyRateLimited(retry_after)
|
raise _AgyRateLimited(retry_after)
|
||||||
raise last_transient or _AgyTransientError("هیچ بکاند agy پاسخ نداد.")
|
raise last_transient or _AgyTransientError("هیچ بکاند agy پاسخ نداد.")
|
||||||
@@ -944,14 +1064,26 @@ def _build_generate_payload(
|
|||||||
role = "model" if message.role == "assistant" else "user"
|
role = "model" if message.role == "assistant" else "user"
|
||||||
contents.append({"role": role, "parts": [{"text": message.content}]})
|
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 = {
|
generation: dict = {
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"maxOutputTokens": max_tokens,
|
"maxOutputTokens": max_tokens,
|
||||||
}
|
}
|
||||||
# Reasoning effort is only sent to models that can think; the backend
|
# Reasoning effort is only sent to models that can think; the backend
|
||||||
# rejects ``thinkingConfig`` everywhere else with a 400.
|
# rejects ``thinkingConfig`` everywhere else with a 400.
|
||||||
if reasoning_effort and _supports_thinking(model_name):
|
if effective_effort and _supports_thinking(backend_model):
|
||||||
budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192)
|
budget = EFFORT_THINKING_BUDGETS.get(effective_effort, 8192)
|
||||||
generation["thinkingConfig"] = {
|
generation["thinkingConfig"] = {
|
||||||
"thinkingBudget": budget,
|
"thinkingBudget": budget,
|
||||||
"includeThoughts": True,
|
"includeThoughts": True,
|
||||||
@@ -975,16 +1107,14 @@ def _build_generate_payload(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"project": account.project_id or _fallback_project_id(),
|
"project": account.project_id or _fallback_project_id(),
|
||||||
"model": model_name,
|
"model": backend_model,
|
||||||
"userAgent": "antigravity",
|
"userAgent": "antigravity",
|
||||||
"requestId": f"agent-{uuid.uuid4()}",
|
"requestId": f"agent-{uuid.uuid4()}",
|
||||||
"request": request,
|
"request": request,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_generate_response(
|
def _parse_generate_response(data: dict, *, model: AiModel, started: float) -> ModelResponse:
|
||||||
data: dict, *, model: AiModel, started: float
|
|
||||||
) -> ModelResponse:
|
|
||||||
candidates = data.get("candidates") or [{}]
|
candidates = data.get("candidates") or [{}]
|
||||||
candidate = candidates[0] if isinstance(candidates[0], dict) else {}
|
candidate = candidates[0] if isinstance(candidates[0], dict) else {}
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
@@ -1025,14 +1155,10 @@ async def load_agy_accounts() -> list[ModelCredential]:
|
|||||||
|
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
providers = await ProviderRepository(session).list()
|
providers = await ProviderRepository(session).list()
|
||||||
provider = next(
|
provider = next((p for p in providers if p.type.value == "agy" and p.is_active), None)
|
||||||
(p for p in providers if p.type.value == "agy" and p.is_active), None
|
|
||||||
)
|
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return []
|
return []
|
||||||
return await ModelCredentialRepository(session).list_active_for_provider(
|
return await ModelCredentialRepository(session).list_active_for_provider(provider.id)
|
||||||
provider.id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def report_agy_state(
|
async def report_agy_state(
|
||||||
|
|||||||
+126
-50
@@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
GOOD_GENERATE: dict = {
|
GOOD_GENERATE: dict = {
|
||||||
"candidates": [
|
"candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}],
|
||||||
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
|
|
||||||
],
|
|
||||||
"usageMetadata": {
|
"usageMetadata": {
|
||||||
"promptTokenCount": 7,
|
"promptTokenCount": 7,
|
||||||
"candidatesTokenCount": 3,
|
"candidatesTokenCount": 3,
|
||||||
@@ -192,9 +190,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
|||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
json={
|
json={
|
||||||
"login_id": login_id,
|
"login_id": login_id,
|
||||||
"callback_url": (
|
"callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"),
|
||||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert completed.status_code == 201, completed.text
|
assert completed.status_code == 201, completed.text
|
||||||
@@ -209,9 +205,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
|||||||
assert "agy-proj-123" in completed.text # project id is admin metadata
|
assert "agy-proj-123" in completed.text # project id is admin metadata
|
||||||
|
|
||||||
# 3. the account is listed.
|
# 3. the account is listed.
|
||||||
listed = await client.get(
|
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
|
||||||
)
|
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
emails = [a["email"] for a in listed.json()]
|
emails = [a["email"] for a in listed.json()]
|
||||||
assert emails == ["person@gmail.com"]
|
assert emails == ["person@gmail.com"]
|
||||||
@@ -232,9 +226,7 @@ async def test_agy_complete_login_rejects_unrelated_url(client, google, platform
|
|||||||
)
|
)
|
||||||
assert completed.status_code == 422
|
assert completed.status_code == 422
|
||||||
# The failed attempt must not have created an account.
|
# The failed attempt must not have created an account.
|
||||||
listed = await client.get(
|
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
|
||||||
)
|
|
||||||
assert listed.json() == []
|
assert listed.json() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -249,9 +241,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
|||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
json={
|
json={
|
||||||
"login_id": started.json()["login_id"],
|
"login_id": started.json()["login_id"],
|
||||||
"callback_url": (
|
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
account_id = created.json()["id"]
|
account_id = created.json()["id"]
|
||||||
@@ -270,9 +260,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
|||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
)
|
)
|
||||||
assert deleted.status_code == 204
|
assert deleted.status_code == 204
|
||||||
listed = await client.get(
|
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
|
||||||
)
|
|
||||||
assert listed.json() == []
|
assert listed.json() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -289,15 +277,11 @@ async def test_agy_same_account_twice_replaces(client, google, platform_owner_to
|
|||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
json={
|
json={
|
||||||
"login_id": started.json()["login_id"],
|
"login_id": started.json()["login_id"],
|
||||||
"callback_url": (
|
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert done.status_code == 201
|
assert done.status_code == 201
|
||||||
listed = await client.get(
|
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
|
||||||
)
|
|
||||||
assert len(listed.json()) == 1
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -390,9 +374,7 @@ async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_f
|
|||||||
def test_auth_url_shape():
|
def test_auth_url_shape():
|
||||||
challenge = agy.AgyAuthClient().begin()
|
challenge = agy.AgyAuthClient().begin()
|
||||||
query = parse_qs(urlparse(challenge.auth_url).query)
|
query = parse_qs(urlparse(challenge.auth_url).query)
|
||||||
assert challenge.auth_url.startswith(
|
assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
|
||||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
|
||||||
)
|
|
||||||
assert query["client_id"][0] == (
|
assert query["client_id"][0] == (
|
||||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||||
)
|
)
|
||||||
@@ -410,9 +392,7 @@ async def test_paste_flow_exchanges_and_resolves_account(google):
|
|||||||
"""start → paste callback URL → account material, verifier stays server-side."""
|
"""start → paste callback URL → account material, verifier stays server-side."""
|
||||||
login_id, _auth_url = await agy.begin_login()
|
login_id, _auth_url = await agy.begin_login()
|
||||||
challenge = agy._CHALLENGES[login_id]
|
challenge = agy._CHALLENGES[login_id]
|
||||||
callback = (
|
callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
|
||||||
)
|
|
||||||
result = await agy.redeem_login(login_id, callback)
|
result = await agy.redeem_login(login_id, callback)
|
||||||
assert result.email == "person@gmail.com"
|
assert result.email == "person@gmail.com"
|
||||||
assert result.project_id == "agy-proj-123"
|
assert result.project_id == "agy-proj-123"
|
||||||
@@ -446,11 +426,10 @@ async def test_google_error_page_is_rejected(google):
|
|||||||
|
|
||||||
async def test_refresh_token_missing_is_actionable(monkeypatch):
|
async def test_refresh_token_missing_is_actionable(monkeypatch):
|
||||||
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
||||||
|
|
||||||
async def handler(method, url, json_body, headers, data):
|
async def handler(method, url, json_body, headers, data):
|
||||||
if url.endswith("/token"):
|
if url.endswith("/token"):
|
||||||
return _FakeResponse(
|
return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600})
|
||||||
json_data={"access_token": "a", "expires_in": 3600}
|
|
||||||
)
|
|
||||||
return await default_handler(method, url, json_body, headers, data)
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
patch_google(monkeypatch, handler)
|
patch_google(monkeypatch, handler)
|
||||||
@@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts():
|
|||||||
async def test_complete_happy_path(google, decrypted):
|
async def test_complete_happy_path(google, decrypted):
|
||||||
adapter = agy.AgyModelAdapter()
|
adapter = agy.AgyModelAdapter()
|
||||||
response = await adapter.complete(
|
response = await adapter.complete(
|
||||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
model=_MODEL,
|
||||||
|
credentials=[_agy_credential(email="one@gmail.com")],
|
||||||
messages=_MESSAGES,
|
messages=_MESSAGES,
|
||||||
)
|
)
|
||||||
assert response.content == "سلام"
|
assert response.content == "سلام"
|
||||||
@@ -631,6 +611,7 @@ async def test_complete_happy_path(google, decrypted):
|
|||||||
|
|
||||||
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
|
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
|
||||||
"""A 429 on one account must not fail the request: the pool moves on."""
|
"""A 429 on one account must not fail the request: the pool moves on."""
|
||||||
|
|
||||||
async def handler(method, url, json_body, headers, data):
|
async def handler(method, url, json_body, headers, data):
|
||||||
if url.endswith("/token"):
|
if url.endswith("/token"):
|
||||||
# Each account's refresh token maps to its own access token.
|
# Each account's refresh token maps to its own access token.
|
||||||
@@ -642,9 +623,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
|||||||
auth = headers.get("Authorization", "")
|
auth = headers.get("Authorization", "")
|
||||||
if auth.endswith("access-one"):
|
if auth.endswith("access-one"):
|
||||||
# Both base URLs hit the same quota.
|
# Both base URLs hit the same quota.
|
||||||
return _FakeResponse(
|
return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"})
|
||||||
status_code=429, text="quota", headers={"Retry-After": "120"}
|
|
||||||
)
|
|
||||||
return _FakeResponse(json_data=GOOD_GENERATE)
|
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||||
return await default_handler(method, url, json_body, headers, data)
|
return await default_handler(method, url, json_body, headers, data)
|
||||||
|
|
||||||
@@ -659,9 +638,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
|||||||
adapter._state.cursor = -1
|
adapter._state.cursor = -1
|
||||||
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
||||||
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
||||||
response = await adapter.complete(
|
response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES)
|
||||||
model=_MODEL, credentials=[one, two], messages=_MESSAGES
|
|
||||||
)
|
|
||||||
assert response.content == "سلام"
|
assert response.content == "سلام"
|
||||||
|
|
||||||
cooldowns = [r for r in reported if r[1] == "cooldown"]
|
cooldowns = [r for r in reported if r[1] == "cooldown"]
|
||||||
@@ -696,6 +673,7 @@ async def test_every_account_rate_limited_raises(monkeypatch, decrypted):
|
|||||||
|
|
||||||
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||||
"""401 means the OAuth grant died — bench as error, move to next account."""
|
"""401 means the OAuth grant died — bench as error, move to next account."""
|
||||||
|
|
||||||
async def handler(method, url, json_body, headers, data):
|
async def handler(method, url, json_body, headers, data):
|
||||||
if url.endswith("/token"):
|
if url.endswith("/token"):
|
||||||
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
||||||
@@ -719,9 +697,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
|||||||
adapter._state.cursor = -1
|
adapter._state.cursor = -1
|
||||||
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
||||||
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
||||||
response = await adapter.complete(
|
response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES)
|
||||||
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
|
|
||||||
)
|
|
||||||
assert response.content == "سلام"
|
assert response.content == "سلام"
|
||||||
errors = [r for r in reported if r[1] == "error"]
|
errors = [r for r in reported if r[1] == "error"]
|
||||||
assert errors and errors[0][0] == dead.id
|
assert errors and errors[0][0] == dead.id
|
||||||
@@ -731,6 +707,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
|||||||
|
|
||||||
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
|
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
|
||||||
"""5xx on one backend URL is not the account's fault — try the other."""
|
"""5xx on one backend URL is not the account's fault — try the other."""
|
||||||
|
|
||||||
async def handler(method, url, json_body, headers, data):
|
async def handler(method, url, json_body, headers, data):
|
||||||
if url.endswith("/token"):
|
if url.endswith("/token"):
|
||||||
return _token_response()
|
return _token_response()
|
||||||
@@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted
|
|||||||
patch_google(monkeypatch, handler)
|
patch_google(monkeypatch, handler)
|
||||||
adapter = agy.AgyModelAdapter()
|
adapter = agy.AgyModelAdapter()
|
||||||
response = await adapter.complete(
|
response = await adapter.complete(
|
||||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
model=_MODEL,
|
||||||
|
credentials=[_agy_credential(email="one@gmail.com")],
|
||||||
messages=_MESSAGES,
|
messages=_MESSAGES,
|
||||||
)
|
)
|
||||||
assert response.content == "سلام"
|
assert response.content == "سلام"
|
||||||
@@ -782,9 +760,7 @@ async def test_available_models(google, decrypted):
|
|||||||
async def test_available_models_for_one_account(google, decrypted):
|
async def test_available_models_for_one_account(google, decrypted):
|
||||||
"""The console asks for a specific account, so the fetch is per-account."""
|
"""The console asks for a specific account, so the fetch is per-account."""
|
||||||
adapter = agy.AgyModelAdapter()
|
adapter = agy.AgyModelAdapter()
|
||||||
models = await adapter.available_models_for(
|
models = await adapter.available_models_for(_agy_credential(email="one@gmail.com"))
|
||||||
_agy_credential(email="one@gmail.com")
|
|
||||||
)
|
|
||||||
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||||
assert models[1].supported_efforts == ["low", "medium", "high"]
|
assert models[1].supported_efforts == ["low", "medium", "high"]
|
||||||
# The list is fetched from Google every call — no cached catalog.
|
# The list is fetched from Google every call — no cached catalog.
|
||||||
@@ -818,9 +794,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
|||||||
headers=auth_header(platform_owner_token),
|
headers=auth_header(platform_owner_token),
|
||||||
json={
|
json={
|
||||||
"login_id": started.json()["login_id"],
|
"login_id": started.json()["login_id"],
|
||||||
"callback_url": (
|
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
@@ -843,6 +817,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
|||||||
|
|
||||||
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||||
"""The end-to-end path: effort on the model row reaches the payload."""
|
"""The end-to-end path: effort on the model row reaches the payload."""
|
||||||
|
|
||||||
async def handler(method, url, json_body, headers, data):
|
async def handler(method, url, json_body, headers, data):
|
||||||
if url.endswith("/token"):
|
if url.endswith("/token"):
|
||||||
return _token_response()
|
return _token_response()
|
||||||
@@ -868,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
|||||||
"thinkingBudget": 4096,
|
"thinkingBudget": 4096,
|
||||||
"includeThoughts": True,
|
"includeThoughts": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_cli_models_output():
|
||||||
|
"""Verify parsing raw output from agy models CLI."""
|
||||||
|
sample_output = (
|
||||||
|
"Fetching available models...\n"
|
||||||
|
"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n"
|
||||||
|
"gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\n"
|
||||||
|
"gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n"
|
||||||
|
"claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n"
|
||||||
|
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
||||||
|
"gpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n"
|
||||||
|
)
|
||||||
|
models = agy.AgyModelAdapter._parse_cli_models_output(sample_output)
|
||||||
|
assert len(models) == 6
|
||||||
|
|
||||||
|
m_high = next(m for m in models if m.id == "gemini-3.8-flash-high")
|
||||||
|
assert m_high.display_name == "Gemini 3.8 Flash (High)"
|
||||||
|
assert m_high.supports_thinking is True
|
||||||
|
assert m_high.supported_efforts == ["high"]
|
||||||
|
assert m_high.default_effort == "high"
|
||||||
|
|
||||||
|
m_med = next(m for m in models if m.id == "gemini-3.8-flash-medium")
|
||||||
|
assert m_med.supported_efforts == ["medium"]
|
||||||
|
assert m_med.default_effort == "medium"
|
||||||
|
|
||||||
|
m_low = next(m for m in models if m.id == "gemini-3.8-flash-low")
|
||||||
|
assert m_low.supported_efforts == ["low"]
|
||||||
|
assert m_low.default_effort == "low"
|
||||||
|
|
||||||
|
m_claude = next(m for m in models if m.id == "claude-sonnet-4-6")
|
||||||
|
assert m_claude.supports_thinking is True
|
||||||
|
|
||||||
|
m_gpt = next(m for m in models if m.id == "gpt-oss-120b-medium")
|
||||||
|
assert m_gpt.supported_efforts == ["medium"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_generate_payload_tiered_mapping():
|
||||||
|
"""Verify virtual CLI models map to underlying tiered model with appropriate budget."""
|
||||||
|
account = agy.AgyAccount(
|
||||||
|
credential_id="cred-1",
|
||||||
|
refresh_token="rt-1",
|
||||||
|
email="test@example.com",
|
||||||
|
project_id="proj-123",
|
||||||
|
)
|
||||||
|
messages = [ChatMessage(role="user", content="Hello")]
|
||||||
|
|
||||||
|
payload_low = agy._build_generate_payload(
|
||||||
|
account,
|
||||||
|
"gemini-3.8-flash-low",
|
||||||
|
messages,
|
||||||
|
system="",
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
assert payload_low["model"] == "gemini-3.8-flash-tiered"
|
||||||
|
assert payload_low["request"]["generationConfig"]["thinkingConfig"] == {
|
||||||
|
"thinkingBudget": 4096,
|
||||||
|
"includeThoughts": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload_high = agy._build_generate_payload(
|
||||||
|
account,
|
||||||
|
"gemini-3.8-flash-high",
|
||||||
|
messages,
|
||||||
|
system="",
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
assert payload_high["model"] == "gemini-3.8-flash-tiered"
|
||||||
|
assert payload_high["request"]["generationConfig"]["thinkingConfig"] == {
|
||||||
|
"thinkingBudget": 16384,
|
||||||
|
"includeThoughts": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_models_via_cli_mocked(monkeypatch):
|
||||||
|
"""Verify _fetch_models_via_cli invokes the CLI binary and handles output."""
|
||||||
|
account = agy.AgyAccount(
|
||||||
|
credential_id="cred-1",
|
||||||
|
refresh_token="rt-test-123",
|
||||||
|
email="test@example.com",
|
||||||
|
project_id="proj-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
return b"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n", b""
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(agy.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||||
|
monkeypatch.setattr(agy.shutil, "which", lambda cmd: "/usr/bin/agy")
|
||||||
|
|
||||||
|
models = await agy.AgyModelAdapter._fetch_models_via_cli(account)
|
||||||
|
assert len(models) == 1
|
||||||
|
assert models[0].id == "gemini-3.8-flash-high"
|
||||||
|
assert models[0].display_name == "Gemini 3.8 Flash (High)"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@agy/web",
|
"name": "@agy/web",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
|
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* "the app is on the wrong version" bug into a failing test, which is the
|
* "the app is on the wrong version" bug into a failing test, which is the
|
||||||
* whole point of keeping them separate.
|
* whole point of keeping them separate.
|
||||||
*/
|
*/
|
||||||
export const APP_VERSION = "0.1.3";
|
export const APP_VERSION = "0.1.4";
|
||||||
|
|
||||||
/** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
|
/** What the footer pill renders — ``v`` prefix keeps it unambiguous in the UI. */
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smart-org",
|
"name": "smart-org",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.",
|
"description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.",
|
||||||
"packageManager": "pnpm@9.12.0",
|
"packageManager": "pnpm@9.12.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user