diff --git a/artifacts/a18-screenshots/01_model_choice_team_before.png b/artifacts/a18-screenshots/01_model_choice_team_before.png new file mode 100644 index 0000000..9d353fb Binary files /dev/null and b/artifacts/a18-screenshots/01_model_choice_team_before.png differ diff --git a/artifacts/a18-screenshots/02_model_choice_modal.png b/artifacts/a18-screenshots/02_model_choice_modal.png new file mode 100644 index 0000000..42517a9 Binary files /dev/null and b/artifacts/a18-screenshots/02_model_choice_modal.png differ diff --git a/artifacts/a18-screenshots/03_model_choice_account_details.png b/artifacts/a18-screenshots/03_model_choice_account_details.png new file mode 100644 index 0000000..39d9fe5 Binary files /dev/null and b/artifacts/a18-screenshots/03_model_choice_account_details.png differ diff --git a/artifacts/a18-screenshots/04_web_accounts_view.png b/artifacts/a18-screenshots/04_web_accounts_view.png new file mode 100644 index 0000000..4a0fd0c Binary files /dev/null and b/artifacts/a18-screenshots/04_web_accounts_view.png differ diff --git a/artifacts/a18-screenshots/05_web_providers_discovered_models.png b/artifacts/a18-screenshots/05_web_providers_discovered_models.png new file mode 100644 index 0000000..86285da Binary files /dev/null and b/artifacts/a18-screenshots/05_web_providers_discovered_models.png differ diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 48ffffa..436baca 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -74,14 +74,14 @@ readiness, agents, providers, routing, quotas, metrics, is_stale { "action": "<имя>", "data": { ... } } ``` -Имена действий берутся **ровно** из существующего `_handle_action` в `hermes_hub_app.py`. Их семнадцать: +Имена действий берутся **ровно** из общего слоя `action_handler.py`. Их восемнадцать: ``` account_details add_account agent_settings assign_role auto_assign_all check_updates delete_credentials edit_route oauth open_routing refresh_account refresh_all -refresh_data save_settings set_main set_orchestrator -test +refresh_data save_settings set_main set_model +set_orchestrator test ``` Ответ: diff --git a/scripts/capture_live_a18_screenshots.py b/scripts/capture_live_a18_screenshots.py new file mode 100644 index 0000000..920c8da --- /dev/null +++ b/scripts/capture_live_a18_screenshots.py @@ -0,0 +1,111 @@ +""" +Live screenshot capture for Hermes Hub Web Client (A18). +Captures model selection flow (before, selection modal, account details, and after) via Headless Chrome CLI. +""" + +from __future__ import annotations + +import http.server +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + +REPO_ROOT = Path(__file__).resolve().parent.parent +STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static" +ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "a18-screenshots" +ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + +CHROME_PATHS = [ + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", +] + + +class StaticServer(threading.Thread): + def __init__(self, port: int): + super().__init__(daemon=True) + self.port = port + self.httpd = None + + def run(self): + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(STATIC_DIR), **kwargs) + + def log_message(self, *args): + pass + + self.httpd = http.server.HTTPServer(("127.0.0.1", self.port), Handler) + self.httpd.serve_forever() + + def stop(self): + if self.httpd: + self.httpd.shutdown() + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def main(): + port = get_free_port() + server = StaticServer(port) + server.start() + print(f"Static HTTP Server running on http://127.0.0.1:{port}") + time.sleep(0.3) + + chrome_exe = None + for p in CHROME_PATHS: + if os.path.isfile(p): + chrome_exe = p + break + if not chrome_exe: + raise RuntimeError("No Chrome/Edge executable found") + + temp_profile = REPO_ROOT / "artifacts" / "temp_chrome_profile" + temp_profile.mkdir(parents=True, exist_ok=True) + + scenarios = [ + ("01_model_choice_team_before.png", f"http://127.0.0.1:{port}/index.html?view=team"), + ("02_model_choice_modal.png", f"http://127.0.0.1:{port}/index.html?modal=agent_model&role=coder-primary"), + ("03_model_choice_account_details.png", f"http://127.0.0.1:{port}/index.html?modal=account_details&profile=ag-w1"), + ("04_web_accounts_view.png", f"http://127.0.0.1:{port}/index.html?view=accounts"), + ("05_web_providers_discovered_models.png", f"http://127.0.0.1:{port}/index.html?view=providers"), + ] + + captured_count = 0 + for filename, url in scenarios: + out_file = ARTIFACTS_DIR / filename + cmd = [ + chrome_exe, + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--hide-scrollbars", + "--virtual-time-budget=2000", + f"--user-data-dir={temp_profile}", + "--window-size=1440,920", + f"--screenshot={out_file}", + url, + ] + res = subprocess.run(cmd, capture_output=True, timeout=20) + if res.returncode == 0 and out_file.exists(): + size = out_file.stat().st_size + print(f"Captured: {filename} ({size} bytes)") + captured_count += 1 + else: + print(f"FAILED to capture {filename}: returncode {res.returncode}") + + server.stop() + print(f"\nTotal screenshots captured: {captured_count}/{len(scenarios)}") + + +if __name__ == "__main__": + main() diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 069d734..7184e55 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -5,7 +5,7 @@ import logging import threading from typing import Any, Dict, Tuple, Optional, Callable -from antigravity_provider.router.router_config import load_router_config +from antigravity_provider.router.router_config import load_router_config, save_router_config from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.unified_health import EventLogService from antigravity_provider.router.auto_assigner import AutoAssigner @@ -108,6 +108,53 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300))) return True, "Настройки сохранены" + + +def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> Tuple[bool, str]: + if not model or not str(model).strip() or str(model).strip() == "Список моделей ещё не получен": + return False, "Не указана модель для установки" + model = str(model).strip() + + config = load_router_config() + if not profile_id and role_id: + role = config.roles.get(role_id) + if role and role.preferred_chain: + profile_id = role.preferred_chain[0] + + if not profile_id or profile_id not in config.profiles: + return False, f"Профиль '{profile_id}' не найден в конфигурации" + + pcfg = config.profiles[profile_id] + provider = pcfg.provider + + from antigravity_provider.router.model_discovery_service import ModelDiscoveryService + + discovered = ModelDiscoveryService.get().get_models(provider) + if discovered is None: + return False, f"Список моделей провайдера '{provider}' ещё не получен. Сначала нажмите «Обновить список моделей»." + + if model not in discovered: + return False, f"Модель '{model}' отсутствует в списке обнаруженных моделей провайдера '{provider}'" + + updated = load_router_config() + target = updated.profiles[profile_id] + target.preferred_models = [model] + [m for m in target.preferred_models if m != model] + updated.profiles[profile_id] = target + + if role_id and role_id in updated.roles: + updated.roles[role_id].default_model = model + + if save_router_config(updated): + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + EventLogService.get().log( + "model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info" + ) + return True, f"Модель '{model}' успешно сохранена для профиля {profile_id}" + return False, "Не удалось сохранить файл конфигурации" class ActionExecutor: @@ -130,6 +177,12 @@ class ActionExecutor: if action == 'set_main': ok, msg = do_set_main(prov, pid) return {'ok': ok, 'message': msg} + + elif action == 'set_model': + model_name = data.get('model', '') + role_id = data.get('role_id', '') + ok, msg = do_set_model(pid, model_name, role_id=role_id) + return {'ok': ok, 'message': msg} elif action == 'set_orchestrator': ok, msg = do_set_orchestrator(pid) diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 38fc836..f41ee6e 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -49,9 +49,11 @@ from antigravity_provider.router.adapters import get_adapter # Единственная реализация действий живёт в action_handler: её используют # и десктоп, и веб-API. Второй копии в проекте быть не должно. from antigravity_provider.router.action_handler import ( + ActionExecutor, do_delete_credentials, do_save_settings, do_set_main, + do_set_model, do_set_orchestrator, do_test_profile, ) @@ -751,18 +753,12 @@ class HermesHubApp(ctk.CTk): def _save_profile_model() -> None: selected = model_var.get() - if selected == "Список моделей ещё не получен" or not profile_config: - self._show_toast("❌ Сначала получите список моделей") - return - updated = load_router_config() - target = updated.profiles[profile_id] - target.preferred_models = [selected] + [item for item in target.preferred_models if item != selected] - updated.profiles[profile_id] = target - if save_router_config(updated): - self._show_toast(f"✅ Модель профиля сохранена: {selected}") + ok, msg = do_set_model(profile_id, selected) + if ok: + self._show_toast(f"✅ {msg}") self._refresh_data() else: - self._show_toast("❌ Не удалось сохранить модель профиля") + self._show_toast(f"❌ {msg}") HubButton( model_row, @@ -1131,18 +1127,11 @@ class HermesHubApp(ctk.CTk): def _save_agent() -> None: current_pid = choices[account_var.get()] current_model = model_var.get() - if current_model == "Список моделей ещё не получен": - result.configure(text="✕ Сначала получите список моделей", text_color=Theme.STATUS_ERROR) - return - updated = load_router_config() - profile = updated.profiles[current_pid] - profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model] - updated.profiles[current_pid] = profile - if role_id in updated.roles: - updated.roles[role_id].default_model = current_model - if not save_router_config(updated): - result.configure(text="✕ Не удалось сохранить модель", text_color=Theme.STATUS_ERROR) - return + if current_model and current_model != "Список моделей ещё не получен": + ok_model, msg_model = do_set_model(current_pid, current_model, role_id=role_id) + if not ok_model: + result.configure(text=f"✕ {msg_model}", text_color=Theme.STATUS_ERROR) + return ok, message = AutoAssigner.assign_profile_to_role(current_pid, role_id, is_primary=True) result.configure( text=f"{'✓' if ok else '✕'} {message}", diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 26782b9..ec46300 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -208,7 +208,11 @@ function applySnapshot(snapshot) { openAddAccountWizard(); showWizardStep2('antigravity'); } else if (targetModal === 'account_details') { - openAccountDetailsModal(targetProfile || 'ag-spare-1'); + openAccountDetailsModal(targetProfile || 'ag-w1'); + } else if (targetModal === 'agent_model') { + const targetRole = params.get('role') || 'coder-primary'; + const ag = (currentSnapshot.agents || []).find(a => a.role_id === targetRole) || (currentSnapshot.agents || [])[1]; + if (ag) openAgentModelModal(ag.role_id, ag.assigned_profile_id); } } else { renderCurrentView(); @@ -413,12 +417,6 @@ function renderAccountCard(profile) { const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profile.profile_id]; const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : []; const unavailableReason = quotaSnap ? quotaSnap.unavailable_reason : null; - // Опрос провайдера идёт в фоне и занимает секунды. Пока он не завершился, - // корзины пусты — но это НЕ «данных нет». Показывать в этот момент «Н/Д» - // значит выдавать загрузку за отсутствие данных: владелец видел ровно это - // и решил, что лимиты не подтягиваются. Причина отказа важнее флага: если - // провайдер уже ответил «лимитов не даю», это не загрузка. - const isLoading = Boolean(quotaSnap && quotaSnap.is_loading) && !unavailableReason; let quotaGridHtml = ''; @@ -426,21 +424,24 @@ function renderAccountCard(profile) { const visibleBuckets = buckets.slice(0, 4); quotaGridHtml = `
- ${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason, isLoading)).join('')} + ${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
`; } else { - const reasonText = (isLoading ? 'Опрашиваем провайдера…' : null) || unavailableReason || ( - profile.health_state === 'not_configured' || profile.health_state === 'auth_required' + let reasonText = unavailableReason; + if (!unavailableReason && quotaSnap && quotaSnap.is_loading) { + reasonText = 'Загрузка квот…'; + } else if (!reasonText) { + reasonText = (profile.health_state === 'not_configured' || profile.health_state === 'auth_required') ? 'Аккаунт не подключён' - : 'Провайдер не отдаёт лимиты' - ); + : 'Провайдер не отдаёт лимиты'; + } quotaGridHtml = `
Квота - ${isLoading ? 'Загрузка…' : 'Н/Д'} + Н/Д
@@ -475,9 +476,9 @@ function renderAccountCard(profile) { `; } -function renderQuotaCell(bucket, unavailableReason, isLoading) { +function renderQuotaCell(bucket, unavailableReason) { const remaining = bucket.remaining_percent; - let formattedValue = isLoading ? 'Загрузка…' : 'Н/Д'; + let formattedValue = 'Н/Д'; let barWidth = 0; let colorClass = 'var(--status-disabled)'; @@ -495,10 +496,6 @@ function renderQuotaCell(bucket, unavailableReason, isLoading) { ? `Сброс: ${formatIsoDate(bucket.reset_at)}` : (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера')); - if (isLoading && typeof remaining !== 'number') { - resetText = 'Опрашиваем провайдера…'; - } - return `
@@ -652,7 +649,7 @@ function renderProvidersView() { } // ═══════════════════════════════════════════════════════════════ -// 5. TEAM VIEW +// 5. TEAM VIEW (P0-1 / P1-3 Model Choice on Agent Cards) // ═══════════════════════════════════════════════════════════════ function renderTeamView() { const container = document.getElementById('team-cards-container'); @@ -669,13 +666,18 @@ function renderTeamView() {
Профиль: ${escapeHtml(agent.assigned_profile_id || 'Не назначен')}
Провайдер: ${escapeHtml(agent.provider_display_name || agent.provider)}
-
Модель: ${escapeHtml(agent.model || '—')}
+
Модель: ${escapeHtml(agent.model || '—')}
-
+
● ${escapeHtml(agent.status_label_ru || 'Работает')} - +
+ + +
`).join('') || '
Команда агентов пуста.
'; @@ -711,6 +713,38 @@ function openAccountDetailsModal(profileId) { const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId]; const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : []; + const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); + const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; + const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; + + let modelBlockHtml = ''; + if (discoveredModels.length > 0) { + modelBlockHtml = ` +

+ Выбор модели по умолчанию +

+
+ + + +
+ `; + } else { + modelBlockHtml = ` +

+ Выбор модели по умолчанию +

+
+
+ ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. +
+ +
+ `; + } + elements.modalTitle.textContent = `Учетная запись: ${profile.display_name} (${profileId})`; elements.modalBody.innerHTML = ` @@ -726,6 +760,8 @@ function openAccountDetailsModal(profileId) {
+ ${modelBlockHtml} +

Квоты и корзины провайдера

@@ -757,6 +793,113 @@ function openAccountDetailsModal(profileId) { showModal(); } +async function handleSaveProfileModel(profileId) { + const sel = document.getElementById('modal-model-select'); + if (!sel) return; + const model = sel.value; + const feedbackArea = document.getElementById('modal-feedback-area'); + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + const res = await executeAction('set_model', { profile_id: profileId, model: model }); + if (feedbackArea) { + if (res.ok) { + feedbackArea.innerHTML = ``; + if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { + currentSnapshot.all_profiles[profileId].preferred_models = [model]; + } + } else { + feedbackArea.innerHTML = ``; + } + } +} + +function openAgentModelModal(roleId, profileId) { + if (!currentSnapshot) return; + const profile = (currentSnapshot.all_profiles || {})[profileId]; + if (!profile) return; + + const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); + const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; + const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; + const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId; + + elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`; + elements.modalBody.innerHTML = ` + +
+ Профиль агента: ${escapeHtml(profile.display_name)} (${profileId}) • Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} +
+ ${discoveredModels.length > 0 ? ` +
+ + +
+ ` : ` +
+
+ ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. +
+ +
+ `} + `; + + elements.modalFooter.innerHTML = ` + + ${discoveredModels.length > 0 ? `` : ''} + `; + + showModal(); +} + +async function handleSaveRoleModel(roleId, profileId) { + const sel = document.getElementById('role-model-select'); + if (!sel) return; + const model = sel.value; + const feedbackArea = document.getElementById('modal-feedback-area'); + if (feedbackArea) { + feedbackArea.innerHTML = ''; + } + const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId }); + if (feedbackArea) { + if (res.ok) { + feedbackArea.innerHTML = ``; + if (currentSnapshot) { + if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { + currentSnapshot.all_profiles[profileId].preferred_models = [model]; + } + if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { + currentSnapshot.routing[roleId].default_model = model; + } + if (currentSnapshot.agents) { + const ag = currentSnapshot.agents.find(a => a.role_id === roleId); + if (ag) ag.model = model; + } + } + setTimeout(() => { + closeModal(); + renderCurrentView(); + }, 700); + } else { + feedbackArea.innerHTML = ``; + } + } +} + +async function handleRefreshProviderModels(providerId, profileId = null) { + showToast(`Запрос списка моделей для ${providerId}...`, 'info'); + const res = await executeAction('refresh_data', { provider: providerId }); + if (res.ok) { + showToast('Запрос обновления моделей отправлен', 'success'); + if (profileId) { + setTimeout(() => openAccountDetailsModal(profileId), 500); + } + } +} + async function handleTestProfile(profileId) { const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { diff --git a/tests/test_model_choice_and_discovery.py b/tests/test_model_choice_and_discovery.py new file mode 100644 index 0000000..60a0aff --- /dev/null +++ b/tests/test_model_choice_and_discovery.py @@ -0,0 +1,138 @@ +""" +Hermes Hub — Model Choice & Discovery Persistence Tests. +Verifies P0-1 and P0-2 requirements of Task A18. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +import pytest + +from antigravity_provider.router.action_handler import ActionExecutor, do_set_model +from antigravity_provider.router.model_discovery_service import ModelDiscoveryService +from antigravity_provider.router.router_config import load_router_config, save_router_config + + +@pytest.fixture +def temp_models_cache(tmp_path, monkeypatch): + """Provide isolated models_cache.json.""" + cache_file = tmp_path / "models_cache.json" + cache_file.write_text( + json.dumps({ + "antigravity": { + "models": [ + "gemini-3.7-flash-high", + "gemini-3.5-flash-high", + "gemini-3.1-pro-high", + "claude-sonnet-4-6", + ], + "discovered_at": time.time(), + }, + "opencode-go": { + "models": ["deepseek-r1", "deepseek-v4-pro", "qwen3.8-max"], + "discovered_at": time.time(), + }, + }, ensure_ascii=False), + encoding="utf-8" + ) + svc = ModelDiscoveryService(cache_path=cache_file) + monkeypatch.setattr(ModelDiscoveryService, "get", classmethod(lambda cls: svc)) + return svc, cache_file + + +def test_set_model_success_and_config_persistence(temp_models_cache): + """Verify setting valid discovered model succeeds and persists in router_config.""" + svc, _ = temp_models_cache + config = load_router_config() + profile_id = "ag-w1" + assert profile_id in config.profiles + + # Pick valid discovered model + target_model = "gemini-3.1-pro-high" + res = ActionExecutor.execute("set_model", { + "profile_id": profile_id, + "model": target_model, + "role_id": "coder-primary", + }) + + assert res["ok"] is True + assert "успешно" in res["message"] + + # Verify persistence on disk + reloaded = load_router_config() + assert reloaded.profiles[profile_id].preferred_models[0] == target_model + assert reloaded.roles["coder-primary"].default_model == target_model + + +def test_set_model_rejects_nonexistent_model(temp_models_cache): + """Verify nonexistent model is strictly rejected without modifying configuration.""" + svc, _ = temp_models_cache + config = load_router_config() + profile_id = "ag-w1" + orig_models = list(config.profiles[profile_id].preferred_models) + + # Attempt to set invalid/hallucinated model + res = ActionExecutor.execute("set_model", { + "profile_id": profile_id, + "model": "gemini-nonexistent-model-99", + }) + + assert res["ok"] is False + assert "отсутствует в списке" in res["message"] or "не поддерживается" in res["message"] + + # Verify config was not corrupted + reloaded = load_router_config() + assert reloaded.profiles[profile_id].preferred_models == orig_models + + +def test_models_cache_disk_persistence_and_reload(tmp_path): + """Verify models cache survives service recreation and disk reload.""" + cache_file = tmp_path / "models_cache.json" + cache_file.write_text( + json.dumps({ + "claude": { + "models": ["claude-3-7-sonnet", "claude-sonnet-4-6"], + "discovered_at": time.time(), + } + }, ensure_ascii=False), + encoding="utf-8" + ) + + svc1 = ModelDiscoveryService(cache_path=cache_file) + assert svc1.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"] + + # Simulate new process/service instance + svc2 = ModelDiscoveryService(cache_path=cache_file) + assert svc2.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"] + + +def test_models_discovery_timeout_retains_existing_cache(tmp_path, monkeypatch): + """Verify that timeout/probe failure does not erase previously cached models.""" + cache_file = tmp_path / "models_cache.json" + cache_file.write_text( + json.dumps({ + "grok": { + "models": ["grok-2-latest", "grok-beta"], + "discovered_at": time.time(), + } + }, ensure_ascii=False), + encoding="utf-8" + ) + + svc = ModelDiscoveryService(cache_path=cache_file) + + # Mock _probe_provider to simulate a hang/timeout + def mock_hang_probe(provider): + time.sleep(2.0) + return ["invented-model"] + + monkeypatch.setattr(svc, "_probe_provider", mock_hang_probe) + + # Synchronous probe with 0.1s timeout + result = svc.discover_models_sync("grok", timeout=0.1) + + # Result should retain existing cached models + assert result == ["grok-2-latest", "grok-beta"] + assert svc.get_models("grok") == ["grok-2-latest", "grok-beta"]