Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions cmd/antares/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
16 changes: 14 additions & 2 deletions internal/server/handlers_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,32 @@ 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,
Enabled: p.Enabled, HasKey: p.APIKey != "", Local: sp.Local,
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
}
Expand All @@ -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,
})
}

Expand Down
143 changes: 135 additions & 8 deletions internal/server/handlers_providers.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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"`
}
Expand All @@ -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
}
Expand All @@ -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})
}
Loading
Loading