From afdef4b444ba35742212e750cd560c8ae2cb3566 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 09:00:18 +0000 Subject: [PATCH] =?UTF-8?q?=D8=A8=D9=87=E2=80=8C=D8=B1=D9=88=D8=B2=D8=B1?= =?UTF-8?q?=D8=B3=D8=A7=D9=86=DB=8C:=20=D8=A7=D8=B3=D8=AA=D8=AE=D8=B1?= =?UTF-8?q?=D8=A7=D8=AC=20=D8=AE=D9=88=D8=AF=DA=A9=D8=A7=D8=B1=20=D9=88=20?= =?UTF-8?q?=D9=BE=D9=88=DB=8C=D8=A7=DB=8C=20=D9=81=D9=87=D8=B1=D8=B3=D8=AA?= =?UTF-8?q?=20=D9=85=D8=AF=D9=84=E2=80=8C=D9=87=D8=A7=20=D9=88=20=D8=B3?= =?UTF-8?q?=D8=B7=D9=88=D8=AD=20=D8=AA=D9=84=D8=A7=D8=B4=20=D9=BE=D8=B1?= =?UTF-8?q?=D8=AF=D8=A7=D8=B2=D8=B4=20=D8=A7=D8=B2=20=D8=A7=D8=A8=D8=B2?= =?UTF-8?q?=D8=A7=D8=B1=20=D8=AE=D8=B7=20=D9=81=D8=B1=D9=85=D8=A7=D9=86=20?= =?UTF-8?q?=D9=88=20=D8=A7=D8=B1=D8=AA=D9=82=D8=A7=DB=8C=20=D9=86=D8=B3?= =?UTF-8?q?=D8=AE=D9=87=20=D8=A8=D9=87=20=DB=B0.=DB=B1.=DB=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/infrastructure/adapters/agy.py | 218 +++++++++++++++----- apps/api/tests/test_agy.py | 176 +++++++++++----- apps/web/package.json | 2 +- apps/web/src/lib/version.ts | 2 +- package.json | 2 +- 5 files changed, 301 insertions(+), 99 deletions(-) diff --git a/apps/api/app/infrastructure/adapters/agy.py b/apps/api/app/infrastructure/adapters/agy.py index e554d08..21664b8 100644 --- a/apps/api/app/infrastructure/adapters/agy.py +++ b/apps/api/app/infrastructure/adapters/agy.py @@ -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: \t + parts = line.split("\t", 1) + if len(parts) == 2: + model_id = parts[0].strip() + display_name = parts[1].strip() + else: + tokens = line.split(None, 1) + model_id = tokens[0].strip() + display_name = tokens[1].strip() if len(tokens) > 1 else model_id + + lowered = model_id.lower() + dn_lowered = display_name.lower() + + supports_thinking = ( + "thinking" in lowered + or "thinking" in dn_lowered + or "-high" in lowered + or "-medium" in lowered + or "-low" in lowered + ) + + supported_efforts: list[str] = [] + default_effort: str | None = None + if "-high" in lowered or "(high)" in dn_lowered: + supported_efforts = ["high"] + default_effort = "high" + elif "-medium" in lowered or "(medium)" in dn_lowered: + supported_efforts = ["medium"] + default_effort = "medium" + elif "-low" in lowered or "(low)" in dn_lowered: + supported_efforts = ["low"] + default_effort = "low" + elif "thinking" in lowered or "thinking" in dn_lowered: + if "gemini" in lowered: + supported_efforts = ["low", "medium", "high"] + default_effort = "high" + else: + supported_efforts = [] + default_effort = None + + items.append( + AgyAccountModelInfo( + id=model_id, + display_name=display_name, + supports_thinking=supports_thinking, + supported_efforts=supported_efforts, + default_effort=default_effort, + supports_images=True, + ) + ) + return items + + @classmethod + def _parse_model_catalog(cls, raw_models: dict[str, Any]) -> list[AgyAccountModelInfo]: items: list[AgyAccountModelInfo] = [] for model_id, details in sorted(raw_models.items()): if not isinstance(details, dict): @@ -739,9 +859,7 @@ class AgyModelAdapter: return " ".join(words) @classmethod - def _supported_efforts_for( - cls, model_id: str, details: dict[str, Any] - ) -> list[str]: + def _supported_efforts_for(cls, model_id: str, details: dict[str, Any]) -> list[str]: lowered = model_id.lower() if "image" in lowered: return [] @@ -869,7 +987,12 @@ class AgyModelAdapter: ) -> ModelResponse: token = await self._access_token(account) payload = _build_generate_payload( - account, model.internal_name, messages, system, temperature, max_tokens, + account, + model.internal_name, + messages, + system, + temperature, + max_tokens, reasoning_effort=model.reasoning_effort, ) headers = { @@ -894,9 +1017,9 @@ class AgyModelAdapter: # Both backends share one quota, but a 429 can also be a # backend hiccup — try the other base URL before benching the # whole account. - retry_after = _retry_after_seconds( - response.headers.get("Retry-After") - ) or retry_after + retry_after = ( + _retry_after_seconds(response.headers.get("Retry-After")) or retry_after + ) continue if response.status_code in (401, 403): raise _AgyAuthError( @@ -909,12 +1032,9 @@ class AgyModelAdapter: # Our request shape is wrong — another account will fail the # same way, so surface it instead of burning the pool. raise ProviderUnavailableError( - f"agy درخواست را رد کرد ({response.status_code}): " - f"{response.text[:300]}" + f"agy درخواست را رد کرد ({response.status_code}): {response.text[:300]}" ) - return _parse_generate_response( - response.json(), model=model, started=started - ) + return _parse_generate_response(response.json(), model=model, started=started) if retry_after is not None: raise _AgyRateLimited(retry_after) raise last_transient or _AgyTransientError("هیچ بک‌اند agy پاسخ نداد.") @@ -944,14 +1064,26 @@ def _build_generate_payload( role = "model" if message.role == "assistant" else "user" contents.append({"role": role, "parts": [{"text": message.content}]}) + backend_model = model_name + effective_effort = reasoning_effort + if model_name == "gemini-3.8-flash-high": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "high" + elif model_name == "gemini-3.8-flash-medium": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "medium" + elif model_name == "gemini-3.8-flash-low": + backend_model = "gemini-3.8-flash-tiered" + effective_effort = effective_effort or "low" + generation: dict = { "temperature": temperature, "maxOutputTokens": max_tokens, } # Reasoning effort is only sent to models that can think; the backend # rejects ``thinkingConfig`` everywhere else with a 400. - if reasoning_effort and _supports_thinking(model_name): - budget = EFFORT_THINKING_BUDGETS.get(reasoning_effort, 8192) + if effective_effort and _supports_thinking(backend_model): + budget = EFFORT_THINKING_BUDGETS.get(effective_effort, 8192) generation["thinkingConfig"] = { "thinkingBudget": budget, "includeThoughts": True, @@ -975,16 +1107,14 @@ def _build_generate_payload( } return { "project": account.project_id or _fallback_project_id(), - "model": model_name, + "model": backend_model, "userAgent": "antigravity", "requestId": f"agent-{uuid.uuid4()}", "request": request, } -def _parse_generate_response( - data: dict, *, model: AiModel, started: float -) -> ModelResponse: +def _parse_generate_response(data: dict, *, model: AiModel, started: float) -> ModelResponse: candidates = data.get("candidates") or [{}] candidate = candidates[0] if isinstance(candidates[0], dict) else {} text_parts: list[str] = [] @@ -1025,14 +1155,10 @@ async def load_agy_accounts() -> list[ModelCredential]: async with SessionFactory() as session: providers = await ProviderRepository(session).list() - provider = next( - (p for p in providers if p.type.value == "agy" and p.is_active), None - ) + provider = next((p for p in providers if p.type.value == "agy" and p.is_active), None) if provider is None: return [] - return await ModelCredentialRepository(session).list_active_for_provider( - provider.id - ) + return await ModelCredentialRepository(session).list_active_for_provider(provider.id) async def report_agy_state( diff --git a/apps/api/tests/test_agy.py b/apps/api/tests/test_agy.py index 7137adf..f2c0773 100644 --- a/apps/api/tests/test_agy.py +++ b/apps/api/tests/test_agy.py @@ -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)" diff --git a/apps/web/package.json b/apps/web/package.json index 55ff900..73ab2a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/lib/version.ts b/apps/web/src/lib/version.ts index 7481377..4b59408 100644 --- a/apps/web/src/lib/version.ts +++ b/apps/web/src/lib/version.ts @@ -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}`; diff --git a/package.json b/package.json index fb23386..1fed07d 100644 --- a/package.json +++ b/package.json @@ -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",