From 5b007452c96e50b28ba446688e3efbb27497d065 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:38:24 +0700 Subject: [PATCH 01/11] feat(server): accept loopback and LAN endpoints for user-defined providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateProviderBaseURL only exempted loopback, and only for the built-in local catalogue entries, so a custom provider could never point at 127.0.0.1 or a LAN address — exactly the services people name themselves (llama.cpp on localhost, a box on the home network). Split the core into validateProviderBaseURLWithOptions with a separate allowPrivate tier next to allowLocal, and add validateCustomProviderBaseURL for user-defined endpoints: loopback and private ranges pass, while link-local stays blocked for everyone because it carries the cloud metadata endpoints. The existing allowLocal-only semantics (loopback and nothing else) are preserved for the catalogue entries, so the SSRF posture elsewhere is unchanged. --- internal/server/security.go | 35 +++++++++++++++++++++++-- internal/server/security_test.go | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/internal/server/security.go b/internal/server/security.go index eb0de8d..0845a6f 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -152,6 +152,37 @@ func dns64AddressMatches(ip net.IP, prefixes []nat64Prefix, publicV4 map[string] // for the same host; discovery failure is fail-closed. func validateProviderBaseURLWithResolver( ctx context.Context, raw string, allowLocal bool, resolver providerIPResolver, +) error { + return validateProviderBaseURLWithOptions(ctx, raw, allowLocal, false, resolver) +} + +// providerIPPermitted reports whether an otherwise-blocked address may be used. +// allowLocal (built-in local catalogue entries) admits loopback only. A custom +// user-defined endpoint (allowPrivate) is the user pointing Antares at their +// own service, so private/LAN ranges are accepted there as well. Link-local +// stays blocked for both — it carries the cloud metadata endpoints. +func providerIPPermitted(ip net.IP, allowLocal, allowPrivate bool) bool { + if allowLocal && ip.IsLoopback() { + return true + } + if allowPrivate && (ip.IsLoopback() || ip.IsPrivate()) { + return true + } + return false +} + +// validateCustomProviderBaseURL validates a user-defined provider endpoint: +// the user names the service, so loopback and LAN addresses are allowed. +func (s *Server) validateCustomProviderBaseURL(ctx context.Context, raw string) error { + resolver := s.providerResolver + if resolver == nil { + resolver = net.DefaultResolver + } + return validateProviderBaseURLWithOptions(ctx, raw, true, true, resolver) +} + +func validateProviderBaseURLWithOptions( + ctx context.Context, raw string, allowLocal, allowPrivate bool, resolver providerIPResolver, ) error { raw = strings.TrimSpace(raw) if raw == "" { @@ -170,7 +201,7 @@ func validateProviderBaseURLWithResolver( host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") if ip := net.ParseIP(host); ip != nil { - if providerIPBlocked(ip) && !(allowLocal && ip.IsLoopback()) { + if providerIPBlocked(ip) && !providerIPPermitted(ip, allowLocal, allowPrivate) { return providerIPError(ip) } return nil @@ -189,7 +220,7 @@ func validateProviderBaseURLWithResolver( publicV4 := map[string]struct{}{} var blockedV6 []net.IP for _, ip := range ips { - blocked := providerIPBlocked(ip) && !(allowLocal && ip.IsLoopback()) + blocked := providerIPBlocked(ip) && !providerIPPermitted(ip, allowLocal, allowPrivate) if !blocked { if v4 := ip.To4(); v4 != nil && !providerIPBlocked(v4) { publicV4[v4.String()] = struct{}{} diff --git a/internal/server/security_test.go b/internal/server/security_test.go index af78e0e..9f8922d 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -416,3 +416,48 @@ func TestDashboardGateAcceptsAllowlistedQueryCapability(t *testing.T) { t.Fatalf("ordinary query token bypassed dashboard gate: code=%d body=%s", rr.Code, rr.Body.String()) } } + +func TestValidateCustomProviderBaseURL(t *testing.T) { + var s Server + ctx := context.Background() + // The endpoints a user points Antares at: loopback, LAN, and public. + for _, raw := range []string{ + "http://localhost:20128/v1", + "http://127.0.0.1:20128/v1", + "http://192.168.1.10:8080/v1", + "http://10.0.0.8/v1", + "https://openrouter.ai/api/v1", + } { + if err := s.validateCustomProviderBaseURL(ctx, raw); err != nil { + t.Errorf("validateCustomProviderBaseURL(%q): %v", raw, err) + } + } + // Cloud metadata and other link-local targets stay blocked even for custom + // providers, and so do non-HTTP schemes. + for _, raw := range []string{ + "http://169.254.169.254/latest/meta-data", + "http://[fe80::1]/v1", + "ftp://192.168.1.10/v1", + } { + if err := s.validateCustomProviderBaseURL(ctx, raw); err == nil { + t.Errorf("validateCustomProviderBaseURL(%q) accepted a blocked destination", raw) + } + } +} + +func TestCustomProviderID(t *testing.T) { + cfg := &config.Config{Providers: map[string]config.Provider{}} + if got := customProviderID(cfg, "My LM server"); got != "my-lm-server" { + t.Errorf("customProviderID slugged to %q, want my-lm-server", got) + } + if got := customProviderID(cfg, ""); got != "custom" { + t.Errorf("unnamed custom provider became %q, want the legacy custom slot", got) + } + cfg.Providers["my-lm-server"] = config.Provider{} + if got := customProviderID(cfg, "My LM server"); got != "my-lm-server-2" { + t.Errorf("duplicate name became %q, want my-lm-server-2", got) + } + if got := customProviderID(cfg, "OpenAI"); got != "openai-2" { + t.Errorf("catalogue clash became %q, want openai-2", got) + } +} From d87da0a4b6859312a32dc2466d4cabe103ed59e0 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:38:37 +0700 Subject: [PATCH 02/11] feat(server): unlimited named custom providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single catalogue slot was the only "Something else" a user could reach: /providers/{id}/key rejected any id outside the catalogue, so a second custom endpoint meant hand-editing YAML, and its base URL was validated with allowLocal=false even when it pointed at localhost. Rework the custom entry into a proper user-defined provider: - The catalogue entry becomes "Custom provider" and is marked Custom; a name is minted into a unique config id (my-lm-server, my-lm-server-2, ...) so any number can coexist. An unnamed one keeps the legacy "custom" slot, and a previously configured "custom" entry continues to work unchanged — it just renders as a custom provider now. - setup/complete accepts a name for it; setup/test, /providers/{id}/key and /providers/{id}/settings validate its base URL with the custom rules, and the key endpoint no longer requires a key for custom providers (a keyless LAN service is legitimate) while still managing config-only ids instead of rejecting them. - New POST /api/providers creates a named custom provider (verifying the endpoint/key pair first) and DELETE /api/providers/{id} removes one; built-ins and the active provider are refused. - /model/options omits the unconfigured custom slot — the UI shows an empty add-provider card in its place — and marks every custom provider with custom:true, never local, so it groups under "API key" even when its endpoint is 127.0.0.1. --- internal/server/handlers_config.go | 16 ++- internal/server/handlers_providers.go | 153 ++++++++++++++++++++++++-- internal/server/handlers_setup.go | 131 +++++++++++++++++++--- internal/server/routes.go | 2 + 4 files changed, 276 insertions(+), 26 deletions(-) diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index cb42a2a..23d10e7 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -165,13 +165,24 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { NeedsAPIVersion bool `json:"needs_api_version,omitempty"` NeedsBaseURL bool `json:"needs_base_url,omitempty"` TimeoutSecs int `json:"timeout_seconds,omitempty"` + // Custom marks a user-defined provider. Customs always group under + // "API key" — even a localhost endpoint is a configured service, not + // one of the built-in local runtimes. + Custom bool `json:"custom,omitempty"` } // Every provider from the catalogue (configured or not), so the new kinds // can be set up from here — then any custom providers only in the config. + // The unconfigured "custom" slot is omitted: the UI shows an empty + // add-provider card in its place. seen := map[string]bool{} providerList := make([]providerInfo, 0) for _, sp := range setupProviderCatalogue(cfg) { + if sp.Custom { + if _, configured := cfg.Providers[sp.ID]; !configured { + continue + } + } p := cfg.Providers[sp.ID] providerList = append(providerList, providerInfo{ ID: sp.ID, Label: sp.Label, Kind: sp.Kind, @@ -179,7 +190,7 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { BaseURL: firstNonEmpty(p.BaseURL, sp.BaseURL), Active: sp.ID == cfg.Model.Provider, Hint: sp.Hint, KeyHint: sp.KeyHint, KeyURL: sp.KeyURL, KeyLabel: sp.KeyLabel, Note: sp.Note, NeedsRegion: sp.NeedsRegion, NeedsAPIVersion: sp.NeedsAPIVersion, - NeedsBaseURL: sp.NeedsBaseURL, TimeoutSecs: p.TimeoutSecs, + NeedsBaseURL: sp.NeedsBaseURL, TimeoutSecs: p.TimeoutSecs, Custom: sp.Custom, }) seen[sp.ID] = true } @@ -194,8 +205,9 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { p := cfg.Providers[name] providerList = append(providerList, providerInfo{ ID: name, Label: firstNonEmpty(p.Label, name), Kind: p.Kind, Enabled: p.Enabled, - HasKey: p.APIKey != "", Local: isLocalEndpoint(p.BaseURL), BaseURL: p.BaseURL, + HasKey: p.APIKey != "", BaseURL: p.BaseURL, Active: name == cfg.Model.Provider, TimeoutSecs: p.TimeoutSecs, + Custom: true, NeedsBaseURL: true, }) } diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index ddecced..4d64d09 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -1,11 +1,14 @@ package server import ( + "context" "errors" "net/http" "strings" + "time" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" "github.com/enowdev/antares/internal/providers" ) @@ -171,6 +174,7 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) id := r.PathValue("id") var body struct { BaseURL *string `json:"base_url"` + Label *string `json:"label"` TimeoutSecs *int `json:"timeout_seconds"` Headers map[string]string `json:"headers"` } @@ -184,23 +188,38 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) return } p := cfg.Providers[id] + // Custom providers (user-named entries, plus the legacy "custom" slot) may + // point at loopback or LAN addresses; built-ins keep their catalogue rule. + custom := true + allowLocal := false + for _, provider := range setupProviderCatalogue(cfg) { + if provider.ID == id { + custom = provider.Custom + allowLocal = provider.Local + break + } + } if body.BaseURL != nil { baseURL := strings.TrimSpace(*body.BaseURL) - allowLocal := false - for _, provider := range setupProviderCatalogue(cfg) { - if provider.ID == id { - allowLocal = provider.Local - break - } - } if baseURL != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, allowLocal); err != nil { + var err error + if custom { + err = s.validateCustomProviderBaseURL(r.Context(), baseURL) + } else { + err = s.validateProviderBaseURL(r.Context(), baseURL, allowLocal) + } + if err != nil { writeError(w, http.StatusBadRequest, err) return } } p.BaseURL = baseURL } + if body.Label != nil { + if label := strings.TrimSpace(*body.Label); label != "" { + p.Label = label + } + } if body.TimeoutSecs != nil { p.TimeoutSecs = *body.TimeoutSecs } @@ -219,3 +238,121 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } + +// handleCreateProvider adds a user-defined provider: a name, an +// OpenAI-compatible base URL, and an optional key. Any number may exist, and +// loopback/LAN endpoints are accepted — the user is pointing Antares at their +// own service. +func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + var body struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + writeError(w, http.StatusBadRequest, errors.New("a name is required")) + return + } + baseURL := strings.TrimSpace(body.BaseURL) + if baseURL == "" { + writeError(w, http.StatusBadRequest, errors.New("a base URL is required")) + return + } + if err := s.validateCustomProviderBaseURL(r.Context(), baseURL); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + cfg, err := config.Reload() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + id := customProviderID(cfg, name) + + // Verify the pair now so a bad endpoint or key surfaces at creation time + // rather than on the first turn. A keyless service is allowed. + key := strings.TrimSpace(body.APIKey) + if key != "" { + client, err := llm.New(llm.Options{ + Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, + ProviderID: id, Timeout: 30 * time.Second, + }) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if _, err := client.Models(ctx); err != nil { + if llm.IsAuthError(err) { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) + return + } + if !llm.IsUnsupported(err) { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "ok": false, "error": "The provider could not be reached or returned an invalid response: " + err.Error(), + }) + return + } + } + } + + cfg.Providers[id] = config.Provider{ + Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, + Enabled: true, Label: name, + } + if err := config.Save(cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := s.applyReload(); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) +} + +// handleDeleteProvider removes a user-defined provider. Built-in catalogue +// entries and the active provider are refused. +func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + id := r.PathValue("id") + if isCatalogueProviderID(id) { + writeError(w, http.StatusBadRequest, errors.New("built-in providers cannot be deleted")) + return + } + cfg, err := config.Reload() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if _, ok := cfg.Providers[id]; !ok { + writeError(w, http.StatusNotFound, errors.New("unknown provider")) + return + } + if cfg.Model.Provider == id { + writeError(w, http.StatusBadRequest, errors.New("cannot delete the active provider")) + return + } + delete(cfg.Providers, id) + if err := config.Save(cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := s.applyReload(); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index 5d15bae..9743e6d 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -38,6 +38,9 @@ type setupProvider struct { NeedsAPIVersion bool `json:"needs_api_version,omitempty"` // NeedsBaseURL forces the endpoint field (e.g. the Azure resource URL). NeedsBaseURL bool `json:"needs_base_url,omitempty"` + // Custom marks a user-defined provider: the user names it and points + // Antares at any OpenAI-compatible endpoint, local or remote. + Custom bool `json:"custom,omitempty"` } func setupProviderCatalogue(cfg *config.Config) []setupProvider { @@ -134,8 +137,9 @@ func setupProviderCatalogue(cfg *config.Config) []setupProvider { BaseURL: "https://api.openai.com/v1", }, { - ID: "custom", Label: "Something else", Kind: "openai-compatible", - Hint: "Any OpenAI-compatible endpoint.", + ID: "custom", Label: "Custom provider", Kind: "openai-compatible", + Hint: "Any OpenAI-compatible endpoint — name it yourself.", + NeedsBaseURL: true, Custom: true, }, } for i := range out { @@ -210,7 +214,13 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) if baseURL != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + var err error + if chosen.Custom { + err = s.validateCustomProviderBaseURL(r.Context(), baseURL) + } else { + err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) + } + if err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -282,6 +292,7 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { var body struct { Provider string `json:"provider"` + Name string `json:"name"` BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Model string `json:"model"` @@ -332,27 +343,42 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("unknown provider")) return } + // A custom provider is stored under an id minted from the user's name, so + // more than one can exist. An unnamed one keeps the legacy "custom" slot. + providerID := body.Provider + if chosen.Custom { + providerID = customProviderID(cfg, body.Name) + } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) if baseURL != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + var err error + if chosen.Custom { + err = s.validateCustomProviderBaseURL(r.Context(), baseURL) + } else { + err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) + } + if err != nil { writeError(w, http.StatusBadRequest, err) return } } - entry := cfg.Providers[body.Provider] + entry := cfg.Providers[providerID] entry.Kind = chosen.Kind entry.Enabled = true entry.Label = chosen.Label + if name := strings.TrimSpace(body.Name); chosen.Custom && name != "" { + entry.Label = name + } if baseURL != "" { entry.BaseURL = baseURL } if key := strings.TrimSpace(body.APIKey); key != "" && !strings.Contains(key, "••••") { entry.APIKey = key } - cfg.Providers[body.Provider] = entry + cfg.Providers[providerID] = entry - cfg.Model.Provider = body.Provider + cfg.Model.Provider = providerID cfg.Model.Default = strings.TrimSpace(body.Model) if ws := strings.TrimSpace(body.Workspace); ws != "" { @@ -479,18 +505,36 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { break } } + entry, exists := cfg.Providers[id] if chosen == nil { - writeError(w, http.StatusBadRequest, errors.New("unknown provider")) - return + // Not in the catalogue: still manageable when it is a user-defined + // custom provider already present in the config. + if !exists { + writeError(w, http.StatusBadRequest, errors.New("unknown provider")) + return + } } - - entry := cfg.Providers[id] + custom := chosen == nil || chosen.Custom if entry.Kind == "" { - entry.Kind = chosen.Kind + if chosen != nil { + entry.Kind = chosen.Kind + } else { + entry.Kind = "openai-compatible" + } } - baseURL := firstNonEmpty(body.BaseURL, entry.BaseURL, chosen.BaseURL) + var catalogueBaseURL string + if chosen != nil { + catalogueBaseURL = chosen.BaseURL + } + baseURL := firstNonEmpty(body.BaseURL, entry.BaseURL, catalogueBaseURL) if baseURL != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + var err error + if custom { + err = s.validateCustomProviderBaseURL(r.Context(), baseURL) + } else { + err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) + } + if err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -499,7 +543,8 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { apiVersion := firstNonEmpty(body.APIVersion, entry.APIVersion) key := strings.TrimSpace(body.APIKey) // Bedrock takes its credentials from the AWS environment, so no key here. - if key == "" && entry.Kind != "bedrock" && !isLocalEndpoint(baseURL) { + // Custom providers may be keyless services on a LAN, so no key is forced. + if key == "" && !custom && entry.Kind != "bedrock" && !isLocalEndpoint(baseURL) { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "An API key is required."}) return } @@ -537,7 +582,11 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { entry.APIVersion = apiVersion entry.Enabled = true if entry.Label == "" { - entry.Label = chosen.Label + if chosen != nil { + entry.Label = chosen.Label + } else { + entry.Label = id + } } cfg.Providers[id] = entry @@ -551,3 +600,53 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": len(models)}) } + +// slugifyProviderName turns a display name into a config id: lowercase +// alphanumerics with dashes for everything else. +func slugifyProviderName(name string) string { + var b strings.Builder + prevDash := true // suppresses a leading dash + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + return strings.Trim(b.String(), "-") +} + +// isCatalogueProviderID reports whether id names a built-in provider. +func isCatalogueProviderID(id string) bool { + for _, p := range setupProviderCatalogue(&config.Config{}) { + if p.ID == id { + return true + } + } + return false +} + +// customProviderID mints a unique config id for a user-named provider. A name +// that slugs to nothing (or to the legacy slot itself) keeps "custom", so +// configs written before named custom providers existed stay valid. +func customProviderID(cfg *config.Config, name string) string { + slug := slugifyProviderName(name) + if slug == "" { + slug = "custom" + } + if slug == "custom" { + // The legacy single slot: reuse it rather than minting custom-2. + return "custom" + } + base := slug + for i := 2; ; i++ { + if _, taken := cfg.Providers[slug]; !taken && !isCatalogueProviderID(slug) { + return slug + } + slug = fmt.Sprintf("%s-%d", base, i) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 0d4ac63..7a6291a 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -91,6 +91,8 @@ func (s *Server) routes() { m.HandleFunc("GET /api/model/list", s.handleModelList) m.HandleFunc("GET /api/model/list-all", s.handleModelListAll) m.HandleFunc("POST /api/model/set", s.handleModelSet) + m.HandleFunc("POST /api/providers", s.handleCreateProvider) + m.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider) m.HandleFunc("POST /api/providers/{id}/key", s.handleSetProviderKey) m.HandleFunc("GET /api/providers/{id}/model-info", s.handleProviderModelInfo) m.HandleFunc("POST /api/providers/{id}/model", s.handleAddProviderModel) From 1335ff79e0950263ad08a310ece9dc3bfcfe81c9 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:38:47 +0700 Subject: [PATCH 03/11] feat(setup): name custom providers in the terminal wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal picker still offered an anonymous "Something else" that always wrote the single "custom" slot. Rename it to "Custom provider", ask for a name, and store the entry under an id minted from that name — so the terminal wizard and the web wizard produce the same named providers. No name keeps the legacy "custom" slot; pickModel now resolves against cfg.Model.Provider, which the name step may have changed after the picker ran. --- cmd/antares/setup.go | 54 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index f938f0f..41bedfa 100644 --- a/cmd/antares/setup.go +++ b/cmd/antares/setup.go @@ -250,8 +250,8 @@ var providerChoices = []providerChoice{ hint: "runs on this machine, no key needed", }, { - id: "custom", label: "Something else", - hint: "any OpenAI-compatible endpoint", + id: "custom", label: "Custom provider", + hint: "any OpenAI-compatible endpoint, named by you", }, } @@ -284,6 +284,20 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { // 2. Endpoint for custom / local providers switch chosen.id { case "custom": + // A custom provider is named by the user and stored under an id minted + // from that name, so several can coexist. No name keeps the legacy + // "custom" slot. + name := promptLine("\n Provider name (shown in the UI): ", "") + if name != "" { + id := uniqueCustomProviderID(cfg, name) + cfg.Model.Provider = id + entry = cfg.Providers[id] + if entry.Kind == "" { + entry.Kind = "openai-compatible" + } + entry.Enabled = true + entry.Label = name + } entry.BaseURL = promptLine("\n Base URL (e.g. https://api.example.com/v1): ", entry.BaseURL) if entry.BaseURL == "" { return errors.New("a base URL is required for a custom provider") @@ -426,13 +440,47 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { return nil } +// uniqueCustomProviderID mints a config id from a display name, avoiding the +// built-in provider ids and ids already in use. The legacy "custom" slot is +// reused when the name gives nothing better. +func uniqueCustomProviderID(cfg *config.Config, name string) string { + var b strings.Builder + prevDash := true + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" || slug == "custom" { + return "custom" + } + builtin := map[string]bool{} + for _, p := range providerChoices { + builtin[p.id] = true + } + base := slug + for i := 2; ; i++ { + if _, taken := cfg.Providers[slug]; !taken && !builtin[slug] { + return slug + } + slug = fmt.Sprintf("%s-%d", base, i) + } +} + // pickModel offers the provider's catalogue when it can be listed, and falls // back to the curated suggestions when it cannot. func pickModel(ctx context.Context, cfg *config.Config, chosen providerChoice) string { listCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() - id, p := cfg.ResolveProvider(chosen.id) + id, p := cfg.ResolveProvider(cfg.Model.Provider) var live []llm.ModelInfo if client, err := llm.New(llm.Options{ Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, From 9b61bee73639a795a0aeba6b75aa6276aa20bbca Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:38:47 +0700 Subject: [PATCH 04/11] feat(web): custom provider management in Providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the always-present "Something else" card with an empty dashed add-provider card (plus icon) at the end of the API key group. It opens a small dialog that takes a name, a base URL, and an optional key and creates the provider via POST /api/providers — local addresses included, and there can be any number of them. Custom providers always group under "API key", never "Local", even when their endpoint is localhost: the card shows the configured service, not a built-in runtime. Their manage modal gains a name field under Advanced (rename through /providers/{id}/settings), a delete action, and the key is no longer forced — the backend verifies the pair either way. The first-run setup wizard asks for the provider name too when Custom provider is selected, and sends it with setup/complete. Locale strings land in en; the other dictionaries fall back to it. --- web/src/lib/i18n.tsx | 9 ++ web/src/pages/ProvidersPage.tsx | 161 +++++++++++++++++++++++++++++++- web/src/pages/SetupPage.tsx | 16 ++++ 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index fd662a3..ad86928 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -531,6 +531,15 @@ const en = { 'providers.groupDesc.oauth': 'Authorised with a device login instead of a pasted key.', 'providers.groupDesc.apikey': 'Paste an API key (or, for cloud providers, credentials come from the environment).', 'providers.groupDesc.local': 'Runs on this machine — no key needed.', + 'providers.addCustom': 'Add a custom provider', + 'providers.newTitle': 'Custom provider', + 'providers.newDesc': 'Any OpenAI-compatible endpoint — name it, paste a key if it needs one. Local addresses are fine.', + 'providers.name': 'Name', + 'providers.namePlaceholder': 'e.g. My inference server', + 'providers.keyOptional': 'Optional — leave blank if the endpoint needs no key.', + 'providers.deleteProvider': 'Delete provider', + 'setup.providerName': 'Provider name', + 'setup.providerNameHint': 'Shown in the provider list — more than one custom provider can exist.', 'models.allProviders': 'All providers', 'models.searchAll': 'Search all models…', 'models.pickModel': 'Model', diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index 33ade8d..5f8c7ca 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -8,6 +8,7 @@ import { EyeSlash, Key, Plugs, + Plus, ShieldCheck, Trash, } from '@phosphor-icons/react' @@ -49,6 +50,7 @@ interface ProviderInfo { needs_api_version?: boolean needs_base_url?: boolean timeout_seconds?: number + custom?: boolean } interface OptionsResponse { @@ -72,8 +74,11 @@ type Group = 'oauth' | 'apikey' | 'local' // How a provider authenticates decides its group. Only Copilot uses a device // (OAuth) flow today; local endpoints need no credential; everything else is an // API key (or cloud env credentials, which still live under "API key" here). +// A custom provider is always "API key" — even a localhost endpoint is a +// service the user configured, not a built-in local runtime. function groupOf(p: ProviderInfo): Group { if (p.kind === 'copilot') return 'oauth' + if (p.custom) return 'apikey' if (p.local) return 'local' return 'apikey' } @@ -114,6 +119,7 @@ function ProvidersTab({ onOpenModels }: { onOpenModels: () => void }) { const { t } = useI18n() const { data, loading, reload } = useApi('/model/options') const [target, setTarget] = useState(null) + const [creating, setCreating] = useState(false) const grouped = useMemo(() => { const g: Record = { oauth: [], apikey: [], local: [] } @@ -183,6 +189,15 @@ function ProvidersTab({ onOpenModels }: { onOpenModels: () => void }) { ))} + {g === 'apikey' ? ( + + ) : null} ), @@ -197,10 +212,119 @@ function ProvidersTab({ onOpenModels }: { onOpenModels: () => void }) { onChanged={reload} /> ) : null} + + {creating ? ( + setCreating(false)} + onChanged={reload} + /> + ) : null} ) } +/** + * Create a custom provider: a name, an OpenAI-compatible base URL, and an + * optional key. Local endpoints are accepted; the backend verifies the pair + * before saving. + */ +function AddProviderDialog({ + onClose, + onChanged, +}: { + onClose: () => void + onChanged: () => void +}) { + const { t } = useI18n() + const [name, setName] = useState('') + const [baseURL, setBaseURL] = useState('') + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + const create = async () => { + if (!name.trim() || !baseURL.trim()) return + setBusy(true) + setError(undefined) + try { + const r = await post<{ ok: boolean; error?: string }>('/providers', { + name: name.trim(), + base_url: baseURL.trim(), + api_key: key.trim(), + }) + if (!r.ok) { + setError(r.error ?? t('models.connectFailed')) + return + } + onChanged() + onClose() + } catch (e) { + setError((e as Error).message) + } finally { + setBusy(false) + } + } + + return ( + (!o ? onClose() : null)}> + + + {t('providers.newTitle')} + {t('providers.newDesc')} + + +
+ + setName(e.target.value)} + placeholder={t('providers.namePlaceholder')} + autoFocus + autoComplete="off" + /> +
+
+ + setBaseURL(e.target.value)} + placeholder="https://api.example.com/v1" + className="font-mono text-xs" + autoComplete="off" + /> +
+
+ + setKey(e.target.value)} + placeholder="sk-…" + autoComplete="off" + onKeyDown={(e) => e.key === 'Enter' && create()} + /> +

{t('providers.keyOptional')}

+
+ {error ? ( +

{error}

+ ) : null} +
+ + + + + + +
+
+ ) +} + /** * The Providers page hosts two tabs — Providers (connect/manage credentials) * and Models (pick the active model) — under one sidebar entry. The tab bar @@ -281,9 +405,12 @@ function ProviderModal({ const [modelBusy, setModelBusy] = useState(false) // Advanced + const [label, setLabel] = useState(p.label) const [timeout, setTimeoutSecs] = useState(String(p.timeout_seconds ?? '')) - const keyRequired = p.kind !== 'bedrock' && !p.local + // A custom provider may be a keyless service, so the key is not forced here; + // the backend verifies the pair either way. + const keyRequired = p.kind !== 'bedrock' && !p.local && !p.custom const canConnect = (!keyRequired || key.trim() !== '') && (!p.needs_base_url || baseURL.trim() !== '') && @@ -362,6 +489,7 @@ function ProviderModal({ await post(`/providers/${encodeURIComponent(p.id)}/settings`, { base_url: baseURL.trim(), timeout_seconds: timeout ? Number(timeout) : 0, + ...(p.custom ? { label: label.trim() } : {}), }) onChanged() } finally { @@ -369,6 +497,19 @@ function ProviderModal({ } } + const removeProvider = async () => { + setBusy(true) + try { + await del(`/providers/${encodeURIComponent(p.id)}`) + onChanged() + onClose() + } catch (e) { + setError((e as Error).message) + } finally { + setBusy(false) + } + } + const tabBtn = (id: Section, label: string) => ( + ) : null} diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index 37cee75..d32872a 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -73,6 +73,7 @@ export default function SetupPage() { const [step, setStep] = useState('provider') const [providerId, setProviderId] = useState('openrouter') + const [providerName, setProviderName] = useState('') const [baseURL, setBaseURL] = useState('') const [apiKey, setApiKey] = useState('') const [revealKey, setRevealKey] = useState(false) @@ -156,6 +157,7 @@ export default function SetupPage() { try { await post('/setup/complete', { provider: providerId, + name: providerName, base_url: baseURL || provider?.base_url || '', api_key: apiKey, model, @@ -243,6 +245,20 @@ export default function SetupPage() { ))} + {providerId === 'custom' ? ( +
+ + setProviderName(e.target.value)} + placeholder={t('providers.namePlaceholder')} + autoComplete="off" + /> +

{t('setup.providerNameHint')}

+
+ ) : null} + {providerId === 'custom' || provider?.local ? (
From 3993798f07a6a2f293cbd00ab0907f9885e1476c Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:49:20 +0700 Subject: [PATCH 05/11] fix(server): hide an unused legacy custom slot, not just an absent one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom entry was skipped only when providers.custom was missing from the config entirely. A config that had touched the old "Something else" picker without finishing it carries an empty custom entry (no key, no base URL), which still rendered as a "Custom provider — needs a key" card next to the new add-provider card. Skip the slot whenever it holds nothing worth showing — no credential and no endpoint — so the empty plus card replaces it there too. A custom entry with a key or a base URL is a live provider and keeps appearing. --- internal/server/handlers_config.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 23d10e7..146e0de 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -173,17 +173,15 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { // Every provider from the catalogue (configured or not), so the new kinds // can be set up from here — then any custom providers only in the config. - // The unconfigured "custom" slot is omitted: the UI shows an empty - // add-provider card in its place. + // The "custom" slot is shown only when it actually holds something; an + // unused one is replaced by the empty add-provider card in the UI. seen := map[string]bool{} providerList := make([]providerInfo, 0) for _, sp := range setupProviderCatalogue(cfg) { - if sp.Custom { - if _, configured := cfg.Providers[sp.ID]; !configured { - continue - } - } p := cfg.Providers[sp.ID] + if sp.Custom && !providerEntryInUse(p) { + continue + } providerList = append(providerList, providerInfo{ ID: sp.ID, Label: sp.Label, Kind: sp.Kind, Enabled: p.Enabled, HasKey: p.APIKey != "", Local: sp.Local, @@ -217,6 +215,13 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { }) } +// providerEntryInUse reports whether a config entry holds something worth +// showing: a credential or an endpoint. A custom slot with neither is a +// leftover from an abandoned pick, not a provider. +func providerEntryInUse(p config.Provider) bool { + return p.APIKey != "" || strings.TrimSpace(p.BaseURL) != "" +} + func (s *Server) handleModelList(w http.ResponseWriter, r *http.Request) { provider := r.URL.Query().Get("provider") cfg := s.config() From 6ed1e80deb284ad65dff75c810b2abd7fb3c15a4 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 10:57:16 +0700 Subject: [PATCH 06/11] fix(providers): make the legacy custom slot a real custom provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the old "Something else" slot still felt welded in: The key field disappeared for custom providers — keyRequired drove both whether a key was mandatory and whether the input rendered, so making it optional for customs hid it entirely and an existing key could not be changed. Split the two: the input now shows for every keyed provider and only the requirement is dropped for customs (bedrock and local runtimes stay keyless by design). The delete endpoint refused the "custom" id like any built-in. The legacy slot is user property, not a built-in: allow deleting it (and only it) alongside named custom providers, with a clearer message when the provider is still the active one. --- internal/server/handlers_providers.go | 6 ++++-- web/src/pages/ProvidersPage.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index 4d64d09..3abbeeb 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -328,7 +328,9 @@ func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { return } id := r.PathValue("id") - if isCatalogueProviderID(id) { + // The legacy "custom" slot behaves like any user-defined provider: it can + // be deleted. Other built-ins cannot. + if isCatalogueProviderID(id) && id != "custom" { writeError(w, http.StatusBadRequest, errors.New("built-in providers cannot be deleted")) return } @@ -342,7 +344,7 @@ func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { return } if cfg.Model.Provider == id { - writeError(w, http.StatusBadRequest, errors.New("cannot delete the active provider")) + writeError(w, http.StatusBadRequest, errors.New("this provider is active — pick another model before deleting it")) return } delete(cfg.Providers, id) diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index 5f8c7ca..960cb41 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -408,9 +408,11 @@ function ProviderModal({ const [label, setLabel] = useState(p.label) const [timeout, setTimeoutSecs] = useState(String(p.timeout_seconds ?? '')) - // A custom provider may be a keyless service, so the key is not forced here; - // the backend verifies the pair either way. + // A local runtime needs no key; bedrock takes AWS env credentials. A custom + // provider usually wants one but a keyless service is fine, so the field is + // shown yet optional there. const keyRequired = p.kind !== 'bedrock' && !p.local && !p.custom + const showKey = p.kind !== 'bedrock' && !p.local const canConnect = (!keyRequired || key.trim() !== '') && (!p.needs_base_url || baseURL.trim() !== '') && @@ -545,7 +547,7 @@ function ProviderModal({ setBaseURL(e.target.value)} autoComplete="off" />
) : null} - {keyRequired ? ( + {showKey ? (
@@ -563,6 +565,9 @@ function ProviderModal({ {reveal ? : }
+ {p.custom ? ( +

{t('providers.keyOptional')}

+ ) : null}
) : null} {p.needs_region ? ( From 29a46606d572872d15c74806c2e4bfe8d0f91f49 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 11:05:27 +0700 Subject: [PATCH 07/11] fix(providers): drop the built-in "Custom endpoint" entry for good MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.Default() seeded providers.custom (label "Custom endpoint", kind "custom") into every fresh config — that is where the unmanageable card came from, not the setup catalogue. The old UI could only ever write a base URL into it, which is why it looked connected-forever-but-keyless: "needs a key" with no way to set one. Stop seeding the entry, and never render the catalogue's custom slot on the providers page regardless of what an existing config holds — the slot is reserved for the first-run wizard, where it names a real provider. The providers page now lists built-ins plus only the user-created custom providers (named, renamable, key-settable, deletable). An old config's custom entry keeps functioning if a model still points at it; it just no longer appears as a card. --- internal/config/defaults.go | 6 +++--- internal/server/handlers_config.go | 17 ++++++----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index a36f295..5bafa9d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -41,9 +41,9 @@ func Default() *Config { Kind: "openai-compatible", Label: "LM Studio", Enabled: false, BaseURL: "http://127.0.0.1:1234/v1", TimeoutSecs: 600, }, - "custom": { - Kind: "custom", Label: "Custom endpoint", Enabled: false, TimeoutSecs: 300, - }, + // No default "custom" entry: user-defined providers are created on + // demand via POST /api/providers, named and deletable like any + // other. The legacy single "custom" slot is not seeded anymore. }, Database: Database{ Driver: "sqlite", DSN: filepath.Join(Home(), "antares.db"), diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 146e0de..9677d8b 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -173,15 +173,17 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { // Every provider from the catalogue (configured or not), so the new kinds // can be set up from here — then any custom providers only in the config. - // The "custom" slot is shown only when it actually holds something; an - // unused one is replaced by the empty add-provider card in the UI. + // The catalogue's "custom" entry belongs to the first-run wizard only: it + // never renders here, so the providers page shows solely built-ins and + // providers the user created (named, manageable, deletable). seen := map[string]bool{} providerList := make([]providerInfo, 0) for _, sp := range setupProviderCatalogue(cfg) { - p := cfg.Providers[sp.ID] - if sp.Custom && !providerEntryInUse(p) { + if sp.Custom { + seen[sp.ID] = true continue } + p := cfg.Providers[sp.ID] providerList = append(providerList, providerInfo{ ID: sp.ID, Label: sp.Label, Kind: sp.Kind, Enabled: p.Enabled, HasKey: p.APIKey != "", Local: sp.Local, @@ -215,13 +217,6 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { }) } -// providerEntryInUse reports whether a config entry holds something worth -// showing: a credential or an endpoint. A custom slot with neither is a -// leftover from an abandoned pick, not a provider. -func providerEntryInUse(p config.Provider) bool { - return p.APIKey != "" || strings.TrimSpace(p.BaseURL) != "" -} - func (s *Server) handleModelList(w http.ResponseWriter, r *http.Request) { provider := r.URL.Query().Get("provider") cfg := s.config() From 6ca1204fea96810c7cdc364ffdff29ef70925219 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 11:34:11 +0700 Subject: [PATCH 08/11] fix(providers): a blank key no longer wipes a custom provider's stored key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the key optional for custom providers let a blank Connect submit reach handleSetProviderKey, which wrote entry.APIKey verbatim — so reopening the manage modal (key field starts empty) and reconnecting to update the endpoint silently destroyed the saved credential, and the connection probe ran keyless on top. Treat a blank or redacted key as "keep what is stored", the convention the setup wizard already uses, and probe with the kept key. The modal now says so: a provider with a saved key shows the leave-blank-to-keep hint instead of the optional-key one. Covered by TestSetProviderKeyBlankKeepsStoredKey against a fake OpenAI-compatible endpoint, which also asserts the probe carried the kept key and that a real new key still replaces the old one. --- internal/server/handlers_setup.go | 6 ++ internal/server/provider_key_test.go | 98 ++++++++++++++++++++++++++++ web/src/pages/ProvidersPage.tsx | 4 +- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 internal/server/provider_key_test.go diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index 9743e6d..14bf435 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -541,7 +541,13 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { } region := firstNonEmpty(body.Region, entry.Region) apiVersion := firstNonEmpty(body.APIVersion, entry.APIVersion) + // A blank or redacted key means "keep what is stored" (same convention as + // the setup wizard): reconnecting to update the endpoint must not silently + // wipe the saved credential. The connection test runs with the kept key. key := strings.TrimSpace(body.APIKey) + if key == "" || strings.Contains(key, "••••") { + key = strings.TrimSpace(entry.APIKey) + } // Bedrock takes its credentials from the AWS environment, so no key here. // Custom providers may be keyless services on a LAN, so no key is forced. if key == "" && !custom && entry.Kind != "bedrock" && !isLocalEndpoint(baseURL) { diff --git a/internal/server/provider_key_test.go b/internal/server/provider_key_test.go new file mode 100644 index 0000000..5831004 --- /dev/null +++ b/internal/server/provider_key_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" +) + +// fakeOpenAI serves GET /v1/models the way an OpenAI-compatible endpoint does, +// recording the Authorization header each request carried so tests can tell +// which credential the connection probe used. +func fakeOpenAI(t *testing.T) (*httptest.Server, *[]string) { + t.Helper() + var auths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auths = append(auths, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"model-a","owned_by":"test"}]}`)) + })) + t.Cleanup(srv.Close) + return srv, &auths +} + +// newProviderKeyServer seeds an isolated ANTARES_HOME and returns a Server +// wired the way handleSetProviderKey expects. +func newProviderKeyServer(t *testing.T, seed func(*config.Config)) *Server { + t.Helper() + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + seed(cfg) + // Satisfy the dashboard-password gate the other handler tests keep. + cfg.Server.DashboardPasswordHash = "test-hash" + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatalf("seed config: %v", err) + } + s := &Server{cfg: cfg} + s.agent = &agent.Agent{} + s.agent.SetConfig(cfg) + return s +} + +func postProviderKey(s *Server, id, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/api/providers/"+id+"/key", strings.NewReader(body)) + r.SetPathValue("id", id) + rr := httptest.NewRecorder() + s.handleSetProviderKey(rr, r) + return rr +} + +// TestSetProviderKeyBlankKeepsStoredKey guards the regression where +// reconnecting a custom provider with a blank key field overwrote the stored +// credential with nothing: the modal starts empty, so hitting Connect to +// update the endpoint silently destroyed a working key. A blank (or redacted) +// key must mean "keep what is stored", and the connection probe must run with +// the kept key. +func TestSetProviderKeyBlankKeepsStoredKey(t *testing.T) { + endpoint, auths := fakeOpenAI(t) + s := newProviderKeyServer(t, func(cfg *config.Config) { + cfg.Providers["my-gateway"] = config.Provider{ + Kind: "openai-compatible", BaseURL: endpoint.URL + "/v1", + APIKey: "secret-1", Enabled: true, Label: "My Gateway", + } + }) + + rr := postProviderKey(s, "my-gateway", `{"api_key":"","base_url":"`+endpoint.URL+`/v1"}`) + if rr.Code != http.StatusOK { + t.Fatalf("blank reconnect: status = %d (body=%s)", rr.Code, rr.Body.String()) + } + + reloaded, err := config.Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := reloaded.Providers["my-gateway"].APIKey; got != "secret-1" { + t.Fatalf("blank key overwrote the stored credential: api_key = %q, want secret-1", got) + } + if n := len(*auths); n == 0 || !strings.Contains((*auths)[n-1], "secret-1") { + t.Fatalf("connection probe did not use the kept key: authorizations = %v", *auths) + } + + // A real new key still replaces the old one. + rr = postProviderKey(s, "my-gateway", `{"api_key":"secret-2","base_url":"`+endpoint.URL+`/v1"}`) + if rr.Code != http.StatusOK { + t.Fatalf("key update: status = %d (body=%s)", rr.Code, rr.Body.String()) + } + reloaded, err = config.Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := reloaded.Providers["my-gateway"].APIKey; got != "secret-2" { + t.Fatalf("api_key = %q, want the updated secret-2", got) + } +} diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index 960cb41..04623f0 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -565,7 +565,9 @@ function ProviderModal({ {reveal ? : } - {p.custom ? ( + {p.has_key && !key ? ( +

{t('setup.keyKept')}

+ ) : p.custom ? (

{t('providers.keyOptional')}

) : null} From 249e73b6e1c634312e2b44919cb2f852af4d34da Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 13:45:09 +0700 Subject: [PATCH 09/11] fix(setup): a nameless custom provider no longer lands on the hidden slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customProviderID mapped an empty name (and a name that slugs to "custom") onto the legacy "custom" id. After that slot was removed from the providers page, a first-run setup that skipped the name still saved the provider — base URL, key, active model and all — but saved it onto an id that never renders. The result looked exactly like "setup did nothing": no custom provider on the page, and chat silently pointing at an entry the UI cannot even show. Default a missing or "Custom" name to "custom-provider" (deduped like any other id) in both wizards, so a nameless setup produces a visible, manageable provider labeled "Custom provider". The terminal wizard also defaults its skipped prompt instead of only naming on explicit input. --- cmd/antares/setup.go | 30 ++++++++++++++++++------------ internal/server/handlers_setup.go | 18 ++++++++---------- internal/server/security_test.go | 9 +++++++-- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index 41bedfa..3d11fca 100644 --- a/cmd/antares/setup.go +++ b/cmd/antares/setup.go @@ -285,19 +285,22 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { switch chosen.id { case "custom": // A custom provider is named by the user and stored under an id minted - // from that name, so several can coexist. No name keeps the legacy - // "custom" slot. + // from that name, so several can coexist. A skipped name defaults to + // "Custom provider" rather than the legacy "custom" slot, which the + // providers page no longer shows. name := promptLine("\n Provider name (shown in the UI): ", "") - if name != "" { - id := uniqueCustomProviderID(cfg, name) - cfg.Model.Provider = id - entry = cfg.Providers[id] - if entry.Kind == "" { - entry.Kind = "openai-compatible" - } - entry.Enabled = true - entry.Label = name + name = strings.TrimSpace(name) + if name == "" { + name = "Custom provider" + } + id := uniqueCustomProviderID(cfg, name) + cfg.Model.Provider = id + entry = cfg.Providers[id] + if entry.Kind == "" { + entry.Kind = "openai-compatible" } + entry.Enabled = true + entry.Label = name entry.BaseURL = promptLine("\n Base URL (e.g. https://api.example.com/v1): ", entry.BaseURL) if entry.BaseURL == "" { return errors.New("a base URL is required for a custom provider") @@ -458,8 +461,11 @@ func uniqueCustomProviderID(cfg *config.Config, name string) string { } } slug := strings.Trim(b.String(), "-") + // Never the legacy "custom" slot: the providers page does not render it, + // so a nameless (or literally-"Custom") provider must land somewhere + // visible instead. if slug == "" || slug == "custom" { - return "custom" + slug = "custom-provider" } builtin := map[string]bool{} for _, p := range providerChoices { diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index 14bf435..7a847ff 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -344,7 +344,8 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { return } // A custom provider is stored under an id minted from the user's name, so - // more than one can exist. An unnamed one keeps the legacy "custom" slot. + // more than one can exist. An unnamed one defaults to "custom-provider" + // with the catalogue label — a visible, manageable provider either way. providerID := body.Provider if chosen.Custom { providerID = customProviderID(cfg, body.Name) @@ -636,17 +637,14 @@ func isCatalogueProviderID(id string) bool { return false } -// customProviderID mints a unique config id for a user-named provider. A name -// that slugs to nothing (or to the legacy slot itself) keeps "custom", so -// configs written before named custom providers existed stay valid. +// customProviderID mints a unique config id for a user-named provider. A +// name that slugs to nothing (or to "custom" itself) becomes "custom-provider" +// — never the legacy "custom" slot, which no longer renders on the providers +// page, so a nameless setup still lands on a visible, manageable provider. func customProviderID(cfg *config.Config, name string) string { slug := slugifyProviderName(name) - if slug == "" { - slug = "custom" - } - if slug == "custom" { - // The legacy single slot: reuse it rather than minting custom-2. - return "custom" + if slug == "" || slug == "custom" { + slug = "custom-provider" } base := slug for i := 2; ; i++ { diff --git a/internal/server/security_test.go b/internal/server/security_test.go index 9f8922d..9d1a25f 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -450,8 +450,13 @@ func TestCustomProviderID(t *testing.T) { if got := customProviderID(cfg, "My LM server"); got != "my-lm-server" { t.Errorf("customProviderID slugged to %q, want my-lm-server", got) } - if got := customProviderID(cfg, ""); got != "custom" { - t.Errorf("unnamed custom provider became %q, want the legacy custom slot", got) + // A missing or literally-"Custom" name must not land on the legacy + // "custom" slot — the providers page never renders that id, so the + // provider would look unsaved. Both default to a visible id. + for _, name := range []string{"", "Custom"} { + if got := customProviderID(cfg, name); got != "custom-provider" { + t.Errorf("customProviderID(%q) = %q, want custom-provider", name, got) + } } cfg.Providers["my-lm-server"] = config.Provider{} if got := customProviderID(cfg, "My LM server"); got != "my-lm-server-2" { From 6bd59cf6cd5973f3eb73a3126810c5d3a8db5418 Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 14:23:20 +0700 Subject: [PATCH 10/11] refactor(server): one lookup, validation, and id-minting path for providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-provider work left the same decisions duplicated per handler: a catalogue scan to find the entry, an if/else picking the custom vs built-in URL rule, and — worst — a second id minter in the terminal wizard whose builtin list covered only that wizard's short provider menu. Naming a custom provider "Vertex" there minted the id "vertex", which the dashboard then renders as the built-in Vertex, silently merging the user's endpoint into a catalogue entry. Consolidate: lookupSetupProvider for the catalogue scan, validateChosenBaseURL for the URL rule, and CustomProviderID as the single exported minter (now used by both wizards and the create endpoint). Two setup holes close along the way: setup/test and setup/complete now refuse a custom provider with no base URL instead of saving one that cannot answer, and setup/test no longer demands a key for customs — a keyless LAN service is legitimate there, same as everywhere else. --- cmd/antares/setup.go | 39 +---------- internal/server/handlers_providers.go | 22 ++----- internal/server/handlers_setup.go | 94 ++++++++++++--------------- internal/server/security.go | 10 +++ internal/server/security_test.go | 10 +-- 5 files changed, 63 insertions(+), 112 deletions(-) diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index 3d11fca..6282e4e 100644 --- a/cmd/antares/setup.go +++ b/cmd/antares/setup.go @@ -293,7 +293,7 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { if name == "" { name = "Custom provider" } - id := uniqueCustomProviderID(cfg, name) + id := server.CustomProviderID(cfg, name) cfg.Model.Provider = id entry = cfg.Providers[id] if entry.Kind == "" { @@ -443,43 +443,6 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { return nil } -// uniqueCustomProviderID mints a config id from a display name, avoiding the -// built-in provider ids and ids already in use. The legacy "custom" slot is -// reused when the name gives nothing better. -func uniqueCustomProviderID(cfg *config.Config, name string) string { - var b strings.Builder - prevDash := true - for _, r := range strings.ToLower(name) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevDash = false - continue - } - if !prevDash { - b.WriteByte('-') - prevDash = true - } - } - slug := strings.Trim(b.String(), "-") - // Never the legacy "custom" slot: the providers page does not render it, - // so a nameless (or literally-"Custom") provider must land somewhere - // visible instead. - if slug == "" || slug == "custom" { - slug = "custom-provider" - } - builtin := map[string]bool{} - for _, p := range providerChoices { - builtin[p.id] = true - } - base := slug - for i := 2; ; i++ { - if _, taken := cfg.Providers[slug]; !taken && !builtin[slug] { - return slug - } - slug = fmt.Sprintf("%s-%d", base, i) - } -} - // pickModel offers the provider's catalogue when it can be listed, and falls // back to the curated suggestions when it cannot. func pickModel(ctx context.Context, cfg *config.Config, chosen providerChoice) string { diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index 3abbeeb..51e7b9b 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -190,25 +190,13 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) p := cfg.Providers[id] // Custom providers (user-named entries, plus the legacy "custom" slot) may // point at loopback or LAN addresses; built-ins keep their catalogue rule. - custom := true - allowLocal := false - for _, provider := range setupProviderCatalogue(cfg) { - if provider.ID == id { - custom = provider.Custom - allowLocal = provider.Local - break - } - } + sp := lookupSetupProvider(cfg, id) + custom := sp == nil || sp.Custom + local := sp != nil && sp.Local if body.BaseURL != nil { baseURL := strings.TrimSpace(*body.BaseURL) if baseURL != "" { - var err error - if custom { - err = s.validateCustomProviderBaseURL(r.Context(), baseURL) - } else { - err = s.validateProviderBaseURL(r.Context(), baseURL, allowLocal) - } - if err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, custom, local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -276,7 +264,7 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } - id := customProviderID(cfg, name) + id := CustomProviderID(cfg, name) // Verify the pair now so a bad endpoint or key surfaces at creation time // rather than on the first turn. A keyless service is allowed. diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index 7a847ff..80ef917 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -158,6 +158,18 @@ func setupProviderCatalogue(cfg *config.Config) []setupProvider { return out } +// lookupSetupProvider finds a catalogue entry by id, or nil for ids that are +// not built-ins (user-defined providers, typos). +func lookupSetupProvider(cfg *config.Config, id string) *setupProvider { + catalogue := setupProviderCatalogue(cfg) + for i := range catalogue { + if catalogue[i].ID == id { + return &catalogue[i] + } + } + return nil +} + // NeedsSetup reports whether Antares can answer at all yet. func NeedsSetup(cfg *config.Config) bool { if strings.TrimSpace(cfg.Model.Default) == "" { @@ -213,14 +225,14 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { return } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) + if chosen.Custom && baseURL == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, "error": "A base URL is required for a custom provider.", + }) + return + } if baseURL != "" { - var err error - if chosen.Custom { - err = s.validateCustomProviderBaseURL(r.Context(), baseURL) - } else { - err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) - } - if err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, chosen.Custom, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -232,7 +244,9 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { apiKey = p.APIKey } } - if apiKey == "" && !isLocalEndpoint(baseURL) { + // A keyless custom service on a LAN is legitimate; everything else needs + // a credential unless the endpoint is local. + if apiKey == "" && !chosen.Custom && !isLocalEndpoint(baseURL) { writeJSON(w, http.StatusOK, map[string]any{ "ok": false, "error": "An API key is required for this provider.", }) @@ -331,14 +345,7 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { return } - catalogue := setupProviderCatalogue(cfg) - var chosen *setupProvider - for i := range catalogue { - if catalogue[i].ID == body.Provider { - chosen = &catalogue[i] - break - } - } + chosen := lookupSetupProvider(cfg, body.Provider) if chosen == nil { writeError(w, http.StatusBadRequest, errors.New("unknown provider")) return @@ -348,17 +355,15 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { // with the catalogue label — a visible, manageable provider either way. providerID := body.Provider if chosen.Custom { - providerID = customProviderID(cfg, body.Name) + providerID = CustomProviderID(cfg, body.Name) } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) + if chosen.Custom && baseURL == "" { + writeError(w, http.StatusBadRequest, errors.New("a base URL is required for a custom provider")) + return + } if baseURL != "" { - var err error - if chosen.Custom { - err = s.validateCustomProviderBaseURL(r.Context(), baseURL) - } else { - err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) - } - if err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, chosen.Custom, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -498,14 +503,7 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { return } - var chosen *setupProvider - for _, p := range setupProviderCatalogue(cfg) { - if p.ID == id { - cp := p - chosen = &cp - break - } - } + chosen := lookupSetupProvider(cfg, id) entry, exists := cfg.Providers[id] if chosen == nil { // Not in the catalogue: still manageable when it is a user-defined @@ -516,6 +514,11 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { } } custom := chosen == nil || chosen.Custom + local := chosen != nil && chosen.Local + var catalogueBaseURL string + if chosen != nil { + catalogueBaseURL = chosen.BaseURL + } if entry.Kind == "" { if chosen != nil { entry.Kind = chosen.Kind @@ -523,19 +526,9 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { entry.Kind = "openai-compatible" } } - var catalogueBaseURL string - if chosen != nil { - catalogueBaseURL = chosen.BaseURL - } baseURL := firstNonEmpty(body.BaseURL, entry.BaseURL, catalogueBaseURL) if baseURL != "" { - var err error - if custom { - err = s.validateCustomProviderBaseURL(r.Context(), baseURL) - } else { - err = s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) - } - if err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, custom, local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -629,19 +622,16 @@ func slugifyProviderName(name string) string { // isCatalogueProviderID reports whether id names a built-in provider. func isCatalogueProviderID(id string) bool { - for _, p := range setupProviderCatalogue(&config.Config{}) { - if p.ID == id { - return true - } - } - return false + return lookupSetupProvider(&config.Config{}, id) != nil } -// customProviderID mints a unique config id for a user-named provider. A -// name that slugs to nothing (or to "custom" itself) becomes "custom-provider" +// CustomProviderID mints a unique config id for a user-named provider. A name +// that slugs to nothing (or to "custom" itself) becomes "custom-provider" // — never the legacy "custom" slot, which no longer renders on the providers // page, so a nameless setup still lands on a visible, manageable provider. -func customProviderID(cfg *config.Config, name string) string { +// The id avoids every built-in catalogue id and any provider already in the +// config; it is the single minter shared by the web API and both wizards. +func CustomProviderID(cfg *config.Config, name string) string { slug := slugifyProviderName(name) if slug == "" || slug == "custom" { slug = "custom-provider" diff --git a/internal/server/security.go b/internal/server/security.go index 0845a6f..9d355d6 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -181,6 +181,16 @@ func (s *Server) validateCustomProviderBaseURL(ctx context.Context, raw string) return validateProviderBaseURLWithOptions(ctx, raw, true, true, resolver) } +// validateChosenBaseURL picks the URL rule for a provider being connected: +// user-defined endpoints may live on loopback/LAN, built-ins use their +// catalogue's local flag. Callers pass local=false alongside custom=true. +func (s *Server) validateChosenBaseURL(ctx context.Context, raw string, custom, local bool) error { + if custom { + return s.validateCustomProviderBaseURL(ctx, raw) + } + return s.validateProviderBaseURL(ctx, raw, local) +} + func validateProviderBaseURLWithOptions( ctx context.Context, raw string, allowLocal, allowPrivate bool, resolver providerIPResolver, ) error { diff --git a/internal/server/security_test.go b/internal/server/security_test.go index 9d1a25f..30305b0 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -447,22 +447,22 @@ func TestValidateCustomProviderBaseURL(t *testing.T) { func TestCustomProviderID(t *testing.T) { cfg := &config.Config{Providers: map[string]config.Provider{}} - if got := customProviderID(cfg, "My LM server"); got != "my-lm-server" { + if got := CustomProviderID(cfg, "My LM server"); got != "my-lm-server" { t.Errorf("customProviderID slugged to %q, want my-lm-server", got) } // A missing or literally-"Custom" name must not land on the legacy // "custom" slot — the providers page never renders that id, so the // provider would look unsaved. Both default to a visible id. for _, name := range []string{"", "Custom"} { - if got := customProviderID(cfg, name); got != "custom-provider" { - t.Errorf("customProviderID(%q) = %q, want custom-provider", name, got) + if got := CustomProviderID(cfg, name); got != "custom-provider" { + t.Errorf("CustomProviderID(%q) = %q, want custom-provider", name, got) } } cfg.Providers["my-lm-server"] = config.Provider{} - if got := customProviderID(cfg, "My LM server"); got != "my-lm-server-2" { + if got := CustomProviderID(cfg, "My LM server"); got != "my-lm-server-2" { t.Errorf("duplicate name became %q, want my-lm-server-2", got) } - if got := customProviderID(cfg, "OpenAI"); got != "openai-2" { + if got := CustomProviderID(cfg, "OpenAI"); got != "openai-2" { t.Errorf("catalogue clash became %q, want openai-2", got) } } From e5baae10ebf020a163d776449962a744cba2c4ee Mon Sep 17 00:00:00 2001 From: Reidho Satria Date: Sun, 16 Aug 2026 14:23:25 +0700 Subject: [PATCH 11/11] fix(web): gate custom setup on an endpoint; polish the provider dialogs The provider step's Next button stayed enabled with Custom provider selected and an empty base URL, deferring the failure to the backend's 400 at completion. Disable Next instead, mirroring the model step. The add-provider dialog now submits from Enter on the base URL field, not just the key field, and the Indonesian dictionary gets the nine custom-provider strings (the other non-English dicts carry no providers.* keys at all and fall back to English, matching their existing coverage). --- web/src/lib/i18n.tsx | 9 +++++++++ web/src/pages/ProvidersPage.tsx | 1 + web/src/pages/SetupPage.tsx | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index ad86928..8601511 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -1558,6 +1558,15 @@ const id: Dict = { 'providers.groupDesc.oauth': 'Diotorisasi lewat login perangkat, bukan menempel kunci.', 'providers.groupDesc.apikey': 'Tempel API key (untuk provider cloud, kredensial diambil dari environment).', 'providers.groupDesc.local': 'Jalan di mesin ini — tanpa kunci.', + 'providers.addCustom': 'Tambah provider kustom', + 'providers.newTitle': 'Provider kustom', + 'providers.newDesc': 'Endpoint apa pun yang kompatibel OpenAI — beri nama, tempel kunci bila perlu. Alamat lokal juga bisa.', + 'providers.name': 'Nama', + 'providers.namePlaceholder': 'mis. Server inferensi saya', + 'providers.keyOptional': 'Opsional — kosongkan jika endpoint tidak butuh kunci.', + 'providers.deleteProvider': 'Hapus provider', + 'setup.providerName': 'Nama provider', + 'setup.providerNameHint': 'Tampil di daftar provider — boleh ada lebih dari satu provider kustom.', 'models.allProviders': 'Semua provider', 'models.searchAll': 'Cari semua model…', 'models.pickModel': 'Model', diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index 04623f0..766c6a4 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -293,6 +293,7 @@ function AddProviderDialog({ placeholder="https://api.example.com/v1" className="font-mono text-xs" autoComplete="off" + onKeyDown={(e) => e.key === 'Enter' && create()} />
diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index d32872a..5c36190 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -271,7 +271,11 @@ export default function SetupPage() {
) : null} - + ) : null}