diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index f938f0f..6282e4e 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,23 @@ 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. 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): ", "") + name = strings.TrimSpace(name) + if name == "" { + name = "Custom provider" + } + id := server.CustomProviderID(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") @@ -432,7 +449,7 @@ func pickModel(ctx context.Context, cfg *config.Config, chosen providerChoice) s 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, 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 cb42a2a..9677d8b 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 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) { + 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, @@ -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..51e7b9b 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,26 @@ 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. + sp := lookupSetupProvider(cfg, id) + custom := sp == nil || sp.Custom + local := sp != nil && sp.Local 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 { + if err := s.validateChosenBaseURL(r.Context(), baseURL, custom, local); 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 +226,123 @@ 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") + // 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 + } + 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("this provider is active — pick another model before deleting it")) + 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..80ef917 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 { @@ -154,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) == "" { @@ -209,8 +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 != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, chosen.Custom, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -222,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.", }) @@ -282,6 +306,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"` @@ -320,39 +345,46 @@ 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 } + // A custom provider is stored under an id minted from the user's name, so + // 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) + } 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 != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, chosen.Custom, chosen.Local); 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 != "" { @@ -471,35 +503,48 @@ 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 + // custom provider already present in the config. + if !exists { + writeError(w, http.StatusBadRequest, errors.New("unknown provider")) + return } } - if chosen == nil { - writeError(w, http.StatusBadRequest, errors.New("unknown provider")) - return + custom := chosen == nil || chosen.Custom + local := chosen != nil && chosen.Local + var catalogueBaseURL string + if chosen != nil { + catalogueBaseURL = chosen.BaseURL } - - entry := cfg.Providers[id] 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) + baseURL := firstNonEmpty(body.BaseURL, entry.BaseURL, catalogueBaseURL) if baseURL != "" { - if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateChosenBaseURL(r.Context(), baseURL, custom, local); err != nil { writeError(w, http.StatusBadRequest, err) return } } 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. - 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,47 @@ 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 { + 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" +// — never the legacy "custom" slot, which no longer renders on the providers +// page, so a nameless setup still lands on a visible, manageable provider. +// 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" + } + 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/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/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) diff --git a/internal/server/security.go b/internal/server/security.go index eb0de8d..9d355d6 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -152,6 +152,47 @@ 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) +} + +// 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 { raw = strings.TrimSpace(raw) if raw == "" { @@ -170,7 +211,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 +230,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..30305b0 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -416,3 +416,53 @@ 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) + } + // 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" { + 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) + } +} diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index fd662a3..8601511 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', @@ -1549,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 33ade8d..766c6a4 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,120 @@ 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" + onKeyDown={(e) => e.key === 'Enter' && create()} + /> +
+
+ + 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 +406,14 @@ 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 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() !== '') && @@ -362,6 +492,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 +500,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) => ( + {p.has_key && !key ? ( +

{t('setup.keyKept')}

+ ) : p.custom ? ( +

{t('providers.keyOptional')}

+ ) : null} ) : null} {p.needs_region ? ( @@ -512,6 +661,12 @@ function ProviderModal({ {section === 'advanced' ? ( <> + {p.custom ? ( +
+ + setLabel(e.target.value)} autoComplete="off" /> +
+ ) : null}
setBaseURL(e.target.value)} placeholder={p.kind} autoComplete="off" /> @@ -526,6 +681,18 @@ function ProviderModal({ + {p.custom ? ( + + ) : null} diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index 37cee75..5c36190 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 ? (
@@ -255,7 +271,11 @@ export default function SetupPage() {
) : null} - + ) : null}