Vectrum
Sign In

Configure endpoint and credentials from flags or environment

-base-url, -api-key and -auth-token, each overriding the matching env
var. Every client option now ignores an empty value, so an unset flag
leaves the environment in place rather than clobbering it with "".

Base URLs are trimmed of trailing slashes: a pasted "…/v1/" produced a
request to "//v1/messages", which some proxies 404 rather than normalize.

A non-Anthropic endpoint prints a line to stderr at startup — the prompt
and every file the agent reads go wherever that URL points — and the
no-credentials error now lists all four ways to supply one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 50d688fcommitad96f09

Showing 3 changed files with 182 additions and 12 deletions.

+32−6cmd/harness/main.goView File
@@ -4,6 +4,7 @@ package main
44 import (
55 "bufio"
66 "context"
7+ "errors"
78 "flag"
89 "fmt"
910 "os"
@@ -27,7 +28,10 @@ func main() {
2728
2829 func run() error {
2930 workdir := flag.String("C", ".", "working root the agent operates in")
30- model := flag.String("model", llm.DefaultModel, "model id")
31+ model := flag.String("model", "", "model id (default "+llm.DefaultModel+")")
32+ baseURL := flag.String("base-url", "", "API endpoint, for a proxy, gateway, or local server speaking the Messages API (env ANTHROPIC_BASE_URL, default "+llm.DefaultBaseURL+")")
33+ apiKey := flag.String("api-key", "", "credential sent as x-api-key (env ANTHROPIC_API_KEY). Visible to other processes; prefer the env var")
34+ authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)")
3135 effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
3236 maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt")
3337 logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
@@ -46,11 +50,25 @@ func run() error {
4650 return err
4751 }
4852 defer log.Close()
49- log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{"root": root, "model": *model}})
50-
51- client := llm.New(llm.WithModel(*model))
53+ // Flags win over the environment, which wins over the defaults. Every
54+ // option below ignores an empty value, so an unset flag leaves the
55+ // environment in place.
56+ client := llm.New(
57+ llm.WithModel(*model),
58+ llm.WithBaseURL(*baseURL),
59+ llm.WithAPIKey(*apiKey),
60+ llm.WithAuthToken(*authToken),
61+ )
5262 if !client.HasCredentials() {
53- return fmt.Errorf("no credentials: export ANTHROPIC_API_KEY, or run `ant auth login`")
63+ return errNoCredentials
64+ }
65+ log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
66+ "root": root, "model": client.Model, "base_url": client.BaseURL,
67+ }})
68+ if !client.IsDefaultEndpoint() {
69+ // Worth saying out loud: the prompt and every file the agent reads go
70+ // to this endpoint, not to Anthropic.
71+ fmt.Fprintf(os.Stderr, "harness: sending requests to %s\n", client.BaseURL)
5472 }
5573
5674 state := agent.NewState()
@@ -68,7 +86,7 @@ func run() error {
6886 }
6987
7088 fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, or ctrl-d to exit\n\n",
71- *model, root, strings.Join(toolNames(reg), ", "))
89+ client.Model, root, strings.Join(toolNames(reg), ", "))
7290
7391 in := bufio.NewScanner(os.Stdin)
7492 in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -100,6 +118,14 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, prompt strin
100118 return err
101119 }
102120
121+ // errNoCredentials spells out every way to supply one, because "unauthorized"
122+ // against a gateway that ignores the header is a confusing failure.
123+ var errNoCredentials = errors.New(`no credentials. Supply one of:
124+ export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
125+ export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
126+ -api-key / -auth-token (flags override the environment)
127+ To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
128+
103129 func toolNames(reg *tools.Registry) []string {
104130 var names []string
105131 for _, t := range reg.List() {
+50−6internal/llm/client.goView File
@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111 "os"
1212 "strconv"
13+ "strings"
1314 "time"
1415 )
1516
@@ -41,11 +42,44 @@ type Client struct {
4142 // Option configures a Client.
4243 type Option func(*Client)
4344
44- func WithBaseURL(u string) Option { return func(c *Client) { c.BaseURL = u } }
45- func WithModel(m string) Option { return func(c *Client) { c.Model = m } }
46- func WithMaxTokens(n int) Option { return func(c *Client) { c.MaxTokens = n } }
47- func WithAPIKey(k string) Option { return func(c *Client) { c.apiKey = k; c.authToken = "" } }
48- func WithAuthToken(t string) Option { return func(c *Client) { c.authToken = t; c.apiKey = "" } }
45+ // WithBaseURL points the client at a different endpoint: a proxy, a gateway,
46+ // or a local server speaking the Messages API. An empty string is ignored, so
47+ // callers can pass an unset flag without clobbering the environment.
48+ func WithBaseURL(u string) Option {
49+ return func(c *Client) {
50+ if u = normalizeBaseURL(u); u != "" {
51+ c.BaseURL = u
52+ }
53+ }
54+ }
55+ func WithModel(m string) Option {
56+ return func(c *Client) {
57+ if m != "" {
58+ c.Model = m
59+ }
60+ }
61+ }
62+
63+ func WithMaxTokens(n int) Option { return func(c *Client) { c.MaxTokens = n } }
64+
65+ // WithAPIKey sets the x-api-key credential. An empty string is ignored.
66+ func WithAPIKey(k string) Option {
67+ return func(c *Client) {
68+ if k != "" {
69+ c.apiKey, c.authToken = k, ""
70+ }
71+ }
72+ }
73+
74+ // WithAuthToken sets a bearer credential, which is what OAuth tokens and most
75+ // third-party gateways expect. An empty string is ignored.
76+ func WithAuthToken(t string) Option {
77+ return func(c *Client) {
78+ if t != "" {
79+ c.authToken, c.apiKey = t, ""
80+ }
81+ }
82+ }
4983 func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h } }
5084
5185 // New builds a client, reading credentials from the environment unless an
@@ -66,7 +100,7 @@ func New(opts ...Option) *Client {
66100 if c.apiKey != "" {
67101 c.authToken = ""
68102 }
69- if u := os.Getenv("ANTHROPIC_BASE_URL"); u != "" {
103+ if u := normalizeBaseURL(os.Getenv("ANTHROPIC_BASE_URL")); u != "" {
70104 c.BaseURL = u
71105 }
72106 for _, o := range opts {
@@ -75,6 +109,16 @@ func New(opts ...Option) *Client {
75109 return c
76110 }
77111
112+ // normalizeBaseURL trims whitespace and trailing slashes, so a base URL pasted
113+ // with a trailing "/" does not produce a request to "//v1/messages" — which
114+ // some proxies 404 rather than normalize.
115+ func normalizeBaseURL(u string) string {
116+ return strings.TrimRight(strings.TrimSpace(u), "/")
117+ }
118+
119+ // IsDefaultEndpoint reports whether the client is talking to Anthropic.
120+ func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL }
121+
78122 // HasCredentials reports whether the client can authenticate.
79123 func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" }
80124
+100−0internal/llm/client_test.goView File
@@ -184,3 +184,103 @@ func TestRetryAfterHintIsParsed(t *testing.T) {
184184 t.Error("want no hint when the header was absent")
185185 }
186186 }
187+
188+ func TestBaseURLAndCredentialResolution(t *testing.T) {
189+ tests := []struct {
190+ name string
191+ env map[string]string
192+ opts []Option
193+ wantURL string
194+ wantAPIKey string
195+ wantAuthToken string
196+ }{
197+ {
198+ name: "defaults",
199+ wantURL: DefaultBaseURL,
200+ wantAPIKey: "",
201+ },
202+ {
203+ name: "environment is picked up",
204+ env: map[string]string{"ANTHROPIC_BASE_URL": "http://localhost:4000", "ANTHROPIC_API_KEY": "env-key"},
205+ wantURL: "http://localhost:4000",
206+ wantAPIKey: "env-key",
207+ },
208+ {
209+ name: "flags override the environment",
210+ env: map[string]string{"ANTHROPIC_BASE_URL": "http://from-env", "ANTHROPIC_API_KEY": "env-key"},
211+ opts: []Option{WithBaseURL("http://from-flag"), WithAPIKey("flag-key")},
212+ wantURL: "http://from-flag",
213+ wantAPIKey: "flag-key",
214+ },
215+ {
216+ name: "empty options leave the environment alone",
217+ env: map[string]string{"ANTHROPIC_BASE_URL": "http://from-env", "ANTHROPIC_API_KEY": "env-key"},
218+ opts: []Option{WithBaseURL(""), WithAPIKey(""), WithAuthToken(""), WithModel("")},
219+ wantURL: "http://from-env",
220+ wantAPIKey: "env-key",
221+ },
222+ {
223+ name: "trailing slashes are trimmed",
224+ env: map[string]string{"ANTHROPIC_BASE_URL": "http://gateway.internal/v1/// "},
225+ wantURL: "http://gateway.internal/v1",
226+ },
227+ {
228+ name: "api key shadows a bearer token from the environment",
229+ env: map[string]string{"ANTHROPIC_API_KEY": "k", "ANTHROPIC_AUTH_TOKEN": "t"},
230+ wantURL: DefaultBaseURL,
231+ wantAPIKey: "k",
232+ wantAuthToken: "",
233+ },
234+ {
235+ name: "bearer token is used when no api key is set",
236+ env: map[string]string{"ANTHROPIC_AUTH_TOKEN": "t"},
237+ wantURL: DefaultBaseURL,
238+ wantAuthToken: "t",
239+ },
240+ {
241+ name: "auth-token flag displaces an api key from the environment",
242+ env: map[string]string{"ANTHROPIC_API_KEY": "env-key"},
243+ opts: []Option{WithAuthToken("flag-token")},
244+ wantURL: DefaultBaseURL,
245+ wantAuthToken: "flag-token",
246+ },
247+ }
248+ for _, tc := range tests {
249+ t.Run(tc.name, func(t *testing.T) {
250+ for _, k := range []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"} {
251+ t.Setenv(k, tc.env[k])
252+ }
253+ c := New(tc.opts...)
254+ if c.BaseURL != tc.wantURL {
255+ t.Errorf("BaseURL = %q, want %q", c.BaseURL, tc.wantURL)
256+ }
257+ if c.apiKey != tc.wantAPIKey {
258+ t.Errorf("apiKey = %q, want %q", c.apiKey, tc.wantAPIKey)
259+ }
260+ if c.authToken != tc.wantAuthToken {
261+ t.Errorf("authToken = %q, want %q", c.authToken, tc.wantAuthToken)
262+ }
263+ if wantDefault := tc.wantURL == DefaultBaseURL; c.IsDefaultEndpoint() != wantDefault {
264+ t.Errorf("IsDefaultEndpoint = %v, want %v", c.IsDefaultEndpoint(), wantDefault)
265+ }
266+ })
267+ }
268+ }
269+
270+ func TestCustomBaseURLIsActuallyUsed(t *testing.T) {
271+ var gotPath string
272+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273+ gotPath = r.URL.Path
274+ io.WriteString(w, okResponse)
275+ }))
276+ defer srv.Close()
277+
278+ // A trailing slash must not produce a request to "//v1/messages".
279+ c := New(WithBaseURL(srv.URL+"/"), WithAPIKey("k"))
280+ if _, err := c.CreateMessage(context.Background(), Request{}); err != nil {
281+ t.Fatalf("CreateMessage: %v", err)
282+ }
283+ if gotPath != "/v1/messages" {
284+ t.Errorf("path = %q, want /v1/messages", gotPath)
285+ }
286+ }