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
56 changes: 43 additions & 13 deletions cmd/antares/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions cmd/antares/setup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 44 additions & 0 deletions internal/config/provider_headers.go
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions internal/config/provider_headers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
39 changes: 39 additions & 0 deletions internal/llm/header_precedence_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 1 addition & 2 deletions internal/server/handlers_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading