Skip to content

Commit 9899bb8

Browse files
authored
Add guarded web model evals
1 parent 25baf0b commit 9899bb8

5 files changed

Lines changed: 301 additions & 0 deletions

File tree

internal/adminapi/adminapi_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,75 @@ func TestModelsBrowserRendersFreeCheckAction(t *testing.T) {
288288
}
289289
}
290290

291+
func TestModelsBrowserRendersEvalControlsAndStatus(t *testing.T) {
292+
data := indexData{
293+
Slots: []uiSlot{{Name: "default-or", ModelID: "x-ai/grok-4.3"}},
294+
Models: []uiModel{{
295+
ID: "x-ai/grok-4.3",
296+
PromptPrice: 1.25,
297+
CompletionPrice: 2.50,
298+
ContextLength: 1000000,
299+
Tools: true,
300+
EvalStatus: modelEvalStatus{
301+
CheckedAt: "2026-06-16T10:00:00Z",
302+
Passed: 4,
303+
Failed: 1,
304+
DurationMS: 1200,
305+
Failures: []string{"tool-web-fetch-intent: missing tool call"},
306+
},
307+
}},
308+
CatalogProvider: "openrouter",
309+
}
310+
var buf bytes.Buffer
311+
if err := render(&buf, viewModelsBrowser, data); err != nil {
312+
t.Fatal(err)
313+
}
314+
html := buf.String()
315+
if !strings.Contains(html, "Run paid eval") || !strings.Contains(html, "Paid eval may spend credits") {
316+
t.Fatalf("paid eval warning controls missing: %s", html)
317+
}
318+
if !strings.Contains(html, "eval:") || !strings.Contains(html, "failed") || !strings.Contains(html, "4/5 cases") {
319+
t.Fatalf("eval status missing: %s", html)
320+
}
321+
}
322+
323+
func TestModelEvalRejectsPaidWithoutConfirmation(t *testing.T) {
324+
s := newTestServer(t)
325+
s.settings = &fakeSettingsStore{values: map[string]string{}}
326+
form := url.Values{}
327+
form.Set("provider", "openrouter")
328+
form.Set("model_id", "x-ai/grok-4.3")
329+
req := httptest.NewRequest(http.MethodPost, "/models/eval", strings.NewReader(form.Encode()))
330+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
331+
rec := httptest.NewRecorder()
332+
333+
s.handleModelEval(rec, req)
334+
if rec.Code != http.StatusBadRequest {
335+
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
336+
}
337+
if !strings.Contains(rec.Body.String(), "requires confirmation") {
338+
t.Fatalf("unexpected body: %s", rec.Body.String())
339+
}
340+
if _, ok := s.settings.(*fakeSettingsStore).values[settingKeyModelEvals]; ok {
341+
t.Fatal("paid eval rejection should not write eval settings")
342+
}
343+
}
344+
345+
func TestModelEvalPaidGuard(t *testing.T) {
346+
if isPaidModelEval("openrouter", "google/gemma-3-27b-it:free") {
347+
t.Fatal("OpenRouter :free eval should not be treated as paid")
348+
}
349+
if !isPaidModelEval("openrouter", "x-ai/grok-4.3") {
350+
t.Fatal("paid OpenRouter eval should require confirmation")
351+
}
352+
if !isPaidModelEval("gemini", "gemini-2.5-flash") {
353+
t.Fatal("Gemini eval should require confirmation")
354+
}
355+
if isPaidModelEval("ollama", "qwen3:8b") {
356+
t.Fatal("Ollama eval should be allowed without paid confirmation")
357+
}
358+
}
359+
291360
func TestWebModelCheckPersistsFreeModelStatus(t *testing.T) {
292361
s, _ := newTGModelTestServer(t)
293362
settings := &fakeSettingsStore{values: map[string]string{}}

internal/adminapi/handlers.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type uiModel struct {
6666
CheckedAt string
6767
CheckLatencyMS int64
6868
CheckError string
69+
EvalStatus modelEvalStatus
6970
StatusLabel string
7071
PolicyLabel string
7172
PrimaryReason string
@@ -476,6 +477,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
476477
attachMarketSignals(models, marketSignals)
477478
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
478479
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
480+
attachModelEvals(models, catalogProv, s.loadModelEvals(ctx5))
479481
s.enrichVisibleDescriptions(ctx5, catalogProv, models)
480482
decorateModelDisplay(models, preset)
481483
sections := buildModelSections(models, true)
@@ -594,6 +596,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
594596
attachMarketSignals(models, marketSignals)
595597
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
596598
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
599+
attachModelEvals(models, catalogProv, s.loadModelEvals(ctx5))
597600
asc := sortDir == "asc"
598601
sort.Slice(models, func(i, j int) bool {
599602
var less bool
@@ -680,6 +683,17 @@ func (s *Server) enrichVisibleDescriptions(ctx context.Context, provider string,
680683
}
681684
}
682685

686+
func attachModelEvals(models []uiModel, provider string, evals map[string]modelEvalStatus) {
687+
if len(models) == 0 || len(evals) == 0 {
688+
return
689+
}
690+
for i := range models {
691+
if status, ok := evals[modelCheckKey(provider, models[i].ID)]; ok {
692+
models[i].EvalStatus = status
693+
}
694+
}
695+
}
696+
683697
// orBlendedPrice mirrors AA's 3:1 input/output weighting so prices are
684698
// directly comparable across sources.
685699
func orBlendedPrice(promptPrice, completionPrice float64) float64 {

internal/adminapi/model_evals.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package adminapi
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
"time"
10+
11+
"telegram-agent/internal/evalpack"
12+
"telegram-agent/internal/llm"
13+
)
14+
15+
const (
16+
settingKeyModelEvals = "recommendation.model_evals"
17+
modelEvalDatasetPath = "evals/workload.json"
18+
modelEvalTimeout = 45 * time.Second
19+
)
20+
21+
type modelEvalStatus struct {
22+
CheckedAt string `json:"checked_at,omitempty"`
23+
Passed int `json:"passed"`
24+
Failed int `json:"failed"`
25+
DurationMS int64 `json:"duration_ms"`
26+
Error string `json:"error,omitempty"`
27+
Failures []string `json:"failures,omitempty"`
28+
}
29+
30+
func (s *Server) handleModelEval(w http.ResponseWriter, r *http.Request) {
31+
if r.Method != http.MethodPost {
32+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
33+
return
34+
}
35+
if s.settings == nil {
36+
http.Error(w, "settings store not available", http.StatusServiceUnavailable)
37+
return
38+
}
39+
if err := r.ParseForm(); err != nil {
40+
http.Error(w, "parse form", http.StatusBadRequest)
41+
return
42+
}
43+
provider := strings.TrimSpace(r.FormValue("provider"))
44+
if provider == "" {
45+
provider = "openrouter"
46+
}
47+
modelID := strings.TrimSpace(r.FormValue("model_id"))
48+
if modelID == "" {
49+
http.Error(w, "model_id required", http.StatusBadRequest)
50+
return
51+
}
52+
paid := isPaidModelEval(provider, modelID)
53+
if paid && r.FormValue("confirm_paid_eval") != "1" {
54+
http.Error(w, "paid/cloud eval requires confirmation", http.StatusBadRequest)
55+
return
56+
}
57+
58+
if !s.modelEvalMu.TryLock() {
59+
http.Error(w, "another model eval is already running", http.StatusTooManyRequests)
60+
return
61+
}
62+
defer s.modelEvalMu.Unlock()
63+
64+
caps := s.lookupCapsFor(r.Context(), provider, modelID)
65+
ctx, cancel := context.WithTimeout(r.Context(), modelEvalTimeout)
66+
defer cancel()
67+
status := s.runModelEval(ctx, provider, modelID, caps)
68+
if err := s.saveModelEval(ctx, provider, modelID, status); err != nil {
69+
http.Error(w, err.Error(), http.StatusServiceUnavailable)
70+
return
71+
}
72+
73+
next := r.Clone(r.Context())
74+
next.URL = cloneURL(r.URL)
75+
next.URL.RawQuery = r.Form.Encode()
76+
data := s.buildIndexData(next)
77+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
78+
if err := render(w, viewModelsContent, data); err != nil {
79+
s.logger.Error("render models after eval", "err", err)
80+
}
81+
}
82+
83+
func isPaidModelEval(provider, modelID string) bool {
84+
provider = strings.ToLower(strings.TrimSpace(provider))
85+
switch provider {
86+
case "local", "ollama":
87+
return false
88+
case "openrouter":
89+
return !isFreeVariant(modelID)
90+
default:
91+
return true
92+
}
93+
}
94+
95+
func (s *Server) runModelEval(ctx context.Context, providerType, modelID string, caps llm.Capabilities) modelEvalStatus {
96+
if s.cfgRef == nil {
97+
return failedModelEval("missing runtime config")
98+
}
99+
suite, err := evalpack.LoadSuite(modelEvalDatasetPath)
100+
if err != nil {
101+
return failedModelEval(err.Error())
102+
}
103+
factories := llm.BuildBackendFactories(s.cfgRef)
104+
factory := factories[providerType]
105+
if factory == nil {
106+
return failedModelEval("provider is not eval-capable from current config")
107+
}
108+
provider, err := factory(modelID, caps)
109+
if err != nil {
110+
return failedModelEval(err.Error())
111+
}
112+
report := evalpack.RunSuite(ctx, provider, suite, evalpack.Options{Timeout: 12 * time.Second})
113+
status := modelEvalStatus{
114+
CheckedAt: time.Now().Format(time.RFC3339),
115+
Passed: report.Passed,
116+
Failed: report.Failed,
117+
DurationMS: report.DurationMS,
118+
}
119+
for _, result := range report.Results {
120+
if result.Passed {
121+
continue
122+
}
123+
if result.Error != "" {
124+
status.Failures = append(status.Failures, fmt.Sprintf("%s: %s", result.ID, result.Error))
125+
}
126+
for _, failure := range result.Failures {
127+
status.Failures = append(status.Failures, fmt.Sprintf("%s: %s", result.ID, failure))
128+
}
129+
}
130+
if report.Failed > 0 && len(status.Failures) == 0 {
131+
status.Failures = append(status.Failures, "one or more cases failed")
132+
}
133+
if len(status.Failures) > 5 {
134+
status.Failures = status.Failures[:5]
135+
}
136+
return status
137+
}
138+
139+
func failedModelEval(message string) modelEvalStatus {
140+
return modelEvalStatus{
141+
CheckedAt: time.Now().Format(time.RFC3339),
142+
Failed: 1,
143+
Error: message,
144+
Failures: []string{message},
145+
}
146+
}
147+
148+
func (s *Server) loadModelEvals(ctx context.Context) map[string]modelEvalStatus {
149+
if s.settings == nil {
150+
return nil
151+
}
152+
raw, ok, err := s.settings.GetSetting(ctx, settingKeyModelEvals)
153+
if err != nil || !ok || strings.TrimSpace(raw) == "" {
154+
return nil
155+
}
156+
out := map[string]modelEvalStatus{}
157+
if err := json.Unmarshal([]byte(raw), &out); err != nil {
158+
s.logger.Warn("model eval cache parse failed", "err", err)
159+
return nil
160+
}
161+
return out
162+
}
163+
164+
func (s *Server) saveModelEval(ctx context.Context, provider, modelID string, status modelEvalStatus) error {
165+
if s.settings == nil {
166+
return fmt.Errorf("settings store unavailable")
167+
}
168+
evals := s.loadModelEvals(ctx)
169+
if evals == nil {
170+
evals = map[string]modelEvalStatus{}
171+
}
172+
evals[modelCheckKey(provider, modelID)] = status
173+
data, err := json.Marshal(evals)
174+
if err != nil {
175+
return err
176+
}
177+
return s.settings.PutSetting(ctx, settingKeyModelEvals, string(data))
178+
}

internal/adminapi/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type Server struct {
5151
httpSrv *http.Server
5252
modelChecksCancel context.CancelFunc
5353
modelChecksOnce sync.Once
54+
modelEvalMu sync.Mutex
5455
}
5556

5657
// SetMCPReloader wires the hot-reload hook so saving/deleting MCP servers in
@@ -143,6 +144,7 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
143144
mux.Handle("/", authed(http.HandlerFunc(s.handleIndex)))
144145
mux.Handle("/models/override", authed(http.HandlerFunc(s.handleModelOverride)))
145146
mux.Handle("/models/check", authed(http.HandlerFunc(s.handleModelCheck)))
147+
mux.Handle("/models/eval", authed(http.HandlerFunc(s.handleModelEval)))
146148
mux.Handle("/models", authed(http.HandlerFunc(s.handleModels)))
147149
mux.Handle("/routing", authed(http.HandlerFunc(s.handleRouting)))
148150
mux.Handle("/slots/", authed(http.HandlerFunc(s.handleSlotAssign))) // POST /slots/{slot}/assign

internal/adminapi/templates/partials_models_row.html

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
7d: {{.Telemetry.Calls}} calls · {{.Telemetry.AvgLatencyMS}} ms · {{printf "%.0f%%" .Telemetry.ErrorRatePct}} errors · {{printf "$%.4f" .Telemetry.CostUSD}}
110110
</div>
111111
{{end}}
112+
{{template "model_eval_status" .}}
112113

113114
{{template "model_actions" dict "Model" . "Root" $root}}
114115
</article>
@@ -139,6 +140,30 @@
139140
{{end}}
140141
</div>
141142
{{end}}
143+
<div class="model-actions__eval">
144+
{{if or .Free (eq $root.CatalogProvider "ollama") (eq $root.CatalogProvider "local")}}
145+
<input type="hidden" name="confirm_paid_eval" value="1">
146+
<button class="btn btn--small btn--ghost"
147+
hx-post="/models/eval"
148+
hx-include="#models-filters, closest [data-model-card]"
149+
hx-target="#models-content"
150+
hx-swap="outerHTML">
151+
Run eval
152+
</button>
153+
{{else}}
154+
<label style="display:flex;align-items:center;gap:0.3rem;color:var(--warning,#9a5b00);font-size:0.78rem;">
155+
<input type="checkbox" name="confirm_paid_eval" value="1">
156+
Paid eval may spend credits
157+
</label>
158+
<button class="btn btn--small btn--ghost"
159+
hx-post="/models/eval"
160+
hx-include="#models-filters, closest [data-model-card]"
161+
hx-target="#models-content"
162+
hx-swap="outerHTML">
163+
Run paid eval
164+
</button>
165+
{{end}}
166+
</div>
142167
<div class="model-actions__override">
143168
<input class="form-input" name="note" type="text" value="{{.OverrideNote}}" placeholder="Override note">
144169
<button class="btn btn--small btn--ghost"
@@ -217,6 +242,7 @@
217242
· {{printf "$%.4f" .Telemetry.CostUSD}}
218243
</div>
219244
{{end}}
245+
{{template "model_eval_status" .}}
220246
{{template "model_actions" dict "Model" . "Root" $root}}
221247
</td>
222248
<td class="price-cell">{{priceUSD .PromptPrice}}</td>
@@ -241,3 +267,15 @@
241267
</tr>
242268
{{end}}
243269
{{end}}
270+
271+
{{define "model_eval_status"}}
272+
{{if .EvalStatus.CheckedAt}}
273+
<div style="margin-top:4px;color:{{if gt .EvalStatus.Failed 0}}var(--warning,#9a5b00){{else}}var(--success,#0f7b45){{end}};font-size:0.78rem;">
274+
eval:
275+
{{if gt .EvalStatus.Failed 0}}failed{{else}}passed{{end}}
276+
· {{.EvalStatus.Passed}}/{{add .EvalStatus.Passed .EvalStatus.Failed}} cases
277+
{{if .EvalStatus.DurationMS}} · {{.EvalStatus.DurationMS}} ms{{end}}
278+
{{range .EvalStatus.Failures}} · {{.}}{{end}}
279+
</div>
280+
{{end}}
281+
{{end}}

0 commit comments

Comments
 (0)