Skip to content

Commit b35af71

Browse files
authored
fix(llm): parse DeepSeek cache metrics + honest 'n/a' when unreported (#95)
The Telegram stats line showed 'cache: 0 write / 0 read / 0 total' on every turn. Two issues: 1. odek parsed Anthropic (cache_creation/read_input_tokens) and OpenAI (prompt_tokens_details.cached_tokens) cache fields, but not DeepSeek's native prompt_cache_hit_tokens / prompt_cache_miss_tokens, which many DeepSeek endpoints and gateways return INSTEAD of the OpenAI-style details. DeepSeek hits now map to cache read, misses to cache write (DeepSeek caches missed prefixes automatically). 2. Zeros were displayed even when the provider returned NO cache metrics at all, falsely implying caching ran and missed. A new CacheReported flag (llm.CallResult -> loop totals -> IterationInfo) distinguishes 'no data' from '0 tokens', and the Telegram stats line now shows 'cache: n/a (provider reports no cache metrics)' instead of fake zeros.
1 parent 7b67002 commit b35af71

5 files changed

Lines changed: 93 additions & 6 deletions

File tree

cmd/odek/telegram.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,9 +2138,16 @@ func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
21382138
iters += "s"
21392139
}
21402140

2141-
// Always include cache stats so the user can see them even when zero.
2142-
cacheStr := fmt.Sprintf(" · cache: %d write / %d read / %d total",
2143-
info.CacheCreationTokens, info.CacheReadTokens, info.CachedTokens)
2141+
// Show real numbers when the provider reports cache metrics; otherwise
2142+
// say so — "0 write / 0 read" would wrongly imply caching ran and
2143+
// missed, when in fact the provider returned no data at all.
2144+
var cacheStr string
2145+
if info.CacheReported {
2146+
cacheStr = fmt.Sprintf(" · cache: %d write / %d read / %d total",
2147+
info.CacheCreationTokens, info.CacheReadTokens, info.CachedTokens)
2148+
} else {
2149+
cacheStr = " · cache: n/a (provider reports no cache metrics)"
2150+
}
21442151

21452152
return fmt.Sprintf(
21462153
"```\n✅ Done · %s · %d in / %d out%s · %s — tools: %s\n```",

cmd/odek/telegram_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,7 @@ func TestFormatTelegramStats(t *testing.T) {
11631163
CacheCreationTokens: 10,
11641164
CacheReadTokens: 20,
11651165
CachedTokens: 30,
1166+
CacheReported: true,
11661167
TotalLatency: 5*time.Second + 500*time.Millisecond,
11671168
}
11681169
out := formatTelegramStats(info, []string{"read_file", "shell"})
@@ -1189,6 +1190,18 @@ func TestFormatTelegramStats_SingularTurn(t *testing.T) {
11891190
}
11901191
}
11911192

1193+
// TestFormatTelegramStats_CacheNotReported verifies the honest display when
1194+
// the provider returned no cache metrics at all (vs. real zeros).
1195+
func TestFormatTelegramStats_CacheNotReported(t *testing.T) {
1196+
out := formatTelegramStats(loop.IterationInfo{Turn: 1}, nil)
1197+
if !strings.Contains(out, "cache: n/a") {
1198+
t.Errorf("expected cache n/a when provider reports nothing, got: %s", out)
1199+
}
1200+
if strings.Contains(out, "write") || strings.Contains(out, "read /") {
1201+
t.Errorf("must not show fake zero cache numbers when unreported: %s", out)
1202+
}
1203+
}
1204+
11921205
// TestFormatStopSummary verifies the /stop summary formatting.
11931206
func TestFormatStopSummary(t *testing.T) {
11941207
info := loop.IterationInfo{

internal/llm/client.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,13 @@ type CallResult struct {
135135
// Cache metrics. Only populated when the provider returns them.
136136
// Anthropic: cache_creation_input_tokens, cache_read_input_tokens
137137
// OpenAI: prompt_tokens_details.cached_tokens
138+
// DeepSeek: prompt_cache_hit_tokens (read), prompt_cache_miss_tokens (write)
138139
CacheCreationTokens int // Anthropic — tokens written to cache
139140
CacheReadTokens int // Anthropic — tokens read from cache hit
140141
CachedTokens int // OpenAI — cached tokens in prompt
142+
// CacheReported is true when the provider returned any cache metrics at
143+
// all; false means "no data", which is different from "0 tokens cached".
144+
CacheReported bool
141145
}
142146

143147
// toolChoiceNone forces the model to not call tools.
@@ -451,6 +455,11 @@ func parseResponse(data []byte) (*CallResult, error) {
451455
PromptTokensDetails *struct {
452456
CachedTokens int `json:"cached_tokens"`
453457
} `json:"prompt_tokens_details"`
458+
// DeepSeek native prompt caching (always present on DeepSeek
459+
// endpoints, unlike prompt_tokens_details which varies by
460+
// gateway/proxy).
461+
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
462+
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
454463
} `json:"usage"`
455464
}
456465
if err := json.Unmarshal(data, &raw); err != nil {
@@ -472,6 +481,18 @@ func parseResponse(data []byte) (*CallResult, error) {
472481
result.CacheReadTokens = raw.Usage.CacheReadTokens
473482
if raw.Usage.PromptTokensDetails != nil {
474483
result.CachedTokens = raw.Usage.PromptTokensDetails.CachedTokens
484+
result.CacheReported = true
485+
}
486+
if raw.Usage.CacheCreationTokens > 0 || raw.Usage.CacheReadTokens > 0 {
487+
result.CacheReported = true
488+
}
489+
// DeepSeek native fields: a hit is prompt content read from cache;
490+
// a miss is newly processed content that DeepSeek then caches for
491+
// future requests, i.e. a cache write.
492+
if raw.Usage.PromptCacheHitTokens > 0 || raw.Usage.PromptCacheMissTokens > 0 {
493+
result.CacheReadTokens += raw.Usage.PromptCacheHitTokens
494+
result.CacheCreationTokens += raw.Usage.PromptCacheMissTokens
495+
result.CacheReported = true
475496
}
476497
}
477498
for _, tc := range msg.ToolCalls {

internal/llm/client_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,45 @@ func TestParseResponse_OpenAICacheMetrics(t *testing.T) {
787787
}
788788
}
789789

790+
func TestParseResponse_DeepSeekCacheMetrics(t *testing.T) {
791+
raw := `{
792+
"choices": [{"message": {"content": "deepseek response"}}],
793+
"usage": {
794+
"prompt_tokens": 1000,
795+
"completion_tokens": 40,
796+
"prompt_cache_hit_tokens": 750,
797+
"prompt_cache_miss_tokens": 250
798+
}
799+
}`
800+
result, err := parseResponse([]byte(raw))
801+
if err != nil {
802+
t.Fatal(err)
803+
}
804+
if result.CacheReadTokens != 750 {
805+
t.Errorf("CacheReadTokens = %d, want 750 (deepseek hit)", result.CacheReadTokens)
806+
}
807+
if result.CacheCreationTokens != 250 {
808+
t.Errorf("CacheCreationTokens = %d, want 250 (deepseek miss)", result.CacheCreationTokens)
809+
}
810+
if !result.CacheReported {
811+
t.Error("CacheReported should be true when deepseek cache fields are present")
812+
}
813+
}
814+
815+
func TestParseResponse_CacheNotReported(t *testing.T) {
816+
raw := `{
817+
"choices": [{"message": {"content": "plain response"}}],
818+
"usage": {"prompt_tokens": 100, "completion_tokens": 10}
819+
}`
820+
result, err := parseResponse([]byte(raw))
821+
if err != nil {
822+
t.Fatal(err)
823+
}
824+
if result.CacheReported {
825+
t.Error("CacheReported should be false when no cache fields are present")
826+
}
827+
}
828+
790829
func TestApplyCacheMarkers_WithSystemPrompt(t *testing.T) {
791830
messages := []Message{
792831
{Role: "system", Content: "You are a helpful assistant."},

internal/loop/loop.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ type IterationInfo struct {
9191
CacheCreationTokens int // cumulative cache creation tokens
9292
CacheReadTokens int // cumulative cache read tokens
9393
CachedTokens int // cumulative cached tokens (OpenAI)
94+
CacheReported bool // provider returned cache metrics at least once
9495
TotalLatency time.Duration // cumulative wall time
9596
HasFinalAnswer bool // true when the agent reached a final answer
9697
ReasoningContent string // LLM reasoning before tool calls (empty if none)
@@ -185,9 +186,10 @@ type Engine struct {
185186
TotalOutputTokens int
186187

187188
// Cache metrics accumulated across all iterations.
188-
TotalCacheCreationTokens int // Anthropic: tokens written to cache
189-
TotalCacheReadTokens int // Anthropic: tokens read from cache
190-
TotalCachedTokens int // OpenAI: cached prompt tokens
189+
TotalCacheCreationTokens int // Anthropic: tokens written to cache
190+
TotalCacheReadTokens int // Anthropic: tokens read from cache
191+
TotalCachedTokens int // OpenAI: cached prompt tokens
192+
TotalCacheReported bool // provider returned cache metrics at least once
191193
}
192194

193195
// New creates a new loop Engine.
@@ -587,6 +589,7 @@ func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (s
587589
e.TotalCacheCreationTokens = 0
588590
e.TotalCacheReadTokens = 0
589591
e.TotalCachedTokens = 0
592+
e.TotalCacheReported = false
590593
return e.runLoop(ctx, messages)
591594
}
592595

@@ -867,6 +870,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
867870
e.TotalCacheCreationTokens += result.CacheCreationTokens
868871
e.TotalCacheReadTokens += result.CacheReadTokens
869872
e.TotalCachedTokens += result.CachedTokens
873+
e.TotalCacheReported = e.TotalCacheReported || result.CacheReported
870874

871875
// No tool calls = final answer
872876
if len(result.ToolCalls) == 0 {
@@ -901,6 +905,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
901905
CacheCreationTokens: e.TotalCacheCreationTokens,
902906
CacheReadTokens: e.TotalCacheReadTokens,
903907
CachedTokens: e.TotalCachedTokens,
908+
CacheReported: e.TotalCacheReported,
904909
TotalLatency: time.Since(startTime),
905910
HasFinalAnswer: true,
906911
ReasoningContent: result.ReasoningContent,
@@ -955,6 +960,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
955960
CacheCreationTokens: e.TotalCacheCreationTokens,
956961
CacheReadTokens: e.TotalCacheReadTokens,
957962
CachedTokens: e.TotalCachedTokens,
963+
CacheReported: e.TotalCacheReported,
958964
TotalLatency: time.Since(startTime),
959965
HasFinalAnswer: false,
960966
ReasoningContent: result.ReasoningContent,
@@ -1244,6 +1250,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
12441250
CacheCreationTokens: e.TotalCacheCreationTokens,
12451251
CacheReadTokens: e.TotalCacheReadTokens,
12461252
CachedTokens: e.TotalCachedTokens,
1253+
CacheReported: e.TotalCacheReported,
12471254
TotalLatency: time.Since(startTime),
12481255
HasFinalAnswer: false,
12491256
})

0 commit comments

Comments
 (0)