From 8bc3a41f9118739af65c5629990cd56c119db7cd Mon Sep 17 00:00:00 2001 From: Jared Hatfield Date: Mon, 27 Jul 2026 10:58:52 +0000 Subject: [PATCH] feat: add Models diagnostic tab for upstream model discovery Add a new Models tab to the TUI that queries each upstream backend's /models endpoint and compares results against configured models. The tab displays model status (MATCH, MISSING, UNCONFIGURED, ALLOWED, UNKNOWN) with color-coded rows and a detail view for each model. - Add DiscoverModels to proxy handler for upstream /models queries - Add modelDiscoverer interface and discovery reconciliation logic to UI - Add diagnostic model types, cursor navigation, and detail page - Update tab navigation to cycle through Stats, Models, and Test - Add NO_COLOR support with textual Status column fallback - Add tests for discovery, reconciliation, and UI interaction - Update documentation with Models tab usage and status legend --- docs/README.md | 2 +- docs/USAGE.md | 21 +- internal/proxy/models.go | 124 ++++++++++ internal/proxy/proxy_test.go | 44 ++++ internal/ui/ui.go | 451 +++++++++++++++++++++++++++++++---- internal/ui/ui_test.go | 80 ++++++- main.go | 2 +- 7 files changed, 667 insertions(+), 57 deletions(-) create mode 100644 internal/proxy/models.go diff --git a/docs/README.md b/docs/README.md index 424f00d..3a043aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ Use it when you want to: `localmodelproxy` listens on `127.0.0.1` by default and exposes: -- `GET /v1/models` — always returns the configured models list +- `GET /v1/models` — always returns the configured models list. The interactive TUI's **Models** diagnostic tab separately queries upstream `/models` endpoints and compares their responses with this configuration. - `POST /v1/chat/completions` — validated against configured model IDs and forwarded to the matching backend The proxy selects a backend by inspecting the request model, validates it against configured model IDs (returning a 400 `model_not_found` error if not found), forwards the request transparently, rewrites model aliases when configured, applies the backend's authentication method, and aggregates token usage and optional cost from the response. diff --git a/docs/USAGE.md b/docs/USAGE.md index 8976e76..7b9e447 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -92,13 +92,32 @@ The app uses the TUI when stdout is an interactive terminal and `--headless` is ### TUI Navigation -When the Test tab is enabled (the default), the TUI has two views accessible via the **Tab** key: +The TUI has the following views, accessible with **Tab**, **←/→**, or **h/l**: - **Stats** – Displays per-model statistics and recent requests (the default view). +- **Models** – Queries every upstream backend's `/models` endpoint and compares the response with the configured models. - **Test** – Lists all configured models. Use **↑/↓** to select a model and **Enter** to send a test request. The response is displayed inline. Test requests go through the proxy like any other request, so they count towards stats and token usage. +### Model Discovery Diagnostics + +Opening the **Models** tab calls the OpenAI-compatible `/models` endpoint on each configured upstream backend. The request uses the backend's configured base URL, authentication, HTTP client, and TLS settings. It does not compare against the proxy's own `GET /v1/models` endpoint, because that endpoint is generated from the config itself. + +The combined list distinguishes the source and consistency of every model: + +| Color | Status | Meaning | +|-------|--------|---------| +| Green | `MATCH` | A configured model's effective upstream ID was returned by that backend. | +| Red | `MISSING` | The backend responded successfully but did not return the configured upstream ID. | +| Cyan | `UNCONFIGURED` | The backend returned a model that is not explicitly present in its config. | +| Green | `ALLOWED` | A response model is accepted by a backend configured with `models: all`. | +| Yellow | `UNKNOWN` | Discovery failed, so whether the configured model exists cannot be determined. | + +With color enabled, row colors carry the status and the legend shows colored descriptions without repeating color names. When `NO_COLOR` is set, the list adds a textual **Status** column instead. + +For configured aliases, matching uses `upstream_id`; when it is omitted, matching uses the local `id`. Use **↑/↓** to select a row and **Enter** to open its detail page. The detail page includes the complete JSON object returned for that model, including provider-specific fields. Use **Esc** or **Backspace** to return to the list, and **R** to query all backends again. Backend-level HTTP, authentication, parsing, and connection failures are shown above the list. + ## Backends Each backend declares where requests go, how they authenticate, and which models they serve. diff --git a/internal/proxy/models.go b/internal/proxy/models.go new file mode 100644 index 0000000..eea047c --- /dev/null +++ b/internal/proxy/models.go @@ -0,0 +1,124 @@ +package proxy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "sync" +) + +// DiscoveredModel is one model entry returned by an upstream /models endpoint. +// Details retains the complete response object so diagnostics are not limited to +// the standard OpenAI fields. +type DiscoveredModel struct { + ID string + Details json.RawMessage +} + +// ModelDiscovery describes the result of querying one configured backend. +type ModelDiscovery struct { + Backend string + URL string + StatusCode int + Models []DiscoveredModel + Err error +} + +// DiscoverModels queries every configured backend's OpenAI-compatible /models +// endpoint using the same HTTP and authentication configuration as proxy calls. +// A failed backend is returned alongside successful results so the TUI can show +// a complete diagnostic picture. +func (p *Proxy) DiscoverModels(ctx context.Context) []ModelDiscovery { + results := make([]ModelDiscovery, len(p.backends)) + var wg sync.WaitGroup + for i, backend := range p.backends { + wg.Add(1) + go func() { + defer wg.Done() + results[i] = p.discoverBackendModels(ctx, backend) + }() + } + wg.Wait() + return results +} + +func (p *Proxy) discoverBackendModels(ctx context.Context, backend *resolvedBackend) ModelDiscovery { + result := ModelDiscovery{Backend: backend.cfg.Name} + modelsURL, err := buildUpstreamURL(backend.base, &url.URL{Path: "/v1/models"}) + if err != nil { + result.Err = fmt.Errorf("build models URL: %w", err) + return result + } + result.URL = modelsURL + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil) + if err != nil { + result.Err = fmt.Errorf("create models request: %w", err) + return result + } + req.Header.Set("Accept", "application/json") + token, err := backend.tokens.Token(ctx) + if err != nil { + result.Err = fmt.Errorf("authenticate: %w", err) + return result + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := backend.client.Do(req) + if err != nil { + result.Err = fmt.Errorf("request models: %w", err) + return result + } + defer resp.Body.Close() + result.StatusCode = resp.StatusCode + body, err := io.ReadAll(io.LimitReader(resp.Body, captureLimit+1)) + if err != nil { + result.Err = fmt.Errorf("read models response: %w", err) + return result + } + if len(body) > captureLimit { + result.Err = fmt.Errorf("models response exceeds %d byte limit", captureLimit) + return result + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Err = fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateForError(string(body), 200)) + return result + } + + var response struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &response); err != nil { + result.Err = fmt.Errorf("parse models response: %w", err) + return result + } + for _, raw := range response.Data { + var entry struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &entry); err != nil { + result.Err = fmt.Errorf("parse model entry: %w", err) + return result + } + if entry.ID == "" { + result.Err = fmt.Errorf("models response contains an entry without an id") + return result + } + result.Models = append(result.Models, DiscoveredModel{ID: entry.ID, Details: raw}) + } + sort.Slice(result.Models, func(i, j int) bool { return result.Models[i].ID < result.Models[j].ID }) + return result +} + +func truncateForError(value string, limit int) string { + if len(value) <= limit { + return value + } + return value[:limit] + "..." +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 2167383..aed76a1 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -103,6 +103,50 @@ func TestModelsEmpty(t *testing.T) { } } +func TestDiscoverModelsCallsUpstreamWithAuthenticationAndPreservesDetails(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer diagnostic-token" { + t.Errorf("unexpected authorization %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"list","data":[{"id":"z-model","owned_by":"owner"},{"id":"a-model","custom":{"size":7}}]}`) + })) + defer upstream.Close() + + cfg := testConfig([]config.Model{{ID: "a-model"}}) + cfg.Backends[0].BaseURL = upstream.URL + "/v1" + handler := mustNew(t, Options{Config: cfg, TokenProvider: StaticTokenProvider("diagnostic-token"), Metrics: NewMetrics()}) + results := handler.DiscoverModels(context.Background()) + + if len(results) != 1 || results[0].Err != nil { + t.Fatalf("unexpected discovery result: %#v", results) + } + if results[0].StatusCode != http.StatusOK || len(results[0].Models) != 2 { + t.Fatalf("unexpected discovery response: %#v", results[0]) + } + if results[0].Models[0].ID != "a-model" || !strings.Contains(string(results[0].Models[0].Details), `"size":7`) { + t.Fatalf("model details were not sorted/preserved: %#v", results[0].Models) + } +} + +func TestDiscoverModelsReportsBackendHTTPError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not available", http.StatusServiceUnavailable) + })) + defer upstream.Close() + cfg := testConfig([]config.Model{{ID: "missing"}}) + cfg.Backends[0].BaseURL = upstream.URL + "/v1" + handler := mustNew(t, Options{Config: cfg, TokenProvider: StaticTokenProvider("token"), Metrics: NewMetrics()}) + + results := handler.DiscoverModels(context.Background()) + if len(results) != 1 || results[0].Err == nil || !strings.Contains(results[0].Err.Error(), "HTTP 503") { + t.Fatalf("expected per-backend HTTP error, got %#v", results) + } +} + func TestChatCompletionsMissingModel(t *testing.T) { handler := mustNew(t, Options{ Config: testConfig([]config.Model{{ID: "google/gemini-2.5-flash"}}), diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6d84306..8e42338 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2,7 +2,9 @@ package ui import ( "bufio" + "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -27,7 +29,7 @@ type Renderer struct { input context.CancelFunc } -func Start(ctx context.Context, shutdown context.CancelFunc, cfg *config.Config, metrics *proxy.Metrics, out, errOut *os.File, headless bool) *Renderer { +func Start(ctx context.Context, shutdown context.CancelFunc, cfg *config.Config, metrics *proxy.Metrics, modelDiscoverer modelDiscoverer, out, errOut *os.File, headless bool) *Renderer { mode := resolveMode(out, headless) renderer := &Renderer{ cfg: cfg, @@ -40,7 +42,7 @@ func Start(ctx context.Context, shutdown context.CancelFunc, cfg *config.Config, case "tui": tuiCtx, cancel := context.WithCancel(ctx) renderer.cancel = cancel - model := tuiModel{cfg: cfg, metrics: metrics, shutdown: shutdown, color: colorEnabled(), testResults: make(map[string]string), allModels: []string{"Total"}} + model := tuiModel{cfg: cfg, metrics: metrics, discoverer: modelDiscoverer, discoveryCtx: tuiCtx, shutdown: shutdown, color: colorEnabled(), testResults: make(map[string]string), allModels: []string{"Total"}} renderer.program = tea.NewProgram(model, tea.WithOutput(out), tea.WithContext(tuiCtx), tea.WithAltScreen(), tea.WithMouseAllMotion()) go func() { _, _ = renderer.program.Run() @@ -140,15 +142,34 @@ type testResultMsg struct { err error } +type modelDiscoverer interface { + DiscoverModels(context.Context) []proxy.ModelDiscovery +} + +type discoveryResultMsg struct { + results []proxy.ModelDiscovery +} + +type diagnosticModel struct { + backend string + localID string + upstreamID string + status string + rowStyle string + details string +} + const ( - tabStats = 0 - tabTest = 1 + tabStats = 0 + tabModels = 1 + tabTest = 2 ) const ( hoverNone = iota hoverTab hoverModel + hoverDiagnostic ) const screenTopPadding = 1 @@ -157,15 +178,18 @@ type hoverTarget struct { kind int tab int model string + index int } type tuiModel struct { - cfg *config.Config - metrics *proxy.Metrics - shutdown context.CancelFunc - color bool - width int - height int + cfg *config.Config + metrics *proxy.Metrics + discoverer modelDiscoverer + discoveryCtx context.Context + shutdown context.CancelFunc + color bool + width int + height int // Tab navigation activeTab int @@ -178,6 +202,14 @@ type tuiModel struct { // Test tab state testRunning bool testResults map[string]string // model -> result or error + + // Models diagnostic tab state + discoveryRunning bool + discoveryLoaded bool + discoveryResults []proxy.ModelDiscovery + diagnosticModels []diagnosticModel + diagnosticCursor int + diagnosticDetail bool } func (m tuiModel) Init() tea.Cmd { @@ -194,16 +226,16 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "tab", "right", "l": - if m.cfg.UI.TestEnabled() { - m.activeTab = (m.activeTab + 1) % 2 - } - return m, nil + m.activeTab = m.nextTab(1) + return m, m.startDiscoveryIfNeeded() case "shift+tab", "left", "h": - if m.cfg.UI.TestEnabled() { - m.activeTab = (m.activeTab - 1 + 2) % 2 - } - return m, nil + m.activeTab = m.nextTab(-1) + return m, m.startDiscoveryIfNeeded() case "up", "k": + if m.activeTab == tabModels { + m.moveDiagnosticCursor(-1) + return m, nil + } if len(m.allModels) > 0 { m.modelCursor-- if m.modelCursor < 0 { @@ -212,6 +244,10 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "down", "j": + if m.activeTab == tabModels { + m.moveDiagnosticCursor(1) + return m, nil + } if len(m.allModels) > 0 { m.modelCursor++ if m.modelCursor >= len(m.allModels) { @@ -225,6 +261,20 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } } + if m.activeTab == tabModels && len(m.diagnosticModels) > 0 { + m.diagnosticDetail = true + } + return m, nil + case "esc", "backspace": + if m.activeTab == tabModels && m.diagnosticDetail { + m.diagnosticDetail = false + } + return m, nil + case "r": + if m.activeTab == tabModels && !m.discoveryRunning { + m.discoveryLoaded = false + return m, m.startDiscoveryIfNeeded() + } return m, nil default: return m, nil @@ -235,9 +285,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { switch target.kind { case hoverTab: - if m.cfg.UI.TestEnabled() { - m.activeTab = target.tab - } + m.activeTab = target.tab + return m, m.startDiscoveryIfNeeded() case hoverModel: if idx := m.modelIndex(target.model); idx >= 0 { m.modelCursor = idx @@ -247,6 +296,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case hoverDiagnostic: + if target.index >= 0 && target.index < len(m.diagnosticModels) { + m.diagnosticCursor = target.index + m.diagnosticDetail = true + } } } return m, nil @@ -258,6 +312,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.testResults[msg.model] = msg.content } return m, nil + case discoveryResultMsg: + m.discoveryRunning = false + m.discoveryLoaded = true + m.discoveryResults = msg.results + m.diagnosticModels = reconcileModels(m.cfg, msg.results) + if m.diagnosticCursor >= len(m.diagnosticModels) { + m.diagnosticCursor = 0 + } + return m, nil case tickMsg: newAllModels := m.buildStatsModelList() if m.modelCursor >= len(newAllModels) { @@ -281,6 +344,162 @@ func (m tuiModel) buildStatsModelList() []string { return result } +func (m tuiModel) availableTabs() []int { + tabs := []int{tabStats, tabModels} + if m.cfg.UI.TestEnabled() { + tabs = append(tabs, tabTest) + } + return tabs +} + +func (m tuiModel) nextTab(delta int) int { + tabs := m.availableTabs() + current := 0 + for i, tab := range tabs { + if tab == m.activeTab { + current = i + break + } + } + return tabs[(current+delta+len(tabs))%len(tabs)] +} + +func (m *tuiModel) startDiscoveryIfNeeded() tea.Cmd { + if m.activeTab != tabModels || m.discoveryRunning || m.discoveryLoaded || m.discoverer == nil { + return nil + } + m.discoveryRunning = true + return func() tea.Msg { + parent := m.discoveryCtx + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, 30*time.Second) + defer cancel() + return discoveryResultMsg{results: m.discoverer.DiscoverModels(ctx)} + } +} + +func (m *tuiModel) moveDiagnosticCursor(delta int) { + if len(m.diagnosticModels) == 0 || m.diagnosticDetail { + return + } + m.diagnosticCursor = (m.diagnosticCursor + delta + len(m.diagnosticModels)) % len(m.diagnosticModels) +} + +func (m tuiModel) diagnosticWindow() (int, int) { + count := len(m.diagnosticModels) + if count == 0 || m.height <= 0 { + return 0, count + } + pageSize := m.height - 18 - len(m.discoveryResults) + if pageSize < 3 { + pageSize = 3 + } + if pageSize >= count { + return 0, count + } + start := m.diagnosticCursor - pageSize/2 + if start < 0 { + start = 0 + } + if start+pageSize > count { + start = count - pageSize + } + return start, start + pageSize +} + +func reconcileModels(cfg *config.Config, results []proxy.ModelDiscovery) []diagnosticModel { + byBackend := make(map[string]proxy.ModelDiscovery, len(results)) + for _, result := range results { + byBackend[result.Backend] = result + } + + var rows []diagnosticModel + for _, backend := range cfg.Backends { + result, queried := byBackend[backend.Name] + discovered := make(map[string]proxy.DiscoveredModel, len(result.Models)) + matched := make(map[string]bool) + for _, model := range result.Models { + discovered[model.ID] = model + } + + for _, configured := range backend.Models.Models { + upstreamID := configured.UpstreamID + if upstreamID == "" { + upstreamID = configured.ID + } + row := diagnosticModel{ + backend: backend.Name, localID: configured.ID, upstreamID: upstreamID, + status: "MISSING", rowStyle: "red", + details: configuredModelDetails(backend, configured), + } + if found, ok := discovered[upstreamID]; ok { + row.status, row.rowStyle = "MATCH", "green" + row.details = combinedModelDetails(backend, configured, found) + matched[upstreamID] = true + } else if !queried || result.Err != nil { + row.status = "UNKNOWN" + row.rowStyle = "yellow" + } + rows = append(rows, row) + } + for upstreamID := range matched { + delete(discovered, upstreamID) + } + + for _, found := range discovered { + status, rowStyle := "UNCONFIGURED", "cyan" + if backend.Models.All { + status, rowStyle = "ALLOWED", "green" + } + rows = append(rows, diagnosticModel{ + backend: backend.Name, upstreamID: found.ID, + status: status, rowStyle: rowStyle, + details: responseModelDetails(backend.Name, result.URL, found), + }) + } + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].backend != rows[j].backend { + return rows[i].backend < rows[j].backend + } + return rows[i].upstreamID < rows[j].upstreamID + }) + return rows +} + +func configuredModelDetails(backend config.BackendConfig, model config.Model) string { + upstreamID := model.UpstreamID + if upstreamID == "" { + upstreamID = model.ID + } + return modelConfigDetails(backend.Name, model.ID, upstreamID, model.Cost) + + "\n\nNo matching model was returned by the upstream /models endpoint." +} + +func combinedModelDetails(backend config.BackendConfig, configured config.Model, found proxy.DiscoveredModel) string { + return modelConfigDetails(backend.Name, configured.ID, found.ID, configured.Cost) + + "\n\n/models response:\n" + prettyJSON(found.Details) +} + +func modelConfigDetails(backend, localID, upstreamID string, cost config.ModelCost) string { + return fmt.Sprintf("Backend: %s\nLocal ID: %s\nUpstream ID: %s\nConfigured cost per million tokens: input %.6f, output %.6f, cache %.6f", + backend, localID, upstreamID, cost.InputPerMillion, cost.OutputPerMillion, cost.CachePerMillion) +} + +func responseModelDetails(backend, endpoint string, found proxy.DiscoveredModel) string { + return fmt.Sprintf("Backend: %s\nEndpoint: %s\nUpstream ID: %s\n\n/models response:\n%s", backend, endpoint, found.ID, prettyJSON(found.Details)) +} + +func prettyJSON(raw json.RawMessage) string { + var formatted bytes.Buffer + if err := json.Indent(&formatted, raw, "", " "); err != nil { + return string(raw) + } + return formatted.String() +} + func (m tuiModel) buildModelList() []string { modelNames := make(map[string]struct{}) for _, model := range m.cfg.AllModels() { @@ -352,30 +571,35 @@ func (m tuiModel) View() string { ) } - if m.cfg.UI.TestEnabled() { - header.WriteString(m.renderTabBar()) - header.WriteByte('\n') - header.WriteByte('\n') - } + header.WriteString(m.renderTabBar()) + header.WriteByte('\n') + header.WriteByte('\n') // --- Main content --- var content string switch m.activeTab { case tabStats: content = m.viewStats(tableWidth) + case tabModels: + content = m.viewModels(tableWidth) case tabTest: content = m.viewTest(tableWidth) } // --- Footer --- var footer strings.Builder - if m.cfg.UI.TestEnabled() { - footer.WriteString(muted.Render("←/→: switch view • ")) - } + footer.WriteString(muted.Render("←/→: switch view • ")) footer.WriteString(muted.Render("↑/↓: select • ")) if m.activeTab == tabTest { footer.WriteString(muted.Render("Enter: test • ")) } + if m.activeTab == tabModels { + if m.diagnosticDetail { + footer.WriteString(muted.Render("Esc: back • ")) + } else { + footer.WriteString(muted.Render("Enter: details • R: refresh • ")) + } + } footer.WriteString(muted.Render("Ctrl-C or q to stop")) var b strings.Builder @@ -400,13 +624,15 @@ func (m tuiModel) renderTabBar() string { activeStyle := style(m.color, "tab-active") inactiveStyle := style(m.color, "tab-inactive") - tabs := []string{"Stats", "Test"} + tabNames := map[int]string{tabStats: "Stats", tabModels: "Models", tabTest: "Test"} + tabs := m.availableTabs() parts := make([]string, len(tabs)) - for i, name := range tabs { - hovered := m.hover.kind == hoverTab && m.hover.tab == i - if i == m.activeTab && hovered { + for i, tab := range tabs { + name := tabNames[tab] + hovered := m.hover.kind == hoverTab && m.hover.tab == tab + if tab == m.activeTab && hovered { parts[i] = style(m.color, "tab-active-hover").Render("[" + name + "]") - } else if i == m.activeTab { + } else if tab == m.activeTab { parts[i] = activeStyle.Render("[" + name + "]") } else if hovered { parts[i] = style(m.color, "tab-hover").Render("[" + name + "]") @@ -417,6 +643,99 @@ func (m tuiModel) renderTabBar() string { return strings.Join(parts, " ") } +func (m tuiModel) viewModels(tableWidth int) string { + var b strings.Builder + if m.discoveryRunning { + b.WriteString(style(m.color, "orange").Render("⏳ Querying upstream /models endpoints...")) + b.WriteByte('\n') + if !m.discoveryLoaded { + return b.String() + } + } + if !m.discoveryLoaded && !m.discoveryRunning { + b.WriteString(style(m.color, "muted").Render("Open this tab or press R to query upstream /models endpoints.")) + b.WriteByte('\n') + return b.String() + } + if m.discoveryLoaded { + for _, result := range m.discoveryResults { + if result.Err != nil { + fmt.Fprintf(&b, "%s %s\n", style(m.color, "red").Render(result.Backend+":"), style(m.color, "red").Render(result.Err.Error())) + } else { + fmt.Fprintf(&b, "%s %s\n", style(m.color, "green").Render(result.Backend+":"), style(m.color, "muted").Render(fmt.Sprintf("HTTP %d, %d models from %s", result.StatusCode, len(result.Models), result.URL))) + } + } + b.WriteByte('\n') + } + + if m.diagnosticDetail && len(m.diagnosticModels) > 0 { + selected := m.diagnosticModels[m.diagnosticCursor] + b.WriteString(sectionTitle(m.color, "Model Details — "+selected.upstreamID)) + b.WriteByte('\n') + b.WriteString(wrapText(selected.details, tableWidth)) + b.WriteByte('\n') + return b.String() + } + if m.discoveryLoaded && len(m.diagnosticModels) == 0 { + b.WriteString(style(m.color, "muted").Render("No configured or discovered models.")) + b.WriteByte('\n') + return b.String() + } + + start, end := m.diagnosticWindow() + rows := make([][]string, 0, end-start) + for i := start; i < end; i++ { + model := m.diagnosticModels[i] + indicator := " " + if i == m.diagnosticCursor { + indicator = withStyle("selected", ">") + } + styleName := model.rowStyle + if i == m.diagnosticCursor { + styleName = "selected" + } + localID := model.localID + if localID == "" { + localID = "-" + } + row := []string{ + indicator, + withStyle(styleName, model.backend), + withStyle(styleName, localID), + withStyle(styleName, model.upstreamID), + } + if !m.color { + row = append(row, model.status) + } + rows = append(rows, row) + } + columns := []column{ + {name: " ", width: 1}, + {name: "Backend", width: 18}, + {name: "Local ID", width: 28, flex: true}, + {name: "Upstream ID", width: 28, flex: true}, + } + if !m.color { + columns = append(columns, column{name: "Status", width: 12}) + } + b.WriteString(renderTable(m.color, tableWidth, columns, rows)) + if m.color { + legend := []string{ + style(true, "green").Render("config + response"), + style(true, "red").Render("config only"), + style(true, "cyan").Render("response only"), + style(true, "yellow").Render("discovery unavailable"), + } + b.WriteString(strings.Join(legend, " ")) + b.WriteByte('\n') + } + if start > 0 || end < len(m.diagnosticModels) { + b.WriteString(style(m.color, "muted").Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.diagnosticModels)))) + b.WriteByte('\n') + } + return b.String() +} + func (m tuiModel) viewStats(tableWidth int) string { s := m.metrics.Snapshot() var b strings.Builder @@ -519,21 +838,38 @@ func (m tuiModel) hitTarget(x, y int) hoverTarget { return hoverTarget{} } line := lines[y] - if m.cfg.UI.TestEnabled() { - if statsX := strings.Index(line, "[Stats]"); statsX >= 0 { - if x >= statsX && x < statsX+len("[Stats]") { - return hoverTarget{kind: hoverTab, tab: tabStats} - } + for tab, label := range map[int]string{tabStats: "[Stats]", tabModels: "[Models]", tabTest: "[Test]"} { + if tab == tabTest && !m.cfg.UI.TestEnabled() { + continue } - if testX := strings.Index(line, "[Test]"); testX >= 0 { - if x >= testX && x < testX+len("[Test]") { - return hoverTarget{kind: hoverTab, tab: tabTest} + if tabX := strings.Index(line, label); tabX >= 0 { + if x >= tabX && x < tabX+len(label) { + return hoverTarget{kind: hoverTab, tab: tab} } } } + if m.activeTab == tabModels && !m.diagnosticDetail { + return m.diagnosticHitTarget(x, y, lines) + } return m.modelHitTarget(x, y, lines) } +func (m tuiModel) diagnosticHitTarget(x, y int, lines []string) hoverTarget { + headerY := -1 + for i, line := range lines { + if strings.Contains(line, "Backend") && strings.Contains(line, "Upstream ID") { + headerY = i + break + } + } + row := y - headerY - 2 + start, end := m.diagnosticWindow() + if headerY >= 0 && row >= 0 && start+row < end && x < len(lines[y]) { + return hoverTarget{kind: hoverDiagnostic, index: start + row} + } + return hoverTarget{} +} + func (m tuiModel) hitTestLines() []string { plain := m plain.color = false @@ -632,16 +968,23 @@ func wrapText(text string, width int) string { if width <= 0 { width = 80 } - if len(text) <= width { - return text - } var b strings.Builder - for len(text) > width { - b.WriteString(text[:width]) - b.WriteByte('\n') - text = text[width:] + lines := strings.Split(text, "\n") + for lineIndex, line := range lines { + for len(line) > width { + cut := strings.LastIndex(line[:width+1], " ") + if cut <= 0 { + cut = width + } + b.WriteString(line[:cut]) + b.WriteByte('\n') + line = strings.TrimLeft(line[cut:], " ") + } + b.WriteString(line) + if lineIndex < len(lines)-1 { + b.WriteByte('\n') + } } - b.WriteString(text) return b.String() } @@ -757,12 +1100,12 @@ func fitColumns(columns []column, targetWidth int) []column { for width > targetWidth { flexIndex := -1 for i, col := range columns { - if col.flex { + if col.flex && col.width > 12 { flexIndex = i break } } - if flexIndex == -1 || columns[flexIndex].width <= 12 { + if flexIndex == -1 { break } columns[flexIndex].width-- @@ -1038,6 +1381,10 @@ func style(color bool, name string) lipgloss.Style { return base.Bold(true).Foreground(lipgloss.Color("42")) case "red": return base.Foreground(lipgloss.Color("203")) + case "cyan": + return base.Foreground(lipgloss.Color("81")) + case "yellow": + return base.Foreground(lipgloss.Color("220")) case "bold": return base.Bold(true) case "title": diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 5013811..2387b12 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "testing" @@ -14,7 +15,7 @@ func TestMouseClickSelectsTabAndModel(t *testing.T) { lines := m.hitTestLines() updated, _ := m.Update(tea.MouseMsg{ - X: len("[Stats] "), + X: strings.Index(lines[findLine(t, lines, "[Stats]")], "[Test]") + 1, Y: screenY(findLine(t, lines, "[Stats]")), Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, @@ -45,7 +46,7 @@ func TestMouseClickTestTabDoesNotStartSelectedModelTest(t *testing.T) { lines := m.hitTestLines() updated, cmd := m.Update(tea.MouseMsg{ - X: len("[Stats] "), + X: strings.Index(lines[findLine(t, lines, "[Stats]")], "[Test]") + 1, Y: screenY(findLine(t, lines, "[Stats]")), Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, @@ -62,6 +63,81 @@ func TestMouseClickTestTabDoesNotStartSelectedModelTest(t *testing.T) { } } +func TestReconcileModelsCombinesConfigAndDiscovery(t *testing.T) { + cfg := &config.Config{Backends: []config.BackendConfig{{ + Name: "backend", Models: config.BackendModels{Models: []config.Model{ + {ID: "local-valid", UpstreamID: "upstream-valid"}, + {ID: "local-missing"}, + }}, + }}} + results := []proxy.ModelDiscovery{{Backend: "backend", StatusCode: 200, Models: []proxy.DiscoveredModel{ + {ID: "upstream-valid", Details: []byte(`{"id":"upstream-valid","owned_by":"test"}`)}, + {ID: "unconfigured", Details: []byte(`{"id":"unconfigured"}`)}, + }}} + + rows := reconcileModels(cfg, results) + if len(rows) != 3 { + t.Fatalf("expected three combined rows, got %#v", rows) + } + statuses := map[string]string{} + for _, row := range rows { + statuses[row.upstreamID] = row.status + } + if statuses["upstream-valid"] != "MATCH" || statuses["local-missing"] != "MISSING" || statuses["unconfigured"] != "UNCONFIGURED" { + t.Fatalf("unexpected reconciliation statuses: %#v", statuses) + } +} + +func TestReconcileModelsUsesUnknownWhenDiscoveryFails(t *testing.T) { + cfg := &config.Config{Backends: []config.BackendConfig{{ + Name: "backend", Models: config.BackendModels{Models: []config.Model{{ID: "model"}}}, + }}} + rows := reconcileModels(cfg, []proxy.ModelDiscovery{{Backend: "backend", Err: fmt.Errorf("unavailable")}}) + if len(rows) != 1 || rows[0].status != "UNKNOWN" || rows[0].rowStyle != "yellow" { + t.Fatalf("failed discovery must not report a model missing: %#v", rows) + } +} + +func TestModelsTabOpensDetailsAndReturns(t *testing.T) { + m := testTUIModel() + m.activeTab = tabModels + m.discoveryLoaded = true + m.diagnosticModels = []diagnosticModel{{upstreamID: "alpha", details: `{"id":"alpha"}`}} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(tuiModel) + if !m.diagnosticDetail || !strings.Contains(m.View(), "Model Details") { + t.Fatal("expected model details page") + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(tuiModel) + if m.diagnosticDetail { + t.Fatal("expected escape to return to model list") + } +} + +func TestModelsDiagnosticUsesColorOrStatusColumn(t *testing.T) { + m := testTUIModel() + m.activeTab = tabModels + m.discoveryLoaded = true + m.diagnosticModels = []diagnosticModel{{backend: "backend", localID: "model", upstreamID: "model", status: "MATCH", rowStyle: "green"}} + + m.color = true + colored := stripANSIEscapes(m.viewModels(m.tableWidth())) + if strings.Contains(colored, "Status") || strings.Contains(colored, "Green:") { + t.Fatalf("colored view should use color without color-name labels:\n%s", colored) + } + if !strings.Contains(colored, "config + response") { + t.Fatalf("colored view should retain a descriptive legend:\n%s", colored) + } + + m.color = false + plain := m.viewModels(m.tableWidth()) + if !strings.Contains(plain, "Status") || !strings.Contains(plain, "MATCH") { + t.Fatalf("plain view should add a textual status column:\n%s", plain) + } +} + func TestMouseClickTestModelRowStartsClickedModelTest(t *testing.T) { m := testTUIModel() m.activeTab = tabTest diff --git a/main.go b/main.go index d4d653b..f5e8559 100644 --- a/main.go +++ b/main.go @@ -111,7 +111,7 @@ func run() error { done <- server.ListenAndServe() }() - renderer := ui.Start(ctx, cancel, cfg, metrics, os.Stdout, os.Stderr, headless) + renderer := ui.Start(ctx, cancel, cfg, metrics, handler, os.Stdout, os.Stderr, headless) defer renderer.Stop() select {