| 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 |