mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
The loop no longer classifies a tool-call turn that arrived without reasoning, retries it, or keeps a file-backed cooldown for it. Removes the watch and the cooldown state, the deferred stream sink they needed, the provider policy interfaces and their implementations, the descriptor field on the extension wire (schema and SDK regenerated), the state-dir path, the run-metrics counters and the protocol-recovery kinds. The protocol-recovery audit channel itself stays for now.
267 lines
9.4 KiB
Go
267 lines
9.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"reasonix/internal/contract/event"
|
|
"reasonix/internal/contract/pricing"
|
|
"reasonix/internal/contract/provider"
|
|
)
|
|
|
|
// estimateFailedAttemptUsage fills Estimated usage when a body attempt ends
|
|
// without a terminal provider usage record, so billing and observational Goal
|
|
// usage still include the issued request plus any observed speculative output.
|
|
// Non-interrupt failures that already carry usage (e.g. client reasoning limit)
|
|
// are left intact.
|
|
//
|
|
// httpRequests is the SendWithRetry attempt-counter delta for this body attempt.
|
|
// When it is 0 and there was no speculative output, the failure was local or
|
|
// came from a provider without observable transport accounting; return nil or
|
|
// its existing usage rather than inventing billable tokens.
|
|
func estimateFailedAttemptUsage(usage *provider.Usage, frozen samplingRequest, result streamedTurn, httpRequests int) *provider.Usage {
|
|
if result.err == nil {
|
|
return usage
|
|
}
|
|
// Preserve exact client-side finish reasons that already computed usage.
|
|
if usage != nil && usage.FinishReason != "" && usage.FinishReason != "interrupted" {
|
|
return usage
|
|
}
|
|
// A zero-output, non-interrupted failure with no observed HTTP request is a
|
|
// local/provider validation failure. It is not a billable sampling attempt.
|
|
preBodyLocal := httpRequests <= 0 && !result.interrupted &&
|
|
!provider.IsStreamInterrupted(result.err) && !sawSpeculativeSamplingOutput(result)
|
|
if preBodyLocal {
|
|
if usage != nil && usageTotalTokens(usage) > 0 {
|
|
return usage
|
|
}
|
|
return nil
|
|
}
|
|
if !provider.IsStreamInterrupted(result.err) && !result.interrupted {
|
|
// Auth/cancel/decode/limit paths keep their own accounting.
|
|
if usage != nil {
|
|
return usage
|
|
}
|
|
if httpRequests <= 0 {
|
|
return nil
|
|
}
|
|
}
|
|
textBytes := len(result.text)
|
|
reasoningBytes := len(result.reasoning)
|
|
maxArg := result.maxArgChars
|
|
for _, call := range result.partialCalls {
|
|
if n := len(call.Arguments); n > maxArg {
|
|
maxArg = n
|
|
}
|
|
}
|
|
for _, call := range result.calls {
|
|
if n := len(call.Arguments); n > maxArg {
|
|
maxArg = n
|
|
}
|
|
}
|
|
if usage != nil && !usage.Estimated && usage.TotalTokens > 0 {
|
|
return usage
|
|
}
|
|
finish := "interrupted"
|
|
if usage != nil && usage.FinishReason != "" {
|
|
finish = usage.FinishReason
|
|
}
|
|
est := bestEffortStreamUsage(usage, textBytes, reasoningBytes, finish)
|
|
if est == nil {
|
|
est = &provider.Usage{Estimated: true, FinishReason: finish}
|
|
}
|
|
if est.PromptTokens <= 0 {
|
|
est.PromptTokens = estimateSamplingRequestInputTokens(frozen.req)
|
|
est.Estimated = true
|
|
}
|
|
// Estimated failed attempts without cache split still need Cost() to see
|
|
// billable input — Price falls back to PromptTokens only when hit+miss=0.
|
|
if est.CacheHitTokens+est.CacheMissTokens == 0 && est.PromptTokens > 0 {
|
|
est.CacheMissTokens = est.PromptTokens
|
|
}
|
|
if maxArg > 0 {
|
|
argTokens := (maxArg + 3) / 4
|
|
if est.CompletionTokens < argTokens+estimateTokensFromBytes(textBytes)+estimateTokensFromBytes(reasoningBytes) {
|
|
est.CompletionTokens = argTokens + estimateTokensFromBytes(textBytes) + estimateTokensFromBytes(reasoningBytes)
|
|
est.Estimated = true
|
|
}
|
|
}
|
|
if minTotal := est.PromptTokens + est.CompletionTokens; est.TotalTokens < minTotal {
|
|
est.TotalTokens = minTotal
|
|
est.Estimated = true
|
|
}
|
|
return est
|
|
}
|
|
|
|
func sawSpeculativeSamplingOutput(result streamedTurn) bool {
|
|
return result.text != "" || result.reasoning != "" || result.maxArgChars > 0 ||
|
|
result.partialToolStarted || len(result.calls) > 0 || len(result.partialCalls) > 0
|
|
}
|
|
|
|
// estimateSamplingRequestInputTokens reconstructs a conservative input count
|
|
// only when an interrupted attempt closed before terminal provider usage. It is
|
|
// accounting telemetry, not request admission: the estimate never changes the
|
|
// frozen provider request or imposes a token ceiling.
|
|
func estimateSamplingRequestInputTokens(req provider.Request) int {
|
|
// Uncalibrated on purpose: this runs where no usage record exists, and the
|
|
// billing line it fills is the one the provider never sent.
|
|
shape := requestCalibrationShapeOf(req)
|
|
return max(int(float64(shape.requestChars)*fallbackTokPerChar)+int(shape.imageTokens), 1)
|
|
}
|
|
|
|
// mergeSamplingUsage accumulates billable counters across body attempts.
|
|
// PromptTokens is the billable input total (aligned with cache hit+miss).
|
|
// ContextPromptTokens is set later by finalizeSamplingUsage from the latest attempt.
|
|
func mergeSamplingUsage(acc, attempt *provider.Usage) *provider.Usage {
|
|
if attempt == nil {
|
|
return acc
|
|
}
|
|
billableHitMiss := func(u *provider.Usage) (hit, miss int) {
|
|
if u == nil {
|
|
return 0, 0
|
|
}
|
|
if u.CacheHitTokens+u.CacheMissTokens > 0 {
|
|
return u.CacheHitTokens, u.CacheMissTokens
|
|
}
|
|
// No cache split: treat PromptTokens as uncached billable input.
|
|
return 0, u.PromptTokens
|
|
}
|
|
billablePrompt := func(hit, miss, prompt int) int {
|
|
if hit+miss > 0 {
|
|
return hit + miss
|
|
}
|
|
return prompt
|
|
}
|
|
if acc == nil {
|
|
merged := *attempt
|
|
if merged.RequestCount <= 0 {
|
|
merged.RequestCount = 1
|
|
}
|
|
hit, miss := billableHitMiss(attempt)
|
|
merged.CacheHitTokens = hit
|
|
merged.CacheMissTokens = miss
|
|
merged.PromptTokens = billablePrompt(hit, miss, attempt.PromptTokens)
|
|
return &merged
|
|
}
|
|
merged := *acc
|
|
// Billable input for Cost: sum hit/miss (prompt when no cache split).
|
|
ah, am := billableHitMiss(acc)
|
|
bh, bm := billableHitMiss(attempt)
|
|
// If acc was previously merged, CacheHit+Miss already holds the sum and
|
|
// PromptTokens may still be the first attempt's value — prefer stored sums.
|
|
if acc.CacheHitTokens+acc.CacheMissTokens > 0 {
|
|
ah, am = acc.CacheHitTokens, acc.CacheMissTokens
|
|
}
|
|
merged.CacheHitTokens = ah + bh
|
|
merged.CacheMissTokens = am + bm
|
|
merged.CacheWriteTokens += attempt.CacheWriteTokens
|
|
merged.CacheWriteBilledTokens += attempt.CacheWriteBilledTokens
|
|
merged.PromptTokens = billablePrompt(merged.CacheHitTokens, merged.CacheMissTokens, 0)
|
|
if merged.PromptTokens == 0 {
|
|
merged.PromptTokens = acc.PromptTokens + attempt.PromptTokens
|
|
}
|
|
merged.CompletionTokens += attempt.CompletionTokens
|
|
merged.ReasoningTokens += attempt.ReasoningTokens
|
|
merged.TotalTokens += usageTotalTokens(attempt)
|
|
merged.RequestCount = usageRequestCount(acc) + usageRequestCount(attempt)
|
|
if attempt.Estimated {
|
|
merged.Estimated = true
|
|
}
|
|
if attempt.FinishReason != "" {
|
|
merged.FinishReason = attempt.FinishReason
|
|
}
|
|
return &merged
|
|
}
|
|
|
|
// storeLatestRequestUsage records single-request usage, never a billable aggregate.
|
|
func (a *Agent) storeLatestRequestUsage(attempt *provider.Usage) {
|
|
if a == nil || attempt == nil {
|
|
return
|
|
}
|
|
// Skip request-only shells with no token shape.
|
|
if attempt.PromptTokens <= 0 && attempt.CompletionTokens <= 0 && attempt.TotalTokens <= 0 {
|
|
return
|
|
}
|
|
clone := *attempt
|
|
// Keep the per-attempt RequestCount; context calculations do not use it.
|
|
a.sess.output.lastUsage.Store(&clone)
|
|
a.window().setPromptTokenCalibrationFromUsage(&clone)
|
|
a.window().noteAcceptedPromptTokens(&clone)
|
|
a.window().anchorProviderUsage(&clone)
|
|
}
|
|
|
|
// finalizeSamplingUsage builds the Usage event payload for consumers that
|
|
// expect one coherent billable record:
|
|
// - PromptTokens / cache hit+miss / Completion / Total / RequestCount: billable aggregate
|
|
// - Context* fields: latest attempt only (context gauges + rebind telemetry)
|
|
func finalizeSamplingUsage(billable, latest *provider.Usage) *provider.Usage {
|
|
if billable == nil && latest == nil {
|
|
return nil
|
|
}
|
|
if billable == nil {
|
|
out := *latest
|
|
applyLatestContextShape(&out, latest)
|
|
return &out
|
|
}
|
|
out := *billable
|
|
if latest != nil {
|
|
applyLatestContextShape(&out, latest)
|
|
out.FinishReason = latest.FinishReason
|
|
}
|
|
// Ensure PromptTokens matches billable input (hit+miss) for CLI/ACP/Desktop
|
|
// telemetry that requires cache totals to align with PromptTokens.
|
|
if hitMiss := out.CacheHitTokens + out.CacheMissTokens; hitMiss > 0 {
|
|
out.PromptTokens = hitMiss
|
|
}
|
|
if out.TotalTokens < out.PromptTokens+out.CompletionTokens {
|
|
out.TotalTokens = out.PromptTokens + out.CompletionTokens
|
|
}
|
|
return &out
|
|
}
|
|
|
|
// mergeStreamUsage remains for missing-reasoning style single-repair merges that
|
|
// need a simple sum. Sampling recovery uses mergeSamplingUsage instead.
|
|
func mergeStreamUsage(first, retry *provider.Usage) *provider.Usage {
|
|
return mergeSamplingUsage(first, retry)
|
|
}
|
|
|
|
func usageTotalTokens(u *provider.Usage) int {
|
|
if u == nil {
|
|
return 0
|
|
}
|
|
if u.TotalTokens > 0 {
|
|
return u.TotalTokens
|
|
}
|
|
return u.PromptTokens + u.CompletionTokens
|
|
}
|
|
|
|
func usageRequestCount(usage *provider.Usage) int {
|
|
if usage == nil {
|
|
return 0
|
|
}
|
|
if usage.RequestCount > 0 {
|
|
return usage.RequestCount
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func (a *Agent) emitTurnUsage(usage *provider.Usage, cacheDiagnostics *CacheDiagnostics, attemptID string) *pricing.CostQuote {
|
|
e := event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage, Pricing: a.svc.pricing,
|
|
UsageSource: a.usageSource, AttemptID: attemptID, CacheDiagnostics: cacheDiagnostics,
|
|
SessionHit: int(a.sess.cacheHit.Load()), SessionMiss: int(a.sess.cacheMiss.Load())}
|
|
e.CostQuote = event.QuoteUsage(a.svc.sink, e)
|
|
var accounted *pricing.CostQuote
|
|
if e.CostQuote != nil {
|
|
q := pricing.NormalizeQuote(*e.CostQuote)
|
|
accounted = &q
|
|
}
|
|
if usage == nil || (usage.TotalTokens <= 0 && usage.RequestCount <= 0) {
|
|
return accounted
|
|
}
|
|
// lastUsage must stay as the latest single-request shape (set during
|
|
// sampling recovery). Never overwrite it with a multi-attempt billable
|
|
// aggregate — that would inflate ContextSnapshot and compaction decisions.
|
|
if a.sess.output.lastUsage.Load() == nil && usage.PromptTokens > 0 {
|
|
a.storeLatestRequestUsage(usage)
|
|
}
|
|
a.svc.sink.Emit(e)
|
|
return accounted
|
|
}
|