diff --git a/desktop/frontend-next/src/i18n/msg/session.ts b/desktop/frontend-next/src/i18n/msg/session.ts index 17b9a8f0a1..f5deecd231 100644 --- a/desktop/frontend-next/src/i18n/msg/session.ts +++ b/desktop/frontend-next/src/i18n/msg/session.ts @@ -79,6 +79,11 @@ export const SESSION: Messages = { "session.hit_hit_miss": { zh: "命中 ÷ (命中 + 未命中)", en: "hit ÷ (hit + miss)" }, "session.hit_hit_miss_leaves_out": { zh: "命中 ÷ (命中 + 未命中);不含服务商未区分的 {n} tokens", en: "hit ÷ (hit + miss); leaves out {n} tokens the provider did not split" }, "session.each_sub_agents_write_scope": { zh: "子代理同时改文件时各自能写的范围", en: "What each sub-agent may write when several change files at once" }, + "session.leaves_out_the_cold_start": { zh: "不含首个主代理请求(冷启动)", en: "Leaves out the first main-agent request (the cold start)" }, + "session.leaves_out_the_cold_start_n": { zh: "不含首个主代理请求(冷启动,{n} tokens)", en: "Leaves out the first main-agent request (the cold start, {n} tokens)" }, + "session.output_input": { zh: "输出 ÷ 输入", en: "output ÷ input" }, + "session.output_ratio": { zh: "输出比", en: "Output ratio" }, + "session.warm_cache_hit": { zh: "扣除冷启动后命中", en: "Hit after cold start" }, "session.in_a_out_b": { zh: "输入 {a} · 输出 {b}", en: "In {a} · out {b}" }, "session.keep_it_short_and_recognizable": { zh: "保持简短且易于识别", en: "Keep it short and recognizable" }, "session.live_of_them_are_running": { zh: "其中 {live} 个对话正在运行,停止后才能移除", en: "{live} of them are running; stop them before removing" }, diff --git a/desktop/frontend-next/src/port/session.ts b/desktop/frontend-next/src/port/session.ts index 821749ec9d..9dfdfdefc1 100644 --- a/desktop/frontend-next/src/port/session.ts +++ b/desktop/frontend-next/src/port/session.ts @@ -399,4 +399,14 @@ export interface SessionUsage { cacheUnknown?: number; workMs: number; sources: SourceUsage[]; + // The first executor request in these totals, which it is also in. + coldStart?: ColdStartUsage; +} + +// The session's opening executor request, split the way the ledger split it. +export interface ColdStartUsage { + promptTokens: number; + cacheHit: number; + cacheMiss: number; + cacheUnknown?: number; } diff --git a/desktop/frontend-next/src/state/overview.ts b/desktop/frontend-next/src/state/overview.ts index 048e7f9710..8684e81c93 100644 --- a/desktop/frontend-next/src/state/overview.ts +++ b/desktop/frontend-next/src/state/overview.ts @@ -14,6 +14,20 @@ export function cacheRate(hit: number, miss: number, unknown = 0): number | null return Math.min(1, hit / known); } +/** The session rate with its cold start taken out of hit, miss and unknown alike: + * the first executor request pays for the prefix every later one reuses. Null + * until a request after it split its prompt. */ +export function warmCacheRate(u: Pick): number | null { + const c = u.coldStart; + if (!c) return null; + return cacheRate(u.cacheHit - c.cacheHit, u.cacheMiss - c.cacheMiss, (u.cacheUnknown ?? 0) - (c.cacheUnknown ?? 0)); +} + +/** output ÷ input tokens over the same requests. Null with no input. */ +export function outputRatio(u: Pick): number | null { + return u.promptTokens > 0 ? u.completionTokens / u.promptTokens : null; +} + export type CostSay = | { kind: "exact"; amount: number; currency: string } | { kind: "atLeast"; amount: number; currency: string } diff --git a/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx b/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx index ace7962d25..bce1686cc5 100644 --- a/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/MeterRail.overview.interaction.test.tsx @@ -93,6 +93,34 @@ describe("the session overview behind the cache meter", () => { expect(button().textContent).toContain("99%"); }); + it("states the hit rate with the cold start left out of hit, miss and unknown alike", () => { + rail({ usage: { ...usage, coldStart: { promptTokens: 1000, cacheHit: 800, cacheMiss: 200 } } }); + const row = within(card()).getByText("扣除冷启动后命中").closest("div")!; + expect(row.textContent).toContain("75%"); + expect(row.textContent).toContain("不含首个主代理请求(冷启动,1k tokens)"); + expect(within(card()).getByText("缓存命中").closest("div")!.textContent).toContain("76%"); + expect(button().textContent).toContain("76%"); + }); + + it("owes no warm rate before a cold start is known, or before a request follows it", () => { + rail({ usage: { ...usage, coldStart: undefined } }); + expect(within(card()).getByText("扣除冷启动后命中").closest("div")!.textContent).toContain("—"); + cleanup(); + const only = { promptTokens: 3000, cacheHit: 2300, cacheMiss: 700 }; + rail({ usage: { ...usage, ...only, cacheUnknown: 0, completionTokens: 10, requests: 1, coldStart: only } }); + expect(within(card()).getByText("扣除冷启动后命中").closest("div")!.textContent).toContain("—"); + }); + + it("states output over input for the same requests", () => { + rail(); + const row = within(card()).getByText("输出比").closest("div")!; + expect(row.textContent).toContain("5.6%"); + expect(row.textContent).toContain("输出 ÷ 输入"); + cleanup(); + rail({ usage: { ...usage, promptTokens: 0, completionTokens: 0 } }); + expect(within(card()).getByText("输出比").closest("div")!.textContent).toContain("—"); + }); + it("lists each source with its own cache and cost, and an unpriced one as unknown", () => { rail(); const lines = [...card().querySelectorAll(".studio-overview-row")]; diff --git a/desktop/frontend-next/src/ui/SessionOverviewCard.tsx b/desktop/frontend-next/src/ui/SessionOverviewCard.tsx index 1ea8d07204..e1dccb9ca8 100644 --- a/desktop/frontend-next/src/ui/SessionOverviewCard.tsx +++ b/desktop/frontend-next/src/ui/SessionOverviewCard.tsx @@ -2,11 +2,12 @@ import { t } from "../i18n"; import { money } from "../i18n/format"; import type { SessionUsage } from "../port/session"; import type { CostQuote } from "../port/wire"; -import { cacheRate, costSay, sessionTokens, sourceTokens, type CostSay } from "../state/overview"; +import { cacheRate, costSay, outputRatio, sessionTokens, sourceTokens, warmCacheRate, type CostSay } from "../state/overview"; import { MASK } from "./wallet"; const tokens = (n: number): string => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 100) / 10}k` : String(n)); const percent = (r: number | null): string => (r === null ? "—" : `${Math.floor(r * 100)}%`); +const fine = (r: number | null): string => (r === null ? "—" : `${(r * 100).toFixed(1)}%`); function clock(ms: number): string { if (ms <= 0) return "—"; @@ -78,6 +79,11 @@ export function SessionOverviewCard({ usage, quote, hideAmounts, onContext, clas
{t("common.cache_hit")}
{percent(cacheRate(usage.cacheHit, usage.cacheMiss, unknown))}
{unknown > 0 ? t("session.hit_hit_miss_leaves_out", { n: tokens(unknown) }) : t("session.hit_hit_miss")} +
+
{t("session.warm_cache_hit")}
{percent(warmCacheRate(usage))}
+ {usage.coldStart ? t("session.leaves_out_the_cold_start_n", { n: tokens(usage.coldStart.promptTokens) }) : t("session.leaves_out_the_cold_start")} +
+
{t("session.output_ratio")}
{fine(outputRatio(usage))}
{t("session.output_input")}
{sources.length > 0 && (
diff --git a/internal/base/i18n/i18n.go b/internal/base/i18n/i18n.go index 6f0b9fb627..f1662ae9ad 100644 --- a/internal/base/i18n/i18n.go +++ b/internal/base/i18n/i18n.go @@ -175,6 +175,7 @@ type Messages struct { QueueMessages ResumePickerMessages GoalMessages + UsageMessages SettingMessages ApprovalNeededFmt string // notification text for a pending approval, tool only ApprovalNeededWithSubjectFmt string // notification text for a pending approval with subject diff --git a/internal/base/i18n/messages_en.go b/internal/base/i18n/messages_en.go index 03aa70bbe8..b8594cd580 100644 --- a/internal/base/i18n/messages_en.go +++ b/internal/base/i18n/messages_en.go @@ -201,6 +201,7 @@ var English = Messages{ QueueMessages: queueEN, ResumePickerMessages: resumePickerEN, GoalMessages: goalEN, + UsageMessages: usageEN, SettingMessages: settingEN, ApprovalNeededFmt: "approval needed: %s", ApprovalNeededWithSubjectFmt: "approval needed: %s %s", diff --git a/internal/base/i18n/messages_zh.go b/internal/base/i18n/messages_zh.go index 63211c3ad1..8812d63e07 100644 --- a/internal/base/i18n/messages_zh.go +++ b/internal/base/i18n/messages_zh.go @@ -201,6 +201,7 @@ var Chinese = Messages{ QueueMessages: queueZH, ResumePickerMessages: resumePickerZH, GoalMessages: goalZH, + UsageMessages: usageZH, SettingMessages: settingZH, ApprovalNeededFmt: "需要审批:%s", ApprovalNeededWithSubjectFmt: "需要审批:%s %s", diff --git a/internal/base/i18n/messages_zh_tw.go b/internal/base/i18n/messages_zh_tw.go index 2bdd9ac7f5..840cf99541 100644 --- a/internal/base/i18n/messages_zh_tw.go +++ b/internal/base/i18n/messages_zh_tw.go @@ -202,6 +202,7 @@ var ChineseTraditional = Messages{ QueueMessages: queueZHTW, ResumePickerMessages: resumePickerZHTW, GoalMessages: goalZHTW, + UsageMessages: usageZHTW, SettingMessages: settingZHTW, ApprovalNeededFmt: "需要核准:%s", ApprovalNeededWithSubjectFmt: "需要核准:%s %s", diff --git a/internal/base/i18n/usage_catalog.go b/internal/base/i18n/usage_catalog.go new file mode 100644 index 0000000000..10474d8352 --- /dev/null +++ b/internal/base/i18n/usage_catalog.go @@ -0,0 +1,7 @@ +package i18n + +var usageEN = UsageMessages{ChatStatusCacheWarmFmt: "%s after the cold start", ChatStatusOutputRatioFmt: "%s (out %s / in %s)"} + +var usageZH = UsageMessages{ChatStatusCacheWarmFmt: "扣除冷启动后 %s", ChatStatusOutputRatioFmt: "%s(输出 %s / 输入 %s)"} + +var usageZHTW = UsageMessages{ChatStatusCacheWarmFmt: "扣除冷啟動後 %s", ChatStatusOutputRatioFmt: "%s(輸出 %s / 輸入 %s)"} diff --git a/internal/base/i18n/usage_messages.go b/internal/base/i18n/usage_messages.go new file mode 100644 index 0000000000..63fd558f0d --- /dev/null +++ b/internal/base/i18n/usage_messages.go @@ -0,0 +1,7 @@ +package i18n + +// UsageMessages is how /status words the session's ledger figures; Messages embeds it. +type UsageMessages struct { + ChatStatusCacheWarmFmt string // ledger hit rate with the cold start left out + ChatStatusOutputRatioFmt string // output ÷ input percent, output tokens, input tokens +} diff --git a/internal/contract/pricing/ledger.go b/internal/contract/pricing/ledger.go index 0a477fc977..8f26d7508c 100644 --- a/internal/contract/pricing/ledger.go +++ b/internal/contract/pricing/ledger.go @@ -57,6 +57,18 @@ func NewLedger() *Ledger { return &Ledger{Version: LedgerVersion, Entries: map[string]LedgerEntry{}} } +// LedgerSource is the usage source a ledger files q under. A quote that names +// none is the executor's: the field postdates it. +func LedgerSource(q CostQuote) string { + if q.UsageSource != "" { + return q.UsageSource + } + return LedgerSourceExecutor +} + +// LedgerSourceExecutor is the main agent's source, the one a session opens on. +const LedgerSourceExecutor = "executor" + // Add merges a quote into the ledger under its natural key. func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) { if l == nil { @@ -72,10 +84,7 @@ func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) { occurred = time.Now().UTC() } q = NormalizeQuote(q) - source := q.UsageSource - if source == "" { - source = "executor" - } + source := LedgerSource(q) key := LedgerKey(q.ModelRef, source, q.PricingFingerprint, q.RateDate) ent, ok := l.Entries[key] if !ok { @@ -151,13 +160,10 @@ func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) { ent.PromptTokens += tokens.PromptTokens ent.CompletionTokens += tokens.CompletionTokens ent.TotalTokens += tokens.PromptTokens + tokens.CompletionTokens - if tokens.CacheHitTokens+tokens.CacheMissTokens > 0 { - ent.CacheHitTokens += tokens.CacheHitTokens - ent.CacheMissTokens += tokens.CacheMissTokens - } else { - ent.CacheMissTokens += tokens.PromptTokens - ent.CacheUnknownTokens += tokens.PromptTokens - } + hit, miss, unknown := tokens.CacheSplit() + ent.CacheHitTokens += hit + ent.CacheMissTokens += miss + ent.CacheUnknownTokens += unknown ent.RequestCount++ if occurred.After(ent.OccurredAt) { ent.OccurredAt = occurred diff --git a/internal/contract/pricing/quote.go b/internal/contract/pricing/quote.go index e5e0e8dc4b..5f0de8049a 100644 --- a/internal/contract/pricing/quote.go +++ b/internal/contract/pricing/quote.go @@ -146,6 +146,16 @@ type UsageTokens struct { Estimated bool } +// CacheSplit is how a ledger counts one request's prompt: a request the +// endpoint did not split bills its whole prompt as a miss, and reports it as +// unknown too so a hit rate can leave it out. +func (t UsageTokens) CacheSplit() (hit, miss, unknown int) { + if t.CacheHitTokens+t.CacheMissTokens > 0 { + return t.CacheHitTokens, t.CacheMissTokens, 0 + } + return 0, t.PromptTokens, t.PromptTokens +} + // QuoteInput drives a single CostQuote computation. type QuoteInput struct { Usage UsageTokens diff --git a/internal/frontend/serve/broadcaster.go b/internal/frontend/serve/broadcaster.go index 2cd4f8f173..0fad06673f 100644 --- a/internal/frontend/serve/broadcaster.go +++ b/internal/frontend/serve/broadcaster.go @@ -22,6 +22,7 @@ type Broadcaster struct { ledger *pricing.Ledger displayCurrency string workMs int64 + coldStart *ColdStartUsage // The transport's, not the session's: numbering outlives /new and /resume, so // resuming across one is told to rebuild rather than handed another // conversation's frames under numbers it already has. @@ -85,6 +86,7 @@ func (b *Broadcaster) ResetSession() { b.mu.Lock() b.ledger = pricing.NewLedger() b.workMs = 0 + b.coldStart = nil b.replay.reset() b.initialNoticesReplayed = false b.mu.Unlock() @@ -150,12 +152,16 @@ func (b *Broadcaster) Emit(e event.Event) { if b.ledger == nil { b.ledger = pricing.NewLedger() } - b.ledger.Add(*e.CostQuote, pricing.UsageTokens{ + tokens := pricing.UsageTokens{ PromptTokens: e.Usage.PromptTokens, CompletionTokens: e.Usage.CompletionTokens, CacheHitTokens: e.Usage.CacheHitTokens, CacheMissTokens: e.Usage.CacheMissTokens, CacheWriteTokens: e.Usage.CacheWriteTokens, CacheWriteBilledTokens: e.Usage.CacheWriteBilledTokens, Estimated: e.Usage.Estimated, - }, time.Now().UTC()) + } + b.ledger.Add(*e.CostQuote, tokens, time.Now().UTC()) + if b.coldStart == nil && pricing.LedgerSource(*e.CostQuote) == pricing.LedgerSourceExecutor { + b.coldStart = coldStartOf(tokens) + } } for s := range b.subs { s.push(Frame{Seq: w.Seq, Data: data}, drop) diff --git a/internal/frontend/serve/session_usage.go b/internal/frontend/serve/session_usage.go index a0ace8fd0f..bc200c7c9d 100644 --- a/internal/frontend/serve/session_usage.go +++ b/internal/frontend/serve/session_usage.go @@ -35,6 +35,24 @@ type SessionUsage struct { CacheUnknown int `json:"cacheUnknown,omitempty"` WorkMs int64 `json:"workMs"` Sources []SourceUsage `json:"sources"` + // ColdStart is the first executor request in these totals, which it is also + // in; subtracting it gives the rate once the prefix it paid for was warm. + ColdStart *ColdStartUsage `json:"coldStart,omitempty"` +} + +// ColdStartUsage is the session's opening executor request, split the way the +// ledger split it. It pays for the prefix every later request reuses, so it is +// the one request a warm hit rate leaves out. +type ColdStartUsage struct { + PromptTokens int `json:"promptTokens"` + CacheHit int `json:"cacheHit"` + CacheMiss int `json:"cacheMiss"` + CacheUnknown int `json:"cacheUnknown,omitempty"` +} + +func coldStartOf(tokens pricing.UsageTokens) *ColdStartUsage { + hit, miss, unknown := tokens.CacheSplit() + return &ColdStartUsage{PromptTokens: tokens.PromptTokens, CacheHit: hit, CacheMiss: miss, CacheUnknown: unknown} } // SessionUsage totals the ledger by source. WorkMs is the answered work time of @@ -47,6 +65,10 @@ func (b *Broadcaster) SessionUsage() SessionUsage { b.mu.Lock() defer b.mu.Unlock() out.WorkMs = b.workMs + if b.coldStart != nil { + cold := *b.coldStart + out.ColdStart = &cold + } grouped := b.ledger.EntriesBySource() names := make([]string, 0, len(grouped)) for name := range grouped { diff --git a/internal/frontend/serve/session_usage_test.go b/internal/frontend/serve/session_usage_test.go index be38f8bd2c..886f44a828 100644 --- a/internal/frontend/serve/session_usage_test.go +++ b/internal/frontend/serve/session_usage_test.go @@ -63,3 +63,45 @@ func TestSessionUsageStartsAgainWhenTheSessionDoes(t *testing.T) { t.Fatalf("after a reset: %+v", got) } } + +// The cold start is the first request the ledger files under the executor: +// requests from other sources before it carry their own prefixes and are not it. +func TestSessionUsageColdStartIsTheLedgersFirstExecutorRequest(t *testing.T) { + bc := NewBroadcaster() + if got := bc.SessionUsage(); got.ColdStart != nil { + t.Fatalf("no request yet, cold start = %+v", got.ColdStart) + } + bc.Emit(usageEvent("title", 300, 10, 0, 300)) + bc.Emit(usageEvent("", 4000, 100, 1000, 3000)) + bc.Emit(usageEvent("executor", 5000, 50, 4900, 100)) + got := bc.SessionUsage() + want := ColdStartUsage{PromptTokens: 4000, CacheHit: 1000, CacheMiss: 3000} + if got.ColdStart == nil || *got.ColdStart != want { + t.Fatalf("cold start = %+v, want %+v", got.ColdStart, want) + } + if got.CacheHit-got.ColdStart.CacheHit != 4900 { + t.Fatalf("the cold start must be part of the totals it is subtracted from: %+v", got) + } +} + +func TestSessionUsageColdStartKeepsAnUnsplitPromptUnknown(t *testing.T) { + bc := NewBroadcaster() + bc.Emit(usageEvent("executor", 2000, 10, 0, 0)) + got := bc.SessionUsage().ColdStart + if got == nil || got.CacheMiss != 2000 || got.CacheUnknown != 2000 || got.CacheHit != 0 { + t.Fatalf("cold start = %+v, want the prompt billed as a miss and held as unknown", got) + } +} + +func TestSessionUsageColdStartStartsAgainWhenTheSessionDoes(t *testing.T) { + bc := NewBroadcaster() + bc.Emit(usageEvent("executor", 100, 10, 0, 100)) + bc.ResetSession() + if got := bc.SessionUsage().ColdStart; got != nil { + t.Fatalf("after a reset: %+v", got) + } + bc.Emit(usageEvent("executor", 900, 10, 800, 100)) + if got := bc.SessionUsage().ColdStart; got == nil || got.PromptTokens != 900 { + t.Fatalf("the next session's cold start = %+v", got) + } +} diff --git a/internal/frontend/tui/client.go b/internal/frontend/tui/client.go index 3c9b384a2f..e99bcda956 100644 --- a/internal/frontend/tui/client.go +++ b/internal/frontend/tui/client.go @@ -240,7 +240,25 @@ type Status struct { CacheHitTokens int CacheMissTokens int } `json:"lastUsage"` - SessionCostQuote *CostQuote `json:"sessionCostQuote"` + SessionCostQuote *CostQuote `json:"sessionCostQuote"` + SessionUsage *SessionUsage `json:"sessionUsage"` +} + +// SessionUsage is the slice of the kernel's ledger totals /status reports. +type SessionUsage struct { + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + CacheHit int `json:"cacheHit"` + CacheMiss int `json:"cacheMiss"` + CacheUnknown int `json:"cacheUnknown"` + ColdStart *ColdStartUsage `json:"coldStart"` +} + +// ColdStartUsage is the session's opening executor request, already in the totals. +type ColdStartUsage struct { + CacheHit int `json:"cacheHit"` + CacheMiss int `json:"cacheMiss"` + CacheUnknown int `json:"cacheUnknown"` } // CostQuote is the session's spend as the kernel priced it. diff --git a/internal/frontend/tui/queue_slash_test.go b/internal/frontend/tui/queue_slash_test.go index 25686bd3ca..e21002d2a2 100644 --- a/internal/frontend/tui/queue_slash_test.go +++ b/internal/frontend/tui/queue_slash_test.go @@ -150,6 +150,40 @@ func TestStatusReportsTheFieldsTheFooterCompresses(t *testing.T) { } } +// Both rows read the ledger /status sends: the warm rate leaves the cold start +// out of hit and of the split prompt, and the ratio is output over input. +func TestStatusReportsTheWarmCacheRateAndTheOutputRatio(t *testing.T) { + m, _ := queueModel(t, map[string]any{ + "GET /status": map[string]any{ + "label": "deepseek-chat", "cacheHit": 0, "cacheMiss": 0, + "sessionUsage": map[string]any{ + "promptTokens": 20000, "completionTokens": 500, "cacheHit": 13000, "cacheMiss": 7000, "cacheUnknown": 2000, + "coldStart": map[string]any{"promptTokens": 5000, "cacheHit": 1000, "cacheMiss": 4000}, + }, + }, + }) + queueSend(m, "/status") + got := queueLastNotice(m) + for _, want := range []string{"warm cache 92.31% after the cold start", "out/in 2.5% (out 500 / in 20.0K)"} { + if !strings.Contains(got, want) { + t.Fatalf("status lacks %q:\n%s", want, got) + } + } +} + +func TestStatusOwesNoWarmRateUntilARequestFollowsTheColdStart(t *testing.T) { + m, _ := queueModel(t, map[string]any{ + "GET /status": map[string]any{ + "label": "deepseek-chat", + "sessionUsage": map[string]any{"promptTokens": 5000, "cacheHit": 1000, "cacheMiss": 4000, "coldStart": map[string]any{"promptTokens": 5000, "cacheHit": 1000, "cacheMiss": 4000}}, + }, + }) + queueSend(m, "/status") + if got := queueLastNotice(m); strings.Contains(got, "warm cache") { + t.Fatalf("a session of one request has no warm rate:\n%s", got) + } +} + // The kernel owns where the file goes; this screen only reports it. func TestExportAsksTheKernelAndReportsItsPath(t *testing.T) { m, k := queueModel(t, map[string]any{ diff --git a/internal/frontend/tui/status_cmd.go b/internal/frontend/tui/status_cmd.go index 56631dbf20..792d35f2ba 100644 --- a/internal/frontend/tui/status_cmd.go +++ b/internal/frontend/tui/status_cmd.go @@ -34,6 +34,13 @@ func (m *model) statusDetails() string { if body, _, ok := cacheStatus(s); ok { lines = append(lines, " cache "+body) } + if rate, ok := warmCacheRate(s.SessionUsage); ok { + lines = append(lines, " warm cache "+fmt.Sprintf(i18n.M.ChatStatusCacheWarmFmt, fmt.Sprintf("%.2f%%", rate))) + } + if u := s.SessionUsage; u != nil && u.PromptTokens > 0 { + ratio := fmt.Sprintf("%.1f%%", pct(u.CompletionTokens, u.PromptTokens)) + lines = append(lines, " out/in "+fmt.Sprintf(i18n.M.ChatStatusOutputRatioFmt, ratio, shortTokens(u.CompletionTokens), shortTokens(u.PromptTokens))) + } if git := m.gitText(); git != "" { lines = append(lines, " git "+git) } @@ -49,6 +56,22 @@ func (m *model) statusDetails() string { return strings.Join(append(lines, " config "+activeConfigTag()), "\n") } +// warmCacheRate is the ledger's hit rate with its cold start taken out of both +// sides: hit over the prompt the endpoint split, the same rate the desktop's +// session overview states. Not owed until a request after the cold start split. +func warmCacheRate(u *SessionUsage) (float64, bool) { + if u == nil || u.ColdStart == nil { + return 0, false + } + c := u.ColdStart + hit := u.CacheHit - c.CacheHit + known := hit + (u.CacheMiss - c.CacheMiss) - (u.CacheUnknown - c.CacheUnknown) + if known <= 0 { + return 0, false + } + return pct(min(hit, known), known), true +} + // modeText is the footer's mode badge without its colour. func (m *model) modeText() string { s := m.status diff --git a/tools/repolint/wireparity.go b/tools/repolint/wireparity.go index 69993f7e0c..f56a2e2ef3 100644 --- a/tools/repolint/wireparity.go +++ b/tools/repolint/wireparity.go @@ -122,6 +122,7 @@ var mirroredWireTypesHead = []wireMirror{ {"internal/frontend/serve/subagents.go", "subagentRunView", tsSubagentFile, "SubagentRun"}, {"internal/frontend/serve/subagents.go", "subagentTranscriptView", tsSubagentFile, "SubagentTranscript"}, {"internal/frontend/serve/session_usage.go", "SourceUsage", tsSessionFile, "SourceUsage"}, + {"internal/frontend/serve/session_usage.go", "ColdStartUsage", tsSessionFile, "ColdStartUsage"}, // The agent's tabs. The target id is what the window draws a view by, so a // tab the page cannot read one for is a tab it cannot show. {"internal/platform/browser/session.go", "TabInfo", tsSessionFile, "BrowserTab"},