بهروزرسانی: استخراج خودکار و پویای فهرست مدلها و سطوح تلاش پردازش از ابزار خط فرمان و ارتقای نسخه به ۰.۱.۴
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]}"
|
||||
)
|
||||
return _parse_generate_response(
|
||||
response.json(), model=model, started=started
|
||||
f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}"
|
||||
)
|
||||
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(
|
||||
|
||||
+126
-50
@@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GOOD_GENERATE: dict = {
|
||||
"candidates": [
|
||||
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
|
||||
],
|
||||
"candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 7,
|
||||
"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),
|
||||
json={
|
||||
"login_id": login_id,
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"),
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
# 3. the account is listed.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.status_code == 200
|
||||
emails = [a["email"] for a in listed.json()]
|
||||
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
|
||||
# The failed attempt must not have created an account.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
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),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
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),
|
||||
)
|
||||
assert deleted.status_code == 204
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
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),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
assert done.status_code == 201
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
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():
|
||||
challenge = agy.AgyAuthClient().begin()
|
||||
query = parse_qs(urlparse(challenge.auth_url).query)
|
||||
assert challenge.auth_url.startswith(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
)
|
||||
assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
|
||||
assert query["client_id"][0] == (
|
||||
"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."""
|
||||
login_id, _auth_url = await agy.begin_login()
|
||||
challenge = agy._CHALLENGES[login_id]
|
||||
callback = (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
)
|
||||
callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
result = await agy.redeem_login(login_id, callback)
|
||||
assert result.email == "person@gmail.com"
|
||||
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):
|
||||
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _FakeResponse(
|
||||
json_data={"access_token": "a", "expires_in": 3600}
|
||||
)
|
||||
return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600})
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
patch_google(monkeypatch, handler)
|
||||
@@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts():
|
||||
async def test_complete_happy_path(google, decrypted):
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
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):
|
||||
"""A 429 on one account must not fail the request: the pool moves on."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/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", "")
|
||||
if auth.endswith("access-one"):
|
||||
# Both base URLs hit the same quota.
|
||||
return _FakeResponse(
|
||||
status_code=429, text="quota", headers={"Retry-After": "120"}
|
||||
)
|
||||
return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"})
|
||||
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||
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
|
||||
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
||||
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[one, two], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
|
||||
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):
|
||||
"""401 means the OAuth grant died — bench as error, move to next account."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
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
|
||||
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
||||
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
errors = [r for r in reported if r[1] == "error"]
|
||||
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):
|
||||
"""5xx on one backend URL is not the account's fault — try the other."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
@@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted
|
||||
patch_google(monkeypatch, handler)
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
assert response.content == "سلام"
|
||||
@@ -782,9 +760,7 @@ async def test_available_models(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."""
|
||||
adapter = agy.AgyModelAdapter()
|
||||
models = await adapter.available_models_for(
|
||||
_agy_credential(email="one@gmail.com")
|
||||
)
|
||||
models = await adapter.available_models_for(_agy_credential(email="one@gmail.com"))
|
||||
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
assert models[1].supported_efforts == ["low", "medium", "high"]
|
||||
# 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),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
).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):
|
||||
"""The end-to-end path: effort on the model row reaches the payload."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
@@ -868,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||
"thinkingBudget": 4096,
|
||||
"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",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"description": "Smart Organization frontend — Next.js App Router, RTL, dark-first.",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* "the app is on the wrong version" bug into a failing test, which is the
|
||||
* 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. */
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smart-org",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"description": "Smart Organization — AI Workforce Platform. Standalone multi-tenant SaaS, independent of the Telegram bot runtime.",
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
|
||||
Reference in New Issue
Block a user