返回 DeepSeek-Reasonix
run_budget.go
根目录 / internal / agent / run_budget.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "time"
7
8 "reasonix/internal/billing"
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 )
12
13 // TaskBudget bounds one task on the axes its failures are reported in, and
14 // every axis ships off: stopping a task is the user's call. Tokens is the one
15 // that generalizes — a slow expensive loop accumulates them and so does a fast
16 // empty one, where wall clock catches only the first and money is not portable
17 // across models.
18 type TaskBudget struct {
19 Cost float64
20 Wall time.Duration
21 Tokens int
22 }
23
24 // normalizeTaskBudget reads a negative value as unset, so a disabled axis and
25 // an unconfigured one behave identically.
26 func normalizeTaskBudget(b TaskBudget) TaskBudget {
27 if b.Cost < 0 {
28 b.Cost = 0
29 }
30 if b.Wall < 0 {
31 b.Wall = 0
32 }
33 if b.Tokens < 0 {
34 b.Tokens = 0
35 }
36 return b
37 }
38
39 // runBudget accumulates what a turn has actually spent. Rounds are a poor proxy
40 // for it: the same hundred of them cost minutes or hours depending on what each
41 // one read and how long the model thought, and the failures worth stopping are
42 // reported in hours and tokens, never in rounds.
43 type runBudget struct {
44 started time.Time
45 rounds int
46 requests int
47 promptTokens int
48 outputTokens int
49 cost float64
50 pricedRounds int
51 unpricedTurns bool
52 // limit is configuration, not accumulation: it survives the reset that
53 // starts a new task.
54 limit TaskBudget
55 }
56
57 // observe folds one round's provider usage into the turn's running total.
58 // A round whose usage never arrived still counts as a round, so the axis never
59 // reads cheaper than the turn actually was.
60 func (b *runBudget) observe(usage *provider.Usage, pricing *provider.Pricing) {
61 var quote *billing.CostQuote
62 if usage != nil && pricing != nil {
63 quote = event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: usage, Pricing: pricing}, nil)
64 }
65 b.observeQuote(usage, quote)
66 }
67
68 func (b *runBudget) observeQuote(usage *provider.Usage, quote *billing.CostQuote) {
69 b.rounds++
70 if usage == nil {
71 return
72 }
73 b.requests += usageRequestCount(usage)
74 b.promptTokens += usage.PromptTokens
75 b.outputTokens += usage.CompletionTokens
76 if usage.Unknown {
77 b.unpricedTurns = true
78 }
79 if quote == nil || (!quote.CostComplete && quote.IncompleteReason != "usage_unknown") || quote.Original.Currency == "" {
80 b.unpricedTurns = true
81 return
82 }
83 b.cost += quote.Original.Float64()
84 b.pricedRounds++
85 }
86
87 func (b *runBudget) elapsed() time.Duration {
88 if b.started.IsZero() {
89 return 0
90 }
91 return time.Since(b.started)
92 }
93
94 // totals is the shadow reading for one scope: counts and money, never content.
95 func (b *runBudget) totals() event.RunBudgetTotals {
96 return event.RunBudgetTotals{
97 Rounds: b.rounds,
98 Requests: b.requests,
99 PromptTokens: b.promptTokens,
100 OutputTokens: b.outputTokens,
101 Cost: b.cost,
102 Priced: !b.unpricedTurns && b.pricedRounds > 0,
103 ElapsedMs: b.elapsed().Milliseconds(),
104 }
105 }
106
107 // exceeded names the first axis the task has spent past, or "" while inside
108 // the budget. Cost only counts when the turn was actually priced: an unpriced
109 // model reads as free, and a free reading must never look like a crossing.
110 func (b *runBudget) exceeded(limit TaskBudget) (axis, detail string) {
111 if limit.Tokens > 0 {
112 if used := b.promptTokens + b.outputTokens; used >= limit.Tokens {
113 return "token", fmt.Sprintf("task used %d tokens, reaching the %d budget", used, limit.Tokens)
114 }
115 }
116 if limit.Cost > 0 && b.pricedRounds > 0 && b.cost >= limit.Cost {
117 return "cost", fmt.Sprintf("task spend %.4f reached the %.4f budget", b.cost, limit.Cost)
118 }
119 if limit.Wall > 0 {
120 if elapsed := b.elapsed(); elapsed >= limit.Wall {
121 return "time", fmt.Sprintf("task ran %s, past the %s budget",
122 elapsed.Round(time.Second), limit.Wall)
123 }
124 }
125 return "", ""
126 }
127
128 // taskBudgetLimit resolves this turn's bound: a host-injected budget wins over
129 // the configured one, which is how an unattended loop gets a ceiling while
130 // ordinary chat keeps none.
131 func (a *Agent) taskBudgetLimit(ctx context.Context) TaskBudget {
132 if b, ok := taskBudgetFromContext(ctx); ok {
133 return b
134 }
135 return a.task.budget.limit
136 }
137
138 // ResetTaskBudget starts a fresh user-approved spend slice without touching
139 // Delivery evidence or the persisted Goal usage totals. Callers use this only
140 // after a resumable explicit-budget pause, while no Agent Run is active.
141 func (a *Agent) ResetTaskBudget() {
142 a.task.budget = runBudget{limit: a.task.budget.limit}
143 }
144
145 // observeRunBudget folds a round into both scopes and reports them.
146 func (a *Agent) observeRunBudget(state *turnRuntime, usage *provider.Usage, quotes ...*billing.CostQuote) {
147 if state == nil {
148 return
149 }
150 var quote *billing.CostQuote
151 if len(quotes) > 0 {
152 quote = quotes[0]
153 } else if usage != nil && a.svc.pricing != nil {
154 e := event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage, Pricing: a.svc.pricing, UsageSource: a.usageSource}
155 quote = event.EnsureCostQuote(e, a.svc.quoteContext)
156 }
157 state.budget.observeQuote(usage, quote)
158 if a.task.budget.started.IsZero() {
159 a.task.budget.started = state.budget.started
160 }
161 a.task.budget.observeQuote(usage, quote)
162 currency := ""
163 if quote != nil {
164 currency = billing.CurrencySymbol(quote.Original.Currency)
165 }
166 event.RecordRunBudget(a.svc.sink, event.RunBudgetSample{
167 Turn: state.budget.totals(),
168 Task: a.task.budget.totals(),
169 Currency: currency,
170 })
171 }
172
173 type taskBudgetContextKey struct{}
174
175 // WithTaskBudget overrides a run's task budget for one turn. The agent serving
176 // an unattended loop and the one serving chat are the same instance, so the
177 // bound is a property of the turn, not of construction.
178 func WithTaskBudget(ctx context.Context, b TaskBudget) context.Context {
179 return context.WithValue(ctx, taskBudgetContextKey{}, normalizeTaskBudget(b))
180 }
181
182 func taskBudgetFromContext(ctx context.Context) (TaskBudget, bool) {
183 if ctx == nil {
184 return TaskBudget{}, false
185 }
186 b, ok := ctx.Value(taskBudgetContextKey{}).(TaskBudget)
187 return b, ok
188 }
189
189 lines GO