diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index dc6fa52a93..39ebbf7aeb 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -166,6 +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", "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 f19ddb663c..6250ef298a 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" @@ -113,6 +114,34 @@ func TestMapStopReason(t *testing.T) { } } +// TestStreamSendsReasonixUserAgent verifies outbound Messages API requests carry +// 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) { + 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 gotUA != "Reasonix" { + t.Errorf("User-Agent = %q, want %q", gotUA, "Reasonix") + } +} + 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..d94bd00fa7 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -204,6 +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", "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 44f140d0dc..8570442e8f 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -125,6 +125,34 @@ func TestStreamAuthError(t *testing.T) { } } +// TestStreamSendsReasonixUserAgent verifies outbound requests carry an +// 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) { + 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 gotUA != "Reasonix" { + t.Errorf("User-Agent = %q, want %q", gotUA, "Reasonix") + } +} + // 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,