返回 DeepSeek-Reasonix
usage_accounting_test.go
根目录 / internal / agent / usage_accounting_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "io"
7 "net/http"
8 "strings"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 type accountingRoundTripFunc func(*http.Request) (*http.Response, error)
17
18 func (f accountingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
19 return f(req)
20 }
21
22 type failedRequestProvider struct{}
23
24 func (failedRequestProvider) Name() string { return "failed-request" }
25
26 func (failedRequestProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
27 requestCtx := provider.WithRequestAttemptCounter(ctx)
28 client := &http.Client{Transport: accountingRoundTripFunc(func(*http.Request) (*http.Response, error) {
29 return &http.Response{
30 StatusCode: http.StatusBadRequest,
31 Header: make(http.Header),
32 Body: io.NopCloser(strings.NewReader("bad request")),
33 }, nil
34 })}
35 _, err := provider.SendWithRetry(requestCtx, client, provider.SendOptions{Provider: "failed-request"}, func(reqCtx context.Context) (*http.Request, error) {
36 return http.NewRequestWithContext(reqCtx, http.MethodPost, "https://example.invalid", nil)
37 })
38 return nil, err
39 }
40
41 func TestMergeStreamUsageCountsProviderRequests(t *testing.T) {
42 first := &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, CacheWriteTokens: 2, CacheWriteBilledTokens: 2.5, RequestCount: 1}
43 retry := &provider.Usage{PromptTokens: 20, CompletionTokens: 8, TotalTokens: 28, CacheWriteTokens: 3, CacheWriteBilledTokens: 6, RequestCount: 1}
44 got := mergeStreamUsage(first, retry)
45 if got == nil || got.TotalTokens != 43 || got.RequestCount != 2 || got.CompletionTokens != 13 {
46 t.Fatalf("merged usage = %+v, want total=43 requests=2 completion=13", got)
47 }
48 // Billable PromptTokens align with summed cache hit+miss.
49 if got.CacheMissTokens != 30 || got.PromptTokens != 30 {
50 t.Fatalf("billable input = prompt %d miss %d, want 30/30", got.PromptTokens, got.CacheMissTokens)
51 }
52 if got.CacheWriteTokens != 5 || got.CacheWriteBilledTokens != 8.5 {
53 t.Fatalf("merged cache writes = raw %d billed %v, want 5/8.5", got.CacheWriteTokens, got.CacheWriteBilledTokens)
54 }
55
56 third := &provider.Usage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, RequestCount: 1}
57 got = mergeStreamUsage(got, third)
58 if got.RequestCount != 3 {
59 t.Fatalf("nested merged request count = %d, want 3", got.RequestCount)
60 }
61
62 got = mergeStreamUsage(nil, retry)
63 if got == nil || got.TotalTokens != retry.TotalTokens || got.RequestCount != 1 {
64 t.Fatalf("missing first usage = %+v, want retry tokens and 1 request", got)
65 }
66 got = mergeStreamUsage(first, nil)
67 if got == nil || got.TotalTokens != first.TotalTokens || got.RequestCount != 1 {
68 t.Fatalf("missing retry usage = %+v, want first tokens and 1 request", got)
69 }
70
71 requestOnly := &provider.Usage{RequestCount: 3}
72 got = mergeStreamUsage(first, requestOnly)
73 if got == nil || got.RequestCount != 4 {
74 t.Fatalf("request-only retry usage = %+v, want 4 requests", got)
75 }
76 }
77
78 func TestFinalizeSamplingUsageKeepsLatestPromptContext(t *testing.T) {
79 billable := &provider.Usage{
80 PromptTokens: 90000, CompletionTokens: 30, TotalTokens: 90030,
81 CacheMissTokens: 90000, RequestCount: 3,
82 }
83 latest := &provider.Usage{PromptTokens: 30000, CompletionTokens: 10, TotalTokens: 30010, CacheMissTokens: 30000, RequestCount: 1}
84 got := finalizeSamplingUsage(billable, latest)
85 if got == nil || got.PromptTokens != 90000 {
86 t.Fatalf("prompt tokens = %+v, want billable total 90000", got)
87 }
88 if got.ContextPromptTokens != 30000 || got.ContextCompletionTokens != 10 {
89 t.Fatalf("context shape = prompt %d completion %d, want latest 30000/10", got.ContextPromptTokens, got.ContextCompletionTokens)
90 }
91 if got.ContextFillTokens() != 30010 {
92 t.Fatalf("ContextFillTokens = %d, want 30010", got.ContextFillTokens())
93 }
94 if got.CompletionTokens != 30 || got.RequestCount != 3 {
95 t.Fatalf("billable fields = %+v, want summed completion/requests", got)
96 }
97 // lastUsage stores the latest attempt wholesale (prompt+completion of that
98 // request), never the billable aggregate.
99 if latest.PromptTokens != 30000 || latest.CompletionTokens != 10 {
100 t.Fatalf("latest attempt shape mutated: %+v", latest)
101 }
102 }
103
104 func TestMergeSamplingUsageKeepsBillableTokensAcrossRequestOnlyAttempt(t *testing.T) {
105 first := &provider.Usage{
106 PromptTokens: 100, CompletionTokens: 0, TotalTokens: 100,
107 CacheMissTokens: 100, RequestCount: 1,
108 }
109 second := &provider.Usage{RequestCount: 1}
110 got := mergeSamplingUsage(first, second)
111 if got.PromptTokens != 100 || got.TotalTokens != 100 || got.RequestCount != 2 {
112 t.Fatalf("merged billable = %+v, want first tokens + 2 requests", got)
113 }
114 final := finalizeSamplingUsage(got, second)
115 if final == nil || final.PromptTokens != 100 {
116 t.Fatalf("final usage = %+v, want billable prompt 100", final)
117 }
118 }
119
120 func TestEstimateFailedAttemptUsageIncludesArgChars(t *testing.T) {
121 frozen := samplingRequest{
122 req: provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "write a large file"}}},
123 }
124 // ~8KB of streamed tool args with no terminal usage.
125 result := streamedTurn{
126 maxArgChars: 8192,
127 err: &provider.StreamInterruptedError{Err: io.ErrUnexpectedEOF, Reason: provider.StreamInterruptPrematureEOF},
128 interrupted: true,
129 }
130 got := estimateFailedAttemptUsage(nil, frozen, result, 1)
131 if got == nil || !got.Estimated {
132 t.Fatalf("usage = %+v, want estimated failed-attempt record", got)
133 }
134 argTokens := (8192 + 3) / 4
135 if got.CompletionTokens < argTokens {
136 t.Fatalf("completion tokens = %d, want at least arg estimate %d", got.CompletionTokens, argTokens)
137 }
138 if got.PromptTokens <= 0 {
139 t.Fatalf("prompt tokens = %d, want request input estimate", got.PromptTokens)
140 }
141 }
142
143 func TestEstimateFailedAttemptUsageSkipsZeroHTTPLocalFailure(t *testing.T) {
144 frozen := samplingRequest{
145 req: provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}},
146 }
147 result := streamedTurn{
148 err: errors.New("local request validation failed"),
149 }
150 // No HTTP request and no speculative output: do not invent billable usage.
151 got := estimateFailedAttemptUsage(nil, frozen, result, 0)
152 if got != nil {
153 t.Fatalf("pre-body local reject usage = %+v, want nil (no invented billable tokens)", got)
154 }
155 first := &provider.Usage{PromptTokens: 100, TotalTokens: 100, CacheMissTokens: 100, RequestCount: 1}
156 merged := mergeSamplingUsage(first, got)
157 if merged == nil || merged.PromptTokens != 100 || merged.RequestCount != 1 {
158 t.Fatalf("merged after local reject = %+v, want first attempt only", merged)
159 }
160 }
161
162 func TestStreamReturnsRequestOnlyUsageOnProviderFailure(t *testing.T) {
163 var events []event.Event
164 sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
165 a := New(failedRequestProvider{}, tool.NewRegistry(), NewSession(""), Options{ModelRef: "failed/model"}, sink)
166
167 _, _, _, _, _, _, _, usage, _, _, _, _, err := a.stream(context.Background(), 1, sink)
168 if err == nil {
169 t.Fatal("expected provider failure")
170 }
171 if usage == nil || usage.TotalTokens != 0 || usage.RequestCount != 1 {
172 t.Fatalf("failed stream usage = %+v, want tokens=0 requests=1", usage)
173 }
174 a.emitTurnUsage(usage, nil)
175 if len(events) != 1 || events[0].Kind != event.Usage || events[0].Usage.RequestCount != 1 {
176 t.Fatalf("request-only usage event = %+v", events)
177 }
178 }
179
180 func TestTaskUsageModelRefUsesCanonicalRuntimeIdentity(t *testing.T) {
181 task := (&TaskTool{baseModel: "deepseek/deepseek-v4-pro"}).WithTranscriptIdentityResolver(
182 func(modelRef, effort string) (string, string) {
183 if modelRef == "flash" {
184 return "deepseek/deepseek-v4-flash", effort
185 }
186 return "deepseek/deepseek-v4-pro", effort
187 },
188 )
189 if got := task.usageModelRef("flash", "high"); got != "deepseek/deepseek-v4-flash" {
190 t.Fatalf("alias usage model = %q", got)
191 }
192 if got := task.usageModelRef("", ""); got != "deepseek/deepseek-v4-pro" {
193 t.Fatalf("inherited usage model = %q", got)
194 }
195 }
196
196 lines GO