Skip to content

feat(config): add WithHeader option for custom HTTP headers#65

Open
jrschumacher wants to merge 1 commit intomozilla-ai:mainfrom
jrschumacher:jrschumacher/with-headers
Open

feat(config): add WithHeader option for custom HTTP headers#65
jrschumacher wants to merge 1 commit intomozilla-ai:mainfrom
jrschumacher:jrschumacher/with-headers

Conversation

@jrschumacher
Copy link
Copy Markdown

Summary

  • Add WithHeader(key, value string) config option that injects custom HTTP headers on every provider request
  • Wrap the HTTP client transport with a headerTransport that sets configured headers before each request
  • Update Anthropic provider to use cfg.HTTPClient() so custom headers apply to Anthropic requests too
  • Re-export WithHeader from the root anyllm package

Motivation

Proxy/gateway services like Cloudflare AI Gateway require custom authentication headers (e.g., cf-aig-authorization) alongside the provider's own API key. Currently there's no way to inject custom headers without providing a fully custom HTTP client with a hand-rolled round-tripper.

Usage

provider, err := openai.New(
    anyllm.WithAPIKey("sk-..."),
    anyllm.WithBaseURL("https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openai"),
    anyllm.WithHeader("cf-aig-authorization", "Bearer cf-token"),
)

Works with all providers since they all use cfg.HTTPClient() (Anthropic now does too, via this PR).

Changes

File Change
config/config.go Add Headers field, WithHeader option, headerTransport round-tripper, update HTTPClient() to wrap transport when headers are set
config/config_test.go Tests for WithHeader validation, multiple headers, transport wrapping, custom client + headers
providers/anthropic/anthropic.go Pass cfg.HTTPClient() to SDK client options
anyllm.go Re-export WithHeader

Test plan

  • All existing config tests pass
  • New tests: WithHeader validation, multiple headers, transport wrapping with default and custom clients, no-op when no headers set
  • Full go build ./... passes

🤖 Generated with Claude Code

@peteski22 peteski22 self-assigned this Apr 15, 2026
Add WithHeader(key, value) config option that injects custom HTTP
headers on every request. This enables proxy/gateway authentication
such as Cloudflare AI Gateway's cf-aig-authorization header.

The implementation wraps the HTTP client's transport with a
headerTransport that sets the configured headers before each request.
This works transparently with all providers since they use
cfg.HTTPClient() for their HTTP calls.

Also updates the Anthropic provider to pass cfg.HTTPClient() to the
SDK client, ensuring custom headers are applied to Anthropic requests
as well (previously it used the SDK's default HTTP client).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@peteski22 peteski22 force-pushed the jrschumacher/with-headers branch from e9c9344 to a807465 Compare April 17, 2026 16:18
@peteski22
Copy link
Copy Markdown
Contributor

peteski22 commented Apr 17, 2026

Code review

Found 3 issues:

  1. Data race on shared Headers map. headerTransport.headers aliases Config.Headers (exported, mutable) rather than copying it. If any caller mutates cfg.Headers after HTTPClient() returns while a concurrent request is in RoundTrip ranging over the map, that is an unsynchronised concurrent read/write. Fix by cloning at construction. (.claude/CLAUDE.md Go Proverbs: "Don't communicate by sharing memory, share memory by communicating.")

any-llm-go/config/config.go

Lines 184 to 196 in e9c9344

// Wrap the transport to inject custom headers on every request.
if len(c.Headers) > 0 {
c.httpClient = &http.Client{
Transport: &headerTransport{
base: c.httpClient.Transport,
headers: c.Headers,
},
Timeout: c.httpClient.Timeout,
CheckRedirect: c.httpClient.CheckRedirect,
Jar: c.httpClient.Jar,
}
}
})

  1. WithHeader placed out of alphabetical order — sits between WithBaseURL and WithExtra. Correct order: WithAPIKey, WithBaseURL, WithExtra, WithHeader, WithHTTPClient, WithTimeout. (.claude/CLAUDE.md Go Language Rules: "Within each section, top-level declarations should be in alphabetical order". Root CLAUDE.md File Organization: "Exported methods (alphabetically)".)

}
// WithHeader adds an HTTP header that will be included on every request.
// Useful for proxy/gateway authentication (e.g., cf-aig-authorization for
// Cloudflare AI Gateway). Can be called multiple times to add multiple headers.
func WithHeader(key, value string) Option {
return func(c *Config) error {
key = strings.TrimSpace(key)
if key == "" {
return fmt.Errorf("header key cannot be empty")
}

  1. HTTPClient() docstring is now inaccurate. The note says "If a custom client was provided via WithHTTPClient, that pointer is returned" — but when Headers is non-empty, HTTPClient() constructs a new *http.Client wrapping the original, so the returned pointer is different. Update the doc to reflect the wrapping and the fact that Timeout/CheckRedirect/Jar are copied at that point.

any-llm-go/config/config.go

Lines 172 to 199 in e9c9344

// HTTPClient returns the configured HTTP client, or lazily creates one using
// the configured Timeout if no custom client was provided via WithHTTPClient.
// The lazily-created client is cached and reused on subsequent calls.
//
// Note: If a custom client was provided via WithHTTPClient, that pointer is returned.
func (c *Config) HTTPClient() *http.Client {
c.httpClientOnce.Do(func() {
if c.httpClient == nil {
c.httpClient = &http.Client{Timeout: c.Timeout}
}
// Wrap the transport to inject custom headers on every request.
if len(c.Headers) > 0 {
c.httpClient = &http.Client{
Transport: &headerTransport{
base: c.httpClient.Transport,
headers: c.Headers,
},
Timeout: c.httpClient.Timeout,
CheckRedirect: c.httpClient.CheckRedirect,
Jar: c.httpClient.Jar,
}
}
})
return c.httpClient
}

Copy link
Copy Markdown
Contributor

@peteski22 peteski22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments cover line-anchored issues. Non-anchored items:

Tests

  • No end-to-end test. Spin up httptest.NewServer, assert inbound headers match. Catches the RoundTrip mutation bug and real injection regressions.
  • Missing test for duplicate-key semantics (WithHeader("X","a"); WithHeader("X","b")). Documents overwrite vs append behavior — required if Headers switches to http.Header.

Follow-ups (not blocking)

  • Gateway provider on main has its own headerTransport (providers/gateway/gateway.go:125-128,373-378). Migrate to config.WithHeader in a follow-up to dedupe.
  • Platform provider builds its own &http.Client{Timeout: 30s} and won't pick up WithHeader. Out of scope per gateway replacement.

Verified against e9c9344 + origin/main. Extends prior comment.

Comment thread config/config.go

func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for key, value := range t.headers {
req.Header.Set(key, value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RoundTrip mutates the caller's request. net/http contract: "RoundTrip should not modify the request, except for consuming and closing the Body." Gateway provider already does this correctly — clone first:

clone := req.Clone(req.Context())
for k, v := range t.headers {
    clone.Header.Set(k, v)
}
return t.base.RoundTrip(clone)

See providers/gateway/gateway.go:373-378 for the established pattern.


clientOpts := []option.RequestOption{
option.WithAPIKey(apiKey),
option.WithHTTPClient(cfg.HTTPClient()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix, but silent behavior change. Before this PR the Anthropic SDK used its own default HTTP client (no timeout). Now cfg.Timeout applies — existing callers with Anthropic calls >120s (default) will start timing out. Please note in PR description + CHANGELOG.

Comment thread config/config.go
// Headers holds extra HTTP headers to include on every request.
// Useful for proxy authentication (e.g., Cloudflare AI Gateway's
// cf-aig-authorization header).
Headers map[string]string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map[string]string silently overwrites duplicate keys and can't represent multi-valued headers (RFC 7230 §3.2.2). Suggest http.Header to match stdlib, with WithHeader calling Add (append) or Set (overwrite) — pick one and document the semantic.

Comment thread config/config.go
}

// headerTransport is an http.RoundTripper that injects headers on every request.
type headerTransport struct {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type belongs in the types section near Config/Option, not after HTTPClient(). Project CLAUDE.md File Organization: "Types (exported first, then unexported helpers)" precede constructor and methods.

Comment thread config/config.go
c.httpClient = &http.Client{Timeout: c.Timeout}
}

// Wrap the transport to inject custom headers on every request.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narration — restates the next three lines. Drop, or replace with why (e.g. "applied transport-layer so header injection is provider-agnostic").

Comment thread config/config_test.go
}

require.NoError(t, err)
require.NotNil(t, cfg.Headers)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only checks Headers != nil. Doesn't verify the trimmed key or value landed. Add:

require.Equal(t, expectedKey, /* resolved key */)
require.Equal(t, tc.value, cfg.Headers[expectedKey])

Comment thread config/config_test.go
require.NotNil(t, client)

// Without headers, transport should NOT be a headerTransport.
_, ok := client.Transport.(*headerTransport)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passes for the wrong reason. With no headers + no custom client, client.Transport is nil. Type assertion on nil returns ok=false, so this succeeds even if the wrapping logic were broken. Tighten:

require.Nil(t, client.Transport)

@jrschumacher
Copy link
Copy Markdown
Author

@peteski22 Sounds good I'll take a look at these issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants