From 5afd92f8f3f6a4d88888c8ca42b55e85c6a3ed4b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 18 Aug 2026 13:09:42 +0900 Subject: [PATCH] =?UTF-8?q?agy=20=EB=AA=A8=EB=8D=B8=20=EC=B9=B4=ED=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=ED=83=AD=20=ED=98=95=EC=8B=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95=EA=B3=BC=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy CLI가 2026-08부터 'agy models'를 'model_id라벨' 형태로 출력하는데 _discover_agy_cli가 공백 포함 줄을 전부 버려 카탈로그가 비어 available=false가 됐다 (관리자 AI 운영 화면에서 'Agy가 선택 가능한 모델을 반환하지 않았습니다'). - 탭 줄은 왼쪽 토큰을 id로, CLI 라벨을 우선 적용. 탭 없는 공백 줄(상태 문구)은 계속 무시하고 구형 베어 id 출력 계약 유지 — 형식 이중 호환 - _binary가 PATH에 없어도 LOCALAPPDATA agy bin 표준 설치 경로를 폴백으로 탐색 - 검증: 신규 탭 형식 회귀 RED→GREEN, engine_gateway 63 passed, 실제 agy CLI로 15모델 live 조회 확인(default gemini-3.6-flash-high) --- apps/api/engine_gateway/provider_registry.py | 23 +++++++++++-- .../engine_gateway/test_provider_registry.py | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/api/engine_gateway/provider_registry.py b/apps/api/engine_gateway/provider_registry.py index 9c4592a..92c91f1 100644 --- a/apps/api/engine_gateway/provider_registry.py +++ b/apps/api/engine_gateway/provider_registry.py @@ -109,6 +109,14 @@ def _binary(env_name: str, fallback: str) -> str | None: executable = shutil.which(f"{fallback}.exe") if executable: return executable + if fallback == "agy": + # agy 인스톨러의 Windows 표준 위치(%LOCALAPPDATA%\agy\bin). PATH에 + # 올라 있지 않은 머신에서도 게이트웨이가 공급자를 잃지 않게 한다. + local_app_data = os.environ.get("LOCALAPPDATA", "") + if local_app_data: + well_known = Path(local_app_data) / "agy" / "bin" / "agy.exe" + if well_known.exists(): + return str(well_known) return shim return shutil.which(fallback) @@ -356,7 +364,18 @@ async def _discover_agy_cli() -> EngineCapabilitiesResponse: models: list[EngineModelOption] = [] for line in stdout.splitlines(): - model_id = line.strip() + # agy CLI는 2026-08부터 "model_id\t표시 라벨" 형태로 출력한다. 탭 왼쪽 토큰이 + # 모델 id고 라벨은 CLI가 준 값을 우선한다. 탭 없이 공백이 섞인 줄은 상태/안내 + # 문구이므로 건너뛴다(구형 베어 id 출력 계약은 그대로 유지). + label: str | None = None + if "\t" in line: + model_id, _, rest = line.partition("\t") + model_id = model_id.strip() + label = rest.strip() or None + if not model_id: + continue + else: + model_id = line.strip() if not model_id or any(char.isspace() for char in model_id): continue suffix = model_id.rsplit("-", 1)[-1] @@ -369,7 +388,7 @@ async def _discover_agy_cli() -> EngineCapabilitiesResponse: models.append( EngineModelOption( id=model_id, - label=_display_model_name(model_id), + label=label or _display_model_name(model_id), description="Agy CLI가 현재 계정에 노출한 모델입니다.", reasoning_efforts=efforts, default_reasoning_effort=default_effort, diff --git a/apps/api/engine_gateway/test_provider_registry.py b/apps/api/engine_gateway/test_provider_registry.py index f11f27c..a2c30c6 100644 --- a/apps/api/engine_gateway/test_provider_registry.py +++ b/apps/api/engine_gateway/test_provider_registry.py @@ -117,6 +117,39 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(selected.reasoning_efforts, ["high"]) self.assertEqual(selected.label, "Gemini 3.6 Flash (High)") + async def test_agy_catalog_parses_tab_separated_id_label_lines(self): + # 2026-08 agy CLI는 `agy models`를 "model_id\t표시 라벨" 형태로 출력한다. + # 탭이 포함된 줄을 통째로 버리면 카탈로그가 비어 available=false가 된다. + stdout = "\n".join( + [ + "gemini-3.7-flash-high\tGemini 3.7 Flash (High)", + "gemini-3.7-flash-medium\tGemini 3.7 Flash (Medium)", + "claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)", + "Fetching available models...", + ] + ) + with ( + patch.object(provider_registry, "_binary", return_value="agy.exe"), + patch.object( + provider_registry, + "_run_process", + AsyncMock(return_value=(stdout, "")), + ), + ): + result = await provider_registry.discover_capabilities("agy_cli") + + self.assertTrue(result.available) + self.assertEqual( + [model.id for model in result.models], + ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "claude-sonnet-4-6"], + ) + # 기본 모델 gemini-3.6-flash-high 가 목록에 없으면 첫 모델로 폴백한다. + self.assertEqual(result.default_model, "gemini-3.7-flash-high") + self.assertEqual(result.default_reasoning_effort, "high") + selected = next(model for model in result.models if model.id == result.default_model) + self.assertEqual(selected.reasoning_efforts, ["high"]) + self.assertEqual(selected.label, "Gemini 3.7 Flash (High)") + async def test_claude_cli_catalog_is_explicit_static_alias_fallback(self): with patch.object(provider_registry, "_binary", return_value="claude.exe"): result = await provider_registry.discover_capabilities("claude_cli")