A frozen authToken cannot outlive its own expiry. WithTokenSource makes the bearer a function; WithAuthToken is now its constant case. Resolution happens before the retry loop because a credential error is not an *APIError, and the loop only returns early for those — resolving inside attempt would retry a dead credential three times with backoff. Both request paths now share one apply instead of repeating the headers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 3 changed files with 186 additions and 21 deletions.
| @@ -35,8 +35,9 @@ type Client struct { | ||
| 35 | 35 | RetryDelay time.Duration |
| 36 | 36 | HTTP *http.Client |
| 37 | 37 | |
| 38 | - apiKey string | |
| 39 | - authToken string | |
| 38 | + apiKey string | |
| 39 | + authToken string | |
| 40 | + tokenSource func(context.Context) (string, error) | |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 43 | // Option configures a Client. |
| @@ -80,6 +81,17 @@ func WithAuthToken(t string) Option { | ||
| 80 | 81 | } |
| 81 | 82 | } |
| 82 | 83 | } |
| 84 | + | |
| 85 | + // WithTokenSource sets a bearer credential resolved per request, so a token | |
| 86 | + // that expires mid-session can be replaced without rebuilding the client. A | |
| 87 | + // nil function is ignored. | |
| 88 | + func WithTokenSource(f func(context.Context) (string, error)) Option { | |
| 89 | + return func(c *Client) { | |
| 90 | + if f != nil { | |
| 91 | + c.tokenSource, c.apiKey, c.authToken = f, "", "" | |
| 92 | + } | |
| 93 | + } | |
| 94 | + } | |
| 83 | 95 | func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h } } |
| 84 | 96 | |
| 85 | 97 | // New builds a client, reading credentials from the environment unless an |
| @@ -120,7 +132,9 @@ func normalizeBaseURL(u string) string { | ||
| 120 | 132 | func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL } |
| 121 | 133 | |
| 122 | 134 | // HasCredentials reports whether the client can authenticate. |
| 123 | - func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" } | |
| 135 | + func (c *Client) HasCredentials() bool { | |
| 136 | + return c.apiKey != "" || c.authToken != "" || c.tokenSource != nil | |
| 137 | + } | |
| 124 | 138 | |
| 125 | 139 | // APIError is a non-2xx response. |
| 126 | 140 | type APIError struct { |
| @@ -145,6 +159,42 @@ func (e *APIError) Retryable() bool { | ||
| 145 | 159 | e.StatusCode >= 500 |
| 146 | 160 | } |
| 147 | 161 | |
| 162 | + // auth is one request's resolved credential. Resolving returns a value rather | |
| 163 | + // than mutating the client so concurrent requests cannot interleave. | |
| 164 | + type auth struct { | |
| 165 | + apiKey string | |
| 166 | + bearer string | |
| 167 | + } | |
| 168 | + | |
| 169 | + // apply writes the credential headers. An OAuth bearer also needs the beta | |
| 170 | + // flag; an API key does not. | |
| 171 | + func (a auth) apply(h http.Header) { | |
| 172 | + if a.apiKey != "" { | |
| 173 | + h.Set("x-api-key", a.apiKey) | |
| 174 | + return | |
| 175 | + } | |
| 176 | + h.Set("authorization", "Bearer "+a.bearer) | |
| 177 | + h.Set("anthropic-beta", oauthBeta) | |
| 178 | + } | |
| 179 | + | |
| 180 | + // resolveAuth produces the credential for one request. It runs before the | |
| 181 | + // retry loop: a credential that will not resolve is permanent, and the loop | |
| 182 | + // only short-circuits on a non-retryable *APIError, so resolving inside | |
| 183 | + // attempt would retry a dead credential three times with backoff. | |
| 184 | + func (c *Client) resolveAuth(ctx context.Context) (auth, error) { | |
| 185 | + if c.apiKey != "" { | |
| 186 | + return auth{apiKey: c.apiKey}, nil | |
| 187 | + } | |
| 188 | + if c.tokenSource != nil { | |
| 189 | + tok, err := c.tokenSource(ctx) | |
| 190 | + if err != nil { | |
| 191 | + return auth{}, err | |
| 192 | + } | |
| 193 | + return auth{bearer: tok}, nil | |
| 194 | + } | |
| 195 | + return auth{bearer: c.authToken}, nil | |
| 196 | + } | |
| 197 | + | |
| 148 | 198 | // CreateMessage sends one request, filling in the client's defaults and |
| 149 | 199 | // retrying the errors worth retrying. |
| 150 | 200 | func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, error) { |
| @@ -153,6 +203,10 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 153 | 203 | if !c.HasCredentials() { |
| 154 | 204 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 155 | 205 | } |
| 206 | + a, err := c.resolveAuth(ctx) | |
| 207 | + if err != nil { | |
| 208 | + return nil, err | |
| 209 | + } | |
| 156 | 210 | if req.Model == "" { |
| 157 | 211 | req.Model = c.Model |
| 158 | 212 | } |
| @@ -171,7 +225,7 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 171 | 225 | return nil, err |
| 172 | 226 | } |
| 173 | 227 | } |
| 174 | - resp, err := c.attempt(ctx, body) | |
| 228 | + resp, err := c.attempt(ctx, body, a) | |
| 175 | 229 | if err == nil { |
| 176 | 230 | return resp, nil |
| 177 | 231 | } |
| @@ -187,21 +241,14 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 187 | 241 | return nil, lastErr |
| 188 | 242 | } |
| 189 | 243 | |
| 190 | - func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { | |
| 244 | + func (c *Client) attempt(ctx context.Context, body []byte, a auth) (*Response, error) { | |
| 191 | 245 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body)) |
| 192 | 246 | if err != nil { |
| 193 | 247 | return nil, err |
| 194 | 248 | } |
| 195 | 249 | httpReq.Header.Set("content-type", "application/json") |
| 196 | 250 | httpReq.Header.Set("anthropic-version", apiVersion) |
| 197 | - switch { | |
| 198 | - case c.apiKey != "": | |
| 199 | - httpReq.Header.Set("x-api-key", c.apiKey) | |
| 200 | - default: | |
| 201 | - // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. | |
| 202 | - httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 203 | - httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 204 | - } | |
| 251 | + a.apply(httpReq.Header) | |
| 205 | 252 | |
| 206 | 253 | httpResp, err := c.HTTP.Do(httpReq) |
| 207 | 254 | if err != nil { |
| @@ -3,6 +3,8 @@ package llm | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "encoding/json" |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 6 | 8 | "io" |
| 7 | 9 | "net/http" |
| 8 | 10 | "net/http/httptest" |
| @@ -284,3 +286,120 @@ func TestCustomBaseURLIsActuallyUsed(t *testing.T) { | ||
| 284 | 286 | t.Errorf("path = %q, want /v1/messages", gotPath) |
| 285 | 287 | } |
| 286 | 288 | } |
| 289 | + | |
| 290 | + // A token source is consulted per request, so a credential that rotates | |
| 291 | + // mid-session reaches the wire without rebuilding the client. | |
| 292 | + func TestTokenSourceIsCalledPerRequest(t *testing.T) { | |
| 293 | + var got []string | |
| 294 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 295 | + got = append(got, r.Header.Get("authorization")) | |
| 296 | + io.WriteString(w, okResponse) | |
| 297 | + })) | |
| 298 | + defer srv.Close() | |
| 299 | + | |
| 300 | + n := 0 | |
| 301 | + c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { | |
| 302 | + n++ | |
| 303 | + return fmt.Sprintf("tok-%d", n), nil | |
| 304 | + })) | |
| 305 | + for i := 0; i < 2; i++ { | |
| 306 | + if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { | |
| 307 | + t.Fatalf("CreateMessage: %v", err) | |
| 308 | + } | |
| 309 | + } | |
| 310 | + if len(got) != 2 || got[0] != "Bearer tok-1" || got[1] != "Bearer tok-2" { | |
| 311 | + t.Errorf("authorization headers = %v, want Bearer tok-1 then tok-2", got) | |
| 312 | + } | |
| 313 | + } | |
| 314 | + | |
| 315 | + func TestTokenSourceGetsTheOAuthBetaHeader(t *testing.T) { | |
| 316 | + var got http.Header | |
| 317 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 318 | + got = r.Header.Clone() | |
| 319 | + io.WriteString(w, okResponse) | |
| 320 | + })) | |
| 321 | + defer srv.Close() | |
| 322 | + | |
| 323 | + c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { | |
| 324 | + return "tok", nil | |
| 325 | + })) | |
| 326 | + if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { | |
| 327 | + t.Fatalf("CreateMessage: %v", err) | |
| 328 | + } | |
| 329 | + if got.Get("anthropic-beta") != oauthBeta { | |
| 330 | + t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta) | |
| 331 | + } | |
| 332 | + if got.Get("x-api-key") != "" { | |
| 333 | + t.Errorf("x-api-key should be unset, got %q", got.Get("x-api-key")) | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 337 | + // A credential that cannot be resolved is permanent. Retrying it three times | |
| 338 | + // with backoff just delays the error, so resolution happens before the loop. | |
| 339 | + func TestTokenSourceErrorIsNotRetried(t *testing.T) { | |
| 340 | + var hits int | |
| 341 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 342 | + hits++ | |
| 343 | + io.WriteString(w, okResponse) | |
| 344 | + })) | |
| 345 | + defer srv.Close() | |
| 346 | + | |
| 347 | + var calls int | |
| 348 | + c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { | |
| 349 | + calls++ | |
| 350 | + return "", errors.New("login expired") | |
| 351 | + })) | |
| 352 | + c.RetryDelay = time.Millisecond | |
| 353 | + _, err := c.CreateMessage(context.Background(), Request{}) | |
| 354 | + if err == nil || !strings.Contains(err.Error(), "login expired") { | |
| 355 | + t.Fatalf("err = %v, want the source's error", err) | |
| 356 | + } | |
| 357 | + if calls != 1 { | |
| 358 | + t.Errorf("token source called %d times, want 1", calls) | |
| 359 | + } | |
| 360 | + if hits != 0 { | |
| 361 | + t.Errorf("server hit %d times, want 0 — no request should have been sent", hits) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | + func TestHasCredentialsCountsATokenSource(t *testing.T) { | |
| 366 | + c := New(WithBaseURL("http://127.0.0.1:1")) | |
| 367 | + c.apiKey, c.authToken = "", "" | |
| 368 | + if c.HasCredentials() { | |
| 369 | + t.Fatal("no credentials expected before the source is set") | |
| 370 | + } | |
| 371 | + c.tokenSource = func(context.Context) (string, error) { return "tok", nil } | |
| 372 | + if !c.HasCredentials() { | |
| 373 | + t.Error("a token source is a credential") | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + // The streaming path must authenticate identically to the buffered one. | |
| 378 | + func TestStreamUsesTheTokenSource(t *testing.T) { | |
| 379 | + streamOK := sse( | |
| 380 | + `{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5","usage":{"input_tokens":100,"cache_read_input_tokens":40}}}`, | |
| 381 | + `{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":55}}`, | |
| 382 | + `{"type":"message_stop"}`, | |
| 383 | + ) | |
| 384 | + | |
| 385 | + var got http.Header | |
| 386 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 387 | + got = r.Header.Clone() | |
| 388 | + w.Header().Set("content-type", "text/event-stream") | |
| 389 | + io.WriteString(w, streamOK) | |
| 390 | + })) | |
| 391 | + defer srv.Close() | |
| 392 | + | |
| 393 | + c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { | |
| 394 | + return "stream-tok", nil | |
| 395 | + })) | |
| 396 | + if _, err := c.StreamMessage(context.Background(), Request{}, func(StreamEvent) {}); err != nil { | |
| 397 | + t.Fatalf("StreamMessage: %v", err) | |
| 398 | + } | |
| 399 | + if got.Get("authorization") != "Bearer stream-tok" { | |
| 400 | + t.Errorf("authorization = %q", got.Get("authorization")) | |
| 401 | + } | |
| 402 | + if got.Get("anthropic-beta") != oauthBeta { | |
| 403 | + t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta) | |
| 404 | + } | |
| 405 | + } | |
| @@ -37,6 +37,10 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St | ||
| 37 | 37 | if !c.HasCredentials() { |
| 38 | 38 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 39 | 39 | } |
| 40 | + a, err := c.resolveAuth(ctx) | |
| 41 | + if err != nil { | |
| 42 | + return nil, err | |
| 43 | + } | |
| 40 | 44 | if req.Model == "" { |
| 41 | 45 | req.Model = c.Model |
| 42 | 46 | } |
| @@ -57,7 +61,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St | ||
| 57 | 61 | return nil, err |
| 58 | 62 | } |
| 59 | 63 | } |
| 60 | - resp, err := c.streamAttempt(ctx, body, onEvent) | |
| 64 | + resp, err := c.streamAttempt(ctx, body, a, onEvent) | |
| 61 | 65 | if err == nil { |
| 62 | 66 | return resp, nil |
| 63 | 67 | } |
| @@ -78,7 +82,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St | ||
| 78 | 82 | return nil, lastErr |
| 79 | 83 | } |
| 80 | 84 | |
| 81 | - func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(StreamEvent)) (*Response, error) { | |
| 85 | + func (c *Client) streamAttempt(ctx context.Context, body []byte, a auth, onEvent func(StreamEvent)) (*Response, error) { | |
| 82 | 86 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body)) |
| 83 | 87 | if err != nil { |
| 84 | 88 | return nil, err |
| @@ -86,12 +90,7 @@ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(St | ||
| 86 | 90 | httpReq.Header.Set("content-type", "application/json") |
| 87 | 91 | httpReq.Header.Set("accept", "text/event-stream") |
| 88 | 92 | httpReq.Header.Set("anthropic-version", apiVersion) |
| 89 | - if c.apiKey != "" { | |
| 90 | - httpReq.Header.Set("x-api-key", c.apiKey) | |
| 91 | - } else { | |
| 92 | - httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 93 | - httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 94 | - } | |
| 93 | + a.apply(httpReq.Header) | |
| 95 | 94 | |
| 96 | 95 | httpResp, err := c.HTTP.Do(httpReq) |
| 97 | 96 | if err != nil { |