From 883cdd89447df6f9b2d69e1a2f45a3cbf44b3a1f Mon Sep 17 00:00:00 2001 From: Sergey Rubanov Date: Tue, 5 May 2026 16:51:13 +0200 Subject: [PATCH 1/8] feat(providers): discover local runtimes during onboarding Add a local-provider discovery endpoint that checks known local runtime commands and probes unique default HTTP endpoints for Ollama, LM Studio, llama.cpp, and LocalAI. The response reports installed/running/not-detected states with command paths, probe URLs, model counts, and errors so the UI can guide first-run setup without guessing. Extract the shared Add Provider modal and use it from both Providers and Chats. The modal now opens on the Local tab, shows discovery badges, blocks duplicate endpoint submissions, prompts for custom names on provider-id collisions, and uses the same Hecate modal pattern for provider deletion instead of native browser confirm dialogs. Improve Chats empty-state behavior for model routing. When no providers are configured, Chats shows detected local runtimes and can add all addable local providers with one click. When providers are already configured but no models are routable, Chats now shows troubleshooting details instead of repeating setup prompts. Make provider deletion optimistic with rollback on failure, keep provider status refreshes from blocking empty provider states, and update docs for local discovery, duplicate endpoint rules, and the new discovery API. Tests cover the discovery API, Add Provider modal validation, optimistic delete/rollback, Chats detected-provider onboarding, configured-provider troubleshooting, Hecate delete confirmation, duplicate endpoint blocking, and the full provider/chat e2e flows. Verified with: - cd ui && bun run typecheck - cd ui && bun run test - cd ui && bun run test:e2e - GOCACHE=/Users/chicoxyzzy/dev/hecate/.gocache go test ./internal/api --- docs/providers.md | 20 + docs/runtime-api.md | 42 ++ .../api/handler_local_provider_discovery.go | 197 +++++++ .../handler_local_provider_discovery_test.go | 111 ++++ internal/api/openai.go | 20 + internal/api/server.go | 1 + ui/e2e/chat.spec.ts | 74 +++ ui/e2e/fixtures.ts | 39 +- ui/e2e/provider-lifecycle.spec.ts | 2 + ui/e2e/providers.spec.ts | 69 ++- ui/src/app/AppShell.tsx | 2 +- ui/src/app/useRuntimeConsole.test.tsx | 108 ++++ ui/src/app/useRuntimeConsole.ts | 27 +- ui/src/features/chats/ChatView.test.tsx | 126 +++++ ui/src/features/chats/ChatView.tsx | 374 +++++++++++++- .../features/providers/AddProviderModal.tsx | 479 ++++++++++++++++++ .../features/providers/ProvidersView.test.tsx | 190 ++++++- ui/src/features/providers/ProvidersView.tsx | 387 ++------------ ui/src/lib/api.test.ts | 12 + ui/src/lib/api.ts | 5 + ui/src/lib/provider-utils.test.ts | 34 +- ui/src/lib/provider-utils.ts | 24 - ui/src/types/runtime.ts | 20 + 23 files changed, 1892 insertions(+), 471 deletions(-) create mode 100644 internal/api/handler_local_provider_discovery.go create mode 100644 internal/api/handler_local_provider_discovery_test.go create mode 100644 ui/src/features/providers/AddProviderModal.tsx diff --git a/docs/providers.md b/docs/providers.md index 1374d8563..a4f89d690 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -39,6 +39,23 @@ Click **Add provider** to open the modal: - **API Key** is shown for cloud and custom-cloud providers; stored encrypted at rest with `GATEWAY_CONTROL_PLANE_SECRET_KEY`. 4. Click **Add provider**. +The Local tab also runs a lightweight discovery check before you choose a +preset. Hecate checks whether the expected command is on `PATH` (`ollama`, +`lms`, `llama-server`, `local-ai` / `localai`) and probes each unique local +HTTP endpoint once. The preset cards then show: + +- **Running** — the local HTTP API responded; model count is shown when the + provider returned one. +- **Installed** — the command is available, but the HTTP server is not running + yet. +- **Not detected** — no command on `PATH` and no response from the default + endpoint. + +`llamacpp` and `localai` share `127.0.0.1:8080` by default, so Hecate sends one +HTTP request and reuses that result for both cards. The signal is advisory: +adding the provider still uses the configured endpoint URL, and routing health +continues to come from the normal `/admin/providers` probes. + A provider you add is immediately routable. There is no separate enable/disable toggle — to take a provider out of rotation, delete it. ![Providers table populated with three providers — Health, Endpoint, Credentials, Models columns](screenshots/providers.png) @@ -114,6 +131,9 @@ normalized assistant text, model, and token usage are. Every UI action maps to a control-plane endpoint: - `POST /admin/control-plane/providers` — add a provider. Body `{name, kind, protocol, base_url?, api_key?, custom_name?, preset_id?}`. +- `GET /admin/control-plane/providers/local-discovery` — probe local presets + for command presence and default endpoint availability. Used by the Add + provider modal before a provider is created. - `DELETE /admin/control-plane/providers/{id}` — remove it. - `PATCH /admin/control-plane/providers/{id}` — partial update; accepts `base_url`, `name`, and `custom_name`. - `PUT /admin/control-plane/providers/{id}/api-key` — set the API key (empty `key` clears it). diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 26c7130c7..83811c02c 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -349,6 +349,48 @@ GET /v1/provider-presets The list is built from `config.BuiltInProviders()` — see [`docs/providers.md`](providers.md) for the full catalog and OpenAI-compatible custom-endpoint flow. +### `GET /admin/control-plane/providers/local-discovery` + +Advisory discovery for the Providers tab's **Add provider → Local** catalog. +The gateway checks whether the expected provider command is on `PATH` and +probes each unique default local endpoint once. Shared endpoints, such as the +`llama.cpp` / `LocalAI` default `127.0.0.1:8080/v1`, are only called once and +then reused for every matching preset card. + +```json +GET /admin/control-plane/providers/local-discovery +→ 200 +{ + "object": "local_provider_discovery", + "data": [ + { + "preset_id": "ollama", + "name": "Ollama", + "base_url": "http://127.0.0.1:11434/v1", + "probe_url": "http://127.0.0.1:11434/api/tags", + "status": "running", + "command": "ollama", + "command_available": true, + "command_path": "/opt/homebrew/bin/ollama", + "http_available": true, + "model_count": 2, + "models": ["llama3.1:8b", "qwen2.5:7b"] + } + ] +} +``` + +`status` is one of: + +- `running` — the HTTP probe returned 2xx. +- `installed` — the command is present on `PATH`, but the default HTTP + endpoint did not respond. +- `not_detected` — neither the command nor the default HTTP endpoint was found. + +This endpoint does not create or mutate provider records. It is a UX helper for +the picker; routing readiness still comes from `GET /admin/providers` after the +operator adds a provider. + ### `GET /v1/agent-adapters` External coding-agent adapter catalog. This is the first discovery surface for diff --git a/internal/api/handler_local_provider_discovery.go b/internal/api/handler_local_provider_discovery.go new file mode 100644 index 000000000..d0c7cc753 --- /dev/null +++ b/internal/api/handler_local_provider_discovery.go @@ -0,0 +1,197 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os/exec" + "strings" + "time" + + "github.com/hecate/agent-runtime/internal/config" +) + +const localProviderDiscoveryTimeout = 700 * time.Millisecond + +type localProviderLookPath func(string) (string, error) + +type localProviderHTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type localHTTPProbeResult struct { + available bool + models []string + err string +} + +func (h *Handler) HandleLocalProviderDiscovery(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), localProviderDiscoveryTimeout) + defer cancel() + + items := discoverLocalProviders(ctx, config.BuiltInProviders(), exec.LookPath, http.DefaultClient) + WriteJSON(w, http.StatusOK, LocalProviderDiscoveryResponse{ + Object: "local_provider_discovery", + Data: items, + }) +} + +func discoverLocalProviders(ctx context.Context, providers []config.BuiltInProvider, lookPath localProviderLookPath, client localProviderHTTPDoer) []LocalProviderDiscoveryResponseItem { + httpResults := make(map[string]localHTTPProbeResult) + out := make([]LocalProviderDiscoveryResponseItem, 0, len(providers)) + + for _, provider := range providers { + if provider.Kind != "local" { + continue + } + + command, commandPath := findLocalProviderCommand(provider.ID, lookPath) + probeURL := localProviderProbeURL(provider) + result, ok := httpResults[probeURL] + if !ok { + result = probeLocalProviderHTTP(ctx, client, probeURL, provider.ID) + httpResults[probeURL] = result + } + + status := "not_detected" + if commandPath != "" { + status = "installed" + } + if result.available { + status = "running" + } else if result.err != "" && commandPath != "" { + status = "installed" + } + + out = append(out, LocalProviderDiscoveryResponseItem{ + PresetID: provider.ID, + Name: provider.Name, + BaseURL: provider.BaseURL, + ProbeURL: probeURL, + Status: status, + Command: command, + CommandAvailable: commandPath != "", + CommandPath: commandPath, + HTTPAvailable: result.available, + ModelCount: len(result.models), + Models: result.models, + Error: result.err, + }) + } + + return out +} + +func findLocalProviderCommand(providerID string, lookPath localProviderLookPath) (string, string) { + for _, command := range localProviderCommandCandidates(providerID) { + path, err := lookPath(command) + if err == nil && strings.TrimSpace(path) != "" { + return command, path + } + } + candidates := localProviderCommandCandidates(providerID) + if len(candidates) == 0 { + return "", "" + } + return candidates[0], "" +} + +func localProviderCommandCandidates(providerID string) []string { + switch providerID { + case "ollama": + return []string{"ollama"} + case "lmstudio": + return []string{"lms"} + case "llamacpp": + return []string{"llama-server", "llama-server.exe"} + case "localai": + return []string{"local-ai", "localai"} + default: + return nil + } +} + +func localProviderProbeURL(provider config.BuiltInProvider) string { + if provider.ID == "ollama" { + if parsed, err := url.Parse(provider.BaseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.Path = "/api/tags" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() + } + } + + base := strings.TrimRight(provider.BaseURL, "/") + if strings.HasSuffix(base, "/models") { + return base + } + return base + "/models" +} + +func probeLocalProviderHTTP(ctx context.Context, client localProviderHTTPDoer, probeURL, providerID string) localHTTPProbeResult { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) + if err != nil { + return localHTTPProbeResult{err: err.Error()} + } + resp, err := client.Do(req) + if err != nil { + return localHTTPProbeResult{err: compactLocalProbeError(err)} + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return localHTTPProbeResult{err: fmt.Sprintf("HTTP %d", resp.StatusCode)} + } + + models := decodeLocalProviderModels(resp, providerID) + return localHTTPProbeResult{available: true, models: models} +} + +func decodeLocalProviderModels(resp *http.Response, providerID string) []string { + if providerID == "ollama" { + var payload struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil + } + models := make([]string, 0, len(payload.Models)) + for _, model := range payload.Models { + if strings.TrimSpace(model.Name) != "" { + models = append(models, model.Name) + } + } + return models + } + + var payload struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil + } + models := make([]string, 0, len(payload.Data)) + for _, model := range payload.Data { + if strings.TrimSpace(model.ID) != "" { + models = append(models, model.ID) + } + } + return models +} + +func compactLocalProbeError(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "request timed out" + } + if strings.Contains(err.Error(), "connection refused") { + return "connection refused" + } + return err.Error() +} diff --git a/internal/api/handler_local_provider_discovery_test.go b/internal/api/handler_local_provider_discovery_test.go new file mode 100644 index 000000000..df408ac5c --- /dev/null +++ b/internal/api/handler_local_provider_discovery_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/hecate/agent-runtime/internal/config" +) + +type localProviderRoundTrip struct { + calls map[string]int + body map[string]string + err map[string]error +} + +func (rt *localProviderRoundTrip) Do(req *http.Request) (*http.Response, error) { + if rt.calls == nil { + rt.calls = make(map[string]int) + } + rt.calls[req.URL.String()]++ + if err := rt.err[req.URL.String()]; err != nil { + return nil, err + } + body := rt.body[req.URL.String()] + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +func TestDiscoverLocalProvidersDedupesSharedHTTPProbe(t *testing.T) { + t.Parallel() + + providers := []config.BuiltInProvider{ + {ID: "llamacpp", Name: "llama.cpp", Kind: "local", BaseURL: "http://127.0.0.1:8080/v1"}, + {ID: "localai", Name: "LocalAI", Kind: "local", BaseURL: "http://127.0.0.1:8080/v1"}, + } + rt := &localProviderRoundTrip{ + body: map[string]string{ + "http://127.0.0.1:8080/v1/models": `{"data":[{"id":"local-model"}]}`, + }, + } + + items := discoverLocalProviders(context.Background(), providers, missingLocalCommand, rt) + + if len(items) != 2 { + t.Fatalf("items = %d, want 2", len(items)) + } + if got := rt.calls["http://127.0.0.1:8080/v1/models"]; got != 1 { + t.Fatalf("shared endpoint request count = %d, want 1", got) + } + for _, item := range items { + if item.Status != "running" || !item.HTTPAvailable || item.ModelCount != 1 { + t.Fatalf("item = %+v, want running with one model", item) + } + } +} + +func TestDiscoverLocalProvidersChecksCommandPresence(t *testing.T) { + t.Parallel() + + providers := []config.BuiltInProvider{ + {ID: "ollama", Name: "Ollama", Kind: "local", BaseURL: "http://127.0.0.1:11434/v1"}, + } + rt := &localProviderRoundTrip{ + err: map[string]error{ + "http://127.0.0.1:11434/api/tags": errors.New("connection refused"), + }, + } + + items := discoverLocalProviders(context.Background(), providers, func(command string) (string, error) { + if command == "ollama" { + return "/usr/local/bin/ollama", nil + } + return "", errors.New("missing") + }, rt) + + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + item := items[0] + if item.Status != "installed" { + t.Fatalf("status = %q, want installed", item.Status) + } + if !item.CommandAvailable || item.CommandPath != "/usr/local/bin/ollama" { + t.Fatalf("command detection = available %v path %q", item.CommandAvailable, item.CommandPath) + } + if item.HTTPAvailable { + t.Fatal("HTTPAvailable = true, want false") + } +} + +func TestLocalProviderProbeURLUsesOllamaNativeTagsEndpoint(t *testing.T) { + t.Parallel() + + got := localProviderProbeURL(config.BuiltInProvider{ + ID: "ollama", + BaseURL: "http://127.0.0.1:11434/v1", + }) + if got != "http://127.0.0.1:11434/api/tags" { + t.Fatalf("probe URL = %q, want Ollama native tags endpoint", got) + } +} + +func missingLocalCommand(string) (string, error) { + return "", errors.New("missing") +} diff --git a/internal/api/openai.go b/internal/api/openai.go index 77942b3ad..59e4bb591 100644 --- a/internal/api/openai.go +++ b/internal/api/openai.go @@ -538,6 +538,26 @@ type ProviderPresetResponseItem struct { EnvSnippet string `json:"env_snippet,omitempty"` } +type LocalProviderDiscoveryResponse struct { + Object string `json:"object"` + Data []LocalProviderDiscoveryResponseItem `json:"data"` +} + +type LocalProviderDiscoveryResponseItem struct { + PresetID string `json:"preset_id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + ProbeURL string `json:"probe_url"` + Status string `json:"status"` + Command string `json:"command,omitempty"` + CommandAvailable bool `json:"command_available"` + CommandPath string `json:"command_path,omitempty"` + HTTPAvailable bool `json:"http_available"` + ModelCount int `json:"model_count,omitempty"` + Models []string `json:"models,omitempty"` + Error string `json:"error,omitempty"` +} + type AgentAdapterResponseItem struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/api/server.go b/internal/api/server.go index f9961c173..38a7105d6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -77,6 +77,7 @@ func NewServer(logger *slog.Logger, handler *Handler) http.Handler { mux.HandleFunc("POST /admin/budget/limit", handler.HandleBudgetSetLimit) mux.HandleFunc("POST /admin/budget/reset", handler.HandleBudgetReset) mux.HandleFunc("GET /admin/control-plane", handler.HandleControlPlaneStatus) + mux.HandleFunc("GET /admin/control-plane/providers/local-discovery", handler.HandleLocalProviderDiscovery) mux.HandleFunc("POST /admin/control-plane/providers", handler.HandleControlPlaneCreateProvider) mux.HandleFunc("PATCH /admin/control-plane/providers/{id}", handler.HandleControlPlaneUpdateProvider) mux.HandleFunc("DELETE /admin/control-plane/providers/{id}", handler.HandleControlPlaneDeleteProvider) diff --git a/ui/e2e/chat.spec.ts b/ui/e2e/chat.spec.ts index ee983e52c..8e291ef0f 100644 --- a/ui/e2e/chat.spec.ts +++ b/ui/e2e/chat.spec.ts @@ -183,6 +183,80 @@ test("chat error renders inline with the humanized message", async ({ page }) => await expect(page.getByText(/has no API key/i).first()).toBeVisible(); }); +test("empty model chat can add all detected local providers in one click", async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); + await mockGatewayAPIs(page); + const created: Array> = []; + await page.route("/admin/control-plane/providers", async route => { + if (route.request().method() === "POST") { + created.push(JSON.parse(route.request().postData() ?? "{}")); + } + await route.fallback(); + }); + + await page.goto("/"); + await page.waitForSelector(".hecate-activitybar"); + await switchToModel(page); + + await expect(page.getByText("Detected locally")).toBeVisible(); + await expect(page.getByText("Ollama")).toBeVisible(); + await expect(page.getByText("LM Studio")).toBeVisible(); + await expect(page.getByText("Installed")).toBeVisible(); + await expect(page.getByText("Running")).toBeVisible(); + + await page.getByRole("button", { name: "Add detected providers" }).click(); + + await expect.poll(() => created.map(body => body.preset_id).sort()).toEqual(["lmstudio", "ollama"]); + await expect(page.getByText("Provider is configured")).toBeVisible(); + await expect(page.getByText("none discovered")).toBeVisible(); + await expect(page.getByRole("button", { name: /Add detected provider/i })).toHaveCount(0); +}); + +test("configured provider with no models shows troubleshooting, not detected-provider setup", async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); + await mockGatewayAPIs(page, { + adminConfig: { + providers: [ + { id: "ollama", name: "Ollama", preset_id: "ollama", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:11434/v1", enabled: true, credential_configured: false }, + ], + tenants: [], + api_keys: [], + policy_rules: [], + }, + }); + await page.route("/v1/models*", route => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ object: "list", data: [] }), + })); + await page.route("/admin/providers*", route => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + object: "provider_status", + data: [{ + name: "ollama", + kind: "local", + healthy: true, + status: "healthy", + base_url: "http://127.0.0.1:11434/v1", + models: [], + model_count: 0, + }], + }), + })); + + await page.goto("/"); + await page.waitForSelector(".hecate-activitybar"); + await switchToModel(page); + + await expect(page.getByText("Provider is configured")).toBeVisible(); + await expect(page.getByText("none discovered")).toBeVisible(); + await expect(page.getByText(/Start the local provider app/)).toBeVisible(); + await expect(page.getByText("Detected locally")).toHaveCount(0); + await expect(page.getByRole("button", { name: /Add detected provider/i })).toHaveCount(0); +}); + // Slice 3 commit 2: approval flow happy path. Seeds an active agent // chat session with one pending approval, then exercises the operator // path: catch-up refetch populates the banner → click Review → diff --git a/ui/e2e/fixtures.ts b/ui/e2e/fixtures.ts index 7777d1511..db8c6c146 100644 --- a/ui/e2e/fixtures.ts +++ b/ui/e2e/fixtures.ts @@ -221,6 +221,40 @@ export async function mockGatewayAPIs(page: Page, opts: GatewayMockOptions = {}) body: JSON.stringify({ object: "configured_state", data: state }) }); }); + await page.route("/admin/control-plane/providers/local-discovery", async route => { + await route.fulfill(ok({ + object: "local_provider_discovery", + data: [ + { + preset_id: "ollama", + name: "Ollama", + base_url: "http://127.0.0.1:11434/v1", + probe_url: "http://127.0.0.1:11434/api/tags", + status: "installed", + command: "ollama", + command_available: true, + command_path: "/usr/local/bin/ollama", + http_available: false, + model_count: 0, + models: [], + }, + { + preset_id: "lmstudio", + name: "LM Studio", + base_url: "http://127.0.0.1:1234/v1", + probe_url: "http://127.0.0.1:1234/v1/models", + status: "running", + command: "lms", + command_available: true, + command_path: "/Users/alice/.lmstudio/bin/lms", + http_available: true, + model_count: 1, + models: ["qwen2.5"], + }, + ], + })); + }); + // POST /admin/control-plane/providers → create. Slugifies the name to id, // appends to the in-memory list, and returns 201. Stateful so the next // GET /admin/control-plane reflects the new row. @@ -232,12 +266,13 @@ export async function mockGatewayAPIs(page: Page, opts: GatewayMockOptions = {}) const body = JSON.parse(route.request().postData() ?? "{}") as { name?: string; preset_id?: string; + custom_name?: string; base_url?: string; api_key?: string; kind?: string; protocol?: string; }; - const id = slugify(body.name ?? ""); + const id = slugify([body.name, body.custom_name].filter(Boolean).join(" ")); if (!id) { await route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: { type: "invalid_request", message: "provider name is required" } }) }); @@ -260,6 +295,8 @@ export async function mockGatewayAPIs(page: Page, opts: GatewayMockOptions = {}) const record = { id, name: body.name ?? id, + custom_name: body.custom_name, + preset_id: body.preset_id, kind: body.kind || "cloud", protocol: body.protocol || "openai", base_url: trimmedURL, diff --git a/ui/e2e/provider-lifecycle.spec.ts b/ui/e2e/provider-lifecycle.spec.ts index 7da267fa4..85dbdfad5 100644 --- a/ui/e2e/provider-lifecycle.spec.ts +++ b/ui/e2e/provider-lifecycle.spec.ts @@ -32,6 +32,8 @@ test("adding and deleting a provider keeps chat available", async ({ page }) => await page.keyboard.press("2"); await page.waitForSelector("text=Providers"); await page.getByTitle("Remove Ollama").click(); + await expect(page.getByRole("dialog", { name: "Remove provider?" })).toBeVisible(); + await page.getByRole("dialog", { name: "Remove provider?" }).getByRole("button", { name: "Remove provider", exact: true }).click(); await expect(page.locator("tbody tr", { hasText: "Ollama" })).toHaveCount(0); // Chats remains available after deleting the only configured provider. diff --git a/ui/e2e/providers.spec.ts b/ui/e2e/providers.spec.ts index aea6aa8b3..58a6e63a9 100644 --- a/ui/e2e/providers.spec.ts +++ b/ui/e2e/providers.spec.ts @@ -24,29 +24,35 @@ function dialog(page: Page) { function pickPreset(page: Page, name: string) { return dialog(page).getByText(name, { exact: true }).click(); } +async function pickCloudPreset(page: Page, name: string) { + await dialog(page).getByRole("button", { name: "Cloud", exact: true }).click(); + await pickPreset(page, name); +} test("empty state shows the placeholder and an Add provider CTA", async ({ page }) => { await expect(page.getByText("No providers configured")).toBeVisible(); await expect(page.getByRole("button", { name: /add provider/i }).first()).toBeVisible(); }); -test("Add provider modal opens on the Cloud tab by default", async ({ page }) => { +test("Add provider modal opens on the Local tab by default", async ({ page }) => { await page.getByRole("button", { name: /add provider/i }).first().click(); - // Anthropic is a Cloud preset — its visibility proves the Cloud tab is + // Ollama is a Local preset — its visibility proves the Local tab is // active without depending on a tab-specific aria attribute. - await expect(dialog(page).getByText("Anthropic", { exact: true })).toBeVisible(); + await expect(dialog(page).getByText("Ollama", { exact: true })).toBeVisible(); + await expect(dialog(page).getByText("Running")).toBeVisible(); }); -test("switching to the Local tab swaps the preset list", async ({ page }) => { +test("switching to the Cloud tab swaps the preset list", async ({ page }) => { await page.getByRole("button", { name: /add provider/i }).first().click(); - await dialog(page).getByRole("button", { name: "Local", exact: true }).click(); - await expect(dialog(page).getByText("Ollama", { exact: true })).toBeVisible(); - // Anthropic is a Cloud preset — should not be visible on the Local tab. - await expect(dialog(page).getByText("Anthropic", { exact: true })).not.toBeVisible(); + await dialog(page).getByRole("button", { name: "Cloud", exact: true }).click(); + await expect(dialog(page).getByText("Anthropic", { exact: true })).toBeVisible(); + // Ollama is a Local preset — should not be visible on the Cloud tab. + await expect(dialog(page).getByText("Ollama", { exact: true })).not.toBeVisible(); }); test("adding an Anthropic preset surfaces the row in the Cloud table", async ({ page }) => { await page.getByRole("button", { name: /add provider/i }).first().click(); + await dialog(page).getByRole("button", { name: "Cloud", exact: true }).click(); await pickPreset(page, "Anthropic"); // Form pre-fills name from the preset. @@ -77,20 +83,8 @@ test("adding a custom local provider surfaces the row with the entered URL", asy await expect(page.locator("tbody tr", { hasText: "My Local" })).toBeVisible(); }); -test("multiple instances of the same preset coexist when names differ", async ({ page }) => { - // Override the create route — the default fixture rejects duplicate - // base_urls (mirrors the real backend's 409), but this test exists to - // pin the slug-uniqueness path: same preset, different display names → - // distinct rows. We skip the URL check and accept all creates so the - // unique-id flow shows through. - // The default fixture rejects duplicate base_urls (mirrors the real - // backend's 409). Layer a fresh stateful mock on top that only checks - // id uniqueness so the same-preset/different-name flow works. Also wire - // /admin/control-plane GET so the list reflects what was just created. +test("duplicate preset prompts for custom name but still blocks duplicate endpoint", async ({ page }) => { const created: Array<{ id: string; name: string; custom_name?: string; kind: string; protocol: string; base_url: string; enabled: boolean; credential_configured: boolean }> = []; - // Mirror the backend slug rule: name + space + custom_name when set, - // otherwise just name. The custom_name is what makes two instances of - // the same preset land at distinct ids. const slug = (name: string, customName?: string) => { const src = customName ? `${name} ${customName}` : name; return src.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); @@ -133,27 +127,28 @@ test("multiple instances of the same preset coexist when names differ", async ({ body: JSON.stringify({ object: "control_plane_provider", data: record }) }); }); - // First instance — preset Name is locked, fill the Custom name field - // (the second text input) to disambiguate. + // First instance — preset Name is locked and no custom name is needed. await page.getByRole("button", { name: /add provider/i }).first().click(); - await pickPreset(page, "Anthropic"); - await dialog(page).locator("input[type='text']").nth(1).fill("Prod"); + await pickCloudPreset(page, "Anthropic"); await dialog(page).locator("input[type='password']").fill("sk-prod"); await dialog(page).getByRole("button", { name: "Add provider", exact: true }).click(); - // Row contains Name "Anthropic" and CustomName "Prod" — both render in - // the Provider cell, so a row-level hasText match against "Prod" finds - // exactly the new instance. - await expect(page.locator("tbody tr", { hasText: "Prod" })).toBeVisible(); + await expect(page.locator("tbody tr", { hasText: "Anthropic" })).toBeVisible(); - // Second instance — same preset, different custom_name. + // Second instance — same preset. Without custom_name the id collision + // tells the operator how to disambiguate the name. await page.getByRole("button", { name: /add provider/i }).first().click(); - await pickPreset(page, "Anthropic"); + await pickCloudPreset(page, "Anthropic"); + await expect(dialog(page).getByText(/Anthropic is already configured/)).toBeVisible(); + + // A different custom name resolves the id collision, but the default + // Anthropic endpoint is still already taken. The modal should keep the + // save button disabled and explain the endpoint collision even though + // preset endpoint fields are hidden. await dialog(page).locator("input[type='text']").nth(1).fill("Dev"); + await expect(dialog(page).getByText(/Endpoint .* already used by/i)).toBeVisible(); await dialog(page).locator("input[type='password']").fill("sk-dev"); - await dialog(page).getByRole("button", { name: "Add provider", exact: true }).click(); - - await expect(page.locator("tbody tr", { hasText: "Prod" })).toBeVisible(); - await expect(page.locator("tbody tr", { hasText: "Dev" })).toBeVisible(); + await expect(dialog(page).getByRole("button", { name: "Add provider", exact: true })).toBeDisabled(); + await expect(page.locator("tbody tr", { hasText: "Dev" })).toHaveCount(0); }); test("conflict response surfaces the inline error inside the modal", async ({ page }) => { @@ -202,6 +197,8 @@ test("deleting a provider removes its row after confirmation", async ({ context // Trash button on the Anthropic row. Title attr is "Remove Anthropic". await populated.getByTitle("Remove Anthropic").click(); + await expect(populated.getByRole("dialog", { name: "Remove provider?" })).toBeVisible(); + await populated.getByRole("dialog", { name: "Remove provider?" }).getByRole("button", { name: "Remove provider", exact: true }).click(); await expect.poll(() => deleteCalled).toBe(true); await expect(populated.locator("tbody tr", { hasText: "Anthropic" })).toHaveCount(0); @@ -270,7 +267,7 @@ test("editing a local endpoint URL PATCHes /providers/{id} with the new base_url test("breadcrumb returns from the form step to the preset picker", async ({ page }) => { await page.getByRole("button", { name: /add provider/i }).first().click(); - await pickPreset(page, "Anthropic"); + await pickCloudPreset(page, "Anthropic"); // Form is showing — Name field is pre-filled. await expect(dialog(page).locator("input[type='text']").first()).toHaveValue("Anthropic"); diff --git a/ui/src/app/AppShell.tsx b/ui/src/app/AppShell.tsx index 05fba8bbf..7ad4b00ee 100644 --- a/ui/src/app/AppShell.tsx +++ b/ui/src/app/AppShell.tsx @@ -173,7 +173,7 @@ function AuthenticatedShell({ {state.error &&
{state.error}
}
{activeWorkspace === "overview" && } - {activeWorkspace === "chats" && } + {activeWorkspace === "chats" && } {activeWorkspace === "runs" && } {activeWorkspace === "providers" && } {activeWorkspace === "costs" && } diff --git a/ui/src/app/useRuntimeConsole.test.tsx b/ui/src/app/useRuntimeConsole.test.tsx index 477a43593..d58455980 100644 --- a/ui/src/app/useRuntimeConsole.test.tsx +++ b/ui/src/app/useRuntimeConsole.test.tsx @@ -344,6 +344,114 @@ describe("useRuntimeConsole", () => { await waitFor(() => expect(result.current.state.notice?.message).toBe("Policy rule deleted.")); expect(JSON.parse(deleteBody)).toEqual({ id: "deny-cloud" }); }); + + it("deleteProvider optimistically removes the provider before the dashboard refresh completes", async () => { + let deleted = false; + let resolveDelete: ((response: Response) => void) | undefined; + let deleteCalls = 0; + fetchMock.mockImplementation(async (input, init) => { + const url = String(input); + if (url === "/admin/control-plane/providers/ollama" && init?.method === "DELETE") { + deleteCalls++; + return new Promise(resolve => { + resolveDelete = response => { + deleted = true; + resolve(response); + }; + }); + } + if (url === "/admin/control-plane") { + return jsonResponse({ + object: "control_plane", + data: { + backend: "memory", + providers: [ + { id: "openai", name: "OpenAI", preset_id: "openai", kind: "cloud", protocol: "openai", base_url: "https://api.openai.com/v1", credential_configured: true }, + ...deleted ? [] : [{ id: "ollama", name: "Ollama", preset_id: "ollama", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:11434/v1", credential_configured: false }], + ], + pricebook: [], policy_rules: [], events: [], + }, + }); + } + if (url === "/admin/providers") { + return jsonResponse({ + object: "provider_status", + data: [ + { name: "openai", kind: "cloud", healthy: true, status: "healthy", models: ["gpt-4o-mini"] }, + ...deleted ? [] : [{ name: "ollama", kind: "local", healthy: true, status: "healthy", models: ["llama3.1:8b"] }], + ], + }); + } + return defaultBackendMock()(input, init); + }); + + const { result } = renderHook(() => useRuntimeConsole()); + await waitFor(() => expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai", "ollama"])); + + let pendingDelete: Promise | undefined; + await act(async () => { + pendingDelete = result.current.actions.deleteProvider("ollama"); + }); + + expect(deleteCalls).toBe(1); + expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai"]); + expect(result.current.state.providers.map(p => p.name)).toEqual(["openai"]); + + resolveDelete?.(new Response(null, { status: 204 })); + await act(async () => { + await pendingDelete; + }); + + await waitFor(() => expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai"])); + expect(result.current.state.notice).toEqual({ kind: "success", message: "Provider removed." }); + }); + + it("deleteProvider rolls back the optimistic removal when the request fails", async () => { + fetchMock.mockImplementation(async (input, init) => { + const url = String(input); + if (url === "/admin/control-plane/providers/ollama" && init?.method === "DELETE") { + return new Response( + JSON.stringify({ error: { message: "provider is still referenced by a policy rule" } }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/admin/control-plane") { + return jsonResponse({ + object: "control_plane", + data: { + backend: "memory", + providers: [ + { id: "openai", name: "OpenAI", preset_id: "openai", kind: "cloud", protocol: "openai", base_url: "https://api.openai.com/v1", credential_configured: true }, + { id: "ollama", name: "Ollama", preset_id: "ollama", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:11434/v1", credential_configured: false }, + ], + pricebook: [], policy_rules: [], events: [], + }, + }); + } + if (url === "/admin/providers") { + return jsonResponse({ + object: "provider_status", + data: [ + { name: "openai", kind: "cloud", healthy: true, status: "healthy", models: ["gpt-4o-mini"] }, + { name: "ollama", kind: "local", healthy: true, status: "healthy", models: ["llama3.1:8b"] }, + ], + }); + } + return defaultBackendMock()(input, init); + }); + + const { result } = renderHook(() => useRuntimeConsole()); + await waitFor(() => expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai", "ollama"])); + + await act(async () => { + await result.current.actions.deleteProvider("ollama"); + }); + + expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai", "ollama"]); + expect(result.current.state.providers.map(p => p.name)).toEqual(["openai", "ollama"]); + expect(result.current.state.notice).toEqual({ kind: "error", message: "Failed to remove provider." }); + expect(result.current.state.controlPlaneError).toContain("provider is still referenced"); + }); }); // ─── chat session actions ────────────────────────────────────────────────── diff --git a/ui/src/app/useRuntimeConsole.ts b/ui/src/app/useRuntimeConsole.ts index f7aa1ecd2..bb2c9af9a 100644 --- a/ui/src/app/useRuntimeConsole.ts +++ b/ui/src/app/useRuntimeConsole.ts @@ -1077,8 +1077,31 @@ export function useRuntimeConsole() { } async function deleteProvider(id: string): Promise { - await deleteProviderRequest(id); - await loadDashboard(); + resetControlPlaneFeedback(); + const previousControlPlaneConfig = controlPlaneConfig; + const previousProviders = providers; + const previousProviderFilter = providerFilter; + + setControlPlaneConfig(current => current + ? { ...current, providers: current.providers.filter(provider => provider.id !== id) } + : current); + setProviders(current => current.filter(provider => provider.name !== id)); + if (providerFilter === id) { + setProviderFilter("auto"); + setModel(defaultModelForProvider("auto", models, providers.filter(provider => provider.name !== id), providerPresets)); + } + + try { + await deleteProviderRequest(id); + setNoticeMessage("success", "Provider removed."); + void loadDashboard(); + } catch (error) { + setControlPlaneConfig(previousControlPlaneConfig); + setProviders(previousProviders); + setProviderFilter(previousProviderFilter); + setControlPlaneError(describeError(error, "failed to delete provider")); + setNoticeMessage("error", "Failed to remove provider."); + } } async function setProviderBaseURL(id: string, baseURL: string): Promise { diff --git a/ui/src/features/chats/ChatView.test.tsx b/ui/src/features/chats/ChatView.test.tsx index 7ec3d34c0..2a2258ae4 100644 --- a/ui/src/features/chats/ChatView.test.tsx +++ b/ui/src/features/chats/ChatView.test.tsx @@ -3,8 +3,17 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { ChatView } from "./ChatView"; +import { discoverLocalProviders } from "../../lib/api"; import { createRuntimeConsoleActions, createRuntimeConsoleFixture } from "../../test/runtime-console-fixture"; +vi.mock("../../lib/api", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + discoverLocalProviders: vi.fn(async () => ({ object: "local_provider_discovery", data: [] })), + }; +}); + function setup(stateOverrides = {}, actionOverrides = {}) { const state = createRuntimeConsoleFixture({ providerScopedModels: [ @@ -55,6 +64,123 @@ describe("ChatView input", () => { expect(screen.getByRole("button", { name: /Add provider/i })).toBeTruthy(); }); + it("opens the shared Add provider modal from the model empty state", async () => { + const { state, actions } = setup({ + chatTarget: "model", + controlPlaneConfig: { backend: "memory", providers: [], policy_rules: [], pricebook: [], events: [] }, + agentAdapters: [ + { id: "codex", name: "Codex", kind: "acp", command: "codex-acp", available: true, status: "available", cost_mode: "external" }, + ], + }); + render(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /Add provider/i })); + + expect(screen.getByRole("dialog", { name: "Add provider" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Local" })).toHaveStyle({ color: "var(--t0)" }); + }); + + it("shows provider troubleshooting instead of detected-provider setup when a configured provider has no models", () => { + const { state, actions } = setup({ + chatTarget: "model", + providerFilter: "ollama", + controlPlaneConfig: { + backend: "memory", + providers: [ + { id: "ollama", name: "Ollama", preset_id: "ollama", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:11434/v1", credential_configured: false }, + ], + policy_rules: [], + pricebook: [], + events: [], + }, + providers: [ + { name: "ollama", kind: "local", healthy: true, status: "healthy", base_url: "http://127.0.0.1:11434/v1", models: [], model_count: 0 }, + ], + providerScopedModels: [], + agentAdapters: [ + { id: "codex", name: "Codex", kind: "acp", command: "codex-acp", available: true, status: "available", cost_mode: "external" }, + ], + }); + render(); + + expect(screen.getByText("Provider is configured")).toBeTruthy(); + expect(screen.getAllByText("Ollama").length).toBeGreaterThan(0); + expect(screen.getByText("none discovered")).toBeTruthy(); + expect(screen.getByText(/Start the local provider app/)).toBeTruthy(); + expect(screen.queryByText("Detected locally")).toBeNull(); + expect(screen.queryByRole("button", { name: /Add detected provider/i })).toBeNull(); + }); + + it("quick-adds all installed local providers from the model empty state", async () => { + vi.mocked(discoverLocalProviders).mockResolvedValueOnce({ + object: "local_provider_discovery", + data: [ + { + preset_id: "ollama", + name: "Ollama", + base_url: "http://127.0.0.1:11434/v1", + probe_url: "http://127.0.0.1:11434/api/tags", + status: "installed", + command: "ollama", + command_available: true, + command_path: "/usr/local/bin/ollama", + http_available: false, + model_count: 0, + models: [], + }, + { + preset_id: "lmstudio", + name: "LM Studio", + base_url: "http://127.0.0.1:1234/v1", + probe_url: "http://127.0.0.1:1234/v1/models", + status: "running", + command: "lms", + command_available: true, + command_path: "/Users/alice/.lmstudio/bin/lms", + http_available: true, + model_count: 1, + models: ["qwen2.5"], + }, + ], + }); + const createProvider = vi.fn(async () => undefined); + const { state, actions } = setup({ + chatTarget: "model", + controlPlaneConfig: { backend: "memory", providers: [], policy_rules: [], pricebook: [], events: [] }, + providerPresets: [ + { id: "ollama", name: "Ollama", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:11434/v1", description: "" }, + { id: "lmstudio", name: "LM Studio", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:1234/v1", description: "" }, + ], + providerScopedModels: [], + agentAdapters: [ + { id: "codex", name: "Codex", kind: "acp", command: "codex-acp", available: true, status: "available", cost_mode: "external" }, + ], + }, { createProvider }); + render(); + + const user = userEvent.setup(); + const quickAdd = await screen.findByRole("button", { name: /Add detected providers/i }); + expect(screen.getByText("Ollama")).toBeTruthy(); + expect(screen.getByText("LM Studio")).toBeTruthy(); + await user.click(quickAdd); + + expect(createProvider).toHaveBeenNthCalledWith(1, expect.objectContaining({ + name: "Ollama", + preset_id: "ollama", + base_url: "http://127.0.0.1:11434/v1", + kind: "local", + protocol: "openai", + })); + expect(createProvider).toHaveBeenNthCalledWith(2, expect.objectContaining({ + name: "LM Studio", + preset_id: "lmstudio", + base_url: "http://127.0.0.1:1234/v1", + kind: "local", + protocol: "openai", + })); + }); + it("shows a first-run setup state when providers and agents are unavailable", () => { const { state, actions } = setup({ chatTarget: "model", diff --git a/ui/src/features/chats/ChatView.tsx b/ui/src/features/chats/ChatView.tsx index 004c1dfc0..471131f5a 100644 --- a/ui/src/features/chats/ChatView.tsx +++ b/ui/src/features/chats/ChatView.tsx @@ -1,17 +1,19 @@ import { useEffect, useRef, useState } from "react"; import type { SyntheticEvent } from "react"; import type { RuntimeConsoleViewModel } from "../../app/useRuntimeConsole"; +import { discoverLocalProviders } from "../../lib/api"; import { describeGatewayError, formatErrorCode } from "../../lib/error-diagnostics"; -import type { AgentAdapterRecord, AgentChatActivityRecord, AgentChatSessionRecord, AgentChatUsageRecord } from "../../types/runtime"; +import { describeRoutingBlockedReason } from "../../lib/runtime-utils"; +import type { AgentAdapterRecord, AgentChatActivityRecord, AgentChatSessionRecord, AgentChatUsageRecord, LocalProviderDiscoveryRecord, ProviderPresetRecord } from "../../types/runtime"; import { AgentAdapterPicker, CodeBlock, Icon, Icons, InlineError, ModelPicker, ProviderPicker } from "../shared/ui"; import { TranscriptMessageRow } from "../transcript/TranscriptMessageRow"; import { AgentApprovalAutoModeBanner, AgentApprovalsBanner } from "./AgentApprovalBanner"; import { AgentApprovalModal } from "./AgentApprovalModal"; +import { AddProviderModal } from "../providers/AddProviderModal"; type Props = { state: RuntimeConsoleViewModel["state"]; actions: RuntimeConsoleViewModel["actions"]; - onNavigate?: (workspace: "providers") => void; }; type VisibleChatMessage = { @@ -47,7 +49,7 @@ type SidebarSession = { updated_at?: string; }; -export function ChatView({ state, actions, onNavigate }: Props) { +export function ChatView({ state, actions }: Props) { const [sidebarOpen, setSidebarOpen] = useState(true); const [syspromptOpen, setSyspromptOpen] = useState(false); // approvalModalID is the per-banner-click open state for the @@ -60,8 +62,13 @@ export function ChatView({ state, actions, onNavigate }: Props) { const [copiedMsgId, setCopiedMsgId] = useState(null); const [atBottom, setAtBottom] = useState(true); const [workspaceEntryOpen, setWorkspaceEntryOpen] = useState(false); + const [addProviderOpen, setAddProviderOpen] = useState(false); const [workspacePathValue, setWorkspacePathValue] = useState(""); const [sidebarQuery, setSidebarQuery] = useState(""); + const [quickLocalProviders, setQuickLocalProviders] = useState([]); + const [quickLocalLoading, setQuickLocalLoading] = useState(false); + const [quickLocalError, setQuickLocalError] = useState(""); + const [quickAddingProviders, setQuickAddingProviders] = useState(false); const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform); const modKey = isMac ? "⌘" : "Ctrl"; const [modEnterMode, setModEnterMode] = useState( @@ -157,6 +164,13 @@ export function ChatView({ state, actions, onNavigate }: Props) { }); })(); const modelRouteUnavailable = providerConfigLoaded && selectableModels.length === 0; + const hasConfiguredProviders = configuredProviders.length > 0; + const selectedConfiguredProvider = state.providerFilter === "auto" + ? configuredProviders.length === 1 ? configuredProviders[0] : undefined + : configuredProviders.find(provider => provider.id === state.providerFilter); + const selectedRuntimeProvider = state.providerFilter === "auto" + ? state.providers.length === 1 ? state.providers[0] : undefined + : state.providers.find(provider => provider.name === state.providerFilter); const agentRouteUnavailable = availableAgents.length === 0; const selectedAgentUnavailable = isAgentChat && Boolean(selectedAgent) && !selectedAgent?.available; const nothingRunnable = !state.loading && modelRouteUnavailable && agentRouteUnavailable; @@ -189,6 +203,12 @@ export function ChatView({ state, actions, onNavigate }: Props) { setWorkspacePathValue(state.agentWorkspace); }, [state.agentWorkspace]); + useEffect(() => { + if (isAgentChat || !modelRouteUnavailable || hasConfiguredProviders || quickLocalProviders.length > 0 || quickLocalLoading) return; + void refreshQuickLocalProviders(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAgentChat, modelRouteUnavailable, hasConfiguredProviders]); + function handleScroll() { const el = scrollRef.current; if (!el) return; @@ -241,6 +261,45 @@ export function ChatView({ state, actions, onNavigate }: Props) { }); } + async function refreshQuickLocalProviders() { + setQuickLocalLoading(true); + setQuickLocalError(""); + try { + const response = await discoverLocalProviders(); + setQuickLocalProviders((response.data ?? []).filter(isQuickAddableLocalProvider)); + } catch (error) { + setQuickLocalError(error instanceof Error ? error.message : "Failed to check local providers"); + } finally { + setQuickLocalLoading(false); + } + } + + async function quickAddLocalProviders(discoveries: LocalProviderDiscoveryRecord[]) { + if (quickAddingProviders) return; + const addable = discoveries + .map(discovery => ({ discovery, preset: state.providerPresets.find(p => p.id === discovery.preset_id) })) + .filter((entry): entry is { discovery: LocalProviderDiscoveryRecord; preset: ProviderPresetRecord } => Boolean(entry.preset)); + if (addable.length === 0) return; + + setQuickAddingProviders(true); + setQuickLocalError(""); + try { + for (const { discovery, preset } of addable) { + await actions.createProvider({ + name: preset.name, + preset_id: preset.id, + base_url: discovery.base_url || preset.base_url, + kind: preset.kind, + protocol: preset.protocol ?? "openai", + }); + } + } catch (error) { + setQuickLocalError(error instanceof Error ? error.message : "Failed to add detected providers"); + } finally { + setQuickAddingProviders(false); + } + } + function handleKeyDown(e: React.KeyboardEvent) { if (e.key !== "Enter") return; const modPressed = isMac ? e.metaKey : e.ctrlKey; @@ -744,7 +803,18 @@ export function ChatView({ state, actions, onNavigate }: Props) { agentAdapters={state.agentAdapters} selectedAgent={selectedAgent} selectedAgentUnavailable={selectedAgentUnavailable} - onAddProvider={() => onNavigate?.("providers")} + hasConfiguredProviders={hasConfiguredProviders} + providerFilter={state.providerFilter} + selectedConfiguredProvider={selectedConfiguredProvider} + selectedRuntimeProvider={selectedRuntimeProvider} + providerPresets={state.providerPresets} + quickLocalProviders={quickLocalProviders} + quickLocalLoading={quickLocalLoading} + quickLocalError={quickLocalError} + quickAddingProviders={quickAddingProviders} + onAddProvider={() => setAddProviderOpen(true)} + onQuickAddLocalProviders={quickAddLocalProviders} + onRefreshQuickLocalProviders={refreshQuickLocalProviders} onSwitchTarget={actions.setChatTarget} /> )} @@ -873,6 +943,12 @@ export function ChatView({ state, actions, onNavigate }: Props) { onCancel={actions.cancelAgentChatApproval} /> )} + setAddProviderOpen(false)} + />
); } @@ -968,7 +1044,18 @@ function ChatEmptyState({ agentAdapters, selectedAgent, selectedAgentUnavailable, + hasConfiguredProviders, + providerFilter, + selectedConfiguredProvider, + selectedRuntimeProvider, + providerPresets, + quickLocalProviders, + quickLocalLoading, + quickLocalError, + quickAddingProviders, onAddProvider, + onQuickAddLocalProviders, + onRefreshQuickLocalProviders, onSwitchTarget, }: { isAgentChat: boolean; @@ -978,7 +1065,18 @@ function ChatEmptyState({ agentAdapters: AgentAdapterRecord[]; selectedAgent?: AgentAdapterRecord; selectedAgentUnavailable: boolean; + hasConfiguredProviders: boolean; + providerFilter: string; + selectedConfiguredProvider?: NonNullable["providers"][number]; + selectedRuntimeProvider?: RuntimeConsoleViewModel["state"]["providers"][number]; + providerPresets: ProviderPresetRecord[]; + quickLocalProviders: LocalProviderDiscoveryRecord[]; + quickLocalLoading: boolean; + quickLocalError: string; + quickAddingProviders: boolean; onAddProvider: () => void; + onQuickAddLocalProviders: (providers: LocalProviderDiscoveryRecord[]) => void; + onRefreshQuickLocalProviders: () => void; onSwitchTarget: (target: "model" | "agent") => void; }) { const title = isAgentChat && selectedAgentUnavailable @@ -1007,11 +1105,22 @@ function ChatEmptyState({ {isAgentChat && (agentRouteUnavailable || selectedAgentUnavailable) && ( )} + {!isAgentChat && modelRouteUnavailable && hasConfiguredProviders && ( + + )} {(modelRouteUnavailable || agentRouteUnavailable) && (
{modelRouteUnavailable && !isAgentChat && ( - )} {agentRouteUnavailable && !isAgentChat && ( @@ -1031,10 +1140,263 @@ function ChatEmptyState({ )}
)} + {!isAgentChat && modelRouteUnavailable && !hasConfiguredProviders && ( + + )} ); } +function ModelRouteTroubleshooting({ + providerFilter, + configuredProvider, + runtimeProvider, +}: { + providerFilter: string; + configuredProvider?: NonNullable["providers"][number]; + runtimeProvider?: RuntimeConsoleViewModel["state"]["providers"][number]; +}) { + const providerName = providerFilter === "auto" + ? "configured providers" + : configuredProvider?.name || runtimeProvider?.name || providerFilter; + const isLocal = configuredProvider?.kind === "local" || runtimeProvider?.kind === "local"; + const endpoint = runtimeProvider?.base_url || configuredProvider?.base_url || ""; + const modelCount = runtimeProvider?.model_count ?? runtimeProvider?.models?.length ?? 0; + const blockedReason = runtimeProvider?.routing_blocked_reason ? describeRoutingBlockedReason(runtimeProvider.routing_blocked_reason) : ""; + const lastError = runtimeProvider?.last_error || ""; + + const guidance = isLocal + ? [ + "Start the local provider app or server.", + "Pull or load at least one model in that provider.", + "Click Providers to confirm the endpoint and discovered model list.", + ] + : [ + "Check that credentials are configured for this provider.", + "Confirm the account has access to at least one model.", + "Click Providers to inspect the latest health and discovery error.", + ]; + + return ( +
+
+ + Provider is configured + + + {providerName} + +
+
+ Hecate can see the provider configuration, but no routable models are available yet. +
+
+ + 0 ? String(modelCount) : "none discovered"} /> + +
+ {(blockedReason || lastError) && ( +
+ {blockedReason || lastError} +
+ )} +
    + {guidance.map(item =>
  • {item}
  • )} +
+
+ ); +} + +function InfoChip({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function QuickLocalProviderAdd({ + discoveries, + error, + loading, + presets, + adding, + onAdd, + onRefresh, +}: { + discoveries: LocalProviderDiscoveryRecord[]; + error: string; + loading: boolean; + presets: ProviderPresetRecord[]; + adding: boolean; + onAdd: (providers: LocalProviderDiscoveryRecord[]) => void; + onRefresh: () => void; +}) { + const candidates = discoveries.filter(discovery => presets.some(preset => preset.id === discovery.preset_id)); + if (!loading && !error && candidates.length === 0) return null; + + return ( +
+
0 || error ? 12 : 0 }}> +
+
+ Detected locally +
+
+ Hecate found local inference tools on this machine. Add them now, then pull or load models in the provider app if needed. +
+
+ {loading && Checking...} + +
+ {error && } + {candidates.length > 0 && ( +
+
+ {candidates.map(discovery => { + const preset = presets.find(preset => preset.id === discovery.preset_id); + const status = localProviderReadiness(discovery); + const modelCount = discovery.model_count ?? discovery.models?.length ?? 0; + const detail = discovery.http_available + ? `${discovery.base_url} · ${modelCount} model${modelCount === 1 ? "" : "s"}` + : `${discovery.command || "Command"} found${discovery.command_path ? ` · ${discovery.command_path}` : ""}`; + return ( +
+
+ {(preset?.name || discovery.name)[0]?.toUpperCase()} +
+
+
+
+ {preset?.name || discovery.name} +
+ + {status.label} + +
+
+ {detail} +
+
+
+ ); + })} +
+
+ + Adds {candidates.length} provider{candidates.length === 1 ? "" : "s"} with the detected/default endpoints. You can edit names and URLs later in Providers. + + +
+
+ )} +
+ ); +} + +function isQuickAddableLocalProvider(discovery: LocalProviderDiscoveryRecord): boolean { + return discovery.http_available || discovery.command_available; +} + +function localProviderReadiness(discovery: LocalProviderDiscoveryRecord): { + label: string; + title: string; + color: string; + background: string; + border: string; +} { + if (discovery.http_available) { + const models = discovery.model_count ? ` · ${discovery.model_count} model${discovery.model_count === 1 ? "" : "s"}` : ""; + return { + label: "Running", + title: `HTTP probe passed at ${discovery.probe_url}${models}`, + color: "var(--green)", + background: "var(--green-bg)", + border: "var(--green-border)", + }; + } + return { + label: "Installed", + title: `${discovery.command || "Command"} found${discovery.command_path ? ` at ${discovery.command_path}` : ""}; local HTTP endpoint is not running`, + color: "var(--amber)", + background: "var(--amber-bg)", + border: "var(--amber-border)", + }; +} + function AgentSetupHints({ adapters, selectedID }: { adapters: AgentAdapterRecord[]; selectedID?: string }) { const ordered = adapters .slice() diff --git a/ui/src/features/providers/AddProviderModal.tsx b/ui/src/features/providers/AddProviderModal.tsx new file mode 100644 index 000000000..9c83a8481 --- /dev/null +++ b/ui/src/features/providers/AddProviderModal.tsx @@ -0,0 +1,479 @@ +import { useEffect, useRef, useState } from "react"; +import type { RuntimeConsoleViewModel } from "../../app/useRuntimeConsole"; +import { discoverLocalProviders } from "../../lib/api"; +import { resolvedBaseURL } from "../../lib/provider-utils"; +import type { LocalProviderDiscoveryRecord, ProviderPresetRecord } from "../../types/runtime"; +import { Icon, Icons, InlineError, Modal } from "../shared/ui"; + +type Props = { + open: boolean; + state: RuntimeConsoleViewModel["state"]; + actions: RuntimeConsoleViewModel["actions"]; + onClose: () => void; +}; + +type AddFormState = { + name: string; + custom_name: string; + base_url: string; + api_key: string; + kind: string; + protocol: string; +}; + +const PRESET_COLORS: Record = { + anthropic: "var(--brand-anthropic)", + openai: "var(--brand-openai)", + gemini: "var(--brand-gemini)", + mistral: "var(--brand-mistral)", + groq: "var(--brand-groq)", + deepseek: "var(--teal)", + perplexity: "var(--teal)", + together_ai: "var(--t2)", + xai: "var(--t0)", + ollama: "var(--teal)", + lmstudio: "var(--t2)", + llamacpp: "var(--t2)", + localai: "var(--t2)", +}; + +function iconColorByID(id: string): string { + return PRESET_COLORS[id.toLowerCase()] ?? "var(--teal)"; +} + +export function AddProviderModal({ open, state, actions, onClose }: Props) { + const [step, setStep] = useState<"pick" | "form">("pick"); + const [pickTab, setPickTab] = useState<"cloud" | "local">("local"); + const [preset, setPreset] = useState(null); + const [form, setForm] = useState(emptyAddForm("cloud")); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [localDiscovery, setLocalDiscovery] = useState([]); + const [localDiscoveryLoading, setLocalDiscoveryLoading] = useState(false); + const [localDiscoveryError, setLocalDiscoveryError] = useState(""); + const nameInputRef = useRef(null); + const urlInputRef = useRef(null); + const apiKeyInputRef = useRef(null); + + useEffect(() => { + if (!open) return; + setStep("pick"); + setPickTab("local"); + setPreset(null); + setForm(emptyAddForm("local")); + setError(""); + }, [open]); + + useEffect(() => { + if (!open || step !== "pick" || pickTab !== "local") return; + void refreshLocalDiscovery(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, step, pickTab]); + + useEffect(() => { + if (!open || step !== "form") return; + const target = preset === null + ? nameInputRef.current + : form.kind === "local" + ? urlInputRef.current + : apiKeyInputRef.current; + requestAnimationFrame(() => target?.focus()); + // Focus only when the operator enters the form; per-keystroke form updates + // must not steal focus back to the preset's first editable field. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, step, preset?.id]); + + if (!open) return null; + + const localPresets = state.providerPresets.filter(p => p.kind === "local"); + const cloudPresets = state.providerPresets.filter(p => p.kind === "cloud"); + const configuredProviders = state.controlPlaneConfig?.providers ?? []; + + function close() { + onClose(); + } + + async function refreshLocalDiscovery() { + setLocalDiscoveryLoading(true); + setLocalDiscoveryError(""); + try { + const response = await discoverLocalProviders(); + setLocalDiscovery(response.data ?? []); + } catch (e) { + setLocalDiscoveryError(e instanceof Error ? e.message : "Failed to discover local providers"); + } finally { + setLocalDiscoveryLoading(false); + } + } + + async function submitAdd() { + setLoading(true); + setError(""); + try { + await actions.createProvider({ + name: form.name.trim(), + preset_id: preset?.id, + custom_name: form.custom_name.trim() || undefined, + base_url: form.base_url.trim() || undefined, + api_key: form.api_key.trim() || undefined, + kind: form.kind, + protocol: form.protocol, + }); + close(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to add provider"); + } finally { + setLoading(false); + } + } + + function pickPreset(nextPreset: ProviderPresetRecord) { + const discovery = localDiscovery.find(item => item.preset_id === nextPreset.id); + setPreset(nextPreset); + setForm({ + name: nextPreset.name, + custom_name: "", + base_url: discovery?.base_url || nextPreset.base_url, + api_key: "", + kind: nextPreset.kind, + protocol: nextPreset.protocol ?? "openai", + }); + setStep("form"); + } + + function pickCustom(kind: "cloud" | "local") { + setPreset(null); + setForm(emptyAddForm(kind)); + setStep("form"); + } + + function renderPickStep() { + const presets = pickTab === "cloud" ? cloudPresets : localPresets; + const cardsPerRow = 3; + const maxItemCount = Math.max(cloudPresets.length, localPresets.length) + 1; + const maxRows = Math.ceil(maxItemCount / cardsPerRow); + const rowHeight = 78; + const rowGap = 8; + const gridMinHeight = maxRows * rowHeight + (maxRows - 1) * rowGap; + + return ( +
+
+ setPickTab("cloud")} /> + setPickTab("local")} /> + {pickTab === "local" && ( + + )} +
+ {pickTab === "local" && ( +
+ {localDiscoveryError || "Checks command availability and probes each unique local endpoint once."} +
+ )} +
+ {presets.map(p => ( + item.preset_id === p.id) : undefined} + onClick={() => pickPreset(p)} + /> + ))} + pickCustom(pickTab)} /> +
+
+ ); + } + + function renderFormStep() { + const showURL = form.kind === "local" || (!preset && form.kind === "cloud"); + const showAPIKey = form.kind === "cloud"; + const currentBaseURL = resolvedBaseURL( + preset?.id ?? "", + preset ? { id: preset.id, name: preset.name, kind: preset.kind, protocol: preset.protocol, base_url: preset.base_url, credential_configured: false } : undefined, + state.providerPresets, + ); + const effectiveBaseURL = (showURL ? form.base_url.trim() : currentBaseURL).trim(); + const baseURLTakenBy = effectiveBaseURL + ? configuredProviders.find(p => { + const url = resolvedBaseURL(p.id, p, state.providerPresets); + return url && url === effectiveBaseURL; + }) + : undefined; + const duplicateProvider = findDuplicateProviderID(configuredProviders, form.name, form.custom_name); + const duplicateMessage = duplicateProvider + ? providerDuplicateMessage(duplicateProvider, form.name, form.custom_name, preset !== null) + : ""; + const saveDisabled = loading || !form.name.trim() || Boolean(baseURLTakenBy) || Boolean(duplicateProvider); + return ( +
+ +
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="My Provider" + readOnly={preset !== null} + disabled={preset !== null} + title={preset !== null ? "Preset names are fixed — use Custom name below to disambiguate two instances of the same preset" : undefined} + /> +
+
+ + setForm(f => ({ ...f, custom_name: e.target.value }))} + placeholder="e.g. Prod, Dev, Staging" + aria-invalid={Boolean(duplicateProvider)} + /> + {duplicateMessage && ( +
+ {duplicateMessage} +
+ )} +
+ {showURL && ( +
+ + setForm(f => ({ ...f, base_url: e.target.value }))} + placeholder={currentBaseURL || "http://localhost:11434/v1"} + style={{ fontFamily: "var(--font-mono)" }} + /> + {baseURLTakenBy && ( +
+ Endpoint already used by{" "} + + {baseURLTakenBy.name || baseURLTakenBy.id} + + . Choose another URL to continue. +
+ )} +
+ )} + {!showURL && baseURLTakenBy && ( +
+ Endpoint{" "} + + {effectiveBaseURL} + {" "} + is already used by{" "} + + {baseURLTakenBy.name || baseURLTakenBy.id} + + . Choose another provider entry or remove the existing one first. +
+ )} + {showAPIKey && ( +
+ + setForm(f => ({ ...f, api_key: e.target.value }))} + placeholder="sk-…" + style={{ fontFamily: "var(--font-mono)", letterSpacing: "0.1em" }} + /> +
+ )} + {error && } + +
+ ); + } + + return ( + + {step === "pick" ? renderPickStep() : renderFormStep()} + + ); +} + +function emptyAddForm(kind: "cloud" | "local"): AddFormState { + return { name: kind === "local" ? "Custom" : "Custom", custom_name: "", base_url: "", api_key: "", kind, protocol: "openai" }; +} + +function providerSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function providerIDFor(name: string, customName: string): string { + const idSource = [name.trim(), customName.trim()].filter(Boolean).join(" "); + return providerSlug(idSource); +} + +function findDuplicateProviderID( + providers: Array<{ id: string; name: string; custom_name?: string }>, + name: string, + customName: string, +) { + const id = providerIDFor(name, customName); + if (!id) return undefined; + return providers.find(provider => provider.id === id); +} + +function providerDisplayName(provider: { id: string; name: string; custom_name?: string }): string { + return provider.custom_name ? `${provider.name} (${provider.custom_name})` : provider.name || provider.id; +} + +function providerDuplicateMessage( + provider: { id: string; name: string; custom_name?: string }, + name: string, + customName: string, + isPreset: boolean, +): string { + const displayName = providerDisplayName(provider); + if (customName.trim()) { + return `Custom name is already used by ${displayName}. Choose another custom name to continue.`; + } + if (isPreset) { + return `${name.trim()} is already configured. Add a custom name, like Dev or Local, to create another instance.`; + } + return `A provider named ${displayName} already exists. Add a custom name to create another instance.`; +} + +function PresetButton({ + preset, + discovery, + onClick, +}: { + preset: ProviderPresetRecord; + discovery?: LocalProviderDiscoveryRecord; + onClick: () => void; +}) { + const status = localDiscoveryStatus(discovery); + return ( + + ); +} + +function CustomButton({ onClick }: { kind: "cloud" | "local"; onClick: () => void }) { + return ( + + ); +} + +function TabButton({ id: _id, label, active, onClick }: { id: "cloud" | "local"; label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function localDiscoveryStatus(item: LocalProviderDiscoveryRecord | undefined): { + label: string; + title: string; + color: string; + background: string; + border: string; +} | null { + if (!item) return null; + if (item.http_available) { + const models = item.model_count ? ` · ${item.model_count} model${item.model_count === 1 ? "" : "s"}` : ""; + return { + label: "Running", + title: `HTTP probe passed at ${item.probe_url}${models}`, + color: "var(--green)", + background: "var(--green-bg)", + border: "var(--green-border)", + }; + } + if (item.command_available) { + return { + label: "Installed", + title: `${item.command || "Command"} found${item.command_path ? ` at ${item.command_path}` : ""}; local HTTP endpoint is not running`, + color: "var(--amber)", + background: "var(--amber-bg)", + border: "var(--amber-border)", + }; + } + return { + label: "Not detected", + title: item.error || `No ${item.command || "provider"} command on PATH and no local HTTP response`, + color: "var(--t3)", + background: "var(--bg3)", + border: "var(--border)", + }; +} diff --git a/ui/src/features/providers/ProvidersView.test.tsx b/ui/src/features/providers/ProvidersView.test.tsx index b29111fc3..4b20e851c 100644 --- a/ui/src/features/providers/ProvidersView.test.tsx +++ b/ui/src/features/providers/ProvidersView.test.tsx @@ -1,11 +1,49 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ProvidersView } from "./ProvidersView"; +import { AddProviderModal } from "./AddProviderModal"; import { createRuntimeConsoleActions, createRuntimeConsoleFixture } from "../../test/runtime-console-fixture"; import type { ConfiguredProviderRecord, ProviderPresetRecord, ProviderRecord } from "../../types/runtime"; +vi.mock("../../lib/api", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + discoverLocalProviders: vi.fn(async () => ({ + object: "local_provider_discovery", + data: [ + { + preset_id: "ollama", + name: "Ollama", + base_url: "http://127.0.0.1:11434/v1", + probe_url: "http://127.0.0.1:11434/api/tags", + status: "running", + command: "ollama", + command_available: true, + command_path: "/usr/local/bin/ollama", + http_available: true, + model_count: 2, + models: ["llama3.1:8b", "qwen2.5:7b"], + }, + { + preset_id: "llamacpp", + name: "llama.cpp", + base_url: "http://127.0.0.1:8080/v1", + probe_url: "http://127.0.0.1:8080/v1/models", + status: "not_detected", + command: "llama-server", + command_available: false, + http_available: false, + model_count: 0, + models: [], + }, + ], + })), + }; +}); + const presets: ProviderPresetRecord[] = [ { id: "anthropic", name: "Anthropic", kind: "cloud", protocol: "openai", base_url: "https://api.anthropic.com/v1", description: "" }, { id: "llamacpp", name: "llama.cpp", kind: "local", protocol: "openai", base_url: "http://127.0.0.1:8080/v1", description: "" }, @@ -38,6 +76,12 @@ function makeStatus(name: string, overrides: Partial = {}): Prov const localSession = { label: "Local" }; +const originalRequestAnimationFrame = window.requestAnimationFrame; + +afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; +}); + function emptyControlPlaneConfig() { return { backend: "memory", @@ -82,21 +126,19 @@ describe("ProvidersView delete", () => { providers: [makeStatus("ollama")], }); - // Mock window.confirm to auto-approve - const origConfirm = window.confirm; - window.confirm = () => true; - render(); const user = userEvent.setup(); const trashBtn = screen.getByTitle("Remove Ollama"); await user.click(trashBtn); + expect(screen.getByRole("dialog", { name: "Remove provider?" })).toBeTruthy(); + expect(screen.getByText(/Existing chats stay in history/)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Remove provider" })); + await waitFor(() => { expect(deleteProvider).toHaveBeenCalledWith("ollama"); }); - - window.confirm = origConfirm; }); }); @@ -113,22 +155,34 @@ describe("ProvidersView add provider modal", () => { return { actions }; } - it("clicking 'Add provider' opens the modal on the Cloud tab", async () => { + it("clicking 'Add provider' opens the modal on the Local tab", async () => { openAddModal(); const user = userEvent.setup(); // Two buttons: header + empty-state. Click the first. await user.click(screen.getAllByText("Add provider")[0]); - // Anthropic preset is cloud-only — its presence proves the Cloud tab is active. + // Ollama is local — its presence proves the Local tab is active by default. + expect(screen.getByText("Ollama")).toBeTruthy(); + }); + + it("switching to the Cloud tab swaps the preset list", async () => { + openAddModal(); + const user = userEvent.setup(); + await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getByText("Cloud")); + // Anthropic is cloud-only — appears only when the Cloud tab is active. expect(screen.getByText("Anthropic")).toBeTruthy(); }); - it("switching to the Local tab swaps the preset list", async () => { + it("highlights discovered local providers in the preset picker", async () => { openAddModal(); const user = userEvent.setup(); await user.click(screen.getAllByText("Add provider")[0]); - await user.click(screen.getByText("Local")); - // Ollama is local — appears only when the Local tab is active. - expect(screen.getByText("Ollama")).toBeTruthy(); + + await waitFor(() => { + expect(screen.getByText("Running")).toBeTruthy(); + }); + expect(screen.getByText("Not detected")).toBeTruthy(); + expect(screen.getByText(/Checks command availability/)).toBeTruthy(); }); it("picking a cloud preset prefills Name from the preset and locks the field", async () => { @@ -139,6 +193,7 @@ describe("ProvidersView add provider modal", () => { openAddModal(); const user = userEvent.setup(); await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getByText("Cloud")); await user.click(screen.getByText("Anthropic")); const nameInput = screen.getByPlaceholderText("My Provider") as HTMLInputElement; expect(nameInput.value).toBe("Anthropic"); @@ -161,6 +216,7 @@ describe("ProvidersView add provider modal", () => { const user = userEvent.setup(); await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getByText("Cloud")); await user.click(screen.getByText("Anthropic")); const apiKeyInput = screen.getByPlaceholderText("sk-…") as HTMLInputElement; @@ -206,6 +262,7 @@ describe("ProvidersView add provider modal", () => { const user = userEvent.setup(); await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getByText("Cloud")); await user.click(screen.getByText("Anthropic")); await user.type(screen.getByPlaceholderText("sk-…"), "sk-test"); const addButtons = screen.getAllByText("Add provider"); @@ -361,7 +418,7 @@ describe("ProvidersView table renders", () => { expect(screen.queryByRole("switch")).toBeNull(); }); - it("warns inline when the typed Endpoint URL collides with an existing provider", async () => { + it("blocks submit when the typed Endpoint URL is already taken", async () => { const state = createRuntimeConsoleFixture({ session: localSession, providerPresets: presets, @@ -380,7 +437,6 @@ describe("ProvidersView table renders", () => { await user.click(addBtn); // Switch to the Local tab so the Custom flow lands on a kind whose // Endpoint URL field is shown by default. - await user.click(screen.getByText("Local")); await user.click(screen.getByText("Custom")); // FormStep is redefined on every parent render, so per-keystroke @@ -391,13 +447,107 @@ describe("ProvidersView table renders", () => { fireEvent.change(urlInput(), { target: { value: "http://127.0.0.1:11434/v1" } }); await waitFor(() => { - expect(screen.getByText(/already used by/)).toBeTruthy(); + expect(screen.getByText(/Endpoint already used by/)).toBeTruthy(); }); - expect(screen.getByText(/Backend will reject\./)).toBeTruthy(); + expect(screen.getByText(/Choose another URL to continue\./)).toBeTruthy(); + expect(screen.getAllByText("Add provider").pop()).toBeDisabled(); - // No collision: warning disappears. fireEvent.change(urlInput(), { target: { value: "http://127.0.0.1:9999/v1" } }); - expect(screen.queryByText(/already used by/)).toBeNull(); + expect(screen.queryByText(/Endpoint already used by/)).toBeNull(); + expect(screen.getAllByText("Add provider").pop()).not.toBeDisabled(); + }); + + it("asks for a custom name when the selected preset id already exists", async () => { + const createProvider = vi.fn(async () => undefined); + const state = createRuntimeConsoleFixture({ + session: localSession, + providerPresets: presets, + controlPlaneConfig: { + ...emptyControlPlaneConfig(), + providers: [makeConfigured("llama-cpp", { name: "llama.cpp", base_url: "http://127.0.0.1:9090/v1" })], + }, + providers: [makeStatus("llama-cpp")], + }); + const actions = { ...createRuntimeConsoleActions(), createProvider }; + + render(); + + const user = userEvent.setup(); + await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getAllByText("llama.cpp").pop()!); + + expect(screen.getByText(/llama\.cpp is already configured/)).toBeTruthy(); + expect(screen.getByText(/Add a custom name/)).toBeTruthy(); + expect(screen.getAllByText("Add provider").pop()).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Prod, Dev, Staging/i), "Dev"); + + expect(screen.queryByText(/llama\.cpp is already configured/)).toBeNull(); + expect(screen.getAllByText("Add provider").pop()).not.toBeDisabled(); + }); + + it("blocks submit when the custom name still collides with an existing provider id", async () => { + const createProvider = vi.fn(async () => undefined); + const state = createRuntimeConsoleFixture({ + session: localSession, + providerPresets: presets, + controlPlaneConfig: { + ...emptyControlPlaneConfig(), + providers: [ + makeConfigured("anthropic-dev", { + name: "Anthropic", + custom_name: "Dev", + kind: "cloud", + base_url: "https://api.anthropic-dev.example/v1", + }), + ], + }, + providers: [makeStatus("anthropic-dev", { kind: "cloud" })], + }); + const actions = { ...createRuntimeConsoleActions(), createProvider }; + + render( {}} />); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Cloud" })); + await user.click(within(screen.getByRole("dialog")).getByText("Anthropic", { exact: true })); + + const customNameInput = screen.getByPlaceholderText(/Prod, Dev, Staging/i); + fireEvent.change(customNameInput, { target: { value: "Dev" } }); + + expect(screen.getByText(/Custom name is already used by Anthropic \(Dev\)/)).toBeTruthy(); + expect(screen.getAllByText("Add provider").pop()).toBeDisabled(); + + await user.clear(customNameInput); + fireEvent.change(customNameInput, { target: { value: "Work" } }); + + expect(screen.queryByText(/Custom name is already used/)).toBeNull(); + expect(screen.getAllByText("Add provider").pop()).not.toBeDisabled(); + }); + + it("does not steal focus back to Endpoint URL after typing in Custom name", async () => { + window.requestAnimationFrame = callback => { + callback(0); + return 0; + }; + const state = createRuntimeConsoleFixture({ + session: localSession, + providerPresets: presets, + controlPlaneConfig: emptyControlPlaneConfig(), + providers: [], + }); + + render(); + + const user = userEvent.setup(); + await user.click(screen.getAllByText("Add provider")[0]); + await user.click(screen.getByText("Ollama")); + const customNameInput = screen.getByPlaceholderText(/Prod, Dev, Staging/i) as HTMLInputElement; + await user.click(customNameInput); + await user.type(customNameInput, "Dev"); + + expect(document.activeElement).toBe(customNameInput); + expect((screen.getByDisplayValue("http://127.0.0.1:11434/v1") as HTMLInputElement).value).toBe("http://127.0.0.1:11434/v1"); }); it("shows provider health diagnostics and last errors", async () => { diff --git a/ui/src/features/providers/ProvidersView.tsx b/ui/src/features/providers/ProvidersView.tsx index 76a7507b6..bae5f05f5 100644 --- a/ui/src/features/providers/ProvidersView.tsx +++ b/ui/src/features/providers/ProvidersView.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, type CSSProperties } from "react"; import type { RuntimeConsoleViewModel } from "../../app/useRuntimeConsole"; -import { buildConflictMap, resolvedBaseURL } from "../../lib/provider-utils"; +import { resolvedBaseURL } from "../../lib/provider-utils"; import { describeHealthErrorClass, describeRoutingBlockedReason } from "../../lib/runtime-utils"; -import { Badge, Icon, Icons, InlineError, Modal } from "../shared/ui"; -import type { ProviderPresetRecord } from "../../types/runtime"; +import { Badge, ConfirmModal, Icon, Icons, Modal } from "../shared/ui"; +import { AddProviderModal } from "./AddProviderModal"; type Props = { state: RuntimeConsoleViewModel["state"]; @@ -83,20 +83,15 @@ export function ProvidersView({ state, actions }: Props) { const [pendingURL, setPendingURL] = useState(""); const [pendingName, setPendingName] = useState(""); const [pendingCustomName, setPendingCustomName] = useState(""); - // Add-provider flow state - const [addStep, setAddStep] = useState<"pick" | "form" | null>(null); - const [addPickTab, setAddPickTab] = useState<"cloud" | "local">("cloud"); - const [addPreset, setAddPreset] = useState(null); - const [addForm, setAddForm] = useState({ name: "", custom_name: "", base_url: "", api_key: "", kind: "cloud", protocol: "openai" }); - const [addError, setAddError] = useState(""); - const [addLoading, setAddLoading] = useState(false); + const [addProviderOpen, setAddProviderOpen] = useState(false); + const [deleteConfirmID, setDeleteConfirmID] = useState(null); // Auto-poll model discovery only when there's something to discover for — // either at least one configured provider, or the Add modal is open // (so a freshly-added provider's models surface immediately). Otherwise // /admin/providers + /v1/models are no-op network calls; skip them. const hasProviders = (state.controlPlaneConfig?.providers?.length ?? 0) > 0; - const shouldPoll = hasProviders || addStep !== null; + const shouldPoll = hasProviders || addProviderOpen; useEffect(() => { if (!shouldPoll) return; const id = setInterval(() => { @@ -149,47 +144,17 @@ export function ProvidersView({ state, actions }: Props) { return ai !== bi ? ai - bi : a.localeCompare(b); }; - const configuredByName = new Map( - configuredProviders.filter(p => p.base_url).map(p => [p.name, p]), - ); - const allConfiguredIDs = configuredProviders.map(p => p.id).sort(stableSort); - const conflictMap = buildConflictMap(allConfiguredIDs, configuredByName, state.providerPresets); - const cloudIDs = configuredProviders.filter(p => p.kind === "cloud").map(p => p.id).sort(stableSort); const localIDs = configuredProviders.filter(p => p.kind !== "cloud").map(p => p.id).sort(stableSort); const selectedConfig = selectedID ? configuredByID.get(selectedID) ?? null : null; const selectedStatus = selectedID ? statusByName.get(selectedID) : null; const selectedPreset = selectedID ? state.providerPresets.find(p => p.id === selectedID) : null; - - function closeAdd() { - setAddStep(null); - setAddPickTab("cloud"); - setAddPreset(null); - setAddForm({ name: "", custom_name: "", base_url: "", api_key: "", kind: "cloud", protocol: "openai" }); - setAddError(""); - } - - async function submitAdd() { - setAddLoading(true); - setAddError(""); - try { - await actions.createProvider({ - name: addForm.name.trim(), - preset_id: addPreset?.id, - custom_name: addForm.custom_name.trim() || undefined, - base_url: addForm.base_url.trim() || undefined, - api_key: addForm.api_key.trim() || undefined, - kind: addForm.kind, - protocol: addForm.protocol, - }); - closeAdd(); - } catch (e) { - setAddError(e instanceof Error ? e.message : "Failed to add provider"); - } finally { - setAddLoading(false); - } - } + const deleteConfirmConfig = deleteConfirmID ? configuredByID.get(deleteConfirmID) ?? null : null; + const deleteConfirmPreset = deleteConfirmID ? state.providerPresets.find(p => p.id === deleteConfirmID) : null; + const deleteConfirmName = deleteConfirmConfig + ? deleteConfirmPreset?.name || deleteConfirmConfig.name || deleteConfirmID || "provider" + : "provider"; const selectedHealthCounters = [ selectedStatus?.consecutive_failures ? `${selectedStatus.consecutive_failures} consecutive failures` : "", @@ -205,11 +170,6 @@ export function ProvidersView({ state, actions }: Props) { const rt = statusByName.get(id); const preset = state.providerPresets.find(p => p.id === id); const displayName = preset?.name || cp?.name || id; - const conflicts = conflictMap.get(id) ?? []; - const conflictTitle = - conflicts.length > 0 - ? `Shares endpoint with ${conflicts.join(", ")} — only one can serve traffic at a time` - : undefined; const baseURL = rt?.base_url || resolvedBaseURL(id, cp ?? undefined, state.providerPresets); const modelCount = rt?.model_count ?? rt?.models?.length ?? 0; const protocol = cp?.protocol || preset?.protocol || "—"; @@ -267,15 +227,6 @@ export function ProvidersView({ state, actions }: Props) { {cp.custom_name} )} - {conflicts.length > 0 && ( - - ⚠ - - )} @@ -343,10 +294,7 @@ export function ProvidersView({ state, actions }: Props) { type="button" onClick={e => { e.stopPropagation(); - if (window.confirm(`Remove provider ${displayName}?`)) { - void actions.deleteProvider(id); - if (selectedID === id) setSelectedID(null); - } + setDeleteConfirmID(id); }}> @@ -404,279 +352,6 @@ export function ProvidersView({ state, actions }: Props) { ); } - // ── Pick step ──────────────────────────────────────────────────────────────── - - const localPresets = state.providerPresets.filter(p => p.kind === "local"); - const cloudPresets = state.providerPresets.filter(p => p.kind === "cloud"); - - function PickStep() { - function pickPreset(preset: ProviderPresetRecord) { - setAddPreset(preset); - setAddForm({ name: preset.name, custom_name: "", base_url: preset.base_url, api_key: "", kind: preset.kind, protocol: preset.protocol ?? "openai" }); - setAddStep("form"); - } - function pickCustom(kind: "cloud" | "local") { - setAddPreset(null); - setAddForm({ name: "Custom", custom_name: "", base_url: "", api_key: "", kind, protocol: "openai" }); - setAddStep("form"); - } - - function PresetButton({ preset }: { preset: ProviderPresetRecord }) { - return ( - - ); - } - - function CustomButton({ kind }: { kind: "cloud" | "local" }) { - return ( - - ); - } - - function TabButton({ id, label }: { id: "cloud" | "local"; label: string }) { - const active = addPickTab === id; - return ( - - ); - } - - const presets = addPickTab === "cloud" ? cloudPresets : localPresets; - - // Pin the grid's min-height to the larger tab so the modal doesn't jump - // when the operator switches between Cloud and Local. Each row is sized - // by the tallest card in it (descriptions vary), so we estimate via - // gridAutoRows + a min-height that fits the max row count. - const cardsPerRow = 3; - const maxItemCount = Math.max(cloudPresets.length, localPresets.length) + 1; // +1 for Custom - const maxRows = Math.ceil(maxItemCount / cardsPerRow); - const rowHeight = 78; // tracks PresetButton minHeight + vertical padding - const rowGap = 8; - const gridMinHeight = maxRows * rowHeight + (maxRows - 1) * rowGap; - - return ( -
- {/* Tab bar */} -
- - -
- - {/* Preset grid + Custom for the active tab */} -
- {presets.map(p => )} - -
-
- ); - } - - // ── Form step ──────────────────────────────────────────────────────────────── - - function FormStep() { - const showURL = addForm.kind === "local" || (!addPreset && addForm.kind === "cloud"); - const showAPIKey = addForm.kind === "cloud"; - const currentBaseURL = resolvedBaseURL( - addPreset?.id ?? "", - addPreset ? { id: addPreset.id, name: addPreset.name, kind: addPreset.kind, protocol: addPreset.protocol, base_url: addPreset.base_url, credential_configured: false } : undefined, - state.providerPresets, - ); - const saveDisabled = addLoading || !addForm.name.trim(); - - // The URL the new provider would actually serve traffic on. For local - // and "custom cloud" providers that's whatever the operator typed; for - // a cloud preset it's the preset's fixed base_url. We use this to - // surface a yellow inline warning when the URL collides with an - // existing provider — the backend will 409 either way, but a heads-up - // before submit saves a round-trip. - const effectiveBaseURL = (showURL ? addForm.base_url.trim() : currentBaseURL).trim(); - const baseURLConflictWith = effectiveBaseURL - ? configuredProviders.find(p => { - const url = resolvedBaseURL(p.id, p, state.providerPresets); - return url && url === effectiveBaseURL; - }) - : undefined; - - // First editable field gets autofocus on modal open. Custom → Name; - // cloud preset → API Key (Name + URL are fixed); local preset → URL - // (Name is fixed, URL may need tweaking from the preset default). - const focusName = addPreset === null; - const focusURL = addPreset !== null && showURL; - const focusAPIKey = addPreset !== null && !showURL && showAPIKey; - - return ( -
- {/* Breadcrumb back-link to the preset picker. The Back button at - the bottom of the form is also wired, but a chevron link at - the top is the conventional "go up a level" affordance and - stays visible while the operator is filling fields. */} - -
- - setAddForm(f => ({ ...f, name: e.target.value }))} - placeholder="My Provider" - readOnly={addPreset !== null} - disabled={addPreset !== null} - title={addPreset !== null ? "Preset names are fixed — use Custom name below to disambiguate two instances of the same preset" : undefined} - autoFocus={focusName} - /> -
- {/* Custom name — optional disambiguator. Shown for every provider - but really earns its keep when the operator is adding a second - instance of an already-configured preset. */} -
- - setAddForm(f => ({ ...f, custom_name: e.target.value }))} - placeholder="e.g. Prod, Dev, Staging" - /> -
- {showURL && ( -
- - setAddForm(f => ({ ...f, base_url: e.target.value }))} - placeholder={currentBaseURL || "http://localhost:11434/v1"} - style={{ fontFamily: "var(--font-mono)" }} - autoFocus={focusURL} - /> - {baseURLConflictWith && ( -
- This URL is already used by{" "} - - {baseURLConflictWith.name || baseURLConflictWith.id} - - . Backend will reject. -
- )} -
- )} - {showAPIKey && ( -
- - setAddForm(f => ({ ...f, api_key: e.target.value }))} - placeholder="sk-…" - style={{ fontFamily: "var(--font-mono)", letterSpacing: "0.1em" }} - autoFocus={focusAPIKey} - /> -
- )} - {addError && } - -
- ); - } - // ── Render ─────────────────────────────────────────────────────────────────── return ( @@ -691,7 +366,7 @@ export function ProvidersView({ state, actions }: Props) { @@ -709,7 +384,7 @@ export function ProvidersView({ state, actions }: Props) { @@ -990,15 +665,31 @@ export function ProvidersView({ state, actions }: Props) { )} - {/* Add provider modal */} - {addStep && ( - - {addStep === "pick" ? : } - + setAddProviderOpen(false)} + /> + + {deleteConfirmID && deleteConfirmConfig && ( + + Remove {deleteConfirmName} from Hecate? Existing chats stay in history, but new requests will stop routing through this provider. + + } + confirmLabel="Remove provider" + onClose={() => setDeleteConfirmID(null)} + onConfirm={async () => { + const id = deleteConfirmID; + setDeleteConfirmID(null); + if (selectedID === id) setSelectedID(null); + await actions.deleteProvider(id); + }} + /> )} ); diff --git a/ui/src/lib/api.test.ts b/ui/src/lib/api.test.ts index fa4f73d15..c85d6d7d8 100644 --- a/ui/src/lib/api.test.ts +++ b/ui/src/lib/api.test.ts @@ -6,6 +6,7 @@ import { chatCompletions, deleteAgentChatGrant, deletePolicyRule, + discoverLocalProviders, dispatchAgentChatStreamEvent, getAgentChatMessageFileDiff, getAgentChatApproval, @@ -229,6 +230,17 @@ describe("api client", () => { }), ); }); + + it("GET /providers/local-discovery discovers local presets", async () => { + fetchMock.mockResolvedValue(jsonResponse({ object: "local_provider_discovery", data: [] })); + + await discoverLocalProviders(); + + expect(fetchMock).toHaveBeenCalledWith( + "/admin/control-plane/providers/local-discovery", + expect.objectContaining({ method: "GET" }), + ); + }); }); describe("policy rule REST API", () => { diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 446e802be..9f6b06f23 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -8,6 +8,7 @@ import type { HealthResponse, MCPCacheStatsResponse, ModelResponse, + LocalProviderDiscoveryResponse, PricebookEntryUpsertPayload, PricebookImportDiffResponse, ProviderPresetResponse, @@ -199,6 +200,10 @@ export async function getProviderPresets(): Promise { return fetchJSON("/v1/provider-presets"); } +export async function discoverLocalProviders(): Promise { + return fetchJSON("/admin/control-plane/providers/local-discovery"); +} + export async function getAgentAdapters(): Promise { return fetchJSON("/v1/agent-adapters"); } diff --git a/ui/src/lib/provider-utils.test.ts b/ui/src/lib/provider-utils.test.ts index 7c72994da..669084858 100644 --- a/ui/src/lib/provider-utils.test.ts +++ b/ui/src/lib/provider-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildConflictMap, providerDotColor, resolvedBaseURL } from "./provider-utils"; +import { providerDotColor, resolvedBaseURL } from "./provider-utils"; import type { ConfiguredProviderRecord, ProviderPresetRecord } from "../types/runtime"; const presets: ProviderPresetRecord[] = [ @@ -37,38 +37,6 @@ describe("resolvedBaseURL", () => { }); }); -describe("buildConflictMap", () => { - it("detects providers sharing the same base_url", () => { - const configured = new Map(); - const conflicts = buildConflictMap(["llamacpp", "localai", "ollama"], configured, presets); - - expect(conflicts.get("llamacpp")).toEqual(["localai"]); - expect(conflicts.get("localai")).toEqual(["llamacpp"]); - expect(conflicts.has("ollama")).toBe(false); - }); - - it("returns empty map when no conflicts exist", () => { - const configured = new Map(); - const conflicts = buildConflictMap(["openai", "ollama"], configured, presets); - expect(conflicts.size).toBe(0); - }); - - it("skips providers with no resolvable base_url", () => { - const configured = new Map(); - const conflicts = buildConflictMap(["unknown-a", "unknown-b"], configured, presets); - expect(conflicts.size).toBe(0); - }); - - it("prefers cp base_url over preset when building conflict groups", () => { - const configured = new Map([ - ["llamacpp", makeCP("llamacpp", "http://127.0.0.1:9999/v1")], - ]); - // llamacpp now points elsewhere — no longer conflicts with localai - const conflicts = buildConflictMap(["llamacpp", "localai"], configured, presets); - expect(conflicts.size).toBe(0); - }); -}); - describe("providerDotColor", () => { it("returns red when disabled regardless of health", () => { expect(providerDotColor(false, true)).toBe("red"); diff --git a/ui/src/lib/provider-utils.ts b/ui/src/lib/provider-utils.ts index 486dfcdb1..757781cfa 100644 --- a/ui/src/lib/provider-utils.ts +++ b/ui/src/lib/provider-utils.ts @@ -9,30 +9,6 @@ export function resolvedBaseURL( return presets?.find(p => p.id === name)?.base_url ?? ""; } -export function buildConflictMap( - names: string[], - configuredByName: Map, - presets: ProviderPresetRecord[], -): Map { - const urlToNames = new Map(); - for (const name of names) { - const url = resolvedBaseURL(name, configuredByName.get(name), presets); - if (!url) continue; - const list = urlToNames.get(url) ?? []; - list.push(name); - urlToNames.set(url, list); - } - const conflictMap = new Map(); - for (const group of urlToNames.values()) { - if (group.length > 1) { - for (const name of group) { - conflictMap.set(name, group.filter(n => n !== name)); - } - } - } - return conflictMap; -} - export function providerDotColor(enabled: boolean, healthy: boolean): "green" | "amber" | "red" { if (!enabled) return "red"; if (healthy) return "green"; diff --git a/ui/src/types/runtime.ts b/ui/src/types/runtime.ts index b31382504..185efabac 100644 --- a/ui/src/types/runtime.ts +++ b/ui/src/types/runtime.ts @@ -170,6 +170,26 @@ export type ProviderPresetResponse = { data: ProviderPresetRecord[]; }; +export type LocalProviderDiscoveryRecord = { + preset_id: string; + name: string; + base_url: string; + probe_url: string; + status: "running" | "installed" | "not_detected" | "error" | string; + command?: string; + command_available: boolean; + command_path?: string; + http_available: boolean; + model_count?: number; + models?: string[]; + error?: string; +}; + +export type LocalProviderDiscoveryResponse = { + object: string; + data: LocalProviderDiscoveryRecord[]; +}; + export type AgentAdapterRecord = { id: string; name: string; From 651aaf7f7289e52902b3a2e42b0d26d8d448224a Mon Sep 17 00:00:00 2001 From: Sergey Rubanov Date: Tue, 5 May 2026 17:23:54 +0200 Subject: [PATCH 2/8] fix(ui): tighten local provider review flows Address PR review feedback on the local-provider discovery work. The add-provider modal now resets local discovery results, loading state, and errors every time it opens so stale failed probes cannot leak into the next provider-add flow. The unused CustomButton kind prop is removed as well. Provider deletion rollback now restores the selected provider and selected model while merging the removed provider/status row back into the latest state instead of replacing whole snapshots. That keeps optimistic delete fast without clobbering background dashboard refreshes. The chat quick-add path now creates all detected local providers without triggering a dashboard refresh after every POST, then refreshes once at the end. The e2e gateway fixture also registers the local-discovery mock after the provider wildcard so Playwright route precedence keeps the request in the mock layer. Coverage now includes the stale-discovery modal reopen regression, optimistic rollback preserving model selection, one-refresh quick-add behavior, exact detected-provider e2e assertions, and explicit Ollama discovery states for installed/stopped, running with no models, and running with models. Verified with: - GOCACHE=/Users/chicoxyzzy/dev/hecate/.gocache go test ./internal/api -run 'TestDiscoverLocalProviders' - cd ui && bun run typecheck - cd ui && bun run test -- ChatView ProvidersView useRuntimeConsole api provider-utils - cd ui && bunx playwright test e2e/chat.spec.ts e2e/providers.spec.ts e2e/provider-lifecycle.spec.ts --- .../handler_local_provider_discovery_test.go | 78 +++++++++++++++++++ ui/e2e/chat.spec.ts | 4 +- ui/e2e/fixtures.ts | 70 +++++++++-------- ui/src/app/useRuntimeConsole.test.tsx | 6 ++ ui/src/app/useRuntimeConsole.ts | 28 +++++-- ui/src/features/chats/ChatView.test.tsx | 8 +- ui/src/features/chats/ChatView.tsx | 3 +- .../features/providers/AddProviderModal.tsx | 7 +- .../features/providers/ProvidersView.test.tsx | 37 +++++++++ 9 files changed, 193 insertions(+), 48 deletions(-) diff --git a/internal/api/handler_local_provider_discovery_test.go b/internal/api/handler_local_provider_discovery_test.go index df408ac5c..6ccb3b473 100644 --- a/internal/api/handler_local_provider_discovery_test.go +++ b/internal/api/handler_local_provider_discovery_test.go @@ -94,6 +94,84 @@ func TestDiscoverLocalProvidersChecksCommandPresence(t *testing.T) { } } +func TestDiscoverLocalProvidersOllamaInstalledStoppedAndRunning(t *testing.T) { + t.Parallel() + + providers := []config.BuiltInProvider{ + {ID: "ollama", Name: "Ollama", Kind: "local", BaseURL: "http://127.0.0.1:11434/v1"}, + } + lookPath := func(command string) (string, error) { + if command == "ollama" { + return "/usr/local/bin/ollama", nil + } + return "", errors.New("missing") + } + + tests := []struct { + name string + rt *localProviderRoundTrip + wantStatus string + wantHTTP bool + wantModelList []string + }{ + { + name: "stopped", + rt: &localProviderRoundTrip{ + err: map[string]error{ + "http://127.0.0.1:11434/api/tags": errors.New("connection refused"), + }, + }, + wantStatus: "installed", + wantHTTP: false, + }, + { + name: "running without models", + rt: &localProviderRoundTrip{ + body: map[string]string{ + "http://127.0.0.1:11434/api/tags": `{"models":[]}`, + }, + }, + wantStatus: "running", + wantHTTP: true, + }, + { + name: "running", + rt: &localProviderRoundTrip{ + body: map[string]string{ + "http://127.0.0.1:11434/api/tags": `{"models":[{"name":"llama3.1:8b"},{"name":"qwen2.5:7b"}]}`, + }, + }, + wantStatus: "running", + wantHTTP: true, + wantModelList: []string{"llama3.1:8b", "qwen2.5:7b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + items := discoverLocalProviders(context.Background(), providers, lookPath, tt.rt) + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + item := items[0] + if item.Status != tt.wantStatus { + t.Fatalf("status = %q, want %q", item.Status, tt.wantStatus) + } + if !item.CommandAvailable || item.Command != "ollama" || item.CommandPath != "/usr/local/bin/ollama" { + t.Fatalf("command detection = command %q available %v path %q", item.Command, item.CommandAvailable, item.CommandPath) + } + if item.HTTPAvailable != tt.wantHTTP { + t.Fatalf("HTTPAvailable = %v, want %v", item.HTTPAvailable, tt.wantHTTP) + } + if strings.Join(item.Models, ",") != strings.Join(tt.wantModelList, ",") { + t.Fatalf("models = %#v, want %#v", item.Models, tt.wantModelList) + } + }) + } +} + func TestLocalProviderProbeURLUsesOllamaNativeTagsEndpoint(t *testing.T) { t.Parallel() diff --git a/ui/e2e/chat.spec.ts b/ui/e2e/chat.spec.ts index 8e291ef0f..11db83d57 100644 --- a/ui/e2e/chat.spec.ts +++ b/ui/e2e/chat.spec.ts @@ -199,8 +199,8 @@ test("empty model chat can add all detected local providers in one click", async await switchToModel(page); await expect(page.getByText("Detected locally")).toBeVisible(); - await expect(page.getByText("Ollama")).toBeVisible(); - await expect(page.getByText("LM Studio")).toBeVisible(); + await expect(page.getByText("Ollama", { exact: true })).toBeVisible(); + await expect(page.getByText("LM Studio", { exact: true })).toBeVisible(); await expect(page.getByText("Installed")).toBeVisible(); await expect(page.getByText("Running")).toBeVisible(); diff --git a/ui/e2e/fixtures.ts b/ui/e2e/fixtures.ts index db8c6c146..3cd5f3572 100644 --- a/ui/e2e/fixtures.ts +++ b/ui/e2e/fixtures.ts @@ -221,40 +221,6 @@ export async function mockGatewayAPIs(page: Page, opts: GatewayMockOptions = {}) body: JSON.stringify({ object: "configured_state", data: state }) }); }); - await page.route("/admin/control-plane/providers/local-discovery", async route => { - await route.fulfill(ok({ - object: "local_provider_discovery", - data: [ - { - preset_id: "ollama", - name: "Ollama", - base_url: "http://127.0.0.1:11434/v1", - probe_url: "http://127.0.0.1:11434/api/tags", - status: "installed", - command: "ollama", - command_available: true, - command_path: "/usr/local/bin/ollama", - http_available: false, - model_count: 0, - models: [], - }, - { - preset_id: "lmstudio", - name: "LM Studio", - base_url: "http://127.0.0.1:1234/v1", - probe_url: "http://127.0.0.1:1234/v1/models", - status: "running", - command: "lms", - command_available: true, - command_path: "/Users/alice/.lmstudio/bin/lms", - http_available: true, - model_count: 1, - models: ["qwen2.5"], - }, - ], - })); - }); - // POST /admin/control-plane/providers → create. Slugifies the name to id, // appends to the in-memory list, and returns 201. Stateful so the next // GET /admin/control-plane reflects the new row. @@ -359,6 +325,42 @@ export async function mockGatewayAPIs(page: Page, opts: GatewayMockOptions = {}) await route.continue(); }); + // Register after the provider wildcard above: Playwright resolves routes in + // reverse order, and /providers/* would otherwise shadow this exact probe. + await page.route("/admin/control-plane/providers/local-discovery", async route => { + await route.fulfill(ok({ + object: "local_provider_discovery", + data: [ + { + preset_id: "ollama", + name: "Ollama", + base_url: "http://127.0.0.1:11434/v1", + probe_url: "http://127.0.0.1:11434/api/tags", + status: "installed", + command: "ollama", + command_available: true, + command_path: "/usr/local/bin/ollama", + http_available: false, + model_count: 0, + models: [], + }, + { + preset_id: "lmstudio", + name: "LM Studio", + base_url: "http://127.0.0.1:1234/v1", + probe_url: "http://127.0.0.1:1234/v1/models", + status: "running", + command: "lms", + command_available: true, + command_path: "/Users/alice/.lmstudio/bin/lms", + http_available: true, + model_count: 1, + models: ["qwen2.5"], + }, + ], + })); + }); + const emptyPricebookImportDiff = { fetched_at: "2026-04-25T00:00:00Z", added: [], diff --git a/ui/src/app/useRuntimeConsole.test.tsx b/ui/src/app/useRuntimeConsole.test.tsx index d58455980..d8affc8d7 100644 --- a/ui/src/app/useRuntimeConsole.test.tsx +++ b/ui/src/app/useRuntimeConsole.test.tsx @@ -442,6 +442,10 @@ describe("useRuntimeConsole", () => { const { result } = renderHook(() => useRuntimeConsole()); await waitFor(() => expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai", "ollama"])); + await act(async () => { + result.current.actions.setProviderFilter("ollama"); + result.current.actions.setModel("llama3.1:8b"); + }); await act(async () => { await result.current.actions.deleteProvider("ollama"); @@ -449,6 +453,8 @@ describe("useRuntimeConsole", () => { expect(result.current.state.controlPlaneConfig?.providers.map(p => p.id)).toEqual(["openai", "ollama"]); expect(result.current.state.providers.map(p => p.name)).toEqual(["openai", "ollama"]); + expect(result.current.state.providerFilter).toBe("ollama"); + expect(result.current.state.model).toBe("llama3.1:8b"); expect(result.current.state.notice).toEqual({ kind: "error", message: "Failed to remove provider." }); expect(result.current.state.controlPlaneError).toContain("provider is still referenced"); }); diff --git a/ui/src/app/useRuntimeConsole.ts b/ui/src/app/useRuntimeConsole.ts index bb2c9af9a..2e80b9361 100644 --- a/ui/src/app/useRuntimeConsole.ts +++ b/ui/src/app/useRuntimeConsole.ts @@ -1071,16 +1071,22 @@ export function useRuntimeConsole() { }); } - async function createProvider(params: { name: string; preset_id?: string; custom_name?: string; base_url?: string; api_key?: string; kind: string; protocol: string }): Promise { + async function createProvider( + params: { name: string; preset_id?: string; custom_name?: string; base_url?: string; api_key?: string; kind: string; protocol: string }, + options: { refresh?: boolean } = {}, + ): Promise { await createProviderRequest(params); - await loadDashboard(); + if (options.refresh !== false) { + await loadDashboard(); + } } async function deleteProvider(id: string): Promise { resetControlPlaneFeedback(); - const previousControlPlaneConfig = controlPlaneConfig; - const previousProviders = providers; + const removedConfiguredProvider = controlPlaneConfig?.providers.find(provider => provider.id === id); + const removedProviderStatus = providers.find(provider => provider.name === id); const previousProviderFilter = providerFilter; + const previousModel = model; setControlPlaneConfig(current => current ? { ...current, providers: current.providers.filter(provider => provider.id !== id) } @@ -1096,11 +1102,21 @@ export function useRuntimeConsole() { setNoticeMessage("success", "Provider removed."); void loadDashboard(); } catch (error) { - setControlPlaneConfig(previousControlPlaneConfig); - setProviders(previousProviders); + setControlPlaneConfig(current => { + if (!removedConfiguredProvider) return current; + if (!current) return controlPlaneConfig; + if (current.providers.some(provider => provider.id === id)) return current; + return { ...current, providers: [...current.providers, removedConfiguredProvider] }; + }); + setProviders(current => { + if (!removedProviderStatus || current.some(provider => provider.name === id)) return current; + return [...current, removedProviderStatus]; + }); setProviderFilter(previousProviderFilter); + setModel(previousModel); setControlPlaneError(describeError(error, "failed to delete provider")); setNoticeMessage("error", "Failed to remove provider."); + void refreshProviders(); } } diff --git a/ui/src/features/chats/ChatView.test.tsx b/ui/src/features/chats/ChatView.test.tsx index 2a2258ae4..ce37bffb7 100644 --- a/ui/src/features/chats/ChatView.test.tsx +++ b/ui/src/features/chats/ChatView.test.tsx @@ -145,6 +145,7 @@ describe("ChatView input", () => { ], }); const createProvider = vi.fn(async () => undefined); + const loadDashboard = vi.fn(async () => undefined); const { state, actions } = setup({ chatTarget: "model", controlPlaneConfig: { backend: "memory", providers: [], policy_rules: [], pricebook: [], events: [] }, @@ -156,7 +157,7 @@ describe("ChatView input", () => { agentAdapters: [ { id: "codex", name: "Codex", kind: "acp", command: "codex-acp", available: true, status: "available", cost_mode: "external" }, ], - }, { createProvider }); + }, { createProvider, loadDashboard }); render(); const user = userEvent.setup(); @@ -171,14 +172,15 @@ describe("ChatView input", () => { base_url: "http://127.0.0.1:11434/v1", kind: "local", protocol: "openai", - })); + }), { refresh: false }); expect(createProvider).toHaveBeenNthCalledWith(2, expect.objectContaining({ name: "LM Studio", preset_id: "lmstudio", base_url: "http://127.0.0.1:1234/v1", kind: "local", protocol: "openai", - })); + }), { refresh: false }); + expect(loadDashboard).toHaveBeenCalledTimes(1); }); it("shows a first-run setup state when providers and agents are unavailable", () => { diff --git a/ui/src/features/chats/ChatView.tsx b/ui/src/features/chats/ChatView.tsx index 471131f5a..1ae6db88c 100644 --- a/ui/src/features/chats/ChatView.tsx +++ b/ui/src/features/chats/ChatView.tsx @@ -291,8 +291,9 @@ export function ChatView({ state, actions }: Props) { base_url: discovery.base_url || preset.base_url, kind: preset.kind, protocol: preset.protocol ?? "openai", - }); + }, { refresh: false }); } + await actions.loadDashboard(); } catch (error) { setQuickLocalError(error instanceof Error ? error.message : "Failed to add detected providers"); } finally { diff --git a/ui/src/features/providers/AddProviderModal.tsx b/ui/src/features/providers/AddProviderModal.tsx index 9c83a8481..4362ed9e7 100644 --- a/ui/src/features/providers/AddProviderModal.tsx +++ b/ui/src/features/providers/AddProviderModal.tsx @@ -62,6 +62,9 @@ export function AddProviderModal({ open, state, actions, onClose }: Props) { setPreset(null); setForm(emptyAddForm("local")); setError(""); + setLocalDiscovery([]); + setLocalDiscoveryLoading(false); + setLocalDiscoveryError(""); }, [open]); useEffect(() => { @@ -193,7 +196,7 @@ export function AddProviderModal({ open, state, actions, onClose }: Props) { onClick={() => pickPreset(p)} /> ))} - pickCustom(pickTab)} /> + pickCustom(pickTab)} /> ); @@ -413,7 +416,7 @@ function PresetButton({ ); } -function CustomButton({ onClick }: { kind: "cloud" | "local"; onClick: () => void }) { +function CustomButton({ onClick }: { onClick: () => void }) { return (