Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions autorouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,24 @@ import (
"io"
"net/http"
"strings"

"github.com/agentuity/go-common/slice"
)

var skipHeaders = []string{"Content-Encoding", "Content-Length"}

func copyResponseHeaders(w http.ResponseWriter, headers http.Header) {
header := w.Header()

for k, v := range headers {
if !slice.Contains(skipHeaders, k, slice.WithCaseInsensitive()) {
for _, val := range v {
header.Add(k, val)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Comment on lines +18 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't strip Content-Encoding unless this proxy has already decoded the body.

These paths forward upstream bytes back to the client, but the shared skip list now always removes Content-Encoding. If any upstream response still comes back encoded, clients will receive compressed bytes labeled as a plain payload.

Suggested change
-var skipHeaders = []string{"Content-Encoding", "Content-Length"}
+var skipHeaders = []string{"Content-Length"}

Also applies to: 440-440

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@autorouter.go` around lines 16 - 27, The copyResponseHeaders function is
currently removing headers in skipHeaders (which always includes
"Content-Encoding") causing encoded responses to be forwarded without the
encoding header; update the logic so Content-Encoding is not stripped unless the
proxy actually decoded the body: modify the skipHeaders usage or list so it does
not include "Content-Encoding" by default, or add a condition in
copyResponseHeaders that preserves "Content-Encoding" unless a body-decoding
flag/state (e.g., a decodedBody boolean passed into copyResponseHeaders or a
response-processing function like the upstream decoder) indicates the payload
was decompressed—ensure you adjust references to skipHeaders and the call sites
of copyResponseHeaders accordingly.


type AutoRouter struct {
registry Registry
detector ProviderDetector
Expand Down Expand Up @@ -303,6 +319,9 @@ func (a *AutoRouter) ForwardStreaming(ctx context.Context, req *http.Request, w
upstreamReq.Header[k] = v
}

// FOR SSE, turn off compression explicitly
upstreamReq.Header["Accept-Encoding"] = []string{"identity"}

if err := provider.RequestEnricher().Enrich(upstreamReq, meta, body); err != nil {
return ResponseMetadata{}, err
}
Expand Down Expand Up @@ -340,11 +359,7 @@ func (a *AutoRouter) ForwardStreaming(ctx context.Context, req *http.Request, w
w.Header().Set("Trailer", "X-Gateway-Cost,X-Gateway-Prompt-Tokens,X-Gateway-Completion-Tokens")
}

for k, v := range upstreamResp.Header {
if k != "Content-Length" {
w.Header()[k] = v
}
}
copyResponseHeaders(w, upstreamResp.Header)

w.WriteHeader(upstreamResp.StatusCode)

Expand Down Expand Up @@ -469,9 +484,7 @@ func (a *AutoRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()

for k, v := range resp.Header {
w.Header()[k] = v
}
copyResponseHeaders(w, resp.Header)

if billing, ok := meta.Custom["billing_result"].(BillingResult); ok {
w.Header().Set("X-Gateway-Cost", fmt.Sprintf("%.6f", billing.TotalCost))
Expand Down
73 changes: 73 additions & 0 deletions autorouter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,3 +822,76 @@ func TestAutoRouter_ResponsesAPIStreamingNoStreamOptions(t *testing.T) {
}
})
}

func TestAutoRouter_copyResponseHeaders(t *testing.T) {
w := httptest.NewRecorder()
copyResponseHeaders(w, http.Header{})
var sw strings.Builder
w.Header().Write(&sw)
if sw.Len() != 0 {
t.Errorf("headers should have been empty but was: %s", sw.String())
}
sw.Reset()
w = httptest.NewRecorder()

copyResponseHeaders(w, http.Header{"A": []string{"B"}})
w.Header().Write(&sw)
if sw.Len() == 0 {
t.Error("headers should have content but was empty")
}
val := strings.TrimSpace(sw.String())
if val != "A: B" {
t.Errorf("headers should have A: B but was %s", val)
}
sw.Reset()
w = httptest.NewRecorder()

copyResponseHeaders(w, http.Header{"A": []string{"B"}, "Content-Encoding": []string{"gzip"}})
w.Header().Write(&sw)
if sw.Len() == 0 {
t.Error("headers should have content but was empty")
}
val = strings.TrimSpace(sw.String())
if val != "A: B" {
t.Errorf("headers should have A: B but was %s", val)
}
sw.Reset()
w = httptest.NewRecorder()

copyResponseHeaders(w, http.Header{"A": []string{"B"}, "content-encoding": []string{"gzip"}})
w.Header().Write(&sw)
if sw.Len() == 0 {
t.Error("headers should have content but was empty")
}
val = strings.TrimSpace(sw.String())
if val != "A: B" {
t.Errorf("headers should have A: B but was %s", val)
}
sw.Reset()
w = httptest.NewRecorder()

copyResponseHeaders(w, http.Header{"A": []string{"B"}, "Content-Length": []string{"1"}})
w.Header().Write(&sw)
if sw.Len() == 0 {
t.Error("headers should have content but was empty")
}
val = strings.TrimSpace(sw.String())
if val != "A: B" {
t.Errorf("headers should have A: B but was %s", val)
}
sw.Reset()
w = httptest.NewRecorder()

copyResponseHeaders(w, http.Header{"A": []string{"B"}, "content-length": []string{"1"}})
w.Header().Write(&sw)
if sw.Len() == 0 {
t.Error("headers should have content but was empty")
}
val = strings.TrimSpace(sw.String())
if val != "A: B" {
t.Errorf("headers should have A: B but was %s", val)
}
sw.Reset()
w = httptest.NewRecorder()
Comment on lines +894 to +895
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove dead code at the end of the test.

Line 895 reassigns w but it is never read afterward (SA4006), which can fail lint.

✂️ Proposed fix
-	sw.Reset()
-	w = httptest.NewRecorder()
+	sw.Reset()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sw.Reset()
w = httptest.NewRecorder()
sw.Reset()
🧰 Tools
🪛 golangci-lint (2.11.4)

[error] 895-895: SA4006: this value of w is never used

(staticcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@autorouter_test.go` around lines 894 - 895, The test contains a dead
reassignment "w = httptest.NewRecorder()" after "sw.Reset()" where the variable
w is never used later (SA4006); remove the redundant line assigning to w (or if
intended, replace with a real use of the new recorder) so the unused
reassignment is eliminated and lint stops flagging w as unused.


}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/agentuity/llmproxy
go 1.26.2

require (
github.com/agentuity/go-common v1.0.231
github.com/minio/simdjson-go v0.4.5
go.opentelemetry.io/otel/trace v1.43.0
)
Expand All @@ -12,5 +13,5 @@ require (
github.com/klauspost/compress v1.15.15 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e // indirect
golang.org/x/sys v0.42.0 // indirect
)
5 changes: 4 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/agentuity/go-common v1.0.231 h1:t5CzJuA+yKv6U9lVSvxmiZoNM60ZeBo8U/Vf8P4ce4E=
github.com/agentuity/go-common v1.0.231/go.mod h1:/QxgG4qKu9Rik0084BargZ8wG13/3kdWYI+jIRJYUwI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand All @@ -18,7 +20,8 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e h1:CsOuNlbOuf0mzxJIefr6Q4uAUetRUwZE4qt7VfzP+xo=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2 changes: 2 additions & 0 deletions providers/anthropic/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (p *Parser) Parse(body io.ReadCloser) (llmproxy.BodyMetadata, []byte, error
Model: req.Model,
Messages: make([]llmproxy.Message, len(req.Messages)),
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Custom: make(map[string]any),
}

Expand Down Expand Up @@ -87,6 +88,7 @@ type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
System Content `json:"system,omitempty"`
Custom map[string]interface{} `json:"-"`
}
Expand Down
13 changes: 13 additions & 0 deletions providers/anthropic/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ func TestParser(t *testing.T) {
}
})

t.Run("parses stream flag", func(t *testing.T) {
body := `{"model":"claude-3-opus-20240229","max_tokens":1024,"stream":true,"messages":[{"role":"user","content":"hello"}]}`
parser := &Parser{}

meta, _, err := parser.Parse(io.NopCloser(bytes.NewReader([]byte(body))))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !meta.Stream {
t.Error("expected stream flag to be true")
}
})

t.Run("parses request with system prompt array", func(t *testing.T) {
body := `{"model":"anthropic/claude-sonnet-4-6","max_tokens":1024,"system":[{"type":"text","text":"You are helpful."}],"messages":[{"role":"user","content":"hello"}]}`
parser := &Parser{}
Expand Down
Loading