返回 DeepSeek-Reasonix
live_read_http_budget_test.go
根目录 / internal / agent / live_read_http_budget_test.go
1 //go:build live
2
3 package agent
4
5 import (
6 "bufio"
7 "bytes"
8 "context"
9 "encoding/json"
10 "errors"
11 "io"
12 "net/http"
13 "net/http/httptest"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18 )
19
20 type liveReadBudgetKey struct{}
21
22 // The HTTP boundary counts every upstream attempt, including adapter retries.
23 // Unknown usage retains a conservative full-window reservation. Nothing here
24 // stores or logs Authorization, request bodies, or source text.
25 type liveReadBudget struct {
26 mu sync.Mutex
27 requests, tokens int
28 cancel context.CancelFunc
29 transport http.RoundTripper
30 }
31
32 const liveReadRequestReservation = 128_000
33
34 func (b *liveReadBudget) admit() bool {
35 b.mu.Lock()
36 defer b.mu.Unlock()
37 return b.requests < 600 && b.tokens+liveReadRequestReservation <= 3_000_000
38 }
39
40 func (b *liveReadBudget) ServeHTTP(w http.ResponseWriter, r *http.Request) {
41 b.mu.Lock()
42 if b.requests >= 600 || b.tokens+liveReadRequestReservation > 3_000_000 {
43 b.mu.Unlock()
44 b.cancel()
45 http.Error(w, "live suite resource ceiling", http.StatusForbidden)
46 return
47 }
48 b.requests++
49 b.tokens += liveReadRequestReservation
50 b.mu.Unlock()
51 // The test enforces a real output bound even when the production adapter
52 // deliberately omits max_tokens for a shared context/output window.
53 body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
54 if err != nil {
55 http.Error(w, "cannot read test request", 400)
56 return
57 }
58 var fields map[string]json.RawMessage
59 if json.Unmarshal(body, &fields) != nil {
60 http.Error(w, "invalid test request", 400)
61 return
62 }
63 fields["max_tokens"] = json.RawMessage("2048")
64 body, err = json.Marshal(fields)
65 if err != nil {
66 http.Error(w, "cannot encode test request", 400)
67 return
68 }
69 upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://api.deepseek.com/chat/completions", bytes.NewReader(body))
70 if err != nil {
71 http.Error(w, "request creation failed", 500)
72 return
73 }
74 upstream.Header.Set("Authorization", r.Header.Get("Authorization"))
75 upstream.Header.Set("Content-Type", "application/json")
76 client := &http.Client{Transport: b.transport, Timeout: 150 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
77 response, err := client.Do(upstream)
78 if err != nil {
79 http.Error(w, "upstream request failed", 502)
80 return
81 }
82 defer response.Body.Close()
83 if response.StatusCode != http.StatusOK {
84 if response.StatusCode == 401 || response.StatusCode == 403 {
85 b.cancel()
86 }
87 http.Error(w, "upstream rejected live test request", response.StatusCode)
88 return
89 }
90 w.Header().Set("Content-Type", "text/event-stream")
91 w.WriteHeader(http.StatusOK)
92 scanner := bufio.NewScanner(response.Body)
93 scanner.Buffer(make([]byte, 4096), 1<<20)
94 used := 0
95 defer func() {
96 // The client can finish immediately after the final usage frame and
97 // close before [DONE]. Account that known usage even if Write fails.
98 if used > 0 {
99 b.mu.Lock()
100 b.tokens += used - liveReadRequestReservation
101 b.mu.Unlock()
102 }
103 }()
104 for scanner.Scan() {
105 line := scanner.Bytes()
106 if bytes.HasPrefix(line, []byte("data:")) {
107 var frame struct {
108 Usage *struct {
109 Prompt int `json:"prompt_tokens"`
110 Completion int `json:"completion_tokens"`
111 } `json:"usage"`
112 }
113 if json.Unmarshal(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))), &frame) == nil && frame.Usage != nil {
114 used = max(used, frame.Usage.Prompt+frame.Usage.Completion)
115 }
116 }
117 if _, err := w.Write(append(bytes.Clone(line), '\n')); err != nil {
118 return
119 }
120 if f, ok := w.(http.Flusher); ok {
121 f.Flush()
122 }
123 }
124 }
125
126 type liveBudgetTransport func(*http.Request) (*http.Response, error)
127
128 func (f liveBudgetTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
129
130 type closedLiveWriter struct{ header http.Header }
131
132 func (w closedLiveWriter) Header() http.Header { return w.header }
133 func (closedLiveWriter) WriteHeader(int) {}
134 func (closedLiveWriter) Write([]byte) (int, error) { return 0, errors.New("client closed") }
135
136 func TestLiveReadHTTPBudgetAccountsUsageBeforeClientClose(t *testing.T) {
137 budget := &liveReadBudget{cancel: func() { t.Error("unexpected cancellation") }}
138 budget.transport = liveBudgetTransport(func(r *http.Request) (*http.Response, error) {
139 var fields map[string]any
140 if err := json.NewDecoder(r.Body).Decode(&fields); err != nil {
141 t.Fatal(err)
142 }
143 if fields["max_tokens"] != float64(2048) {
144 t.Fatalf("output cap = %v", fields["max_tokens"])
145 }
146 return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("data: {\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":20}}\n\ndata: [DONE]\n\n"))}, nil
147 })
148 budget.ServeHTTP(closedLiveWriter{header: make(http.Header)}, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"messages":[]}`)))
149 if budget.requests != 1 || budget.tokens != 120 {
150 t.Fatalf("requests=%d tokens=%d", budget.requests, budget.tokens)
151 }
152 }
153
154 func TestLiveReadHTTPBudgetKeepsUnknownReservationAndStops(t *testing.T) {
155 cancelled := false
156 budget := &liveReadBudget{cancel: func() { cancelled = true }, tokens: 2_800_000}
157 budget.transport = liveBudgetTransport(func(*http.Request) (*http.Response, error) {
158 return nil, errors.New("no usage available")
159 })
160 budget.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)))
161 if budget.tokens != 2_928_000 || budget.admit() {
162 t.Fatalf("unknown reservation lost: %d", budget.tokens)
163 }
164 budget.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)))
165 if !cancelled || budget.requests != 1 {
166 t.Fatalf("limit not enforced: cancelled=%v attempts=%d", cancelled, budget.requests)
167 }
168 }
169
169 lines GO