Merge pull request #7751 from SivanCola/fix/cache-metrics-accounting-followup

Fix cache accounting, snapshot races, and adaptive TTL churn / 修复缓存计费、快照竞态与 TTL 抖动
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-06 19:47:17 +08:00
commit cfc6c6f121
17 files changed
+313 -311

No files matched your search

+46 -32
View File
@@ -247,13 +247,17 @@ type readFileRecord struct {
}
type sessionUsageStats struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
Estimated bool `json:"estimated,omitempty"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
// CacheWriteBilledTokens preserves provider-specific cache-write pricing
// across persisted telemetry repricing without changing hit-rate totals.
CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
// LastUsedTokens is the executor-reported context fill (prompt+completion)
// from the most recent turn. It is persisted so the status bar / context
// panel can show a meaningful fill percentage after a session rebind
@@ -280,17 +284,19 @@ type sessionUsageStats struct {
}
type usageSourceStats struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
Estimated bool `json:"estimated,omitempty"`
RequestCount int `json:"requestCount"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
ReasoningTokens int `json:"reasoningTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
CacheWriteTokens int `json:"cacheWriteTokens,omitempty"`
CacheWriteBilledTokens float64 `json:"cacheWriteBilledTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
RequestCount int `json:"requestCount"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
SessionCostUsd float64 `json:"sessionCostUsd,omitempty"`
}
type sourceSessionCacheCounters struct {
@@ -966,6 +972,8 @@ func (t *WorkspaceTab) recordUsage(e event.Event) {
cacheHitTokens, cacheMissTokens := t.usageTelemetry.cacheTokenDelta(source, u, e.SessionHit, e.SessionMiss)
t.usageTelemetry.CacheHitTokens += cacheHitTokens
t.usageTelemetry.CacheMissTokens += cacheMissTokens
t.usageTelemetry.CacheWriteTokens += u.CacheWriteTokens
t.usageTelemetry.CacheWriteBilledTokens += u.CacheWriteBilledTokens
t.usageTelemetry.Estimated = t.usageTelemetry.Estimated || u.Estimated
requestCount := u.RequestCount
if requestCount <= 0 {
@@ -996,6 +1004,8 @@ func (t *WorkspaceTab) recordUsage(e event.Event) {
src.ReasoningTokens += u.ReasoningTokens
src.CacheHitTokens += cacheHitTokens
src.CacheMissTokens += cacheMissTokens
src.CacheWriteTokens += u.CacheWriteTokens
src.CacheWriteBilledTokens += u.CacheWriteBilledTokens
src.Estimated = src.Estimated || u.Estimated
src.RequestCount += requestCount
if e.Pricing != nil {
@@ -1029,25 +1039,29 @@ func (t *WorkspaceTab) recordUsage(e event.Event) {
func usageStatsAsProviderUsage(stats usageSourceStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
Estimated: stats.Estimated,
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
CacheWriteTokens: stats.CacheWriteTokens,
CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
Estimated: stats.Estimated,
}
}
func sessionStatsAsProviderUsage(stats sessionUsageStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
Estimated: stats.Estimated,
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
CacheWriteTokens: stats.CacheWriteTokens,
CacheWriteBilledTokens: stats.CacheWriteBilledTokens,
Estimated: stats.Estimated,
}
}
+44 -8
View File
@@ -135,6 +135,34 @@ func TestWorkspaceTabRepricesUsageWithoutMixingCurrencies(t *testing.T) {
}
}
func TestWorkspaceTabRepricesCacheWritesWithoutLosingBillingTier(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
Usage: &provider.Usage{
PromptTokens: 500_000,
TotalTokens: 500_000,
CacheMissTokens: 500_000,
CacheWriteTokens: 100_000,
CacheWriteBilledTokens: 200_000,
},
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 2, Currency: "CNY"},
})
if ok := tab.repriceUsage(map[string]*provider.Pricing{
event.UsageSourceExecutor: {Input: 1, Currency: "USD"},
}); !ok {
t.Fatal("repriceUsage rejected cache-write usage")
}
got := tab.telemetrySnapshot().Usage
// 400K ordinary input units + 200K billed cache-write units.
if got.SessionCurrency != "$" || got.SessionCost != 0.6 {
t.Fatalf("repriced cache-write usage = %f %q, want 0.6 USD", got.SessionCost, got.SessionCurrency)
}
if got.CacheWriteTokens != 100_000 || got.CacheWriteBilledTokens != 200_000 {
t.Fatalf("persisted cache writes = raw %d billed %v", got.CacheWriteTokens, got.CacheWriteBilledTokens)
}
}
func TestRepriceTabUsageUsesDetectedLocaleForAutoCurrency(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
@@ -278,14 +306,16 @@ func TestTelemetryLastContextRoundTripAndLegacyDefaults(t *testing.T) {
want := tabTelemetrySnapshot{
Version: 2,
Usage: sessionUsageStats{
PromptTokens: 100,
TotalTokens: 120,
LastUsedTokens: 120,
LastPromptTokens: 100,
LastCompletionTokens: 20,
LastReasoningTokens: 8,
LastCacheHitTokens: 70,
LastCacheMissTokens: 30,
PromptTokens: 100,
TotalTokens: 120,
CacheWriteTokens: 5,
CacheWriteBilledTokens: 10,
LastUsedTokens: 120,
LastPromptTokens: 100,
LastCompletionTokens: 20,
LastReasoningTokens: 8,
LastCacheHitTokens: 70,
LastCacheMissTokens: 30,
},
}
if err := saveTelemetry(path, want); err != nil {
@@ -300,11 +330,17 @@ func TestTelemetryLastContextRoundTripAndLegacyDefaults(t *testing.T) {
got.LastCacheMissTokens != want.Usage.LastCacheMissTokens {
t.Fatalf("last context round trip = %+v, want %+v", got, want.Usage)
}
if got.CacheWriteTokens != 5 || got.CacheWriteBilledTokens != 10 {
t.Fatalf("cache-write round trip = raw %d billed %v, want 5/10", got.CacheWriteTokens, got.CacheWriteBilledTokens)
}
if err := os.WriteFile(path, []byte(`{"version":2,"usage":{"promptTokens":50,"totalTokens":50}}`), 0o644); err != nil {
t.Fatalf("write pre-last-context telemetry: %v", err)
}
legacy := loadTelemetry(path).Usage
if legacy.CacheWriteTokens != 0 || legacy.CacheWriteBilledTokens != 0 {
t.Fatalf("legacy cache-write fields = raw %d billed %v, want zero defaults", legacy.CacheWriteTokens, legacy.CacheWriteBilledTokens)
}
if legacy.LastUsedTokens != 0 ||
legacy.LastPromptTokens != 0 ||
legacy.LastCompletionTokens != 0 ||
+4 -35
View File
@@ -286,13 +286,6 @@ type Agent struct {
// run loop writes it while a frontend's status line reads it, so it is atomic.
lastUsage atomic.Pointer[provider.Usage]
// lastRequestSentAt is the wall-clock moment (unix millis) the most recent
// provider attempt was sent. Each attempt updates it, while all attempts in
// one frozen sampling round reuse the first attempt's gap hint so adaptive
// cache TTL cannot change the provider-visible replay body. Zero means no
// request sent yet — a fresh process or sub-agent gets the cheaper default TTL.
lastRequestSentAt atomic.Int64
// sessCacheHit/sessCacheMiss accumulate cache tokens across every API call
// this session, so frontends can show the aggregate hit-rate (Σhit/Σ(hit+miss))
// — a steadier, cost-oriented number than the single-turn rate. They are NOT
@@ -2876,10 +2869,7 @@ func toolBudgetNoticeText() string {
// round. All stream retries replay this exact payload — no synthetic recovery
// messages, no schema reorder, no previous_response_id drift from failed attempts.
type samplingRequest struct {
req provider.Request
cacheGap time.Duration
cacheGapKnown bool
cacheGapPresent bool
req provider.Request
}
// prepareSamplingRequest runs interceptors and schema fetch once per model
@@ -2996,30 +2986,9 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
}
req = prepared.req
}
// Stamp every actual attempt at send time, but freeze the first attempt's
// cache-gap hint for the entire sampling lifecycle. Anthropic derives an
// ephemeral-cache TTL from this context value, so recomputing it on an
// immediate retry would change the HTTP body despite an identical Request.
sentAt := time.Now()
priorMs := a.lastRequestSentAt.Swap(sentAt.UnixMilli())
if frozen == nil {
if priorMs != 0 {
ctx = provider.WithCacheGapHint(ctx, sentAt.Sub(time.UnixMilli(priorMs)))
}
} else {
if !frozen.cacheGapKnown {
frozen.cacheGapKnown = true
if priorMs != 0 {
frozen.cacheGap = sentAt.Sub(time.UnixMilli(priorMs))
frozen.cacheGapPresent = true
}
}
if frozen.cacheGapPresent {
ctx = provider.WithCacheGapHint(ctx, frozen.cacheGap)
}
}
// After #7725 Goal token request admission was removed; stream goes
// directly to the provider while still carrying the cache-gap hint.
// After #7725 Goal token request admission was removed, stream goes
// directly to the provider. Provider-visible cache controls stay stable
// across retries and request timing because they are derived from req alone.
ch, err := a.prov.Stream(ctx, req)
if err != nil {
return "", "", "", "", "", nil, nil, provider.UsageWithRequestAttemptCount(ctx, nil), false, false, nil, 0, err
-84
View File
@@ -1,84 +0,0 @@
package agent
import (
"context"
"testing"
"time"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// gapCaptureProvider records whether/what cache-gap hint arrived on each
// Stream call, so tests can prove Agent.stream threads it into Provider.Stream
// and freezes it across body retries.
type gapCaptureProvider struct {
gaps []time.Duration
ok []bool
interruptNext bool
}
func (p *gapCaptureProvider) Name() string { return "gap-capture" }
func (p *gapCaptureProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
gap, ok := provider.CacheGapHintFromContext(ctx)
p.gaps = append(p.gaps, gap)
p.ok = append(p.ok, ok)
ch := make(chan provider.Chunk, 2)
if p.interruptNext {
p.interruptNext = false
ch <- provider.Chunk{Type: provider.ChunkError, Err: provider.StreamInterrupt(context.Canceled, provider.StreamInterruptConnectionReset)}
close(ch)
return ch, nil
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11}}
close(ch)
return ch, nil
}
func TestStreamRecoveryFreezesCacheGapHint(t *testing.T) {
prov := &gapCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "prime"); err != nil {
t.Fatalf("prime Run: %v", err)
}
time.Sleep(5 * time.Millisecond)
prov.interruptNext = true
if err := a.Run(context.Background(), "retry"); err != nil {
t.Fatalf("recovered Run: %v", err)
}
if len(prov.gaps) != 3 {
t.Fatalf("Stream calls = %d, want prime + failed attempt + retry", len(prov.gaps))
}
if !prov.ok[1] || !prov.ok[2] || prov.gaps[1] <= 0 {
t.Fatalf("retry round hints = ok %v/%v gaps %v/%v", prov.ok[1], prov.ok[2], prov.gaps[1], prov.gaps[2])
}
if prov.gaps[2] != prov.gaps[1] {
t.Fatalf("retry cache gap changed provider-visible TTL input: first=%v retry=%v", prov.gaps[1], prov.gaps[2])
}
}
func TestStreamCarriesCacheGapHintAfterFirstTurn(t *testing.T) {
prov := &gapCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "one"); err != nil {
t.Fatalf("first Run: %v", err)
}
time.Sleep(5 * time.Millisecond)
if err := a.Run(context.Background(), "two"); err != nil {
t.Fatalf("second Run: %v", err)
}
if len(prov.ok) != 2 {
t.Fatalf("got %d Stream calls, want 2", len(prov.ok))
}
if prov.ok[0] {
t.Fatalf("first request should carry no gap hint, got gap=%v", prov.gaps[0])
}
if !prov.ok[1] || prov.gaps[1] <= 0 {
t.Fatalf("second request should carry a positive gap hint, got ok=%v gap=%v", prov.ok[1], prov.gaps[1])
}
}
+2
View File
@@ -719,6 +719,8 @@ func mergeSamplingUsage(acc, attempt *provider.Usage) *provider.Usage {
}
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
+5 -2
View File
@@ -39,8 +39,8 @@ func (failedRequestProvider) Stream(ctx context.Context, _ provider.Request) (<-
}
func TestMergeStreamUsageCountsProviderRequests(t *testing.T) {
first := &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, RequestCount: 1}
retry := &provider.Usage{PromptTokens: 20, CompletionTokens: 8, TotalTokens: 28, RequestCount: 1}
first := &provider.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, CacheWriteTokens: 2, CacheWriteBilledTokens: 2.5, RequestCount: 1}
retry := &provider.Usage{PromptTokens: 20, CompletionTokens: 8, TotalTokens: 28, CacheWriteTokens: 3, CacheWriteBilledTokens: 6, RequestCount: 1}
got := mergeStreamUsage(first, retry)
if got == nil || got.TotalTokens != 43 || got.RequestCount != 2 || got.CompletionTokens != 13 {
t.Fatalf("merged usage = %+v, want total=43 requests=2 completion=13", got)
@@ -49,6 +49,9 @@ func TestMergeStreamUsageCountsProviderRequests(t *testing.T) {
if got.CacheMissTokens != 30 || got.PromptTokens != 30 {
t.Fatalf("billable input = prompt %d miss %d, want 30/30", got.PromptTokens, got.CacheMissTokens)
}
if got.CacheWriteTokens != 5 || got.CacheWriteBilledTokens != 8.5 {
t.Fatalf("merged cache writes = raw %d billed %v, want 5/8.5", got.CacheWriteTokens, got.CacheWriteBilledTokens)
}
third := &provider.Usage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, RequestCount: 1}
got = mergeStreamUsage(got, third)
+2 -3
View File
@@ -142,6 +142,7 @@ func (s *metricsSink) Snapshot() RunMetrics {
func (m RunMetrics) clone() RunMetrics {
out := m
out.PrefixChangeReasonCounts = cloneCounts(m.PrefixChangeReasonCounts)
out.UsageBySource = cloneSourceUsage(m.UsageBySource)
out.ToolCallsByName = cloneCounts(m.ToolCallsByName)
out.ToolFailuresByName = cloneCounts(m.ToolFailuresByName)
@@ -208,9 +209,7 @@ func (s *metricsSink) record(e event.Event) {
s.m.Estimated = s.m.Estimated || u.Estimated
var stepCost float64
if p := e.Pricing; p != nil {
stepCost = (float64(u.CacheHitTokens)*p.CacheHit +
float64(u.CacheMissTokens)*p.Input +
float64(u.CompletionTokens)*p.Output) / 1e6
stepCost = p.Cost(u)
s.m.Cost += stepCost
s.m.Currency = p.Currency
}
+26 -2
View File
@@ -21,6 +21,27 @@ func usageEvent(source string, prompt, completion int) event.Event {
}
}
func usageEventWithCacheReason(reason string) event.Event {
e := usageEvent(event.UsageSourceSubagent, 10, 1)
e.CacheDiagnostics = &event.CacheDiagnostics{PrefixChangeReasons: []string{reason}}
return e
}
func TestSnapshotDeepCopiesPrefixChangeReasons(t *testing.T) {
s := &metricsSink{inner: event.Discard}
s.Emit(usageEventWithCacheReason("compact_auto"))
snapshot := s.Snapshot()
s.Emit(usageEventWithCacheReason("snip"))
if snapshot.PrefixChangeReasonCounts["compact_auto"] != 1 {
t.Fatalf("snapshot compact_auto = %d, want 1", snapshot.PrefixChangeReasonCounts["compact_auto"])
}
if _, changed := snapshot.PrefixChangeReasonCounts["snip"]; changed {
t.Fatalf("snapshot changed after return: %v", snapshot.PrefixChangeReasonCounts)
}
}
// A killed agent writes no final record. Everything it did before the kill is
// only recoverable if snapshots landed on disk while it ran.
func TestSnapshotSurvivesWithoutAFinalWrite(t *testing.T) {
@@ -173,7 +194,7 @@ func TestConcurrentEmitAndSnapshotAreRaceFree(t *testing.T) {
go func() {
defer wg.Done()
for j := 0; j < each; j++ {
s.Emit(usageEvent(event.UsageSourceSubagent, 10, 1))
s.Emit(usageEventWithCacheReason("compact_auto"))
s.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "bash"}})
}
}()
@@ -182,7 +203,10 @@ func TestConcurrentEmitAndSnapshotAreRaceFree(t *testing.T) {
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
_ = s.Snapshot()
if _, err := json.Marshal(s.Snapshot()); err != nil {
t.Errorf("marshal snapshot: %v", err)
return
}
}
}()
wg.Wait()
+17
View File
@@ -8,8 +8,25 @@ import (
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
)
func TestMetricsSinkUsesProviderCacheWriteCost(t *testing.T) {
s := &metricsSink{inner: event.Discard}
s.Emit(event.Event{
Kind: event.Usage,
Usage: &provider.Usage{
CacheMissTokens: 500_000,
CacheWriteTokens: 100_000,
CacheWriteBilledTokens: 200_000,
},
Pricing: &provider.Pricing{Input: 2},
})
if got := s.Snapshot().Cost; got != 1.2 {
t.Fatalf("metrics cost = %f, want 1.2", got)
}
}
func TestMetricsSinkAccumulatesReadinessAudit(t *testing.T) {
s := &metricsSink{inner: event.Discard}
+2
View File
@@ -593,6 +593,8 @@ func (gs *Session) addReviewUsage(usage *provider.Usage) {
gs.reviewUsage.TotalTokens += usage.TotalTokens
gs.reviewUsage.CacheHitTokens += usage.CacheHitTokens
gs.reviewUsage.CacheMissTokens += usage.CacheMissTokens
gs.reviewUsage.CacheWriteTokens += usage.CacheWriteTokens
gs.reviewUsage.CacheWriteBilledTokens += usage.CacheWriteBilledTokens
gs.reviewUsage.ReasoningTokens += usage.ReasoningTokens
gs.reviewUsage.RequestCount += guardianUsageRequestCount(usage)
gs.reviewUsage.Estimated = gs.reviewUsage.Estimated || usage.Estimated
+24 -41
View File
@@ -116,6 +116,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
keySource: keySource,
baseURL: root,
model: cfg.Model,
nativeAnthropic: strings.EqualFold(root, defaultBaseURL),
deepseek: openai.IsDeepSeek(root),
thinking: thinking,
effort: effort,
@@ -142,6 +143,7 @@ type client struct {
keySource string // source of keyEnv, surfaced in auth errors
baseURL string
model string
nativeAnthropic bool // first-party endpoint: documented default-5m cache-write pricing applies
deepseek bool // official DeepSeek Anthropic endpoint: unsigned reasoning replay + automatic cache
thinking string // "adaptive" enables extended thinking; "" = off (config-driven)
effort string // output_config.effort: low|medium|high|xhigh|max; "" = provider default
@@ -302,7 +304,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
// become `tool_use` blocks; RoleTool results become `tool_result` blocks in a user
// turn. Consecutive same-role messages are coalesced because the API requires
// alternating user/assistant turns (tool results are user turns).
func (c *client) buildRequest(ctx context.Context, req provider.Request) anthRequest {
func (c *client) buildRequest(_ context.Context, req provider.Request) anthRequest {
var system []textBlock
var msgs []anthMessage
@@ -395,23 +397,19 @@ func (c *client) buildRequest(ctx context.Context, req provider.Request) anthReq
// tools → system → messages, so a marker on the last system block caches
// tools+system together; with no system, mark the last tool. A marker on the
// last block of the last message caches the conversation prefix, accruing hits
// incrementally as turns are appended. Max 4 breakpoints; we use ≤2. One TTL
// decision (default 5m, or 1h when the previous request was long enough ago
// that the 5m window would likely already have expired) applies to all of
// them — this is a single "extend this prefix" choice, not per-block.
// incrementally as turns are appended. Max 4 breakpoints; we use ≤2. Keep
// Anthropic's default 5m TTL by omitting the ttl field. Besides being cheaper
// than the opt-in 1h write, this keeps provider-visible request bytes stable
// across turns, retries, and wall-clock timing.
if !c.deepseek {
ttl := ""
if useLongCacheTTL(ctx) {
ttl = cacheTTL1Hour
}
if n := len(system); n > 0 {
system[n-1].CacheControl = ephemeral(ttl)
system[n-1].CacheControl = ephemeral()
} else if n := len(tools); n > 0 {
tools[n-1].CacheControl = ephemeral(ttl)
tools[n-1].CacheControl = ephemeral()
}
if n := len(msgs); n > 0 {
if k := len(msgs[n-1].Content); k > 0 {
msgs[n-1].Content[k-1].CacheControl = ephemeral(ttl)
msgs[n-1].Content[k-1].CacheControl = ephemeral()
}
}
}
@@ -687,13 +685,19 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<-
finalize:
if haveUsage {
cacheWriteBilledTokens := 0.0
if cacheCreate > 0 && c.nativeAnthropic {
cacheWriteBilledTokens = float64(cacheCreate) * cacheWrite5MinuteInputMultiplier
}
usage := &provider.Usage{
PromptTokens: inTok + cacheCreate + cacheRead,
CompletionTokens: outTok,
TotalTokens: inTok + cacheCreate + cacheRead + outTok,
CacheHitTokens: cacheRead,
CacheMissTokens: inTok + cacheCreate, // uncached input + cache writes (billed ≥1×)
FinishReason: mapStopReason(stopReason),
PromptTokens: inTok + cacheCreate + cacheRead,
CompletionTokens: outTok,
TotalTokens: inTok + cacheCreate + cacheRead + outTok,
CacheHitTokens: cacheRead,
CacheMissTokens: inTok + cacheCreate,
CacheWriteTokens: cacheCreate,
CacheWriteBilledTokens: cacheWriteBilledTokens,
FinishReason: mapStopReason(stopReason),
}
provider.ApplyRequestAttemptCount(ctx, usage)
if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) {
@@ -770,33 +774,12 @@ func formatWebSearchResults(raw json.RawMessage) string {
// --- Messages API wire protocol ---
// cacheGapLongTTLThreshold is how long the gap since the previous request must
// be before the 1h cache breakpoint (2x write cost) is worth it over the
// default 5m one (1.25x write cost). Comfortably under Anthropic's 5-minute
// expiry so the switch happens before that window would actually go cold, but
// high enough that one ordinary ~60-90s tool round (a build, a test run)
// doesn't flip it: an unneeded 1h choice costs proportionally more (2.1x) than
// an unneeded 5m one (1.35x) would have, so this errs conservative.
const cacheGapLongTTLThreshold = 120 * time.Second
const cacheWrite5MinuteInputMultiplier = 1.25
// cacheTTL1Hour is the opt-in extended cache_control TTL value (GA, no beta
// header required). An empty TTL string omits the field entirely, which is
// Anthropic's default 5-minute window.
const cacheTTL1Hour = "1h"
// useLongCacheTTL reports whether the gap since the previous request (see
// provider.WithCacheGapHint) is large enough that the default 5-minute
// prompt-cache window would likely already have expired.
func useLongCacheTTL(ctx context.Context) bool {
gap, ok := provider.CacheGapHintFromContext(ctx)
return ok && gap >= cacheGapLongTTLThreshold
}
func ephemeral(ttl string) *cacheControl { return &cacheControl{Type: "ephemeral", TTL: ttl} }
func ephemeral() *cacheControl { return &cacheControl{Type: "ephemeral"} }
type cacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
type anthRequest struct {
+73 -59
View File
@@ -9,7 +9,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"reasonix/internal/provider"
)
@@ -100,77 +99,35 @@ func TestBuildRequestNoSystem(t *testing.T) {
}
}
func lastBlockCacheControl(r anthRequest) *cacheControl {
last := r.Messages[len(r.Messages)-1]
return last.Content[len(last.Content)-1].CacheControl
}
func TestBuildRequestDefaultsToShortCacheTTL(t *testing.T) {
func TestBuildRequestKeepsDefaultCacheControlBytesStable(t *testing.T) {
c := &client{model: "claude-opus-4-8"}
r := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
got := lastBlockCacheControl(r)
if got == nil || got.TTL != "" {
t.Fatalf("cache_control = %+v, want empty TTL with no gap hint", got)
req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}
want, err := json.Marshal(c.buildRequest(context.Background(), req))
if err != nil {
t.Fatalf("marshal first request: %v", err)
}
}
func TestBuildRequestUsesLongCacheTTLOnLargeGap(t *testing.T) {
c := &client{model: "claude-opus-4-8"}
ctx := provider.WithCacheGapHint(context.Background(), 3*time.Minute)
r := c.buildRequest(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
got := lastBlockCacheControl(r)
if got == nil || got.TTL != "1h" {
t.Fatalf("cache_control = %+v, want ttl=1h for a 3-minute gap", got)
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
got, err := json.Marshal(c.buildRequest(requestCtx, req))
if err != nil {
t.Fatalf("marshal second request: %v", err)
}
}
func TestBuildRequestKeepsShortCacheTTLBelowThreshold(t *testing.T) {
c := &client{model: "claude-opus-4-8"}
ctx := provider.WithCacheGapHint(context.Background(), 10*time.Second)
r := c.buildRequest(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
got := lastBlockCacheControl(r)
if got == nil || got.TTL != "" {
t.Fatalf("cache_control = %+v, want default ttl for a 10s gap", got)
if string(got) != string(want) {
t.Fatalf("request bytes changed with unrelated context:\nfirst: %s\nsecond: %s", want, got)
}
if strings.Contains(string(got), `"ttl"`) {
t.Fatalf("default cache_control unexpectedly opted into a TTL: %s", got)
}
}
func TestCacheControlOmitsTTLByDefault(t *testing.T) {
b, err := json.Marshal(ephemeral(""))
b, err := json.Marshal(ephemeral())
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(b) != `{"type":"ephemeral"}` {
t.Fatalf("default cache_control = %s, want byte-identical to every prior release", b)
}
b, err = json.Marshal(ephemeral("1h"))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(b) != `{"type":"ephemeral","ttl":"1h"}` {
t.Fatalf("1h cache_control = %s", b)
}
}
func TestUseLongCacheTTL(t *testing.T) {
for _, tc := range []struct {
name string
gap time.Duration
hint bool
want bool
}{
{"no hint", 0, false, false},
{"below threshold", cacheGapLongTTLThreshold - time.Second, true, false},
{"at threshold", cacheGapLongTTLThreshold, true, true},
{"above threshold", cacheGapLongTTLThreshold + time.Minute, true, true},
} {
ctx := context.Background()
if tc.hint {
ctx = provider.WithCacheGapHint(ctx, tc.gap)
}
if got := useLongCacheTTL(ctx); got != tc.want {
t.Errorf("%s: useLongCacheTTL = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestConfiguredMaxOutputTokensRespectsMandatoryAnthropicFallback(t *testing.T) {
@@ -277,6 +234,28 @@ func TestNewDetectsOfficialDeepSeekEndpoint(t *testing.T) {
}
}
func TestNewScopesNativeCacheWritePricingToAnthropic(t *testing.T) {
for _, tc := range []struct {
name string
baseURL string
want bool
}{
{name: "default", want: true},
{name: "official v1", baseURL: "https://api.anthropic.com/v1", want: true},
{name: "compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: false},
} {
t.Run(tc.name, func(t *testing.T) {
p, err := New(provider.Config{Name: "test", BaseURL: tc.baseURL, Model: "claude-sonnet-4-6"})
if err != nil {
t.Fatalf("New: %v", err)
}
if got := p.(*client).nativeAnthropic; got != tc.want {
t.Fatalf("nativeAnthropic = %v, want %v", got, tc.want)
}
})
}
}
func TestMapStopReason(t *testing.T) {
cases := map[string]string{
"end_turn": "stop",
@@ -506,11 +485,46 @@ data: {"type":"message_stop"}
if usage.CacheHitTokens != 7 || usage.CacheMissTokens != 18 {
t.Fatalf("usage cache = hit %d miss %d", usage.CacheHitTokens, usage.CacheMissTokens)
}
if usage.CacheWriteTokens != 5 || usage.CacheWriteBilledTokens != 0 {
t.Fatalf("compatible-gateway cache write = raw %d billed %v, want 5/0", usage.CacheWriteTokens, usage.CacheWriteBilledTokens)
}
if usage.FinishReason != "stop" {
t.Fatalf("finish reason = %q", usage.FinishReason)
}
}
func TestReadStreamPricesNativeCacheWritesAtDefaultTTL(t *testing.T) {
sse := `event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":3,"cache_creation_input_tokens":5,"cache_read_input_tokens":7,"output_tokens":0}}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}
event: message_stop
data: {"type":"message_stop"}
`
c := &client{name: "anthropic", nativeAnthropic: true}
resp := &http.Response{Body: io.NopCloser(strings.NewReader(sse))}
ch := make(chan provider.Chunk)
go c.readStream(context.Background(), resp, ch)
var usage *provider.Usage
for ck := range ch {
if ck.Type == provider.ChunkError {
t.Fatalf("unexpected error chunk: %v", ck.Err)
}
if ck.Type == provider.ChunkUsage {
usage = ck.Usage
}
}
if usage == nil {
t.Fatal("expected a usage chunk")
}
if usage.CacheWriteTokens != 5 || usage.CacheWriteBilledTokens != 6.25 {
t.Fatalf("usage cache write = raw %d billed %v, want 5/6.25", usage.CacheWriteTokens, usage.CacheWriteBilledTokens)
}
}
// TestReadStreamError surfaces a mid-stream error event as a ChunkError.
func TestReadStreamError(t *testing.T) {
sse := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n"
-34
View File
@@ -1,34 +0,0 @@
package provider
import (
"context"
"time"
)
type cacheGapHintKey struct{}
// WithCacheGapHint attaches how long it has been since this Agent last sent a
// provider request, so a provider with an opt-in longer cache TTL (Anthropic's
// 1h ephemeral breakpoint, versus its cheaper default 5m one) can decide
// whether the default window would likely already have expired. Mirrors the
// WithRetryNotify/WithRequestAttemptCounter context-key idiom in retry.go: a
// side channel for per-request signals that must never enter the request body
// or the cacheable prefix itself.
func WithCacheGapHint(ctx context.Context, gap time.Duration) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, cacheGapHintKey{}, gap)
}
// CacheGapHintFromContext returns the gap since the previous request and
// whether one was set. No value — the first request of an Agent's lifetime, a
// fresh sub-agent session, or a caller that never wired the hint — means
// default to the provider's cheaper, shorter cache TTL.
func CacheGapHintFromContext(ctx context.Context) (time.Duration, bool) {
if ctx == nil {
return 0, false
}
gap, ok := ctx.Value(cacheGapHintKey{}).(time.Duration)
return gap, ok
}
+2
View File
@@ -630,6 +630,8 @@ func mergeUsage(total, next *provider.Usage, countRequests bool) *provider.Usage
total.TotalTokens += next.TotalTokens
total.CacheHitTokens += next.CacheHitTokens
total.CacheMissTokens += next.CacheMissTokens
total.CacheWriteTokens += next.CacheWriteTokens
total.CacheWriteBilledTokens += next.CacheWriteBilledTokens
total.ReasoningTokens += next.ReasoningTokens
if countRequests {
total.RequestCount = totalRequests + nextRequests
+5 -2
View File
@@ -76,12 +76,15 @@ func TestStreamRetriesThenSucceeds(t *testing.T) {
}
func TestMergeUsageCountsStreamsNotUsageChunks(t *testing.T) {
firstChunk := &provider.Usage{PromptTokens: 2, TotalTokens: 2, RequestCount: 2}
secondChunk := &provider.Usage{CompletionTokens: 1, TotalTokens: 1, RequestCount: 2}
firstChunk := &provider.Usage{PromptTokens: 2, TotalTokens: 2, RequestCount: 2, CacheWriteTokens: 2, CacheWriteBilledTokens: 2.5}
secondChunk := &provider.Usage{CompletionTokens: 1, TotalTokens: 1, RequestCount: 2, CacheWriteTokens: 3, CacheWriteBilledTokens: 6}
oneStream := mergeUsage(firstChunk, secondChunk, false)
if oneStream.RequestCount != 2 {
t.Fatalf("same-stream request count = %d, want 2", oneStream.RequestCount)
}
if oneStream.CacheWriteTokens != 5 || oneStream.CacheWriteBilledTokens != 8.5 {
t.Fatalf("same-stream cache writes = raw %d billed %v, want 5/8.5", oneStream.CacheWriteTokens, oneStream.CacheWriteBilledTokens)
}
nextStream := &provider.Usage{PromptTokens: 3, TotalTokens: 3, RequestCount: 1}
combined := mergeUsage(oneStream, nextStream, true)
if combined.RequestCount != 3 {
+33 -9
View File
@@ -687,14 +687,16 @@ const (
// Estimated marks counts reconstructed locally because the provider's terminal
// usage record did not arrive; exact provider usage leaves it false.
type Usage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CacheHitTokens int // prompt tokens served from cache
CacheMissTokens int // prompt tokens not cached
ReasoningTokens int // subset of CompletionTokens spent on chain-of-thought
FinishReason string // "stop", "tool_calls", "length", "content_filter", "repetition_truncation", …
Estimated bool
PromptTokens int
CompletionTokens int
TotalTokens int
CacheHitTokens int // prompt tokens served from cache
CacheMissTokens int // prompt tokens not cached, including CacheWriteTokens
CacheWriteTokens int // subset of CacheMissTokens used to create provider cache entries
CacheWriteBilledTokens float64 // cache-write charge expressed in ordinary input-token equivalents
ReasoningTokens int // subset of CompletionTokens spent on chain-of-thought
FinishReason string // "stop", "tool_calls", "length", "content_filter", "repetition_truncation", …
Estimated bool
// RequestCount is the number of provider requests represented by this
// aggregate. Zero means one request for backward compatibility. Recovery
// paths that merge multiple attempts set the exact count.
@@ -757,8 +759,30 @@ func (p *Pricing) Cost(u *Usage) float64 {
} else if miss == 0 && hit > 0 && u.PromptTokens > hit {
miss = u.PromptTokens - hit
}
// CacheMissTokens intentionally remains the raw prompt-token denominator
// used by cache hit-rate displays, so cache writes are included there. For
// cost, split those writes back out and replace them with their provider-
// supplied input-token equivalent (for example Anthropic's 1.25x 5-minute
// writes or 2x 1-hour writes). Older providers leave both fields at zero and
// keep the legacy one-input-rate behavior. A write count without billed
// units also falls back to 1x for backward compatibility.
write := u.CacheWriteTokens
if write < 0 {
write = 0
}
if write > miss {
write = miss
}
billedWrite := 0.0
if write > 0 {
billedWrite = u.CacheWriteBilledTokens
if billedWrite <= 0 {
billedWrite = float64(write)
}
}
inputTokenUnits := float64(miss-write) + billedWrite
return (float64(hit)*p.CacheHit +
float64(miss)*p.Input +
inputTokenUnits*p.Input +
float64(u.CompletionTokens)*p.Output) / 1e6
}
+28
View File
@@ -433,6 +433,34 @@ func TestPricingCostCalculation(t *testing.T) {
}
}
func TestPricingCostUsesCacheWriteBillingTier(t *testing.T) {
p := &Pricing{Input: 2.0}
u := &Usage{
CacheMissTokens: 500_000,
CacheWriteTokens: 100_000,
CacheWriteBilledTokens: 200_000, // 1h write at 2x input
}
// 400K ordinary misses + 100K cache writes billed as 200K input units.
if got := p.Cost(u); got != 1.2 {
t.Errorf("Cost = %f, want 1.2", got)
}
}
func TestPricingCostCacheWriteFieldsAreBackwardCompatible(t *testing.T) {
p := &Pricing{Input: 2.0}
// Old usage records have neither cache-write field and retain the original
// one-input-rate calculation.
if got := p.Cost(&Usage{CacheMissTokens: 500_000}); got != 1.0 {
t.Errorf("legacy Cost = %f, want 1.0", got)
}
// A producer that reports raw write tokens without a billing tier also
// falls back to the ordinary input rate instead of making writes free.
if got := p.Cost(&Usage{CacheMissTokens: 500_000, CacheWriteTokens: 100_000}); got != 1.0 {
t.Errorf("unpriced write Cost = %f, want 1.0", got)
}
}
func TestPricingCostFallsBackToPromptTokensAsMiss(t *testing.T) {
p := &Pricing{Input: 2.0, Output: 10.0}
u := &Usage{PromptTokens: 500_000, CompletionTokens: 100_000}