diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index 0f41577..c14dfab 100644 --- a/cmd/antares/setup.go +++ b/cmd/antares/setup.go @@ -24,17 +24,7 @@ import ( ) // needsSetup reports whether Antares has enough configuration to answer at all. -func needsSetup(cfg *config.Config) bool { - if strings.TrimSpace(cfg.Model.Default) == "" { - return true - } - _, p := cfg.ResolveProvider(cfg.Model.Provider) - // A local endpoint needs no credential; everything else does. - if p.APIKey == "" && !isLocalEndpoint(p.BaseURL) { - return true - } - return false -} +func needsSetup(cfg *config.Config) bool { return server.NeedsSetup(cfg) } func isLocalEndpoint(url string) bool { l := strings.ToLower(url) @@ -305,6 +295,7 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { if entry.BaseURL == "" { return errors.New("a base URL is required for a custom provider") } + entry.Headers = promptProviderHeaders() case "ollama": entry.BaseURL = promptLine("\n Ollama URL (default http://127.0.0.1:11434/v1): ", "http://127.0.0.1:11434/v1") case "lmstudio": @@ -318,13 +309,13 @@ func runTerminalSetup(ctx context.Context, rt *runtimeServices) error { fmt.Printf(" API key %s\n", dim(chosen.keyHint)) } key := promptSecret(" Paste it here (input hidden): ") - if key == "" && entry.APIKey == "" { + if key == "" && entry.APIKey == "" && len(entry.Headers) == 0 { fmt.Println("\n " + warn("No key entered — Antares will not be able to answer until one is set.")) } else if key != "" { entry.APIKey = key } } - cfg.Providers[chosen.id] = entry + cfg.Providers[cfg.Model.Provider] = entry // 4. Model, verified against the provider when possible fmt.Println() @@ -527,6 +518,45 @@ func promptLine(question, def string) string { return line } +func promptProviderHeaders() map[string]string { + headers := make(map[string]string) + fmt.Println(" Headers (optional): enter HEADER=VALUE, one per line; blank line finishes.") + for { + fmt.Print(" Header: ") + line, err := stdinReader.ReadString('\n') + if err != nil && len(line) == 0 { + return headers + } + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if strings.Trim(line, " \t") == "" { + return headers + } + firstEquals := strings.IndexByte(line, '=') + if firstEquals == -1 { + if strings.HasPrefix(strings.TrimLeft(line, " \t"), "#") { + continue + } + fmt.Println(" " + warn("Invalid header entry; use a unique HEADER=VALUE.")) + continue + } + + candidate, validationErr := config.NormalizeProviderHeaders(map[string]string{ + line[:firstEquals]: line[firstEquals+1:], + }) + if validationErr != nil { + fmt.Println(" " + warn("Invalid header entry; use a unique HEADER=VALUE.")) + continue + } + for name, value := range candidate { + if _, exists := headers[name]; exists { + fmt.Println(" " + warn("Invalid header entry; use a unique HEADER=VALUE.")) + continue + } + headers[name] = value + } + } +} + // promptSecret reads without echoing, falling back to a visible read when the // terminal cannot be put into no-echo mode. func promptSecret(question string) string { diff --git a/cmd/antares/setup_test.go b/cmd/antares/setup_test.go new file mode 100644 index 0000000..8ce38f3 --- /dev/null +++ b/cmd/antares/setup_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" +) + +func TestTerminalSetupPersistsNamedProvider(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + var receivedHeaders []string + + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Tenant") != "team=a=b" { + w.WriteHeader(http.StatusUnauthorized) + return + } + receivedHeaders = append(receivedHeaders, r.Header.Get("X-Tenant")) + switch r.URL.Path { + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]string{{"id": "header-model", "owned_by": "test"}}, + }) + case "/v1/chat/completions": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "header-model", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]string{"role": "assistant", "content": "pong"}, + "finish_reason": "stop", + }}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(fixture.Close) + + cfg := config.Default() + cfg.Agent.Workspace = filepath.Join(t.TempDir(), "workspace") + cfg.Model.MaxRetries = -1 + cfg.Providers["custom"] = config.Provider{ + Enabled: true, + Kind: "openai-compatible", + BaseURL: "https://legacy.example/v1", + Label: "Legacy custom", + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + + a := agent.New(cfg, nil, nil, nil, nil) + rt := &runtimeServices{cfg: cfg, agent: a} + oldReader := stdinReader + stdinReader = bufio.NewReader(strings.NewReader(strings.Join([]string{ + "7", // Custom provider + "Named Provider", // provider name + fixture.URL + "/v1", // endpoint + "X-Tenant=team=a=b", // accepted header + "bad header=value", // malformed header + "x-tenant=other", // duplicate canonical header + "", // header terminator + "1", // first live model or manual fallback + "", // workspace + "", // PostgreSQL + "", // RAG + "", // Telegram + "", // dashboard password + }, "\n") + "\n")) + t.Cleanup(func() { stdinReader = oldReader }) + + var setupErr error + output := captureProviderStdout(t, func() { + setupErr = runTerminalSetup(context.Background(), rt) + }) + if setupErr != nil { + t.Fatalf("run terminal setup: %v\noutput:\n%s", setupErr, output) + } + after, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if after.Model.Provider != "named-provider" { + t.Fatalf("model provider = %q, want named-provider", after.Model.Provider) + } + got, ok := after.Providers[after.Model.Provider] + if !ok { + t.Fatalf("named provider %q was not saved", after.Model.Provider) + } + if got.BaseURL != fixture.URL+"/v1" { + t.Fatalf("named provider endpoint = %q, want %q", got.BaseURL, fixture.URL+"/v1") + } + legacy := after.Providers["custom"] + if legacy.BaseURL != "https://legacy.example/v1" || legacy.Label != "Legacy custom" { + t.Fatalf("legacy custom provider changed: %#v", legacy) + } + if _, resolved := after.ResolveProvider(after.Model.Provider); resolved.BaseURL != fixture.URL+"/v1" { + t.Fatalf("resolved provider endpoint = %q, want %q", resolved.BaseURL, fixture.URL+"/v1") + } + if got.Headers["X-Tenant"] != "team=a=b" || len(got.Headers) != 1 { + t.Fatalf("named provider headers = %#v", got.Headers) + } + if len(receivedHeaders) < 2 { + t.Fatalf("provider probes = %d, want model list and final chat", len(receivedHeaders)) + } + for _, header := range receivedHeaders { + if header != "team=a=b" { + t.Fatalf("probe header = %q, want accepted value", header) + } + } +} + +func TestPromptProviderHeadersAllowsInitialBlank(t *testing.T) { + oldReader := stdinReader + stdinReader = bufio.NewReader(strings.NewReader("\n")) + t.Cleanup(func() { stdinReader = oldReader }) + + if headers := promptProviderHeaders(); headers == nil || len(headers) != 0 { + t.Fatalf("initial blank headers = %#v, want allocated empty map", headers) + } +} diff --git a/internal/config/provider_headers.go b/internal/config/provider_headers.go new file mode 100644 index 0000000..bb8d5d0 --- /dev/null +++ b/internal/config/provider_headers.go @@ -0,0 +1,44 @@ +package config + +import ( + "errors" + "net/http" + "strings" + + "golang.org/x/net/http/httpguts" +) + +var ( + ErrInvalidHeaderName = errors.New("invalid header name") + ErrInvalidHeaderValue = errors.New("invalid header value") + ErrDuplicateHeaderName = errors.New("duplicate header name") +) + +// NormalizeProviderHeaders validates an HTTP header map, canonicalizes names, +// and returns an independent map. A nil map remains nil; an allocated empty map +// remains allocated so callers can distinguish an omitted value from a clear. +func NormalizeProviderHeaders(headers map[string]string) (map[string]string, error) { + if headers == nil { + return nil, nil + } + + normalized := make(map[string]string, len(headers)) + seen := make(map[string]struct{}, len(headers)) + for name, value := range headers { + if !httpguts.ValidHeaderFieldValue(value) { + return nil, ErrInvalidHeaderValue + } + name = strings.Trim(name, " \t") + if name == "" || !httpguts.ValidHeaderFieldName(name) { + return nil, ErrInvalidHeaderName + } + canonical := http.CanonicalHeaderKey(name) + folded := strings.ToLower(canonical) + if _, duplicate := seen[folded]; duplicate { + return nil, ErrDuplicateHeaderName + } + seen[folded] = struct{}{} + normalized[canonical] = strings.Trim(value, " \t") + } + return normalized, nil +} diff --git a/internal/config/provider_headers_test.go b/internal/config/provider_headers_test.go new file mode 100644 index 0000000..30771d2 --- /dev/null +++ b/internal/config/provider_headers_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "errors" + "reflect" + "testing" +) + +func TestNormalizeProviderHeaders(t *testing.T) { + input := map[string]string{" x-tenant ": " team=a=b ", "Empty": ""} + got, err := NormalizeProviderHeaders(input) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"X-Tenant": "team=a=b", "Empty": ""} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalized = %#v, want %#v", got, want) + } + got["X-Tenant"] = "changed" + if input[" x-tenant "] != " team=a=b " { + t.Fatalf("normalization mutated input: %#v", input) + } + + nilHeaders, err := NormalizeProviderHeaders(nil) + if err != nil || nilHeaders != nil { + t.Fatalf("nil headers = %#v, %v; want nil, nil", nilHeaders, err) + } + emptyHeaders, err := NormalizeProviderHeaders(map[string]string{}) + if err != nil || emptyHeaders == nil || len(emptyHeaders) != 0 { + t.Fatalf("empty headers = %#v, %v; want allocated empty map", emptyHeaders, err) + } +} + +func TestNormalizeProviderHeadersRejectsMalformedInput(t *testing.T) { + cases := []struct { + name string + headers map[string]string + want error + }{ + {"invalid name", map[string]string{"bad header": "value"}, ErrInvalidHeaderName}, + {"control value", map[string]string{"X-Test": "bad\nvalue"}, ErrInvalidHeaderValue}, + {"duplicate casing", map[string]string{"X-Test": "one", "x-test": "two"}, ErrDuplicateHeaderName}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NormalizeProviderHeaders(tc.headers) + if !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + }) + } +} diff --git a/internal/llm/header_precedence_test.go b/internal/llm/header_precedence_test.go new file mode 100644 index 0000000..4450893 --- /dev/null +++ b/internal/llm/header_precedence_test.go @@ -0,0 +1,39 @@ +package llm + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestAdapterAuthorizationOverridesConfiguredHeader(t *testing.T) { + var authorization string + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + t.Cleanup(fixture.Close) + + externalBaseURL := os.Getenv("ANTARES_HEADER_PRECEDENCE_URL") + baseURL := fixture.URL + "/v1" + if externalBaseURL != "" { + baseURL = externalBaseURL + } + client, err := New(Options{ + Kind: "openai-compatible", + BaseURL: baseURL, + APIKey: "real-key", + Headers: map[string]string{"Authorization": "Bearer configured-header"}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := client.Models(t.Context()); err != nil { + t.Fatal(err) + } + if externalBaseURL == "" && authorization != "Bearer real-key" { + t.Fatalf("Authorization = %q, want adapter-generated API key", authorization) + } +} diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index 1532cda..58ebf94 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -34,8 +34,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // Provider readiness is a config check, not a live call: the status pill // polls every ten seconds and must not bill the user for pings. - _, provider := cfg.ResolveProvider(cfg.Model.Provider) - ready := cfg.Model.Default != "" && (provider.APIKey != "" || isLocalEndpoint(provider.BaseURL)) + ready := !NeedsSetup(cfg) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 6fb6ffe..0449195 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -156,15 +156,16 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { BaseURL string `json:"base_url"` Active bool `json:"active"` // Setup metadata, so the connect form can render the right fields. - Hint string `json:"hint,omitempty"` - KeyHint string `json:"key_hint,omitempty"` - KeyURL string `json:"key_url,omitempty"` - KeyLabel string `json:"key_label,omitempty"` - Note string `json:"note,omitempty"` - NeedsRegion bool `json:"needs_region,omitempty"` - NeedsAPIVersion bool `json:"needs_api_version,omitempty"` - NeedsBaseURL bool `json:"needs_base_url,omitempty"` - TimeoutSecs int `json:"timeout_seconds,omitempty"` + Hint string `json:"hint,omitempty"` + KeyHint string `json:"key_hint,omitempty"` + KeyURL string `json:"key_url,omitempty"` + KeyLabel string `json:"key_label,omitempty"` + Note string `json:"note,omitempty"` + NeedsRegion bool `json:"needs_region,omitempty"` + NeedsAPIVersion bool `json:"needs_api_version,omitempty"` + NeedsBaseURL bool `json:"needs_base_url,omitempty"` + TimeoutSecs int `json:"timeout_seconds,omitempty"` + Headers map[string]string `json:"headers,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. @@ -177,6 +178,7 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { // installations may still use that id for their real custom provider. seen := map[string]bool{} providerList := make([]providerInfo, 0) + exposeHeaders := cfg.Server.DashboardLocked() || s.bearerAuthorizedOrQuery(r) for _, sp := range setupProviderCatalogue(cfg) { p := cfg.Providers[sp.ID] if sp.Custom && !legacyCustomProviderInUse(cfg, p) { @@ -188,14 +190,18 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { label = firstNonEmpty(p.Label, sp.Label) kind = firstNonEmpty(p.Kind, sp.Kind) } - providerList = append(providerList, providerInfo{ + info := providerInfo{ ID: sp.ID, Label: label, Kind: 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, Custom: sp.Custom, - }) + } + if sp.Custom && exposeHeaders { + info.Headers = p.Headers + } + providerList = append(providerList, info) seen[sp.ID] = true } names := make([]string, 0, len(cfg.Providers)) @@ -207,12 +213,16 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { sort.Strings(names) for _, name := range names { p := cfg.Providers[name] - providerList = append(providerList, providerInfo{ + info := providerInfo{ ID: name, Label: firstNonEmpty(p.Label, name), Kind: p.Kind, Enabled: p.Enabled, HasKey: p.APIKey != "", BaseURL: p.BaseURL, Active: name == cfg.Model.Provider, TimeoutSecs: p.TimeoutSecs, Custom: true, NeedsBaseURL: true, - }) + } + if exposeHeaders { + info.Headers = p.Headers + } + providerList = append(providerList, info) } writeJSON(w, http.StatusOK, map[string]any{ @@ -241,7 +251,7 @@ func (s *Server) handleModelList(w http.ResponseWriter, r *http.Request) { // Calling a provider we know has no credential just turns a known state // into an opaque 401. Report the missing key instead. id, p := cfg.ResolveProvider(provider) - if p.APIKey == "" && !isLocalEndpoint(p.BaseURL) { + if p.APIKey == "" && !isLocalEndpoint(p.BaseURL) && !customProviderHasHeaders(cfg, id, p) { writeJSON(w, http.StatusOK, map[string]any{ "models": []any{}, "needs_key": true, "provider": id, }) @@ -276,7 +286,8 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { } cfg := s.config() - // Which providers are worth calling: a stored key, a set key-env, or local. + // Which providers are worth calling: a stored key, a set key-env, local, or + // a configured custom provider with explicit request headers. type target struct { id, label string } @@ -288,7 +299,7 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { } p := cfg.Providers[id] keyed := p.APIKey != "" || (p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "") - if keyed || isLocalEndpoint(p.BaseURL) { + if keyed || isLocalEndpoint(p.BaseURL) || customProviderHasHeaders(cfg, id, p) { targets = append(targets, target{id: id, label: firstNonEmpty(p.Label, label, id)}) seen[id] = true } diff --git a/internal/server/handlers_config_test.go b/internal/server/handlers_config_test.go index 5d3db93..3333524 100644 --- a/internal/server/handlers_config_test.go +++ b/internal/server/handlers_config_test.go @@ -4,21 +4,23 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/enowdev/antares/internal/config" ) type modelOptionsProvider struct { - ID string `json:"id"` - Label string `json:"label"` - Kind string `json:"kind"` - Enabled bool `json:"enabled"` - HasKey bool `json:"has_key"` - BaseURL string `json:"base_url"` - Active bool `json:"active"` - Custom bool `json:"custom"` - NeedsBaseURL bool `json:"needs_base_url"` + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + HasKey bool `json:"has_key"` + BaseURL string `json:"base_url"` + Active bool `json:"active"` + Custom bool `json:"custom"` + NeedsBaseURL bool `json:"needs_base_url"` + Headers map[string]string `json:"headers"` } type modelOptionsResponse struct { @@ -90,3 +92,65 @@ func TestModelOptionsHidesUnusedLegacyCustomPlaceholder(t *testing.T) { t.Fatalf("unused legacy custom provider cards = %d, want 0", count) } } + +func TestModelOptionsProtectsCustomHeaderValues(t *testing.T) { + cfg := config.Default() + cfg.Providers["custom"] = config.Provider{ + Kind: "openai-compatible", BaseURL: "https://legacy.example/v1", Enabled: true, + Headers: map[string]string{"X-Legacy": "legacy-secret"}, + } + cfg.Providers["named"] = config.Provider{ + Kind: "openai-compatible", BaseURL: "https://named.example/v1", Enabled: true, + Headers: map[string]string{"X-Named": "named-secret"}, + } + cfg.Providers["openai"] = config.Provider{Headers: map[string]string{"X-Builtin": "must-not-leak"}} + + for _, provider := range modelOptions(t, cfg).Providers { + if len(provider.Headers) != 0 { + t.Fatalf("unprotected response exposed headers for %s: %#v", provider.ID, provider.Headers) + } + } + + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Server.AuthToken = "header-test-token" + s := New(Options{Config: cfg}) + unauthorized := httptest.NewRecorder() + s.Handler().ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/model/options", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("locked unauthenticated status = %d, want 401", unauthorized.Code) + } + + authorizedRequest := httptest.NewRequest(http.MethodGet, "/api/model/options", nil) + authorizedRequest.Header.Set("Authorization", "Bearer header-test-token") + authorized := httptest.NewRecorder() + s.Handler().ServeHTTP(authorized, authorizedRequest) + if authorized.Code != http.StatusOK { + t.Fatalf("authorized status = %d, body = %s", authorized.Code, authorized.Body.String()) + } + var response modelOptionsResponse + if err := json.Unmarshal(authorized.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + for _, provider := range response.Providers { + switch provider.ID { + case "custom": + if provider.Headers["X-Legacy"] != "legacy-secret" { + t.Fatalf("legacy headers = %#v", provider.Headers) + } + case "named": + if provider.Headers["X-Named"] != "named-secret" { + t.Fatalf("named headers = %#v", provider.Headers) + } + case "openai": + if len(provider.Headers) != 0 { + t.Fatalf("built-in headers leaked: %#v", provider.Headers) + } + } + } + + setupStatus := httptest.NewRecorder() + s.Handler().ServeHTTP(setupStatus, httptest.NewRequest(http.MethodGet, "/api/setup/status", nil)) + if strings.Contains(setupStatus.Body.String(), "legacy-secret") || strings.Contains(setupStatus.Body.String(), "named-secret") { + t.Fatalf("setup status disclosed headers: %s", setupStatus.Body.String()) + } +} diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index 51e7b9b..535655d 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -188,6 +188,14 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) return } p := cfg.Providers[id] + if body.Headers != nil { + headers, err := config.NormalizeProviderHeaders(body.Headers) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + p.Headers = headers + } // 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) @@ -211,9 +219,6 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) if body.TimeoutSecs != nil { p.TimeoutSecs = *body.TimeoutSecs } - if body.Headers != nil { - p.Headers = body.Headers - } cfg.Providers[id] = p if err := config.Save(cfg); err != nil { @@ -236,14 +241,20 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name string `json:"name"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Headers map[string]string `json:"headers"` } if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, err) return } + headers, err := config.NormalizeProviderHeaders(body.Headers) + if 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")) @@ -269,9 +280,9 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { // 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 != "" { + if key != "" || len(headers) > 0 { client, err := llm.New(llm.Options{ - Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, + Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, Headers: headers, ProviderID: id, Timeout: 30 * time.Second, }) if err != nil { @@ -295,7 +306,7 @@ func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { } cfg.Providers[id] = config.Provider{ - Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, + Kind: "openai-compatible", BaseURL: baseURL, APIKey: key, Headers: headers, Enabled: true, Label: name, } if err := config.Save(cfg); err != nil { diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index 80ef917..a37a29e 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -175,8 +175,16 @@ func NeedsSetup(cfg *config.Config) bool { if strings.TrimSpace(cfg.Model.Default) == "" { return true } - _, p := cfg.ResolveProvider(cfg.Model.Provider) - return p.APIKey == "" && !isLocalEndpoint(p.BaseURL) + id, p := cfg.ResolveProvider(cfg.Model.Provider) + return p.APIKey == "" && !isLocalEndpoint(p.BaseURL) && !customProviderHasHeaders(cfg, id, p) +} + +func customProviderHasHeaders(cfg *config.Config, id string, p config.Provider) bool { + if strings.TrimSpace(p.BaseURL) == "" || len(p.Headers) == 0 { + return false + } + provider := lookupSetupProvider(cfg, id) + return provider == nil || provider.Custom } func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { @@ -202,9 +210,10 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { return } var body struct { - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Headers map[string]string `json:"headers"` } if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, err) @@ -244,6 +253,25 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { apiKey = p.APIKey } } + // Headers are wizard-scoped: a custom setup mints a fresh provider id at + // save time, so any headers stored under the legacy "custom" slot belong + // to a different target and must not be forwarded here — that would leak + // unrelated credentials to whatever base URL the user just typed in. For + // built-in providers the slot key matches the stored entry, so reusing + // its recorded headers on a reconnect is safe. + var headers map[string]string + if chosen.Custom { + if body.Headers != nil { + normalized, err := config.NormalizeProviderHeaders(body.Headers) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + headers = normalized + } + } else { + headers = cfg.Providers[body.Provider].Headers + } // 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) { @@ -254,7 +282,7 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { } client, err := llm.New(llm.Options{ - Kind: chosen.Kind, BaseURL: baseURL, APIKey: apiKey, + Kind: chosen.Kind, BaseURL: baseURL, APIKey: apiKey, Headers: headers, ProviderID: body.Provider, Timeout: 30 * time.Second, }) if err != nil { @@ -305,12 +333,13 @@ 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"` - Workspace string `json:"workspace"` + Provider string `json:"provider"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Headers map[string]string `json:"headers"` + Model string `json:"model"` + Workspace string `json:"workspace"` Database struct { Driver string `json:"driver"` DSN string `json:"dsn"` @@ -379,6 +408,24 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { if baseURL != "" { entry.BaseURL = baseURL } + if chosen.Custom { + // A named custom provider mints its own id (providerID), separate from + // the legacy "custom" slot the wizard picker uses (body.Provider). + // The freshly minted entry starts with no stored headers; only headers + // explicitly submitted for this provider are persisted. Falling back + // to the legacy slot here silently attached whatever credential lived + // under "custom" to an unrelated host. Editing an existing entry with + // omitted headers still keeps its own recorded headers, since we read + // them from cfg.Providers[providerID] above. + if body.Headers != nil { + headers, err := config.NormalizeProviderHeaders(body.Headers) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + entry.Headers = headers + } + } if key := strings.TrimSpace(body.APIKey); key != "" && !strings.Contains(key, "••••") { entry.APIKey = key } @@ -551,7 +598,7 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { // Reject a bad key here rather than saving it and failing on the next turn. client, err := llm.New(llm.Options{ - Kind: entry.Kind, BaseURL: baseURL, APIKey: key, + Kind: entry.Kind, BaseURL: baseURL, APIKey: key, Headers: entry.Headers, Region: region, APIVersion: apiVersion, ProviderID: id, Timeout: 30 * time.Second, }) diff --git a/internal/server/provider_headers_test.go b/internal/server/provider_headers_test.go new file mode 100644 index 0000000..dfaaad5 --- /dev/null +++ b/internal/server/provider_headers_test.go @@ -0,0 +1,418 @@ +package server + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/store" +) + +func headerProviderFixture(t *testing.T) (*httptest.Server, *string) { + t.Helper() + expected := "team=a=b" + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" || r.Header.Get("X-Tenant") != expected { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"header-model","owned_by":"test"}]}`)) + })) + t.Cleanup(fixture.Close) + return fixture, &expected +} + +func providerJSONRequest(method, path, body string) *http.Request { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + return r +} + +func assertProviderOK(t *testing.T, rr *httptest.ResponseRecorder) map[string]any { + t.Helper() + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if ok, _ := response["ok"].(bool); !ok { + t.Fatalf("response ok = %v, body = %s", response["ok"], rr.Body.String()) + } + return response +} + +func TestProviderHeadersCreateAndReconnect(t *testing.T) { + fixture, expected := headerProviderFixture(t) + s := newProviderKeyServer(t, func(*config.Config) {}) + + rr := httptest.NewRecorder() + s.handleCreateProvider(rr, providerJSONRequest(http.MethodPost, "/api/providers", `{"name":"Header Gateway","base_url":"`+fixture.URL+`/v1","headers":{"X-Tenant":"team=a=b"}}`)) + response := assertProviderOK(t, rr) + id := response["id"].(string) + + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers[id].Headers["X-Tenant"]; got != *expected { + t.Fatalf("saved header = %q, want %q", got, *expected) + } + + rr = postProviderKey(s, id, `{"api_key":"","base_url":"`+fixture.URL+`/v1"}`) + assertProviderOK(t, rr) +} + +func TestProviderHeadersRejectInvalidAndPreserveSettings(t *testing.T) { + fixture, _ := headerProviderFixture(t) + s := newProviderKeyServer(t, func(cfg *config.Config) { + cfg.Providers["gateway"] = config.Provider{ + Kind: "openai-compatible", BaseURL: fixture.URL + "/v1", Enabled: true, + Headers: map[string]string{"X-Old": "old"}, + } + }) + + for _, body := range []string{ + `{"headers":{"bad header":"value"}}`, + "{\"headers\":{\"X-Test\":\"bad\\nvalue\"}}", + `{"headers":{"X-Test":"one","x-test":"two"}}`, + } { + rr := httptest.NewRecorder() + r := providerJSONRequest(http.MethodPatch, "/api/providers/gateway/settings", body) + r.SetPathValue("id", "gateway") + s.handleProviderSettings(rr, r) + if rr.Code != http.StatusBadRequest { + t.Fatalf("invalid headers status = %d, body = %s", rr.Code, rr.Body.String()) + } + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers["gateway"].Headers["X-Old"]; got != "old" { + t.Fatalf("invalid headers changed saved headers: %#v", reloaded.Providers["gateway"].Headers) + } + } +} + +func TestProviderHeadersSettingsReplacePreserveAndClear(t *testing.T) { + s := newProviderKeyServer(t, func(cfg *config.Config) { + cfg.Providers["gateway"] = config.Provider{Headers: map[string]string{"X-Old": "old"}} + }) + request := func(body string) { + t.Helper() + rr := httptest.NewRecorder() + r := providerJSONRequest(http.MethodPatch, "/api/providers/gateway/settings", body) + r.SetPathValue("id", "gateway") + s.handleProviderSettings(rr, r) + assertProviderOK(t, rr) + } + + request(`{"label":"Gateway"}`) + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers["gateway"].Headers["X-Old"]; got != "old" { + t.Fatalf("omitted headers = %#v, want existing map", reloaded.Providers["gateway"].Headers) + } + + request(`{"headers":{"x-new":" new "}}`) + reloaded, err = config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers["gateway"].Headers; len(got) != 1 || got["X-New"] != "new" { + t.Fatalf("replacement headers = %#v", got) + } + + request(`{"headers":{}}`) + reloaded, err = config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers["gateway"].Headers; got == nil || len(got) != 0 { + t.Fatalf("cleared headers = %#v, want allocated empty map", got) + } +} + +func TestProviderHeadersSetupTestAndComplete(t *testing.T) { + fixture, expected := headerProviderFixture(t) + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Model.Default = "" + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + db, err := store.Open(t.Context(), "sqlite", filepath.Join(t.TempDir(), "test.db"), 1, 5000, false) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, db: db, agent: agent.New(cfg, db, nil, nil, nil)} + + testRequest := providerJSONRequest(http.MethodPost, "/api/setup/test", `{"provider":"custom","base_url":"`+fixture.URL+`/v1","headers":{"X-Tenant":"team=a=b"}}`) + testRequest.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + s.handleSetupTest(rr, testRequest) + assertProviderOK(t, rr) + + completeRequest := providerJSONRequest(http.MethodPost, "/api/setup/complete", `{"provider":"custom","name":"Header Setup","base_url":"`+fixture.URL+`/v1","headers":{"X-Tenant":"team=a=b"},"model":"header-model","workspace":"`+filepath.Join(t.TempDir(), "workspace")+`"}`) + completeRequest.RemoteAddr = "127.0.0.1:12345" + rr = httptest.NewRecorder() + s.handleSetupComplete(rr, completeRequest) + assertProviderOK(t, rr) + + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Providers["header-setup"].Headers["X-Tenant"]; got != *expected { + t.Fatalf("completed headers = %q, want %q", got, *expected) + } +} + +func TestHeaderAuthenticatedCustomProviderIsReadyAndDiscoverable(t *testing.T) { + fixture, expected := headerProviderFixture(t) + aliasURL := "http://127.0.0.2:" + strconv.Itoa(fixture.Listener.Addr().(*net.TCPAddr).Port) + "/v1" + + originalTransport := http.DefaultTransport + transport := originalTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, fixture.Listener.Addr().String()) + } + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = originalTransport }) + + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Model.Provider = "header-gateway" + cfg.Model.Default = "header-model" + cfg.Providers["header-gateway"] = config.Provider{ + Kind: "openai-compatible", BaseURL: aliasURL, Enabled: true, + Headers: map[string]string{"X-Tenant": *expected}, + } + if NeedsSetup(cfg) { + t.Fatal("header-authenticated custom provider still needs setup") + } + if NeedsSetup(&config.Config{Model: config.Model{Provider: "header-gateway", Default: "header-model"}, Providers: map[string]config.Provider{"header-gateway": {BaseURL: aliasURL}}}) == false { + t.Fatal("headerless remote provider did not need setup") + } + if NeedsSetup(&config.Config{Model: config.Model{Provider: "openai", Default: "gpt-5"}, Providers: map[string]config.Provider{"openai": {BaseURL: "https://api.openai.com/v1", Headers: map[string]string{"X-Tenant": *expected}}}}) == false { + t.Fatal("built-in provider headers bypassed setup") + } + + db, err := store.Open(t.Context(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + s := &Server{cfg: cfg, db: db, agent: agent.New(cfg, db, nil, nil, nil)} + + status := httptest.NewRecorder() + s.handleStatus(status, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + var statusBody struct { + ProviderReady bool `json:"provider_ready"` + NeedsSetup bool `json:"needs_setup"` + } + if err := json.Unmarshal(status.Body.Bytes(), &statusBody); err != nil { + t.Fatal(err) + } + if !statusBody.ProviderReady || statusBody.NeedsSetup { + t.Fatalf("status = %#v, want ready and complete", statusBody) + } + + list := httptest.NewRecorder() + s.handleModelList(list, httptest.NewRequest(http.MethodGet, "/api/models?provider=header-gateway", nil)) + var listBody struct { + Models []struct { + ID string `json:"id"` + } `json:"models"` + } + if err := json.Unmarshal(list.Body.Bytes(), &listBody); err != nil { + t.Fatal(err) + } + if len(listBody.Models) != 1 || listBody.Models[0].ID != "header-model" { + t.Fatalf("single provider models = %#v", listBody.Models) + } + + all := httptest.NewRecorder() + s.handleModelListAll(all, httptest.NewRequest(http.MethodGet, "/api/models/all", nil)) + var allBody struct { + Models []struct { + ID string `json:"id"` + Provider string `json:"provider"` + } `json:"models"` + } + if err := json.Unmarshal(all.Body.Bytes(), &allBody); err != nil { + t.Fatal(err) + } + if len(allBody.Models) != 1 || allBody.Models[0].ID != "header-model" || allBody.Models[0].Provider != "header-gateway" { + t.Fatalf("all provider models = %#v", allBody.Models) + } +} + +// TestSetupCompleteDoesNotLeakLegacyCustomHeaders guards a credential leak: +// the legacy "custom" catalogue slot may already hold an Authorization header +// from an earlier configuration. Naming a new custom provider mints a fresh +// id ("header-setup" here), and its entry must not silently inherit those +// headers — that would forward an unrelated bearer token to the newly typed +// base URL. Explicitly submitted headers still land on the new provider, and +// the legacy slot is left exactly as it was. +func TestSetupCompleteDoesNotLeakLegacyCustomHeaders(t *testing.T) { + fixture, _ := headerProviderFixture(t) + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Model.Default = "" + // The legacy "custom" slot carries a bearer that belongs to a different + // host; naming a new provider must not carry it over. + cfg.Providers["custom"] = config.Provider{ + Kind: "openai-compatible", + BaseURL: "https://legacy.example.com/v1", + Headers: map[string]string{"Authorization": "Bearer legacy-secret"}, + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + db, err := store.Open(t.Context(), "sqlite", filepath.Join(t.TempDir(), "leak.db"), 1, 5000, false) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, db: db, agent: agent.New(cfg, db, nil, nil, nil)} + + // New host, new name, no headers submitted. The wizard's `provider` field + // stays "custom" (the catalogue picker id); the save target is minted from + // the name. + body := `{"provider":"custom","name":"Fresh Host","base_url":"` + fixture.URL + `/v1","model":"header-model","workspace":"` + filepath.Join(t.TempDir(), "ws") + `"}` + r := providerJSONRequest(http.MethodPost, "/api/setup/complete", body) + r.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + s.handleSetupComplete(rr, r) + assertProviderOK(t, rr) + + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + fresh, ok := reloaded.Providers["fresh-host"] + if !ok { + t.Fatalf("new provider not saved; providers = %#v", reloaded.Providers) + } + if _, leaked := fresh.Headers["Authorization"]; leaked { + t.Fatalf("legacy custom Authorization leaked onto new provider: %#v", fresh.Headers) + } + if len(fresh.Headers) != 0 { + t.Fatalf("new provider inherited unexpected headers: %#v", fresh.Headers) + } + legacy := reloaded.Providers["custom"] + if got := legacy.Headers["Authorization"]; got != "Bearer legacy-secret" { + t.Fatalf("legacy custom entry mutated: %#v", legacy.Headers) + } + if legacy.BaseURL != "https://legacy.example.com/v1" { + t.Fatalf("legacy custom base URL mutated: %q", legacy.BaseURL) + } +} + +// TestSetupCompleteKeepsExplicitNewHeaders is the companion assertion: the +// wizard must still persist headers the user typed in for the new provider, +// even when the legacy custom slot is holding something entirely different. +func TestSetupCompleteKeepsExplicitNewHeaders(t *testing.T) { + fixture, expected := headerProviderFixture(t) + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Model.Default = "" + cfg.Providers["custom"] = config.Provider{ + Kind: "openai-compatible", + BaseURL: "https://legacy.example.com/v1", + Headers: map[string]string{"Authorization": "Bearer legacy-secret"}, + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + db, err := store.Open(t.Context(), "sqlite", filepath.Join(t.TempDir(), "keep.db"), 1, 5000, false) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, db: db, agent: agent.New(cfg, db, nil, nil, nil)} + + body := `{"provider":"custom","name":"Named Host","base_url":"` + fixture.URL + `/v1","headers":{"X-Tenant":"team=a=b"},"model":"header-model","workspace":"` + filepath.Join(t.TempDir(), "ws") + `"}` + r := providerJSONRequest(http.MethodPost, "/api/setup/complete", body) + r.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + s.handleSetupComplete(rr, r) + assertProviderOK(t, rr) + + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + fresh := reloaded.Providers["named-host"] + if got := fresh.Headers["X-Tenant"]; got != *expected { + t.Fatalf("explicit header dropped: %#v", fresh.Headers) + } + if _, leaked := fresh.Headers["Authorization"]; leaked { + t.Fatalf("legacy Authorization still leaked: %#v", fresh.Headers) + } +} + +// TestSetupTestDoesNotForwardLegacyCustomHeaders locks the sibling behaviour +// on the wizard's dry-run endpoint: pointing a custom probe at a new base URL +// must not attach whatever Authorization the legacy custom slot happens to +// hold. The fixture returns 401 whenever the wrong Authorization arrives, so +// a leaked bearer would flip ok to false with an auth error. +func TestSetupTestDoesNotForwardLegacyCustomHeaders(t *testing.T) { + expected := "team=a=b" + // The fixture rejects any request that carries the leaked Authorization + // header, so a cross-origin leak surfaces as ok:false rather than 200. + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path != "/v1/models" || r.Header.Get("X-Tenant") != expected { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"header-model","owned_by":"test"}]}`)) + })) + t.Cleanup(fixture.Close) + + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Model.Default = "" + cfg.Providers["custom"] = config.Provider{ + Kind: "openai-compatible", + BaseURL: "https://legacy.example.com/v1", + Headers: map[string]string{"Authorization": "Bearer legacy-secret"}, + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + db, err := store.Open(t.Context(), "sqlite", filepath.Join(t.TempDir(), "probe.db"), 1, 5000, false) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, db: db, agent: agent.New(cfg, db, nil, nil, nil)} + + // Explicit headers submitted for the new host: only these should reach the + // probe. Legacy Authorization must stay parked in its own slot. + r := providerJSONRequest(http.MethodPost, "/api/setup/test", `{"provider":"custom","base_url":"`+fixture.URL+`/v1","headers":{"X-Tenant":"team=a=b"}}`) + r.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + s.handleSetupTest(rr, r) + assertProviderOK(t, rr) +} diff --git a/web/src/components/providers/ProviderHeadersField.tsx b/web/src/components/providers/ProviderHeadersField.tsx new file mode 100644 index 0000000..11aa772 --- /dev/null +++ b/web/src/components/providers/ProviderHeadersField.tsx @@ -0,0 +1,28 @@ +import { useI18n } from '@/lib/i18n' +import { Label, Textarea } from '@/components/ui/primitives' + +export function ProviderHeadersField({ + id, + value, + onChange, +}: { + id: string + value: string + onChange: (value: string) => void +}) { + const { t } = useI18n() + return ( +
+ +