Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/adminapi/adminapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,75 @@ func TestModelsBrowserRendersFreeCheckAction(t *testing.T) {
}
}

func TestModelsBrowserRendersEvalControlsAndStatus(t *testing.T) {
data := indexData{
Slots: []uiSlot{{Name: "default-or", ModelID: "x-ai/grok-4.3"}},
Models: []uiModel{{
ID: "x-ai/grok-4.3",
PromptPrice: 1.25,
CompletionPrice: 2.50,
ContextLength: 1000000,
Tools: true,
EvalStatus: modelEvalStatus{
CheckedAt: "2026-06-16T10:00:00Z",
Passed: 4,
Failed: 1,
DurationMS: 1200,
Failures: []string{"tool-web-fetch-intent: missing tool call"},
},
}},
CatalogProvider: "openrouter",
}
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
t.Fatal(err)
}
html := buf.String()
if !strings.Contains(html, "Run paid eval") || !strings.Contains(html, "Paid eval may spend credits") {
t.Fatalf("paid eval warning controls missing: %s", html)
}
if !strings.Contains(html, "eval:") || !strings.Contains(html, "failed") || !strings.Contains(html, "4/5 cases") {
t.Fatalf("eval status missing: %s", html)
}
}

func TestModelEvalRejectsPaidWithoutConfirmation(t *testing.T) {
s := newTestServer(t)
s.settings = &fakeSettingsStore{values: map[string]string{}}
form := url.Values{}
form.Set("provider", "openrouter")
form.Set("model_id", "x-ai/grok-4.3")
req := httptest.NewRequest(http.MethodPost, "/models/eval", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()

s.handleModelEval(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "requires confirmation") {
t.Fatalf("unexpected body: %s", rec.Body.String())
}
if _, ok := s.settings.(*fakeSettingsStore).values[settingKeyModelEvals]; ok {
t.Fatal("paid eval rejection should not write eval settings")
}
}

func TestModelEvalPaidGuard(t *testing.T) {
if isPaidModelEval("openrouter", "google/gemma-3-27b-it:free") {
t.Fatal("OpenRouter :free eval should not be treated as paid")
}
if !isPaidModelEval("openrouter", "x-ai/grok-4.3") {
t.Fatal("paid OpenRouter eval should require confirmation")
}
if !isPaidModelEval("gemini", "gemini-2.5-flash") {
t.Fatal("Gemini eval should require confirmation")
}
if isPaidModelEval("ollama", "qwen3:8b") {
t.Fatal("Ollama eval should be allowed without paid confirmation")
}
}

func TestWebModelCheckPersistsFreeModelStatus(t *testing.T) {
s, _ := newTGModelTestServer(t)
settings := &fakeSettingsStore{values: map[string]string{}}
Expand Down
14 changes: 14 additions & 0 deletions internal/adminapi/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type uiModel struct {
CheckedAt string
CheckLatencyMS int64
CheckError string
EvalStatus modelEvalStatus
StatusLabel string
PolicyLabel string
PrimaryReason string
Expand Down Expand Up @@ -476,6 +477,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
attachMarketSignals(models, marketSignals)
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
attachModelEvals(models, catalogProv, s.loadModelEvals(ctx5))
s.enrichVisibleDescriptions(ctx5, catalogProv, models)
decorateModelDisplay(models, preset)
sections := buildModelSections(models, true)
Expand Down Expand Up @@ -594,6 +596,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
attachMarketSignals(models, marketSignals)
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
attachModelEvals(models, catalogProv, s.loadModelEvals(ctx5))
asc := sortDir == "asc"
sort.Slice(models, func(i, j int) bool {
var less bool
Expand Down Expand Up @@ -680,6 +683,17 @@ func (s *Server) enrichVisibleDescriptions(ctx context.Context, provider string,
}
}

func attachModelEvals(models []uiModel, provider string, evals map[string]modelEvalStatus) {
if len(models) == 0 || len(evals) == 0 {
return
}
for i := range models {
if status, ok := evals[modelCheckKey(provider, models[i].ID)]; ok {
models[i].EvalStatus = status
}
}
}

// orBlendedPrice mirrors AA's 3:1 input/output weighting so prices are
// directly comparable across sources.
func orBlendedPrice(promptPrice, completionPrice float64) float64 {
Expand Down
178 changes: 178 additions & 0 deletions internal/adminapi/model_evals.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package adminapi

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"telegram-agent/internal/evalpack"
"telegram-agent/internal/llm"
)

const (
settingKeyModelEvals = "recommendation.model_evals"
modelEvalDatasetPath = "evals/workload.json"
modelEvalTimeout = 45 * time.Second
)

type modelEvalStatus struct {
CheckedAt string `json:"checked_at,omitempty"`
Passed int `json:"passed"`
Failed int `json:"failed"`
DurationMS int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
Failures []string `json:"failures,omitempty"`
}

func (s *Server) handleModelEval(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.settings == nil {
http.Error(w, "settings store not available", http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "parse form", http.StatusBadRequest)
return
}
provider := strings.TrimSpace(r.FormValue("provider"))
if provider == "" {
provider = "openrouter"
}
modelID := strings.TrimSpace(r.FormValue("model_id"))
if modelID == "" {
http.Error(w, "model_id required", http.StatusBadRequest)
return
}
paid := isPaidModelEval(provider, modelID)
if paid && r.FormValue("confirm_paid_eval") != "1" {
http.Error(w, "paid/cloud eval requires confirmation", http.StatusBadRequest)
return
}

if !s.modelEvalMu.TryLock() {
http.Error(w, "another model eval is already running", http.StatusTooManyRequests)
return
}
defer s.modelEvalMu.Unlock()

caps := s.lookupCapsFor(r.Context(), provider, modelID)
ctx, cancel := context.WithTimeout(r.Context(), modelEvalTimeout)
defer cancel()
status := s.runModelEval(ctx, provider, modelID, caps)
if err := s.saveModelEval(ctx, provider, modelID, status); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}

next := r.Clone(r.Context())
next.URL = cloneURL(r.URL)
next.URL.RawQuery = r.Form.Encode()
data := s.buildIndexData(next)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := render(w, viewModelsContent, data); err != nil {
s.logger.Error("render models after eval", "err", err)
}
}

func isPaidModelEval(provider, modelID string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case "local", "ollama":
return false
case "openrouter":
return !isFreeVariant(modelID)
default:
return true
}
}

func (s *Server) runModelEval(ctx context.Context, providerType, modelID string, caps llm.Capabilities) modelEvalStatus {
if s.cfgRef == nil {
return failedModelEval("missing runtime config")
}
suite, err := evalpack.LoadSuite(modelEvalDatasetPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Package the eval dataset for runtime loads

In the production image I checked, the final Docker stage copies only /app/bin/agent and config/config.yaml into /app, but this new handler reads evals/workload.json from the process working directory. In that container every /models/eval request will fail to load the suite instead of actually running the eval, so the feature is broken for the default deployment unless the suite is embedded or copied into the runtime image.

Useful? React with 👍 / 👎.

if err != nil {
return failedModelEval(err.Error())
}
factories := llm.BuildBackendFactories(s.cfgRef)
factory := factories[providerType]
if factory == nil {
return failedModelEval("provider is not eval-capable from current config")
}
provider, err := factory(modelID, caps)
if err != nil {
return failedModelEval(err.Error())
}
report := evalpack.RunSuite(ctx, provider, suite, evalpack.Options{Timeout: 12 * time.Second})
status := modelEvalStatus{
CheckedAt: time.Now().Format(time.RFC3339),
Passed: report.Passed,
Failed: report.Failed,
DurationMS: report.DurationMS,
}
for _, result := range report.Results {
if result.Passed {
continue
}
if result.Error != "" {
status.Failures = append(status.Failures, fmt.Sprintf("%s: %s", result.ID, result.Error))
}
for _, failure := range result.Failures {
status.Failures = append(status.Failures, fmt.Sprintf("%s: %s", result.ID, failure))
}
}
if report.Failed > 0 && len(status.Failures) == 0 {
status.Failures = append(status.Failures, "one or more cases failed")
}
if len(status.Failures) > 5 {
status.Failures = status.Failures[:5]
}
return status
}

func failedModelEval(message string) modelEvalStatus {
return modelEvalStatus{
CheckedAt: time.Now().Format(time.RFC3339),
Failed: 1,
Error: message,
Failures: []string{message},
}
}

func (s *Server) loadModelEvals(ctx context.Context) map[string]modelEvalStatus {
if s.settings == nil {
return nil
}
raw, ok, err := s.settings.GetSetting(ctx, settingKeyModelEvals)
if err != nil || !ok || strings.TrimSpace(raw) == "" {
return nil
}
out := map[string]modelEvalStatus{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
s.logger.Warn("model eval cache parse failed", "err", err)
return nil
}
return out
}

func (s *Server) saveModelEval(ctx context.Context, provider, modelID string, status modelEvalStatus) error {
if s.settings == nil {
return fmt.Errorf("settings store unavailable")
}
evals := s.loadModelEvals(ctx)
if evals == nil {
evals = map[string]modelEvalStatus{}
}
evals[modelCheckKey(provider, modelID)] = status
data, err := json.Marshal(evals)
if err != nil {
return err
}
return s.settings.PutSetting(ctx, settingKeyModelEvals, string(data))
}
2 changes: 2 additions & 0 deletions internal/adminapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type Server struct {
httpSrv *http.Server
modelChecksCancel context.CancelFunc
modelChecksOnce sync.Once
modelEvalMu sync.Mutex
}

// SetMCPReloader wires the hot-reload hook so saving/deleting MCP servers in
Expand Down Expand Up @@ -143,6 +144,7 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.Handle("/", authed(http.HandlerFunc(s.handleIndex)))
mux.Handle("/models/override", authed(http.HandlerFunc(s.handleModelOverride)))
mux.Handle("/models/check", authed(http.HandlerFunc(s.handleModelCheck)))
mux.Handle("/models/eval", authed(http.HandlerFunc(s.handleModelEval)))
mux.Handle("/models", authed(http.HandlerFunc(s.handleModels)))
mux.Handle("/routing", authed(http.HandlerFunc(s.handleRouting)))
mux.Handle("/slots/", authed(http.HandlerFunc(s.handleSlotAssign))) // POST /slots/{slot}/assign
Expand Down
38 changes: 38 additions & 0 deletions internal/adminapi/templates/partials_models_row.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
7d: {{.Telemetry.Calls}} calls · {{.Telemetry.AvgLatencyMS}} ms · {{printf "%.0f%%" .Telemetry.ErrorRatePct}} errors · {{printf "$%.4f" .Telemetry.CostUSD}}
</div>
{{end}}
{{template "model_eval_status" .}}

{{template "model_actions" dict "Model" . "Root" $root}}
</article>
Expand Down Expand Up @@ -139,6 +140,30 @@
{{end}}
</div>
{{end}}
<div class="model-actions__eval">
{{if or .Free (eq $root.CatalogProvider "ollama") (eq $root.CatalogProvider "local")}}
<input type="hidden" name="confirm_paid_eval" value="1">
Comment on lines +144 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require explicit confirmation for zero-priced OpenRouter rows

For OpenRouter rows whose catalog price is zero or missing but whose ID is not a :free variant, .Free is true because Capabilities.Free treats zero price as free/unknown, so this branch silently posts confirm_paid_eval=1. handleModelEval still classifies that model as paid via isPaidModelEval, but it accepts the hidden confirmation, allowing a paid/cloud eval to run without the explicit checkbox warning.

Useful? React with 👍 / 👎.

<button class="btn btn--small btn--ghost"
hx-post="/models/eval"
hx-include="#models-filters, closest [data-model-card]"
hx-target="#models-content"
hx-swap="outerHTML">
Run eval
</button>
{{else}}
<label style="display:flex;align-items:center;gap:0.3rem;color:var(--warning,#9a5b00);font-size:0.78rem;">
<input type="checkbox" name="confirm_paid_eval" value="1">
Paid eval may spend credits
</label>
<button class="btn btn--small btn--ghost"
hx-post="/models/eval"
hx-include="#models-filters, closest [data-model-card]"
hx-target="#models-content"
hx-swap="outerHTML">
Run paid eval
</button>
{{end}}
</div>
<div class="model-actions__override">
<input class="form-input" name="note" type="text" value="{{.OverrideNote}}" placeholder="Override note">
<button class="btn btn--small btn--ghost"
Expand Down Expand Up @@ -217,6 +242,7 @@
· {{printf "$%.4f" .Telemetry.CostUSD}}
</div>
{{end}}
{{template "model_eval_status" .}}
{{template "model_actions" dict "Model" . "Root" $root}}
</td>
<td class="price-cell">{{priceUSD .PromptPrice}}</td>
Expand All @@ -241,3 +267,15 @@
</tr>
{{end}}
{{end}}

{{define "model_eval_status"}}
{{if .EvalStatus.CheckedAt}}
<div style="margin-top:4px;color:{{if gt .EvalStatus.Failed 0}}var(--warning,#9a5b00){{else}}var(--success,#0f7b45){{end}};font-size:0.78rem;">
eval:
{{if gt .EvalStatus.Failed 0}}failed{{else}}passed{{end}}
· {{.EvalStatus.Passed}}/{{add .EvalStatus.Passed .EvalStatus.Failed}} cases
{{if .EvalStatus.DurationMS}} · {{.EvalStatus.DurationMS}} ms{{end}}
{{range .EvalStatus.Failures}} · {{.}}{{end}}
</div>
{{end}}
{{end}}
Loading