Skip to content

Commit f40726c

Browse files
committed
agentfuse v0.5.0: iteration amendments + regression tests
1 parent fc18f28 commit f40726c

6 files changed

Lines changed: 426 additions & 10 deletions

File tree

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@
187187
same "printed page" as the copyright notice for easier
188188
identification within third-party archives.
189189

190-
Copyright [yyyy] [name of copyright owner]
190+
Copyright 2026 SuperMarioYL
191191

192192
Licensed under the Apache License, Version 2.0 (the "License");
193193
you may not use this file except in compliance with the License.

internal/proxy/anthropic.go

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ type anthropicResponse struct {
3232
InputTokens int `json:"input_tokens"`
3333
OutputTokens int `json:"output_tokens"`
3434
} `json:"usage"`
35-
Model string `json:"model"`
35+
Model string `json:"model"`
36+
Content []struct {
37+
Type string `json:"type"`
38+
Text string `json:"text"`
39+
} `json:"content"`
3640
}
3741

3842
func anthropicHandler(s *Server) http.Handler {
@@ -144,11 +148,18 @@ func anthropicHandler(s *Server) http.Handler {
144148
// Both upstream usage AND a local estimate are available — record
145149
// the comparison so the §8 >25% accuracy criterion is measurable.
146150
// v0.4: use EstimateCompletion (the estimator the fallback actually
147-
// bills with, incl. the +100 round-up) on the completion text — the
151+
// bills with, incl. +100 round-up) on the completion text — the
148152
// harness must measure the estimator the cap uses, not EstimatePrompt
149153
// run on completion text (wrong side + no round-up = biased-low).
150-
tokens.RecordSample("anthropic", model,
151-
tokens.EstimateCompletion(model, completionText), outTok)
154+
// v0.5.0: guard — skip the sample when completionText is still "".
155+
// An empty completion yields EstimateCompletion(model,"")=100
156+
// (roundUp n<=0 -> step), which false-triggers the §8 >25% kill
157+
// criterion the harness was built to make evaluable. There is
158+
// nothing meaningful to compare against outTok in that case.
159+
if completionText != "" {
160+
tokens.RecordSample("anthropic", model,
161+
tokens.EstimateCompletion(model, completionText), outTok)
162+
}
152163
}
153164
usd := budget.CostFromUsageWithProvider("anthropic", model, inTok, outTok)
154165
if _, err := s.led.CommitDelta(s.projectRoot, estimate, inTok, outTok, usd); err != nil {
@@ -199,7 +210,14 @@ func parseAnthropicUsage(body []byte) (int, int, string, string) {
199210
if err := json.Unmarshal(body, &unary); err == nil &&
200211
(unary.Usage.InputTokens > 0 || unary.Usage.OutputTokens > 0 || unary.Model != "") {
201212
if unary.Usage.InputTokens > 0 || unary.Usage.OutputTokens > 0 {
202-
return unary.Usage.InputTokens, unary.Usage.OutputTokens, "", unary.Model
213+
// v0.5.0: fix-accuracy-harness-empty-completion-unary — the unary
214+
// branch previously returned "" for completionText, so RecordSample
215+
// measured EstimateCompletion(model, "")=100 on every unary response
216+
// regardless of real completion length, false-triggering the §8 >25%
217+
// kill criterion. Extract the content-block text so the harness
218+
// measures a real estimate on unary traffic too.
219+
return unary.Usage.InputTokens, unary.Usage.OutputTokens,
220+
anthropicContentText(unary.Content), unary.Model
203221
}
204222
}
205223

@@ -248,6 +266,25 @@ func parseAnthropicUsage(body []byte) (int, int, string, string) {
248266
return inTok, outTok, text.String(), model
249267
}
250268

269+
// anthropicContentText flattens the content blocks of a unary Anthropic
270+
// response into one string, so the accuracy harness can EstimateCompletion on
271+
// the real completion text instead of "" (which rounds up to 100 and
272+
// false-triggers the §8 >25% kill criterion on unary traffic). Text blocks
273+
// contribute their text; non-text blocks (tool_use, etc.) contribute nothing —
274+
// they are not billed as completion tokens by the estimator.
275+
func anthropicContentText(blocks []struct {
276+
Type string `json:"type"`
277+
Text string `json:"text"`
278+
}) string {
279+
var b strings.Builder
280+
for _, blk := range blocks {
281+
if blk.Type == "text" || blk.Type == "" {
282+
b.WriteString(blk.Text)
283+
}
284+
}
285+
return b.String()
286+
}
287+
251288
// anthropicPromptText flattens an Anthropic Messages request body into one big
252289
// string for tiktoken estimation when the upstream omits usage entirely.
253290
func anthropicPromptText(body []byte) string {
@@ -291,9 +328,27 @@ func estimatePromptTokens(body []byte) int {
291328
func copyHeader(dst, src http.Header) {
292329
for k, vv := range src {
293330
// Drop hop-by-hop and host headers; let Go re-set transport.
331+
// Accept-Encoding is also dropped: Go's http transport only
332+
// auto-decompresses a gzipped response when IT added the
333+
// Accept-Encoding header. A caller-set value (Go/Node/fetch add
334+
// "gzip" by default) is forwarded verbatim, the upstream then
335+
// gzips the unary JSON body, and the transport passes the raw
336+
// \x1f\x8b bytes through untouched — so io.ReadAll(resp.Body)
337+
// yields gzip bytes, parseAnthropicUsage/parseDeepSeekUsage
338+
// json.Unmarshal them into an error (in=0, out=0,
339+
// completionText=""), and the per-side fallback bills
340+
// promptTokens + EstimateCompletion(model, "")=100 instead of
341+
// the real usage. For a unary response with thousands of real
342+
// output tokens that under-bills by orders of magnitude, so
343+
// realized spend can exceed the cap without tripping it
344+
// (fail-open) — the exact property this product exists to
345+
// prevent. Stripping it here lets the proxy's own transport
346+
// manage gzip (adds Accept-Encoding, decompresses, strips
347+
// Content-Encoding) for all five handlers that share this
348+
// copier. v0.5.0: fix-gzip-accept-encoding-forwarded.
294349
if k == "Connection" || k == "Keep-Alive" || k == "Proxy-Connection" ||
295350
k == "Te" || k == "Trailer" || k == "Transfer-Encoding" || k == "Upgrade" ||
296-
k == "Host" || k == "Content-Length" {
351+
k == "Host" || k == "Content-Length" || k == "Accept-Encoding" {
297352
continue
298353
}
299354
for _, v := range vv {

internal/proxy/deepseek.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,16 @@ func parseDeepSeekUsage(body []byte) (int, int, string, string) {
173173
outTok = unary.Usage.OutputTokens
174174
}
175175
if inTok > 0 || outTok > 0 {
176-
return inTok, outTok, "", unary.Model
176+
// v0.5.0: fix-accuracy-harness-empty-completion-unary — extract
177+
// choices[].message.content so RecordSample (called by openai.go,
178+
// which reuses this parser) measures a real
179+
// EstimateCompletion(model, completionText) instead of
180+
// EstimateCompletion(model, "")=100 on unary traffic.
181+
var text strings.Builder
182+
for _, c := range unary.Choices {
183+
text.WriteString(c.Message.Content)
184+
}
185+
return inTok, outTok, text.String(), unary.Model
177186
}
178187
}
179188

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package proxy
2+
3+
import (
4+
"bytes"
5+
"compress/gzip"
6+
"context"
7+
"encoding/json"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/SuperMarioYL/agentfuse/internal/account"
16+
"github.com/SuperMarioYL/agentfuse/internal/budget"
17+
)
18+
19+
// TestGzipAcceptEncodingNotForwarded is the v0.5.0 regression for the HIGH
20+
// fail-open cap-correctness defect fix-gzip-accept-encoding-forwarded.
21+
//
22+
// copyHeader forwarded the inbound "Accept-Encoding: gzip" (which Go/Node/fetch
23+
// HTTP clients add by default) onto upReq.Header on all five handlers. Go's
24+
// http transport only auto-decompresses a gzipped response when IT added the
25+
// Accept-Encoding header; a caller-set value is passed through untouched, so a
26+
// gzipped unary JSON body is read raw by io.ReadAll(resp.Body).
27+
// parseAnthropicUsage then json.Unmarshals the gzip bytes into an error
28+
// (in=0, out=0, completionText=""), the per-side fallback bills promptTokens +
29+
// EstimateCompletion(model, "")=100, and realized spend exceeds the cap without
30+
// tripping it (fail-open) — the exact property the product exists to prevent.
31+
//
32+
// This test reproduces the defect hermetically: the httptest upstream HONORS
33+
// Accept-Encoding: gzip (the existing tests' upstreams never did, which is why
34+
// the defect slipped past them). The inbound client request sets
35+
// Accept-Encoding: gzip. Without the fix the proxy forwards it, the upstream
36+
// gzips, the proxy reads raw \x1f\x8b bytes, parsing fails, and the ledger
37+
// bills the fallback (outTok=100) instead of the real usage (outTok=500). With
38+
// the fix copyHeader strips Accept-Encoding, the proxy's own transport manages
39+
// gzip (re-adds Accept-Encoding, decompresses, strips Content-Encoding), and
40+
// the usage parser sees plain JSON — the ledger bills the real 1000/500.
41+
func TestGzipAcceptEncodingNotForwarded(t *testing.T) {
42+
var upstreamSawAcceptEncoding string
43+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+
upstreamSawAcceptEncoding = r.Header.Get("Accept-Encoding")
45+
payload, _ := json.Marshal(map[string]any{
46+
"id": "msg_01",
47+
"model": "claude-sonnet-4",
48+
"usage": map[string]int{"input_tokens": 1000, "output_tokens": 500},
49+
"content": []map[string]string{
50+
{"type": "text", "text": "unary gzipped response"},
51+
},
52+
})
53+
if strings.Contains(upstreamSawAcceptEncoding, "gzip") {
54+
// Honor the advertised encoding — this is what a real upstream
55+
// (api.anthropic.com, api.openai.com, …) does for a non-streamed
56+
// request that advertised gzip.
57+
var buf bytes.Buffer
58+
gz := gzip.NewWriter(&buf)
59+
_, _ = gz.Write(payload)
60+
_ = gz.Close()
61+
w.Header().Set("Content-Type", "application/json")
62+
w.Header().Set("Content-Encoding", "gzip")
63+
w.WriteHeader(http.StatusOK)
64+
_, _ = w.Write(buf.Bytes())
65+
return
66+
}
67+
w.Header().Set("Content-Type", "application/json")
68+
w.WriteHeader(http.StatusOK)
69+
_, _ = w.Write(payload)
70+
}))
71+
defer upstream.Close()
72+
73+
prev := AnthropicUpstream
74+
AnthropicUpstream = upstream.URL
75+
defer func() { AnthropicUpstream = prev }()
76+
77+
led := mustOpenLedger(t)
78+
cfg := &budget.Config{CapUSD: 5.00, Window: "project"}
79+
s := New("/proj/gzip", cfg, led, &account.File{Accounts: map[string]account.Account{}})
80+
if err := s.Start(); err != nil {
81+
t.Fatal(err)
82+
}
83+
defer func() {
84+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
85+
defer cancel()
86+
_ = s.Stop(ctx)
87+
}()
88+
89+
body := []byte(`{"model":"claude-sonnet-4","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}`)
90+
req, _ := http.NewRequest(http.MethodPost, "http://"+s.Addr()+"/anthropic/v1/messages", bytes.NewReader(body))
91+
req.Header.Set("Content-Type", "application/json")
92+
req.Header.Set("x-api-key", "sk-ant-test-1234567890")
93+
// Simulate a caller (Go/Node/fetch default) that advertises gzip. Before
94+
// the fix, copyHeader forwarded this verbatim and the upstream gzipped the
95+
// unary JSON body, which Go's transport then handed to the handler raw
96+
// (no auto-decompress for a caller-set header).
97+
req.Header.Set("Accept-Encoding", "gzip")
98+
99+
resp, err := http.DefaultClient.Do(req)
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
_, _ = io.Copy(io.Discard, resp.Body)
104+
_ = resp.Body.Close()
105+
if resp.StatusCode != http.StatusOK {
106+
t.Fatalf("status=%d", resp.StatusCode)
107+
}
108+
109+
// Sanity: the test is only meaningful if the upstream actually received a
110+
// gzip advertisement and gzipped the body. Without this, the defect would
111+
// not reproduce and the test would pass vacuously.
112+
if !strings.Contains(upstreamSawAcceptEncoding, "gzip") {
113+
t.Fatalf("test setup broken: upstream never saw Accept-Encoding: gzip (got %q) — defect not exercised", upstreamSawAcceptEncoding)
114+
}
115+
116+
got, err := led.ProjectTotal("/proj/gzip")
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
// The defect: a forwarded Accept-Encoding: gzip made the upstream gzip the
121+
// unary JSON, the parser read raw \x1f\x8b bytes, json.Unmarshal errored,
122+
// and the per-side fallback billed promptTokens + EstimateCompletion(model,
123+
// "")=100 instead of the real 1000/500. Asserting the REAL usage lands
124+
// proves the skip-list fix let the proxy transport manage gzip.
125+
if got.TokensIn != 1000 || got.TokensOut != 500 {
126+
t.Fatalf("gzipped unary response not parsed — ledger has in=%d out=%d (want 1000/500); "+
127+
"copyHeader is still forwarding caller-set Accept-Encoding and the parser is reading raw gzip bytes",
128+
got.TokensIn, got.TokensOut)
129+
}
130+
if got.USD <= 0 {
131+
t.Fatalf("gzipped unary response billed $0 — fail-open defect still present")
132+
}
133+
}

internal/proxy/openai.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ type openaiResponse struct {
3131
OutputTokens int `json:"output_tokens"`
3232
} `json:"usage"`
3333
Model string `json:"model"`
34+
// Choices carries the assistant message on a unary (non-streamed) Chat
35+
// Completions response. v0.5.0: fix-accuracy-harness-empty-completion-unary
36+
// — the unary parseDeepSeekUsage branch (reused by openai.go) returned ""
37+
// for completionText, so RecordSample measured EstimateCompletion(model,
38+
// "")=100 on every unary OpenAI/DeepSeek response. Extracting
39+
// choices[].message.content here lets the harness measure a real estimate.
40+
Choices []struct {
41+
Message struct {
42+
Role string `json:"role"`
43+
Content string `json:"content"`
44+
} `json:"message"`
45+
} `json:"choices"`
3446
}
3547

3648
func openaiHandler(s *Server) http.Handler {
@@ -135,8 +147,15 @@ func openaiHandler(s *Server) http.Handler {
135147
// with, incl. +100 round-up) on the completion text — previously
136148
// EstimatePrompt(completionText) compared against outTok, which is
137149
// the wrong side + structurally biased low (no round-up).
138-
tokens.RecordSample("openai", model,
139-
tokens.EstimateCompletion(model, completionText), outTok)
150+
// v0.5.0: guard — skip the sample when completionText is still "".
151+
// An empty completion yields EstimateCompletion(model,"")=100
152+
// (roundUp n<=0 -> step), which false-triggers the §8 >25% kill
153+
// criterion the harness was built to make evaluable. There is
154+
// nothing meaningful to compare against outTok in that case.
155+
if completionText != "" {
156+
tokens.RecordSample("openai", model,
157+
tokens.EstimateCompletion(model, completionText), outTok)
158+
}
140159
}
141160
usd := budget.CostFromUsageWithProvider("openai", model, inTok, outTok)
142161
if _, err := s.led.CommitDelta(s.projectRoot, estimate, inTok, outTok, usd); err != nil {

0 commit comments

Comments
 (0)