Skip to content

Commit d76b352

Browse files
authored
Add observable model operations UI
1 parent 9899bb8 commit d76b352

9 files changed

Lines changed: 560 additions & 35 deletions

File tree

internal/adminapi/adminapi_test.go

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
"telegram-agent/internal/agent"
2222
"telegram-agent/internal/config"
23+
"telegram-agent/internal/evalpack"
2324
"telegram-agent/internal/llm"
2425
"telegram-agent/internal/store"
2526
)
@@ -161,7 +162,30 @@ func TestTemplatesParse(t *testing.T) {
161162
viewIndex: data,
162163
viewRouting: data.Routing, // routing view takes uiRouting directly
163164
viewModelsBrowser: data,
164-
viewTGAdmin: tgAdminData{FullAdminURL: "https://assistant.example/admin"},
165+
viewEvals: modelOpsData{
166+
ActiveTab: "evals",
167+
Checks: []modelCheckStatus{{
168+
Status: "free_verified",
169+
Provider: "openrouter",
170+
ModelID: "qwen/qwen3.5-flash:free",
171+
CheckedAt: "2026-06-16T10:00:00Z",
172+
}},
173+
Evals: []modelEvalStatus{{
174+
Status: "passed",
175+
Provider: "openrouter",
176+
ModelID: "qwen/qwen3.5-flash:free",
177+
CheckedAt: "2026-06-16T10:00:00Z",
178+
FinishedAt: "2026-06-16T10:00:00Z",
179+
Passed: 1,
180+
Results: []evalpack.Result{{ID: "case-1", Category: "smoke", Passed: true}},
181+
}},
182+
Suite: evalpack.Suite{Version: 1, Name: "suite", Cases: []evalpack.Case{{
183+
ID: "case-1",
184+
Category: "smoke",
185+
Prompt: "Say ok",
186+
}}},
187+
},
188+
viewTGAdmin: tgAdminData{FullAdminURL: "https://assistant.example/admin"},
165189
}
166190
for v, d := range cases {
167191
t.Run(v, func(t *testing.T) {
@@ -357,6 +381,121 @@ func TestModelEvalPaidGuard(t *testing.T) {
357381
}
358382
}
359383

384+
func TestEvalsPageRendersChecksReportsAndSuite(t *testing.T) {
385+
data := modelOpsData{
386+
ActiveTab: "evals",
387+
Checks: []modelCheckStatus{{
388+
Status: "checking",
389+
Provider: "openrouter",
390+
ModelID: "qwen/qwen3.5-flash:free",
391+
StartedAt: "2026-06-16T10:00:00Z",
392+
}},
393+
Evals: []modelEvalStatus{{
394+
Status: "failed",
395+
Provider: "openrouter",
396+
ModelID: "x-ai/grok-4.3",
397+
Suite: "personal-assistant-core-workloads",
398+
StartedAt: "2026-06-16T10:00:00Z",
399+
FinishedAt: "2026-06-16T10:00:02Z",
400+
Passed: 4,
401+
Failed: 1,
402+
Failures: []string{"tool-web-fetch-intent: missing tool call"},
403+
Results: []evalpack.Result{{
404+
ID: "tool-web-fetch-intent",
405+
Category: "web_fetch",
406+
Passed: false,
407+
LatencyMS: 1200,
408+
ContentPreview: "I cannot open links.",
409+
Failures: []string{"missing tool call \"web_fetch\""},
410+
}},
411+
}},
412+
Suite: evalpack.Suite{Version: 1, Name: "personal-assistant-core-workloads", Cases: []evalpack.Case{{
413+
ID: "tool-web-fetch-intent",
414+
Category: "web_fetch",
415+
Prompt: "Open https://example.com",
416+
Expect: evalpack.Expect{ToolCall: "web_fetch"},
417+
}}},
418+
}
419+
var buf bytes.Buffer
420+
if err := render(&buf, viewEvals, data); err != nil {
421+
t.Fatal(err)
422+
}
423+
html := buf.String()
424+
for _, want := range []string{"Model Operations", "checking", "x-ai/grok-4.3", "tool-web-fetch-intent", "Open https://example.com", "missing tool call"} {
425+
if !strings.Contains(html, want) {
426+
t.Fatalf("evals page missing %q: %s", want, html)
427+
}
428+
}
429+
}
430+
431+
func TestModelChecksPersistCheckingBeforeProbe(t *testing.T) {
432+
s, _ := newTGModelTestServer(t)
433+
settings := &fakeSettingsStore{values: map[string]string{}}
434+
s.settings = settings
435+
s.modelProbe = func(ctx context.Context, provider, modelID string, _ llm.Capabilities) modelCheckStatus {
436+
got := s.modelCheckStatus(ctx, provider, modelID)
437+
if got.Status != "checking" || got.StartedAt == "" {
438+
t.Fatalf("expected checking state before probe, got %+v", got)
439+
}
440+
return modelCheckStatus{Status: "free_verified", CheckedAt: time.Now().Format(time.RFC3339), LatencyMS: 22}
441+
}
442+
443+
form := url.Values{}
444+
form.Set("provider", "openrouter")
445+
form.Set("model_id", "qwen/qwen3.5-flash:free")
446+
req := httptest.NewRequest(http.MethodPost, "/models/check", strings.NewReader(form.Encode()))
447+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
448+
rec := httptest.NewRecorder()
449+
s.handleModelCheck(rec, req)
450+
if rec.Code != http.StatusOK {
451+
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
452+
}
453+
got := s.modelCheckStatus(context.Background(), "openrouter", "qwen/qwen3.5-flash:free")
454+
if got.Status != "free_verified" || got.LatencyMS != 22 {
455+
t.Fatalf("final check state not persisted: %+v", got)
456+
}
457+
}
458+
459+
func TestModelEvalLoadLegacyAndDetailedReports(t *testing.T) {
460+
s := newTestServer(t)
461+
legacy := map[string]modelEvalStatus{
462+
modelCheckKey("openrouter", "legacy/model"): {
463+
CheckedAt: "2026-06-16T10:00:00Z",
464+
Passed: 4,
465+
Failed: 1,
466+
Failures: []string{"legacy failure"},
467+
},
468+
}
469+
data, err := json.Marshal(legacy)
470+
if err != nil {
471+
t.Fatal(err)
472+
}
473+
s.settings = &fakeSettingsStore{values: map[string]string{settingKeyModelEvals: string(data)}}
474+
evals := s.loadModelEvals(context.Background())
475+
got := evals[modelCheckKey("openrouter", "legacy/model")]
476+
if got.Status != "failed" || got.Provider != "openrouter" || got.ModelID != "legacy/model" {
477+
t.Fatalf("legacy eval not normalized: %+v", got)
478+
}
479+
480+
detailed := modelEvalStatus{
481+
Status: "passed",
482+
Provider: "openrouter",
483+
ModelID: "new/model",
484+
Suite: "suite",
485+
StartedAt: "2026-06-16T10:01:00Z",
486+
Passed: 1,
487+
Results: []evalpack.Result{{ID: "case-1", Category: "smoke", Passed: true, ContentPreview: "ok"}},
488+
}
489+
if err := s.saveModelEval(context.Background(), "openrouter", "new/model", detailed); err != nil {
490+
t.Fatal(err)
491+
}
492+
evals = s.loadModelEvals(context.Background())
493+
got = evals[modelCheckKey("openrouter", "new/model")]
494+
if got.Status != "passed" || len(got.Results) != 1 || got.Results[0].ContentPreview != "ok" {
495+
t.Fatalf("detailed eval not persisted: %+v", got)
496+
}
497+
}
498+
360499
func TestWebModelCheckPersistsFreeModelStatus(t *testing.T) {
361500
s, _ := newTGModelTestServer(t)
362501
settings := &fakeSettingsStore{values: map[string]string{}}

internal/adminapi/handlers.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
144144
}
145145
}
146146

147+
func (s *Server) handleEvals(w http.ResponseWriter, r *http.Request) {
148+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
149+
defer cancel()
150+
data := s.buildModelOpsData(ctx)
151+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
152+
if err := render(w, viewEvals, data); err != nil {
153+
s.logger.Error("render evals", "err", err)
154+
http.Error(w, "render error", http.StatusInternalServerError)
155+
}
156+
}
157+
147158
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
148159
data := s.buildIndexData(r)
149160
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -183,6 +194,15 @@ func (s *Server) handleModelCheck(w http.ResponseWriter, r *http.Request) {
183194
http.Error(w, "only free OpenRouter models can be checked here", http.StatusBadRequest)
184195
return
185196
}
197+
if !s.modelEvalMu.TryLock() {
198+
http.Error(w, "another model operation is already running", http.StatusTooManyRequests)
199+
return
200+
}
201+
defer s.modelEvalMu.Unlock()
202+
if err := s.saveModelCheck(r.Context(), provider, modelID, checkingModelCheck(provider, modelID)); err != nil {
203+
http.Error(w, err.Error(), http.StatusServiceUnavailable)
204+
return
205+
}
186206

187207
caps := s.lookupCapsFor(r.Context(), provider, modelID)
188208
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
@@ -842,7 +862,7 @@ func attachModelChecks(models []uiModel, provider string, checks map[string]mode
842862
models[i].CheckedAt = check.CheckedAt
843863
models[i].CheckLatencyMS = check.LatencyMS
844864
models[i].CheckError = check.Error
845-
if check.Status != "free_unverified" {
865+
if check.Status != "free_unverified" && check.Status != "checking" {
846866
models[i].Policy = check.Status
847867
models[i].Warnings = withoutWarning(models[i].Warnings, "free model: validate availability before routing")
848868
}

internal/adminapi/model_checks.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ const (
2929

3030
type modelCheckStatus struct {
3131
Status string `json:"status"`
32+
Provider string `json:"provider,omitempty"`
33+
ModelID string `json:"model_id,omitempty"`
34+
StartedAt string `json:"started_at,omitempty"`
3235
CheckedAt string `json:"checked_at,omitempty"`
36+
UpdatedAt string `json:"updated_at,omitempty"`
3337
LatencyMS int64 `json:"latency_ms,omitempty"`
3438
Error string `json:"error,omitempty"`
3539
}
@@ -106,14 +110,25 @@ func (s *Server) saveModelCheck(ctx context.Context, provider, modelID string, s
106110
if checks == nil {
107111
checks = map[string]modelCheckStatus{}
108112
}
109-
checks[modelCheckKey(provider, modelID)] = status
113+
checks[modelCheckKey(provider, modelID)] = normalizeSingleModelCheck(provider, modelID, status)
110114
data, err := json.Marshal(checks)
111115
if err != nil {
112116
return err
113117
}
114118
return s.settings.PutSetting(ctx, settingKeyModelChecks, string(data))
115119
}
116120

121+
func checkingModelCheck(provider, modelID string) modelCheckStatus {
122+
now := time.Now().Format(time.RFC3339)
123+
return modelCheckStatus{
124+
Status: "checking",
125+
Provider: provider,
126+
ModelID: modelID,
127+
StartedAt: now,
128+
UpdatedAt: now,
129+
}
130+
}
131+
117132
func (s *Server) modelCheckInterval() time.Duration {
118133
return time.Duration(s.intSetting(SettingKeyModelCheckIntervalHours, defaultModelCheckIntervalHours, 1, 168)) * time.Hour
119134
}
@@ -221,11 +236,32 @@ func isBlockedModelCheckError(message string) bool {
221236

222237
func normalizeModelChecks(checks map[string]modelCheckStatus) {
223238
for key, check := range checks {
224-
if check.Status == "free_degraded" && isBlockedModelCheckError(check.Error) {
225-
check.Status = "free_blocked"
226-
checks[key] = check
239+
provider, modelID, _ := strings.Cut(key, "|")
240+
checks[key] = normalizeSingleModelCheck(provider, modelID, check)
241+
}
242+
}
243+
244+
func normalizeSingleModelCheck(provider, modelID string, check modelCheckStatus) modelCheckStatus {
245+
if check.Provider == "" {
246+
check.Provider = provider
247+
}
248+
if check.ModelID == "" {
249+
check.ModelID = modelID
250+
}
251+
if check.Status == "" && check.CheckedAt != "" {
252+
if check.Error != "" {
253+
check.Status = "free_degraded"
254+
} else {
255+
check.Status = "free_verified"
227256
}
228257
}
258+
if check.Status == "free_degraded" && isBlockedModelCheckError(check.Error) {
259+
check.Status = "free_blocked"
260+
}
261+
if check.UpdatedAt == "" {
262+
check.UpdatedAt = check.CheckedAt
263+
}
264+
return check
229265
}
230266

231267
func (s *Server) startModelCheckScheduler() {

0 commit comments

Comments
 (0)