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

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 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( return _parse_generate_response(response.json(), model=model, started=started)
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
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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
View File
@@ -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",