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
141 changes: 132 additions & 9 deletions internal/adminapi/adminapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
Slots: []uiSlot{{Name: "default-or", ModelID: "qwen/qwen3.5-plus"}},
Models: []uiModel{{
ID: "qwen/qwen3.5-plus",
Name: "Qwen 3.5 Plus",
Description: "Balanced multilingual model for tool-heavy assistant workflows.",
PromptPrice: 1.0,
CompletionPrice: 3.0,
ContextLength: 128000,
Expand All @@ -193,19 +195,22 @@ func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
CatalogProvider: "openrouter",
}

t.Run("cards default", func(t *testing.T) {
t.Run("compact default", func(t *testing.T) {
data := base
data.Filters.View = "cards"
data.Filters.View = "compact"
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
t.Fatal(err)
}
html := buf.String()
if !strings.Contains(html, "catalog-grid") || !strings.Contains(html, "catalog-card") {
t.Fatalf("card view missing catalog cards: %s", html)
if !strings.Contains(html, "catalog-list") || !strings.Contains(html, "catalog-row") {
t.Fatalf("compact view missing catalog rows: %s", html)
}
if !strings.Contains(html, "Balanced multilingual model") {
t.Fatalf("compact view missing model description: %s", html)
}
if strings.Contains(html, "catalog-audit-table") {
t.Fatalf("card view should not render audit table")
t.Fatalf("compact view should not render audit table")
}
})

Expand All @@ -220,14 +225,14 @@ func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
if !strings.Contains(html, "catalog-audit-table") {
t.Fatalf("table view missing audit table: %s", html)
}
if strings.Contains(html, "catalog-grid") || strings.Contains(html, "catalog-card") {
t.Fatalf("table view should not render card layout")
if strings.Contains(html, "catalog-list") || strings.Contains(html, "catalog-row") {
t.Fatalf("table view should not render compact layout")
}
})

t.Run("preset state is preserved except clear", func(t *testing.T) {
data := base
data.Filters.View = "cards"
data.Filters.View = "compact"
data.Filters.ActivePreset = "default"
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
Expand All @@ -237,12 +242,130 @@ func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
if !strings.Contains(html, `name="preset" value="default"`) {
t.Fatalf("active preset should be submitted with filters and view toggle: %s", html)
}
if !strings.Contains(html, `hx-get="/models?provider=openrouter&view=cards&full=1"`) {
if !strings.Contains(html, `hx-get="/models?provider=openrouter&view=compact&full=1"`) {
t.Fatalf("clear preset should refresh browser without preset: %s", html)
}
})
}

func TestModelsBrowserRendersFreeCheckAction(t *testing.T) {
data := indexData{
Slots: []uiSlot{{Name: "default-or", ModelID: "qwen/qwen3.5-plus"}},
Models: []uiModel{{
ID: "qwen/qwen3.5-flash:free",
Description: "Free endpoint for quick routing checks.",
PromptPrice: 0,
CompletionPrice: 0,
ContextLength: 64000,
Tools: true,
Free: true,
Policy: "free_degraded",
CheckStatus: "free_degraded",
CheckLatencyMS: 321,
StatusLabel: "Untested",
PolicyLabel: "Free degraded",
PrimaryReason: "Free endpoint degraded",
}},
Filters: uiFilters{View: "compact"},
CatalogProvider: "openrouter",
}
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
t.Fatal(err)
}
html := buf.String()
if !strings.Contains(html, `hx-post="/models/check"`) || !strings.Contains(html, "Check free") {
t.Fatalf("free check action missing: %s", html)
}
if !strings.Contains(html, "Free model must pass check before routing") {
t.Fatalf("unverified free model should not render active assign buttons: %s", html)
}
if !strings.Contains(html, "free_degraded") || !strings.Contains(html, "321 ms") {
t.Fatalf("check status not rendered: %s", html)
}
}

func TestWebModelCheckPersistsFreeModelStatus(t *testing.T) {
s, _ := newTGModelTestServer(t)
settings := &fakeSettingsStore{values: map[string]string{}}
s.settings = settings
s.modelProbe = func(context.Context, string, string, llm.Capabilities) modelCheckStatus {
return modelCheckStatus{
Status: "free_verified",
CheckedAt: "2026-06-15T12:00:00Z",
LatencyMS: 88,
}
}

form := url.Values{}
form.Set("provider", "openrouter")
form.Set("model_id", "qwen/qwen3.5-flash:free")
form.Set("view", "compact")
req := httptest.NewRequest(http.MethodPost, "/models/check", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleModelCheck(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
checks := s.loadModelChecks(context.Background())
got := checks[modelCheckKey("openrouter", "qwen/qwen3.5-flash:free")]
if got.Status != "free_verified" || got.LatencyMS != 88 {
t.Fatalf("status not persisted: %+v", checks)
}
if !strings.Contains(rec.Body.String(), "free_verified") {
t.Fatalf("response should render updated check status: %s", rec.Body.String())
}
}

func TestWebSlotAssignRejectsUnverifiedFreeModel(t *testing.T) {
s, provider := newTGModelTestServer(t)
s.settings = &fakeSettingsStore{values: map[string]string{}}

form := url.Values{}
form.Set("provider", "openrouter")
form.Set("model_id", "qwen/qwen3.5-flash:free")
req := httptest.NewRequest(http.MethodPost, "/slots/default-or/assign", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleSlotAssign(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if provider.CurrentModel() == "qwen/qwen3.5-flash:free" {
t.Fatalf("unverified free model should not be assigned")
}
}

func TestWebSlotAssignAllowsVerifiedFreeModel(t *testing.T) {
s, provider := newTGModelTestServer(t)
checks := map[string]modelCheckStatus{
modelCheckKey("openrouter", "qwen/qwen3.5-flash:free"): {Status: "free_verified", CheckedAt: time.Now().Format(time.RFC3339)},
}
data, err := json.Marshal(checks)
if err != nil {
t.Fatal(err)
}
s.settings = &fakeSettingsStore{values: map[string]string{settingKeyModelChecks: string(data)}}

form := url.Values{}
form.Set("provider", "openrouter")
form.Set("model_id", "qwen/qwen3.5-flash:free")
req := httptest.NewRequest(http.MethodPost, "/slots/default-or/assign", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleSlotAssign(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if provider.CurrentModel() != "qwen/qwen3.5-flash:free" {
t.Fatalf("model = %q, want free model", provider.CurrentModel())
}
}

func signedTGInitData(t *testing.T, token string, userID int64, authTime time.Time) string {
t.Helper()
userJSON, err := json.Marshal(tgAdminUser{ID: userID, FirstName: "Alex"})
Expand Down
104 changes: 102 additions & 2 deletions internal/adminapi/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type uiSlot struct {

type uiModel struct {
ID string
Name string
Description string
PromptPrice float64
CompletionPrice float64
ContextLength int
Expand All @@ -58,6 +60,10 @@ type uiModel struct {
Warnings []string
Telemetry modelTelemetry
Market llm.OpenRouterMarketSignal
CheckStatus string
CheckedAt string
CheckLatencyMS int64
CheckError string
StatusLabel string
PolicyLabel string
PrimaryReason string
Expand Down Expand Up @@ -94,7 +100,7 @@ type uiFilters struct {
ValueLeaderHint string // e.g. "85% quality @ 30% price" (preset path only)
Sort string // active sort column: "prompt", "completion", "score", "context", "id"
SortDir string // "asc" or "desc"
View string // "cards" (default) or "table"
View string // "compact" (default) or "table"
}

type uiModelSection struct {
Expand Down Expand Up @@ -148,6 +154,52 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
}
}

func (s *Server) handleModelCheck(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
}
if provider != "openrouter" || !isFreeVariant(modelID) {
http.Error(w, "only free OpenRouter models can be checked here", http.StatusBadRequest)
return
}

caps := s.lookupCapsFor(r.Context(), provider, modelID)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
status := s.probeModel(ctx, provider, modelID, caps)
if err := s.saveModelCheck(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 check", "err", err)
}
}

func (s *Server) handleModelOverride(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
Expand Down Expand Up @@ -239,6 +291,13 @@ func (s *Server) handleSlotAssign(w http.ResponseWriter, r *http.Request) {
providerType = mc.Provider
}
}
if providerType == "openrouter" && isFreeVariant(modelID) {
check := s.modelCheckStatus(r.Context(), providerType, modelID)
if check.Status != "free_verified" {
http.Error(w, "free model must pass check before routing", http.StatusBadRequest)
return
}
}
caps := s.lookupCapsFor(r.Context(), providerType, modelID)
if err := s.router.SetProviderModel(slot, providerType, modelID, caps); err != nil {
s.logger.Warn("slot assign failed", "slot", slot, "model", modelID, "err", err)
Expand Down Expand Up @@ -372,7 +431,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
}
viewMode := q.Get("view")
if viewMode != "table" {
viewMode = "cards"
viewMode = "compact"
}

ctx5, cancel5 := context.WithTimeout(r.Context(), 5*time.Second)
Expand Down Expand Up @@ -414,6 +473,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
models = filterModelOverrides(models, catalogProv, overrides, true)
attachMarketSignals(models, marketSignals)
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
decorateModelDisplay(models, preset)
sections := buildModelSections(models, true)
filters := uiFilters{
Expand Down Expand Up @@ -497,6 +557,8 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
}
m := uiModel{
ID: id,
Name: c.Name,
Description: c.Description,
PromptPrice: c.PromptPrice,
CompletionPrice: c.CompletionPrice,
ContextLength: c.ContextLength,
Expand Down Expand Up @@ -528,6 +590,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
models = filterModelOverrides(models, catalogProv, overrides, f.Search == "")
attachMarketSignals(models, marketSignals)
attachModelTelemetry(models, s.loadModelTelemetry(ctx5, catalogProv))
attachModelChecks(models, catalogProv, s.loadModelChecks(ctx5))
decorateModelDisplay(models, "")
asc := sortDir == "asc"
sort.Slice(models, func(i, j int) bool {
Expand Down Expand Up @@ -709,6 +772,43 @@ func attachModelTelemetry(models []uiModel, telemetry map[string]modelTelemetry)
}
}

func attachModelChecks(models []uiModel, provider string, checks map[string]modelCheckStatus) {
if len(models) == 0 || len(checks) == 0 {
return
}
for i := range models {
if !models[i].Free {
continue
}
check := checks[modelCheckKey(provider, models[i].ID)]
if check.Status == "" {
continue
}
models[i].CheckStatus = check.Status
models[i].CheckedAt = check.CheckedAt
models[i].CheckLatencyMS = check.LatencyMS
models[i].CheckError = check.Error
if check.Status != "free_unverified" {
models[i].Policy = check.Status
models[i].Warnings = withoutWarning(models[i].Warnings, "free model: validate availability before routing")
}
if check.Error != "" {
models[i].Warnings = append(models[i].Warnings, check.Error)
}
}
}

func withoutWarning(warnings []string, remove string) []string {
out := warnings[:0]
for _, warning := range warnings {
if warning == remove {
continue
}
out = append(out, warning)
}
return out
}

func (s *Server) loadMarketSignals(ctx context.Context, provider string) map[string]llm.OpenRouterMarketSignal {
if provider != "openrouter" || s.settings == nil {
return nil
Expand Down
2 changes: 2 additions & 0 deletions internal/adminapi/model_overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ func appendAllowedOverrideCandidates(models []uiModel, allCaps map[string]llm.Ca
}
m := uiModel{
ID: modelID,
Name: c.Name,
Description: c.Description,
PromptPrice: c.PromptPrice,
CompletionPrice: c.CompletionPrice,
ContextLength: c.ContextLength,
Expand Down
2 changes: 2 additions & 0 deletions internal/adminapi/recommend.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ func applyPreset(all map[string]llm.Capabilities, aaModels map[string]llm.AAMode
}
m := uiModel{
ID: id,
Name: c.Name,
Description: c.Description,
PromptPrice: c.PromptPrice,
CompletionPrice: c.CompletionPrice,
ContextLength: c.ContextLength,
Expand Down
1 change: 1 addition & 0 deletions internal/adminapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
authed := s.requireAuth
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", 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
Loading
Loading