From 229fec41fa3da40607a4d07840f8de0632cbde9a Mon Sep 17 00:00:00 2001 From: CheerC Date: Wed, 19 Aug 2026 15:41:52 +0800 Subject: [PATCH 1/2] fix: send opencode User-Agent on upstream requests to avoid Zen free-tier 429 --- internal/provider/opencode_go.go | 3 + internal/provider/opencode_useragent_test.go | 264 +++++++++++++++++++ internal/provider/opencode_zen.go | 8 + 3 files changed, 275 insertions(+) create mode 100644 internal/provider/opencode_useragent_test.go diff --git a/internal/provider/opencode_go.go b/internal/provider/opencode_go.go index 789f3902..f4b4d7e8 100644 --- a/internal/provider/opencode_go.go +++ b/internal/provider/opencode_go.go @@ -191,6 +191,7 @@ func (p *OpenCodeGoProvider) executeAnthropic(ctx context.Context, req *core.Nor } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) start := time.Now() @@ -234,6 +235,7 @@ func (p *OpenCodeGoProvider) streamAnthropic(ctx context.Context, req *core.Norm } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) httpReq.Header.Set("Accept", "text/event-stream") @@ -265,6 +267,7 @@ func (p *OpenCodeGoProvider) doRequest(ctx context.Context, endpoint, apiKey str } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) if stream { httpReq.Header.Set("Accept", "text/event-stream") } diff --git a/internal/provider/opencode_useragent_test.go b/internal/provider/opencode_useragent_test.go new file mode 100644 index 00000000..54fbbb14 --- /dev/null +++ b/internal/provider/opencode_useragent_test.go @@ -0,0 +1,264 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/routatic/proxy/internal/config" + "github.com/routatic/proxy/internal/core" + "github.com/routatic/proxy/pkg/types" +) + +func assertOpencodeUserAgentServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua := r.Header.Get("User-Agent") + if !strings.HasPrefix(ua, "opencode/") { + t.Errorf("User-Agent = %q, want prefix %q", ua, "opencode/") + } + handler(w, r) + })) +} + +func chatCompletionServer(t *testing.T) *httptest.Server { + t.Helper() + return assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("Authorization = %q, want %q", r.Header.Get("Authorization"), "Bearer test-key") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(types.ChatCompletionResponse{ + ID: "cmpl-test", + Model: "test-model", + Choices: []types.Choice{ + {Index: 0, Message: types.ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, FinishReason: "stop"}, + }, + Usage: types.UsageInfo{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + }) + }) +} + +func sseServer(t *testing.T) *httptest.Server { + t.Helper() + return assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + }) +} + +func TestOpenCodeZenProvider_Execute_OpencodeUserAgent(t *testing.T) { + server := chatCompletionServer(t) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeZen: config.OpenCodeZenConfig{BaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-flash-free", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}} + model := config.ModelConfig{ModelID: "deepseek-v4-flash-free"} + if got := p.WireFormat(model.ModelID); got != core.WireFormatOpenAIChat { + t.Fatalf("WireFormat(%q) = %v, want OpenAIChat", model.ModelID, got) + } + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestOpenCodeZenProvider_Stream_OpencodeUserAgent(t *testing.T) { + server := sseServer(t) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeZen: config.OpenCodeZenConfig{BaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-flash-free", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, Stream: true} + model := config.ModelConfig{ModelID: "deepseek-v4-flash-free"} + + body, err := p.Stream(context.Background(), req, model) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer func() { _ = body.Close() }() + + buf := make([]byte, 1024) + if n, _ := body.Read(buf); n == 0 { + t.Error("Stream() returned empty body") + } +} + +func TestOpenCodeZenProvider_ExecuteAnthropic_OpencodeUserAgent(t *testing.T) { + server := assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("Authorization = %q, want %q", r.Header.Get("Authorization"), "Bearer test-key") + } + if r.Header.Get("x-api-key") != "test-key" { + t.Errorf("x-api-key = %q, want %q", r.Header.Get("x-api-key"), "test-key") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg-test","content":[{"type":"text","text":"hi"}]}`)) + }) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeZen: config.OpenCodeZenConfig{BaseURL: server.URL, AnthropicBaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "claude-sonnet-4.5", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}} + model := config.ModelConfig{ModelID: "claude-sonnet-4.5"} + if got := p.WireFormat(model.ModelID); got != core.WireFormatAnthropic { + t.Fatalf("WireFormat(%q) = %v, want Anthropic", model.ModelID, got) + } + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestOpenCodeZenProvider_StreamAnthropic_OpencodeUserAgent(t *testing.T) { + server := assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message_start\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\n")) + }) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeZen: config.OpenCodeZenConfig{BaseURL: server.URL, AnthropicBaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "claude-sonnet-4.5", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, Stream: true} + model := config.ModelConfig{ModelID: "claude-sonnet-4.5"} + + body, err := p.Stream(context.Background(), req, model) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer func() { _ = body.Close() }() + + buf := make([]byte, 1024) + if n, _ := body.Read(buf); n == 0 { + t.Error("Stream() returned empty body") + } +} + +func TestOpenCodeZenProvider_ExecuteResponses_OpencodeUserAgent(t *testing.T) { + server := assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(types.ResponsesResponse{ + ID: "resp-test", Object: "response", Created: 1, Model: "gpt-5.4", + Output: []types.ResponsesOutput{{Type: "message", Role: "assistant", Content: []types.ResponsesContent{{Type: "output_text", Text: "hi"}}}}, + Usage: types.ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + }) + }) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeZen: config.OpenCodeZenConfig{BaseURL: server.URL, ResponsesBaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeZenProvider(atomic) + + req := &core.NormalizedRequest{Model: "gpt-5.4", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}} + model := config.ModelConfig{ModelID: "gpt-5.4"} + if got := p.WireFormat(model.ModelID); got != core.WireFormatOpenAIResponses { + t.Fatalf("WireFormat(%q) = %v, want OpenAIResponses", model.ModelID, got) + } + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestOpenCodeGoProvider_Execute_OpencodeUserAgent(t *testing.T) { + server := chatCompletionServer(t) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeGo: config.OpenCodeGoConfig{BaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeGoProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-pro", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}} + model := config.ModelConfig{ModelID: "deepseek-v4-pro"} + if got := p.WireFormat(model.ModelID); got != core.WireFormatOpenAIChat { + t.Fatalf("WireFormat(%q) = %v, want OpenAIChat", model.ModelID, got) + } + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestOpenCodeGoProvider_Stream_OpencodeUserAgent(t *testing.T) { + server := sseServer(t) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeGo: config.OpenCodeGoConfig{BaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeGoProvider(atomic) + + req := &core.NormalizedRequest{Model: "deepseek-v4-pro", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, Stream: true} + model := config.ModelConfig{ModelID: "deepseek-v4-pro"} + + body, err := p.Stream(context.Background(), req, model) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer func() { _ = body.Close() }() + + buf := make([]byte, 1024) + if n, _ := body.Read(buf); n == 0 { + t.Error("Stream() returned empty body") + } +} + +func TestOpenCodeGoProvider_ExecuteAnthropic_OpencodeUserAgent(t *testing.T) { + server := assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-api-key") != "test-key" { + t.Errorf("x-api-key = %q, want %q", r.Header.Get("x-api-key"), "test-key") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg-test","content":[{"type":"text","text":"hi"}]}`)) + }) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeGo: config.OpenCodeGoConfig{BaseURL: server.URL, AnthropicBaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeGoProvider(atomic) + + req := &core.NormalizedRequest{Model: "qwen3.5-plus", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}} + model := config.ModelConfig{ModelID: "qwen3.5-plus"} + if got := p.WireFormat(model.ModelID); got != core.WireFormatAnthropic { + t.Fatalf("WireFormat(%q) = %v, want Anthropic", model.ModelID, got) + } + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestOpenCodeGoProvider_StreamAnthropic_OpencodeUserAgent(t *testing.T) { + server := assertOpencodeUserAgentServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message_start\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\n")) + }) + defer server.Close() + + cfg := &config.Config{APIKey: "test-key", OpenCodeGo: config.OpenCodeGoConfig{BaseURL: server.URL, AnthropicBaseURL: server.URL}} + atomic := config.NewAtomicConfig(cfg, "") + p := NewOpenCodeGoProvider(atomic) + + req := &core.NormalizedRequest{Model: "qwen3.5-plus", Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, Stream: true} + model := config.ModelConfig{ModelID: "qwen3.5-plus"} + + body, err := p.Stream(context.Background(), req, model) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer func() { _ = body.Close() }() + + buf := make([]byte, 1024) + if n, _ := body.Read(buf); n == 0 { + t.Error("Stream() returned empty body") + } +} diff --git a/internal/provider/opencode_zen.go b/internal/provider/opencode_zen.go index d4c0eef3..6e36bebe 100644 --- a/internal/provider/opencode_zen.go +++ b/internal/provider/opencode_zen.go @@ -18,6 +18,10 @@ import ( "github.com/routatic/proxy/pkg/types" ) +// upstreamUserAgent mirrors the opencode client User-Agent so Zen free-tier +// rate limiting treats routatic-proxy traffic like the native client. +const upstreamUserAgent = "opencode/routatic-proxy" + // OpenCodeZenProvider implements core.Provider for the OpenCode Zen backend. // Zen supports four wire formats determined by model ID: Anthropic (Claude, // Qwen), Responses (GPT), Gemini, and Chat Completions (everything else). @@ -206,6 +210,7 @@ func (p *OpenCodeZenProvider) executeAnthropic(ctx context.Context, req *core.No } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) start := time.Now() @@ -249,6 +254,7 @@ func (p *OpenCodeZenProvider) streamAnthropic(ctx context.Context, req *core.Nor } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) httpReq.Header.Set("x-api-key", apiKey) httpReq.Header.Set("Accept", "text/event-stream") @@ -394,6 +400,7 @@ func (p *OpenCodeZenProvider) doRequest(ctx context.Context, endpoint, apiKey st } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) if stream { httpReq.Header.Set("Accept", "text/event-stream") } @@ -424,6 +431,7 @@ func (p *OpenCodeZenProvider) doJSONRequest(ctx context.Context, endpoint, apiKe } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", upstreamUserAgent) resp, err := p.httpClient.Do(httpReq) if err != nil { From 4e1506966f300c8f132702fbd43547456ee9a002 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Thu, 20 Aug 2026 21:15:30 +0200 Subject: [PATCH 2/2] fix: add User-Agent and attribution headers for OpenRouter and AWS Bedrock requests --- internal/client/opencode.go | 21 ++++++++ internal/client/opencode_test.go | 46 +++++++++++++++++ internal/provider/aws_bedrock.go | 3 ++ internal/provider/aws_bedrock_test.go | 72 +++++++++++++++++++++++++++ internal/provider/provider.go | 2 + 5 files changed, 144 insertions(+) diff --git a/internal/client/opencode.go b/internal/client/opencode.go index b9b7a0ea..23bc2792 100644 --- a/internal/client/opencode.go +++ b/internal/client/opencode.go @@ -52,6 +52,11 @@ const ( ProviderOpenCodeZen = "opencode-zen" ProviderAWSBedrock = "aws-bedrock" ProviderOpenRouter = "openrouter" + + routaticUserAgent = "routatic-proxy" + openRouterReferer = "https://github.com/routatic/proxy" + openRouterTitle = "routatic-proxy" + openRouterCategories = "cli-agent" ) // APIError represents an HTTP API error returned by an upstream provider. @@ -322,6 +327,13 @@ func IsOpenRouter(model config.ModelConfig) bool { return Provider(model) == ProviderOpenRouter } +func setOpenRouterHeaders(req *http.Request) { + req.Header.Set("User-Agent", routaticUserAgent) + req.Header.Set("HTTP-Referer", openRouterReferer) + req.Header.Set("X-OpenRouter-Title", openRouterTitle) + req.Header.Set("X-OpenRouter-Categories", openRouterCategories) +} + // EndpointType determines which Zen endpoint format to use. type EndpointType int @@ -431,6 +443,9 @@ func (c *OpenCodeClient) ChatCompletion( } else { httpReq.Header.Set("Authorization", "Bearer "+endpoint.APIKey) } + if IsOpenRouter(modelConfig) { + setOpenRouterHeaders(httpReq) + } if req.Stream != nil && *req.Stream { httpReq.Header.Set("Accept", "text/event-stream") @@ -578,6 +593,9 @@ func (c *OpenCodeClient) ResponsesCompletion( httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+endpoint.APIKey) + if IsOpenRouter(modelConfig) { + setOpenRouterHeaders(httpReq) + } resp, err := c.httpClient.Do(httpReq) if err != nil { @@ -675,6 +693,9 @@ func (c *OpenCodeClient) GeminiCompletion( httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+endpoint.APIKey) + if IsOpenRouter(modelConfig) { + setOpenRouterHeaders(httpReq) + } resp, err := c.httpClient.Do(httpReq) if err != nil { diff --git a/internal/client/opencode_test.go b/internal/client/opencode_test.go index bed2169a..77d1f132 100644 --- a/internal/client/opencode_test.go +++ b/internal/client/opencode_test.go @@ -955,6 +955,52 @@ func TestOpenRouterChatCompletion_UsesBearerAuth(t *testing.T) { } } +func TestOpenRouterChatCompletion_UsesAttributionHeaders(t *testing.T) { + var gotHeaders http.Header + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp-1","object":"chat.completion","created":1,"model":"openrouter/model","choices":[],"usage":{}}`)) + })) + defer ts.Close() + + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + BaseURL: ts.URL, + APIKey: "openrouter-key", + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter/model"} + req := &types.ChatCompletionRequest{ + Model: "openrouter/model", + Messages: []types.ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}}, + } + if _, err := c.ChatCompletionNonStreaming(context.Background(), "openrouter/model", req, model); err != nil { + t.Fatalf("ChatCompletionNonStreaming() error = %v", err) + } + + tests := []struct { + name string + header string + want string + }{ + {name: "user agent", header: "User-Agent", want: "routatic-proxy"}, + {name: "referer", header: "HTTP-Referer", want: "https://github.com/routatic/proxy"}, + {name: "title", header: "X-OpenRouter-Title", want: "routatic-proxy"}, + {name: "category", header: "X-OpenRouter-Categories", want: "cli-agent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := gotHeaders.Get(tt.header); got != tt.want { + t.Errorf("%s = %q, want %q", tt.header, got, tt.want) + } + }) + } +} + func TestOpenRouterChatCompletion_UsesOpenRouterBaseURL(t *testing.T) { var gotURL string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/provider/aws_bedrock.go b/internal/provider/aws_bedrock.go index 79fb9c27..cfb33181 100644 --- a/internal/provider/aws_bedrock.go +++ b/internal/provider/aws_bedrock.go @@ -266,6 +266,7 @@ func (p *AWSBedrockProvider) executeAnthropic(ctx context.Context, req *core.Nor } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", routaticUserAgent) if cfg.AWSBedrock.ProjectID != "" { httpReq.Header.Set("OpenAI-Project", cfg.AWSBedrock.ProjectID) } @@ -314,6 +315,7 @@ func (p *AWSBedrockProvider) streamAnthropic(ctx context.Context, req *core.Norm } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", routaticUserAgent) if cfg.AWSBedrock.ProjectID != "" { httpReq.Header.Set("OpenAI-Project", cfg.AWSBedrock.ProjectID) } @@ -390,6 +392,7 @@ func (p *AWSBedrockProvider) doBedrockRequest(ctx context.Context, endpoint, api } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("User-Agent", routaticUserAgent) if projectID != "" { httpReq.Header.Set("OpenAI-Project", projectID) } diff --git a/internal/provider/aws_bedrock_test.go b/internal/provider/aws_bedrock_test.go index 2e3bd7ec..789fb29c 100644 --- a/internal/provider/aws_bedrock_test.go +++ b/internal/provider/aws_bedrock_test.go @@ -111,6 +111,9 @@ func TestAWSBedrockProvider_Execute(t *testing.T) { if r.Header.Get("OpenAI-Project") != "proj_123" { t.Errorf("OpenAI-Project = %q, want %q", r.Header.Get("OpenAI-Project"), "proj_123") } + if r.Header.Get("User-Agent") != "routatic-proxy" { + t.Errorf("User-Agent = %q, want %q", r.Header.Get("User-Agent"), "routatic-proxy") + } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Content-Type = %q, want %q", r.Header.Get("Content-Type"), "application/json") } @@ -219,6 +222,75 @@ func TestAWSBedrockProvider_Stream(t *testing.T) { } } +func TestAWSBedrockProvider_ExecuteAnthropic_UsesUserAgent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") != "routatic-proxy" { + t.Errorf("User-Agent = %q, want %q", r.Header.Get("User-Agent"), "routatic-proxy") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg-test","content":[{"type":"text","text":"hi"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + AWSBedrock: config.AWSBedrockConfig{ + AnthropicBaseURL: server.URL, + APIKey: "test-key", + }, + } + atomic := config.NewAtomicConfig(cfg, "") + p := NewAWSBedrockProvider(atomic) + + req := &core.NormalizedRequest{ + Model: "anthropic.claude-sonnet-4", + Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, + } + model := config.ModelConfig{ModelID: "anthropic.claude-sonnet-4"} + + if _, err := p.Execute(context.Background(), req, model); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestAWSBedrockProvider_StreamAnthropic_UsesUserAgent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") != "routatic-proxy" { + t.Errorf("User-Agent = %q, want %q", r.Header.Get("User-Agent"), "routatic-proxy") + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message_start\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\n")) + })) + defer server.Close() + + cfg := &config.Config{ + AWSBedrock: config.AWSBedrockConfig{ + AnthropicBaseURL: server.URL, + APIKey: "test-key", + }, + } + atomic := config.NewAtomicConfig(cfg, "") + p := NewAWSBedrockProvider(atomic) + + req := &core.NormalizedRequest{ + Model: "anthropic.claude-sonnet-4", + Messages: []core.NormalizedMessage{{Role: "user", Content: "Hi"}}, + Stream: true, + } + model := config.ModelConfig{ModelID: "anthropic.claude-sonnet-4"} + + body, err := p.Stream(context.Background(), req, model) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer func() { _ = body.Close() }() + + buf := make([]byte, 1024) + if n, _ := body.Read(buf); n == 0 { + t.Error("Stream() returned empty body") + } +} + func TestAWSBedrockProvider_Execute_NoProjectID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("OpenAI-Project") != "" { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 683fc50c..08295f0a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -10,6 +10,8 @@ import ( "github.com/routatic/proxy/internal/config" ) +const routaticUserAgent = "routatic-proxy" + // baseProvider holds shared HTTP transport and key rotation used by all // provider implementations in this package. type baseProvider struct {