-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>
Showing 3 changed files with 182 additions and 12 deletions.
| @@ -4,6 +4,7 @@ package main | ||
| 4 | 4 | import ( |
| 5 | 5 | "bufio" |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 7 | 8 | "flag" |
| 8 | 9 | "fmt" |
| 9 | 10 | "os" |
| @@ -27,7 +28,10 @@ func main() { | ||
| 27 | 28 | |
| 28 | 29 | func run() error { |
| 29 | 30 | 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)") | |
| 31 | 35 | effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)") |
| 32 | 36 | maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt") |
| 33 | 37 | logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root") |
| @@ -46,11 +50,25 @@ func run() error { | ||
| 46 | 50 | return err |
| 47 | 51 | } |
| 48 | 52 | 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 | + ) | |
| 52 | 62 | 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) | |
| 54 | 72 | } |
| 55 | 73 | |
| 56 | 74 | state := agent.NewState() |
| @@ -68,7 +86,7 @@ func run() error { | ||
| 68 | 86 | } |
| 69 | 87 | |
| 70 | 88 | 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), ", ")) | |
| 72 | 90 | |
| 73 | 91 | in := bufio.NewScanner(os.Stdin) |
| 74 | 92 | 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 | ||
| 100 | 118 | return err |
| 101 | 119 | } |
| 102 | 120 | |
| 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 | + | |
| 103 | 129 | func toolNames(reg *tools.Registry) []string { |
| 104 | 130 | var names []string |
| 105 | 131 | for _, t := range reg.List() { |
| @@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | "net/http" |
| 11 | 11 | "os" |
| 12 | 12 | "strconv" |
| 13 | + "strings" | |
| 13 | 14 | "time" |
| 14 | 15 | ) |
| 15 | 16 | |
| @@ -41,11 +42,44 @@ type Client struct { | ||
| 41 | 42 | // Option configures a Client. |
| 42 | 43 | type Option func(*Client) |
| 43 | 44 | |
| 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 | + } | |
| 49 | 83 | func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h } } |
| 50 | 84 | |
| 51 | 85 | // New builds a client, reading credentials from the environment unless an |
| @@ -66,7 +100,7 @@ func New(opts ...Option) *Client { | ||
| 66 | 100 | if c.apiKey != "" { |
| 67 | 101 | c.authToken = "" |
| 68 | 102 | } |
| 69 | - if u := os.Getenv("ANTHROPIC_BASE_URL"); u != "" { | |
| 103 | + if u := normalizeBaseURL(os.Getenv("ANTHROPIC_BASE_URL")); u != "" { | |
| 70 | 104 | c.BaseURL = u |
| 71 | 105 | } |
| 72 | 106 | for _, o := range opts { |
| @@ -75,6 +109,16 @@ func New(opts ...Option) *Client { | ||
| 75 | 109 | return c |
| 76 | 110 | } |
| 77 | 111 | |
| 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 | + | |
| 78 | 122 | // HasCredentials reports whether the client can authenticate. |
| 79 | 123 | func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" } |
| 80 | 124 | |
| @@ -184,3 +184,103 @@ func TestRetryAfterHintIsParsed(t *testing.T) { | ||
| 184 | 184 | t.Error("want no hint when the header was absent") |
| 185 | 185 | } |
| 186 | 186 | } |
| 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 | + } | |