From 7d3edc0da40e682d7c9638117cce69badd4d11d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=86=8A=E8=BF=AA?= Date: Wed, 24 Jun 2026 16:27:37 +0800 Subject: [PATCH 1/2] feat(provider): identify outbound LLM requests with a Reasonix User-Agent (#5226) Outbound provider requests carried no User-Agent, so Go's default "Go-http-client/1.1" went out and an upstream gateway/proxy could not attribute traffic to Reasonix. Add a small buildinfo package that owns the build version (set once in cli.Run from the -ldflags-injected value, falling back to "dev") and exposes UserAgent() == "Reasonix/". The openai and anthropic providers now set this User-Agent on every request. This is the zero-config subset of #3824 (fully user-configurable headers): no config or UI, just a stable, well-known field gateways can match on. Co-authored-by: Cursor --- internal/buildinfo/buildinfo.go | 43 +++++++++++++++++++ internal/buildinfo/buildinfo_test.go | 32 ++++++++++++++ internal/cli/cli.go | 4 ++ internal/provider/anthropic/anthropic.go | 2 + internal/provider/anthropic/anthropic_test.go | 33 ++++++++++++++ internal/provider/openai/openai.go | 2 + internal/provider/openai/openai_test.go | 32 ++++++++++++++ 7 files changed, 148 insertions(+) create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000000..16d12a3e4c --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,43 @@ +// Package buildinfo exposes the running build's identity so outbound HTTP +// requests can be attributed to Reasonix. The version is injected at build time +// via -ldflags "-X main.version=..." and handed to SetVersion once at startup; +// providers read UserAgent() when constructing requests. +package buildinfo + +import ( + "strings" + "sync/atomic" +) + +// devVersion is the placeholder for source/test builds that carry no injected +// version (the same default as cmd/reasonix/main.go's `version` var). +const devVersion = "dev" + +// version holds the running build's version. Stored atomically because it is +// set once at startup but read from every provider request goroutine. +var version atomic.Value + +// SetVersion records the running build's version. An empty value (source builds, +// tests that never set it) is normalized to "dev". Intended to be called once, +// early in startup, before any request is issued. +func SetVersion(v string) { + v = strings.TrimSpace(v) + if v == "" { + v = devVersion + } + version.Store(v) +} + +// Version returns the running build's version, or "dev" if none was set. +func Version() string { + if v, ok := version.Load().(string); ok && v != "" { + return v + } + return devVersion +} + +// UserAgent returns the identifying User-Agent for outbound Reasonix requests, +// e.g. "Reasonix/v1.2.3" (or "Reasonix/dev" for source builds). +func UserAgent() string { + return "Reasonix/" + Version() +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000000..cf54947d0b --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,32 @@ +package buildinfo + +import "testing" + +func TestUserAgentDefaultsToDev(t *testing.T) { + // A fresh process (no SetVersion) reports the dev placeholder. + if got := Version(); got != devVersion { + t.Fatalf("Version() = %q, want %q", got, devVersion) + } + if got, want := UserAgent(), "Reasonix/dev"; got != want { + t.Fatalf("UserAgent() = %q, want %q", got, want) + } +} + +func TestSetVersion(t *testing.T) { + t.Cleanup(func() { SetVersion(devVersion) }) // don't leak into other tests in the package + + SetVersion("v1.2.3") + if got, want := Version(), "v1.2.3"; got != want { + t.Fatalf("Version() = %q, want %q", got, want) + } + if got, want := UserAgent(), "Reasonix/v1.2.3"; got != want { + t.Fatalf("UserAgent() = %q, want %q", got, want) + } + + // An empty/whitespace version is normalized back to the dev placeholder + // rather than producing a bare "Reasonix/". + SetVersion(" ") + if got, want := UserAgent(), "Reasonix/dev"; got != want { + t.Fatalf("UserAgent() after blank SetVersion = %q, want %q", got, want) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2dffe79fd2..44dc11e853 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,6 +23,7 @@ import ( "reasonix/internal/agent" "reasonix/internal/boot" + "reasonix/internal/buildinfo" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" @@ -44,6 +45,9 @@ var ( // Run is the CLI entry point; it returns a process exit code. func Run(args []string, version string) int { + // Record the build version once so outbound provider requests can carry an + // identifying User-Agent (Reasonix/). + buildinfo.SetVersion(version) // Pick the UI language up front so even pre-config paths (the first-run // welcome banner) come through localized. Env-only first; if a config // exists and pins a language, that wins. diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index dc6fa52a93..8f2ca29fae 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -28,6 +28,7 @@ import ( "sync/atomic" "time" + "reasonix/internal/buildinfo" "reasonix/internal/netclient" "reasonix/internal/provider" ) @@ -166,6 +167,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi httpReq.Header.Set("Accept", "text/event-stream") httpReq.Header.Set("x-api-key", c.apiKey) httpReq.Header.Set("anthropic-version", anthropicVersion) + httpReq.Header.Set("User-Agent", buildinfo.UserAgent()) return httpReq, nil } resp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq) diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index f19ddb663c..bf5fef4c75 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" + "reasonix/internal/buildinfo" "reasonix/internal/provider" ) @@ -113,6 +115,37 @@ func TestMapStopReason(t *testing.T) { } } +// TestStreamSendsReasonixUserAgent verifies outbound Messages API requests carry +// an identifying User-Agent (Reasonix/) so an upstream gateway can +// attribute the traffic to Reasonix instead of Go's default UA (#5226). +func TestStreamSendsReasonixUserAgent(t *testing.T) { + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + })) + defer srv.Close() + + p, err := New(provider.Config{Name: "anthropic", BaseURL: srv.URL, Model: "claude-opus-4-8", APIKey: "k"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + for range ch { // drain + } + + if want := buildinfo.UserAgent(); gotUA != want { + t.Errorf("User-Agent = %q, want %q", gotUA, want) + } + if !strings.HasPrefix(gotUA, "Reasonix/") { + t.Errorf("User-Agent = %q, want a Reasonix/ prefix", gotUA) + } +} + const sseFixture = `event: message_start data: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":50,"output_tokens":1}}} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index f2d0f0fc49..276d957f6f 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -24,6 +24,7 @@ import ( "sync/atomic" "time" + "reasonix/internal/buildinfo" "reasonix/internal/netclient" "reasonix/internal/provider" ) @@ -204,6 +205,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) } httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("User-Agent", buildinfo.UserAgent()) return httpReq, nil } resp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 44f140d0dc..e9dec7eaa0 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "reasonix/internal/buildinfo" "reasonix/internal/provider" ) @@ -125,6 +126,37 @@ func TestStreamAuthError(t *testing.T) { } } +// TestStreamSendsReasonixUserAgent verifies outbound requests carry an +// identifying User-Agent (Reasonix/) so an upstream gateway can +// attribute the traffic to Reasonix instead of seeing Go's default UA (#5226). +func TestStreamSendsReasonixUserAgent(t *testing.T) { + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + for range ch { // drain + } + + if want := buildinfo.UserAgent(); gotUA != want { + t.Errorf("User-Agent = %q, want %q", gotUA, want) + } + if !strings.HasPrefix(gotUA, "Reasonix/") { + t.Errorf("User-Agent = %q, want a Reasonix/ prefix", gotUA) + } +} + // TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression: // DeepSeek rejects a message missing the `content` field, so every message must // serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec, From db1967d2d2b740dd469f0914bebeaf0cc5bca65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=86=8A=E8=BF=AA?= Date: Wed, 24 Jun 2026 16:52:08 +0800 Subject: [PATCH 2/2] refactor(provider): send a fixed Reasonix User-Agent, drop version plumbing Simplify the change in response to review: instead of a buildinfo package threading the build version into "Reasonix/", the openai and anthropic providers just send a constant `User-Agent: Reasonix`. Removes internal/buildinfo and the cli.Run wiring; keeps the identifying header (and its provider tests). Co-authored-by: Cursor --- internal/buildinfo/buildinfo.go | 43 ------------------- internal/buildinfo/buildinfo_test.go | 32 -------------- internal/cli/cli.go | 4 -- internal/provider/anthropic/anthropic.go | 3 +- internal/provider/anthropic/anthropic_test.go | 12 ++---- internal/provider/openai/openai.go | 3 +- internal/provider/openai/openai_test.go | 12 ++---- 7 files changed, 10 insertions(+), 99 deletions(-) delete mode 100644 internal/buildinfo/buildinfo.go delete mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go deleted file mode 100644 index 16d12a3e4c..0000000000 --- a/internal/buildinfo/buildinfo.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package buildinfo exposes the running build's identity so outbound HTTP -// requests can be attributed to Reasonix. The version is injected at build time -// via -ldflags "-X main.version=..." and handed to SetVersion once at startup; -// providers read UserAgent() when constructing requests. -package buildinfo - -import ( - "strings" - "sync/atomic" -) - -// devVersion is the placeholder for source/test builds that carry no injected -// version (the same default as cmd/reasonix/main.go's `version` var). -const devVersion = "dev" - -// version holds the running build's version. Stored atomically because it is -// set once at startup but read from every provider request goroutine. -var version atomic.Value - -// SetVersion records the running build's version. An empty value (source builds, -// tests that never set it) is normalized to "dev". Intended to be called once, -// early in startup, before any request is issued. -func SetVersion(v string) { - v = strings.TrimSpace(v) - if v == "" { - v = devVersion - } - version.Store(v) -} - -// Version returns the running build's version, or "dev" if none was set. -func Version() string { - if v, ok := version.Load().(string); ok && v != "" { - return v - } - return devVersion -} - -// UserAgent returns the identifying User-Agent for outbound Reasonix requests, -// e.g. "Reasonix/v1.2.3" (or "Reasonix/dev" for source builds). -func UserAgent() string { - return "Reasonix/" + Version() -} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go deleted file mode 100644 index cf54947d0b..0000000000 --- a/internal/buildinfo/buildinfo_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package buildinfo - -import "testing" - -func TestUserAgentDefaultsToDev(t *testing.T) { - // A fresh process (no SetVersion) reports the dev placeholder. - if got := Version(); got != devVersion { - t.Fatalf("Version() = %q, want %q", got, devVersion) - } - if got, want := UserAgent(), "Reasonix/dev"; got != want { - t.Fatalf("UserAgent() = %q, want %q", got, want) - } -} - -func TestSetVersion(t *testing.T) { - t.Cleanup(func() { SetVersion(devVersion) }) // don't leak into other tests in the package - - SetVersion("v1.2.3") - if got, want := Version(), "v1.2.3"; got != want { - t.Fatalf("Version() = %q, want %q", got, want) - } - if got, want := UserAgent(), "Reasonix/v1.2.3"; got != want { - t.Fatalf("UserAgent() = %q, want %q", got, want) - } - - // An empty/whitespace version is normalized back to the dev placeholder - // rather than producing a bare "Reasonix/". - SetVersion(" ") - if got, want := UserAgent(), "Reasonix/dev"; got != want { - t.Fatalf("UserAgent() after blank SetVersion = %q, want %q", got, want) - } -} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 44dc11e853..2dffe79fd2 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,7 +23,6 @@ import ( "reasonix/internal/agent" "reasonix/internal/boot" - "reasonix/internal/buildinfo" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" @@ -45,9 +44,6 @@ var ( // Run is the CLI entry point; it returns a process exit code. func Run(args []string, version string) int { - // Record the build version once so outbound provider requests can carry an - // identifying User-Agent (Reasonix/). - buildinfo.SetVersion(version) // Pick the UI language up front so even pre-config paths (the first-run // welcome banner) come through localized. Env-only first; if a config // exists and pins a language, that wins. diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index 8f2ca29fae..39ebbf7aeb 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -28,7 +28,6 @@ import ( "sync/atomic" "time" - "reasonix/internal/buildinfo" "reasonix/internal/netclient" "reasonix/internal/provider" ) @@ -167,7 +166,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi httpReq.Header.Set("Accept", "text/event-stream") httpReq.Header.Set("x-api-key", c.apiKey) httpReq.Header.Set("anthropic-version", anthropicVersion) - httpReq.Header.Set("User-Agent", buildinfo.UserAgent()) + httpReq.Header.Set("User-Agent", "Reasonix") return httpReq, nil } resp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq) diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index bf5fef4c75..6250ef298a 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -9,7 +9,6 @@ import ( "strings" "testing" - "reasonix/internal/buildinfo" "reasonix/internal/provider" ) @@ -116,8 +115,8 @@ func TestMapStopReason(t *testing.T) { } // TestStreamSendsReasonixUserAgent verifies outbound Messages API requests carry -// an identifying User-Agent (Reasonix/) so an upstream gateway can -// attribute the traffic to Reasonix instead of Go's default UA (#5226). +// an identifying User-Agent so an upstream gateway can attribute the traffic to +// Reasonix instead of Go's default UA (#5226). func TestStreamSendsReasonixUserAgent(t *testing.T) { var gotUA string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -138,11 +137,8 @@ func TestStreamSendsReasonixUserAgent(t *testing.T) { for range ch { // drain } - if want := buildinfo.UserAgent(); gotUA != want { - t.Errorf("User-Agent = %q, want %q", gotUA, want) - } - if !strings.HasPrefix(gotUA, "Reasonix/") { - t.Errorf("User-Agent = %q, want a Reasonix/ prefix", gotUA) + if gotUA != "Reasonix" { + t.Errorf("User-Agent = %q, want %q", gotUA, "Reasonix") } } diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 276d957f6f..d94bd00fa7 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -24,7 +24,6 @@ import ( "sync/atomic" "time" - "reasonix/internal/buildinfo" "reasonix/internal/netclient" "reasonix/internal/provider" ) @@ -205,7 +204,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) } httpReq.Header.Set("Accept", "text/event-stream") - httpReq.Header.Set("User-Agent", buildinfo.UserAgent()) + httpReq.Header.Set("User-Agent", "Reasonix") return httpReq, nil } resp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index e9dec7eaa0..8570442e8f 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -10,7 +10,6 @@ import ( "strings" "testing" - "reasonix/internal/buildinfo" "reasonix/internal/provider" ) @@ -127,8 +126,8 @@ func TestStreamAuthError(t *testing.T) { } // TestStreamSendsReasonixUserAgent verifies outbound requests carry an -// identifying User-Agent (Reasonix/) so an upstream gateway can -// attribute the traffic to Reasonix instead of seeing Go's default UA (#5226). +// identifying User-Agent so an upstream gateway can attribute the traffic to +// Reasonix instead of seeing Go's default UA (#5226). func TestStreamSendsReasonixUserAgent(t *testing.T) { var gotUA string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -149,11 +148,8 @@ func TestStreamSendsReasonixUserAgent(t *testing.T) { for range ch { // drain } - if want := buildinfo.UserAgent(); gotUA != want { - t.Errorf("User-Agent = %q, want %q", gotUA, want) - } - if !strings.HasPrefix(gotUA, "Reasonix/") { - t.Errorf("User-Agent = %q, want a Reasonix/ prefix", gotUA) + if gotUA != "Reasonix" { + t.Errorf("User-Agent = %q, want %q", gotUA, "Reasonix") } }