feat(router): A18 — выбор модели, действие set_model, персистентный кэш и выбор в веб-клиенте

This commit is contained in:
Hermes Team 2026-08-23 22:08:08 +07:00
parent 18695a3f6e
commit 05cf17503d
11 changed files with 485 additions and 51 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -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
```
Ответ:

View file

@ -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()

View file

@ -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)

View file

@ -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}",

View file

@ -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 = `
<div class="account-quota-grid ${visibleBuckets.length === 1 ? 'single-cell' : ''}">
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason, isLoading)).join('')}
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
</div>
`;
} 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 = `
<div class="account-quota-grid single-cell">
<div class="quota-cell">
<div class="quota-cell-top">
<span class="quota-cell-title">Квота</span>
<span class="quota-cell-value text-muted">${isLoading ? 'Загрузка…' : 'Н/Д'}</span>
<span class="quota-cell-value text-muted">Н/Д</span>
</div>
<div class="quota-bar-track">
<div class="quota-bar-fill" style="width: 0%; background-color: var(--status-disabled);"></div>
@ -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 `
<div class="quota-cell">
<div class="quota-cell-top">
@ -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() {
<div style="background:var(--surface-muted); padding:8px 10px; border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px;">
<div>Профиль: <strong>${escapeHtml(agent.assigned_profile_id || 'Не назначен')}</strong></div>
<div>Провайдер: <strong>${escapeHtml(agent.provider_display_name || agent.provider)}</strong></div>
<div>Модель: <strong>${escapeHtml(agent.model || '—')}</strong></div>
<div style="margin-top:2px;">Модель: <strong class="text-accent">${escapeHtml(agent.model || '—')}</strong></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; font-size:11px;">
<div style="display:flex; justify-content:space-between; align-items:center; font-size:11px; gap:6px;">
<span class="text-healthy"> ${escapeHtml(agent.status_label_ru || 'Работает')}</span>
<button class="btn btn-ghost btn-sm" onclick="openAccountDetailsModal('${escapeHtml(agent.assigned_profile_id)}')">
Детали
</button>
<div style="display:flex; gap:4px;">
<button class="btn btn-secondary btn-sm" onclick="openAgentModelModal('${escapeHtml(agent.role_id)}', '${escapeHtml(agent.assigned_profile_id)}')">
Сменить модель
</button>
<button class="btn btn-ghost btn-sm" onclick="openAccountDetailsModal('${escapeHtml(agent.assigned_profile_id)}')">
Детали
</button>
</div>
</div>
</div>
`).join('') || '<div class="empty-text">Команда агентов пуста.</div>';
@ -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 = `
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Выбор модели по умолчанию
</h3>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:16px;">
<select id="modal-model-select" class="select-filter" style="flex:1;">
${discoveredModels.map(m => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить модель</button>
<button class="btn btn-ghost btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Обновить список</button>
</div>
`;
} else {
modelBlockHtml = `
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Выбор модели по умолчанию
</h3>
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Запросить список моделей</button>
</div>
`;
}
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name} (${profileId})`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
@ -726,6 +760,8 @@ function openAccountDetailsModal(profileId) {
</div>
</div>
${modelBlockHtml}
<h3 style="font-size:13px; font-weight:700; margin-bottom:8px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Квоты и корзины провайдера
</h3>
@ -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 = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
}
const res = await executeAction('set_model', { profile_id: profileId, model: model });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) {
currentSnapshot.all_profiles[profileId].preferred_models = [model];
}
} else {
feedbackArea.innerHTML = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
}
}
}
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 = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:12px; font-size:12px; color:var(--text-muted);">
Профиль агента: <strong>${escapeHtml(profile.display_name)} (${profileId})</strong> Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
</div>
${discoveredModels.length > 0 ? `
<div style="margin-bottom:16px;">
<label style="display:block; font-weight:600; margin-bottom:6px;">Выберите модель из обнаруженного списка:</label>
<select id="role-model-select" class="select-filter" style="width:100%;">
${discoveredModels.map(m => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
</div>
` : `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}')"> Запросить список моделей</button>
</div>
`}
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
${discoveredModels.length > 0 ? `<button class="btn btn-primary" onclick="handleSaveRoleModel('${escapeHtml(roleId)}', '${escapeHtml(profileId)}')">Сохранить модель</button>` : ''}
`;
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 = '<div class="modal-feedback info">⏳ Сохранение модели...</div>';
}
const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId });
if (feedbackArea) {
if (res.ok) {
feedbackArea.innerHTML = `<div class="modal-feedback success">✓ ${escapeHtml(res.message || 'Модель сохранена')}</div>`;
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 = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения модели')}</div>`;
}
}
}
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) {

View file

@ -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"]