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
67 changes: 67 additions & 0 deletions internal/adminapi/adminapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,73 @@ func TestTemplatesParse(t *testing.T) {
}
}

func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
slotInfos := []uiSlotInfo{{Name: "default-or", Provider: "openrouter"}}
base := indexData{
Routing: uiRouting{AllSlots: slotInfos},
Slots: []uiSlot{{Name: "default-or", ModelID: "qwen/qwen3.5-plus"}},
Models: []uiModel{{
ID: "qwen/qwen3.5-plus",
PromptPrice: 1.0,
CompletionPrice: 3.0,
ContextLength: 128000,
Tools: true,
StatusLabel: "Candidate",
PrimaryReason: "tool calling",
}},
CatalogProvider: "openrouter",
}

t.Run("cards default", func(t *testing.T) {
data := base
data.Filters.View = "cards"
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-audit-table") {
t.Fatalf("card view should not render audit table")
}
})

t.Run("table audit", func(t *testing.T) {
data := base
data.Filters.View = "table"
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
t.Fatal(err)
}
html := buf.String()
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")
}
})

t.Run("preset state is preserved except clear", func(t *testing.T) {
data := base
data.Filters.View = "cards"
data.Filters.ActivePreset = "default"
var buf bytes.Buffer
if err := render(&buf, viewModelsBrowser, data); err != nil {
t.Fatal(err)
}
html := buf.String()
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"`) {
t.Fatalf("clear preset should refresh browser without preset: %s", html)
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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
7 changes: 7 additions & 0 deletions internal/adminapi/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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"
}

type uiModelSection struct {
Expand Down Expand Up @@ -369,6 +370,10 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
if catalogProv != "gemini" {
catalogProv = "openrouter"
}
viewMode := q.Get("view")
if viewMode != "table" {
viewMode = "cards"
}

ctx5, cancel5 := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel5()
Expand Down Expand Up @@ -417,6 +422,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
Tools: requiresTools(preset),
Vision: preset == "multimodal",
Reasoning: preset == "complex",
View: viewMode,
}
// Knee-point value leader: best quality/price among frontier models
// with quality ≥ 50% of top. Skipped when the top model is already
Expand Down Expand Up @@ -468,6 +474,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
Reasoning: q.Get("reasoning") != "",
Sort: sortCol,
SortDir: sortDir,
View: viewMode,
}

models := make([]uiModel, 0, len(allCaps))
Expand Down
41 changes: 40 additions & 1 deletion internal/adminapi/templates/partials_layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,39 @@
.admin-section { margin-bottom: 2rem; }
.filters { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
.filters label { display: inline-flex; gap: 0.25rem; align-items: center; }
.catalog-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(0,0,0,0.1); flex-wrap: wrap; }
.catalog-tabs, .catalog-view-toggle { display: inline-flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.catalog-sort { width: 10rem; }
.catalog-sort-dir { width: 6.5rem; }
.model-id { font-family: var(--font-mono, monospace); font-size: 0.875rem; }
.caps-badges .badge { margin-right: 0.25rem; }
.assign-buttons { margin-top: 0.25rem; }
.assign-buttons { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.25rem; }
.assign-buttons .btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
.model-actions { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
.model-actions__override { display: flex; gap: 0.35rem; align-items: center; flex-wrap: wrap; }
.model-actions__override .form-input { width: 12rem; min-width: 9rem; font-size: 0.8rem; }
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); gap: 0.75rem; align-items: stretch; }
.catalog-card-section { margin-bottom: 1rem; }
.catalog-section-head { display: flex; justify-content: space-between; gap: 1rem; align-items: end; padding: 0.35rem 0 0.5rem; }
.catalog-section-head span, .catalog-section-head p { color: var(--text-secondary); font-size: 0.82rem; margin: 0; }
.catalog-card { border: 1px solid rgba(0,0,0,0.10); border-radius: 8px; padding: 0.85rem; background: var(--surface, #fff); display: flex; flex-direction: column; min-width: 0; }
.catalog-card__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.catalog-card__title { min-width: 0; }
.catalog-card__title .model-id { overflow-wrap: anywhere; line-height: 1.25; }
.catalog-card__chips { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.4rem; }
.catalog-card__price { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.catalog-card__price span { font-weight: 700; display: block; }
.catalog-card__price small { color: var(--text-secondary); font-size: 0.72rem; }
.catalog-card__why { margin-top: 0.65rem; font-size: 0.86rem; color: var(--text); }
.catalog-card__reasons, .catalog-card__warnings { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.45rem; font-size: 0.78rem; }
.catalog-card__reasons { color: var(--text-secondary); }
.catalog-card__warnings { color: var(--warning, #9a5b00); }
.catalog-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.35rem; margin-top: 0.75rem; }
.catalog-metrics div { min-width: 0; padding: 0.45rem 0.5rem; border-radius: 6px; background: var(--surface-2, rgba(0,0,0,0.04)); }
.catalog-metrics span { display: block; color: var(--text-secondary); font-size: 0.68rem; text-transform: uppercase; }
.catalog-metrics strong { display: block; margin-top: 0.12rem; font-size: 0.82rem; overflow-wrap: anywhere; }
.catalog-card__telemetry { margin-top: 0.55rem; color: var(--text-secondary); font-size: 0.78rem; }
.catalog-empty { text-align: center; padding: 2rem; color: var(--text-secondary); border: 1px dashed rgba(0,0,0,0.16); border-radius: 8px; }
.price-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
.preset-banner { padding: 0.75rem 1rem; border-radius: var(--radius); background: var(--surface-2); margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.preset-banner .preset-meta { color: var(--text-secondary); font-size: 0.875rem; margin-top: 0.25rem; }
Expand Down Expand Up @@ -61,6 +90,16 @@
.admin-section { margin-bottom: 1.25rem; }
.filters { gap: 0.5rem; }
.filters input[type="text"], .filters input[type="search"] { flex: 1 1 100%; min-width: 0; }
.catalog-toolbar { align-items: stretch; }
.catalog-tabs, .catalog-view-toggle { width: 100%; }
.catalog-view-toggle .btn, .catalog-tabs .btn { flex: 1 1 auto; }
.catalog-sort, .catalog-sort-dir { flex: 1 1 8rem; width: auto; }
.catalog-grid { grid-template-columns: 1fr; }
.catalog-card { padding: 0.75rem; }
.catalog-card__header { flex-direction: column; gap: 0.5rem; }
.catalog-card__price { text-align: left; }
.catalog-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.model-actions__override .form-input { width: 100%; }
.usage-chart-wrap { padding: 0.5rem 0.6rem; }
.usage-breakdown { grid-template-columns: 1fr; gap: 1rem; }
.preset-banner { padding: 0.6rem 0.75rem; flex-direction: column; align-items: flex-start; }
Expand Down
79 changes: 61 additions & 18 deletions internal/adminapi/templates/partials_models_browser.html
Original file line number Diff line number Diff line change
@@ -1,27 +1,63 @@
{{define "models_browser"}}
<div id="models-browser">
<div class="catalog-tabs" style="display:flex;gap:0.5rem;margin-bottom:0.75rem;border-bottom:1px solid rgba(0,0,0,0.1);">
<div class="catalog-toolbar">
<div class="catalog-tabs">
<button type="button" class="btn btn--small {{if eq .CatalogProvider "openrouter"}}btn--primary{{else}}btn--ghost{{end}}"
hx-get="/models?provider=openrouter&full=1"
hx-get="/models?provider=openrouter&full=1&view={{.Filters.View}}{{if .Filters.ActivePreset}}&preset={{.Filters.ActivePreset}}{{end}}"
hx-target="#models-browser"
hx-swap="outerHTML">OpenRouter</button>
<button type="button" class="btn btn--small {{if eq .CatalogProvider "gemini"}}btn--primary{{else}}btn--ghost{{end}}"
hx-get="/models?provider=gemini&full=1"
hx-get="/models?provider=gemini&full=1&view={{.Filters.View}}{{if .Filters.ActivePreset}}&preset={{.Filters.ActivePreset}}{{end}}"
hx-target="#models-browser"
hx-swap="outerHTML">Gemini</button>
</div>
<div class="catalog-view-toggle" role="group" aria-label="Catalog view">
<button type="button" class="btn btn--small {{if ne .Filters.View "table"}}btn--primary{{else}}btn--ghost{{end}}"
hx-get="/models?full=1"
hx-target="#models-browser"
hx-swap="outerHTML"
hx-include="#models-filters"
hx-vals='{"view":"cards"}'>Cards</button>
<button type="button" class="btn btn--small {{if eq .Filters.View "table"}}btn--primary{{else}}btn--ghost{{end}}"
hx-get="/models?full=1"
hx-target="#models-browser"
hx-swap="outerHTML"
hx-include="#models-filters"
hx-vals='{"view":"table"}'>Audit table</button>
Comment on lines +22 to +26

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 Preserve presets when toggling audit view

When this toggle is used from a role's Suggest results, the request is only /models?full=1 plus #models-filters; that form does not submit .Filters.ActivePreset, so buildIndexData leaves the preset path and rebuilds the ordinary catalog. The Audit table button therefore clears the recommendation banner/sections instead of showing the same suggested candidates in table mode; include the active preset in the submitted state when it is set.

Useful? React with 👍 / 👎.

</div>
</div>
<form id="models-filters" class="filters"
hx-get="/models"
hx-target="#models-content"
hx-swap="outerHTML"
hx-trigger="input delay:300ms, change">
<input type="hidden" name="provider" value="{{.CatalogProvider}}">
<input type="hidden" name="view" value="{{.Filters.View}}">
{{if .Filters.ActivePreset}}<input type="hidden" name="preset" value="{{.Filters.ActivePreset}}">{{end}}
<input class="form-input" type="search" name="q" placeholder="Search model id…"
value="{{.Filters.Search}}" style="flex:1;min-width:12rem;">
<label><input type="checkbox" name="free" {{if .Filters.Free}}checked{{end}}> Free</label>
<label><input type="checkbox" name="vision" {{if .Filters.Vision}}checked{{end}}> Vision</label>
<label><input type="checkbox" name="tools" {{if .Filters.Tools}}checked{{end}}> Tools</label>
<label><input type="checkbox" name="reasoning" {{if .Filters.Reasoning}}checked{{end}}> Reasoning</label>
<select class="form-input catalog-sort" name="sort" title="Sort models">
<option value="prompt" {{if eq .Filters.Sort "prompt"}}selected{{end}}>Prompt price</option>
<option value="completion" {{if eq .Filters.Sort "completion"}}selected{{end}}>Completion price</option>
<option value="effective" {{if eq .Filters.Sort "effective"}}selected{{end}}>Effective price</option>
<option value="score" {{if eq .Filters.Sort "score"}}selected{{end}}>Intel</option>
<option value="coding" {{if eq .Filters.Sort "coding"}}selected{{end}}>Coding</option>
<option value="agentic" {{if eq .Filters.Sort "agentic"}}selected{{end}}>Agentic</option>
<option value="speed" {{if eq .Filters.Sort "speed"}}selected{{end}}>Speed</option>
<option value="ttft" {{if eq .Filters.Sort "ttft"}}selected{{end}}>TTFT</option>
<option value="context" {{if eq .Filters.Sort "context"}}selected{{end}}>Context</option>
<option value="market" {{if eq .Filters.Sort "market"}}selected{{end}}>Market</option>
<option value="value" {{if eq .Filters.Sort "value"}}selected{{end}}>Value</option>
<option value="id" {{if eq .Filters.Sort "id"}}selected{{end}}>Model id</option>
</select>
<select class="form-input catalog-sort-dir" name="dir" title="Sort direction">
<option value="asc" {{if eq .Filters.SortDir "asc"}}selected{{end}}>Asc</option>
<option value="desc" {{if eq .Filters.SortDir "desc"}}selected{{end}}>Desc</option>
</select>
</form>
{{template "models_content" .}}
</div>
Expand Down Expand Up @@ -62,21 +98,29 @@
</details>
</div>
<button class="btn btn--ghost btn--small"
hx-get="/models"
hx-target="#models-content"
hx-swap="outerHTML"
hx-include="#models-filters">
hx-get="/models?provider={{.CatalogProvider}}&view={{.Filters.View}}&full=1"
hx-target="#models-browser"
hx-swap="outerHTML">
× Clear preset
</button>
</div>
{{end}}

<div class="table-wrap">
<table>
<thead>
<tr>
{{- $s := .Filters.Sort}}{{- $d := .Filters.SortDir}}
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
{{if eq .Filters.View "table"}}
{{template "models_table" .}}
{{else}}
{{template "models_cards" .}}
{{end}}
</div>
{{end}}

{{define "models_table"}}
<div class="table-wrap catalog-audit-table">
<table>
<thead>
<tr>
{{- $s := .Filters.Sort}}{{- $d := .Filters.SortDir}}
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
hx-vals='{"sort":"id","dir":"{{if and (eq $s "id") (eq $d "asc")}}desc{{else}}asc{{end}}"}'
style="cursor:pointer" title="Model identifier on OpenRouter (provider/model)">Model{{if eq $s "id"}} {{if eq $d "asc"}}↑{{else}}↓{{end}}{{end}}</th>
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
Expand Down Expand Up @@ -119,10 +163,9 @@
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
hx-vals='{"sort":"value","dir":"{{if and (eq $s "value") (eq $d "desc")}}asc{{else}}desc{{end}}"}'
style="cursor:pointer" title="Quality per $ (higher = more bang per buck). Role-specific in Suggest mode (uses role's Pareto axes); generic agentic/prompt in browse mode.">Value{{if eq $s "value"}} {{if eq $d "asc"}}↑{{else}}↓{{end}}{{end}}</th>
</tr>
</thead>
{{template "models_tbody" .}}
</table>
</div>
</tr>
</thead>
{{template "models_tbody" .}}
</table>
</div>
{{end}}
Loading
Loading