detached 5d2b756

feat(provider): account for OpenAI cache usage

commit
5d2b75626f6decbad0b2e94d3f386b32f11d2d89
author
brice · · 2026-08-18 08:09 UTC
parent
29be61c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +343 -125
M packages/agent/modes/resident_subagent_indicator_test.go +1 -1
@@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
3131 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
3232 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
3333 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},
34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
3535 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
3636 }}, 100, now)
3737 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
@@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
35 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,35 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36 }}, 100, now)36 }}, 100, now)
37 got := strings.Join(plainResidentIndicatorLines(lines), "\n")37 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
M packages/agent/modes/resident_subagents_dialog_test.go +1 -1
@@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
3535 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
3636 journal.ConfigureUsage(272_000, true)
3737 return func(context.Context, string) error {
38- usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
3939 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
4040 }, nil
4141 })
@@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
35 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {35 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36 journal.ConfigureUsage(272_000, true)36 journal.ConfigureUsage(272_000, true)
37 return func(context.Context, string) error {37 return func(context.Context, string) error {
38- usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40 }, nil40 }, nil
41 })41 })
M packages/agent/subagent_factory.go +11 -2
@@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
4747 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
4848 agent.SeedCost(baseline.Usage)
4949 }
50- if err := agent.BindSessionID(spec.SessionID); err != nil {
51- return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)
50+ rootCacheID := strings.TrimSpace(spec.RootCacheID)
51+ if rootCacheID == "" {
52+ // Journals written before root cache identity was persisted can still
53+ // resume safely: their immediate parent was the only available root.
54+ rootCacheID = strings.TrimSpace(spec.ParentSessionID)
55+ if rootCacheID == "" {
56+ rootCacheID = spec.SessionID
57+ }
58+ }
59+ if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
60+ return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
5261 }
5362 if journal != nil {
5463 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
@@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
47 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")47 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48 agent.SeedCost(baseline.Usage)48 agent.SeedCost(baseline.Usage)
49 }49 }
50- if err := agent.BindSessionID(spec.SessionID); err != nil {50+ rootCacheID := strings.TrimSpace(spec.RootCacheID)
51- return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)51+ if rootCacheID == "" {
52+ // Journals written before root cache identity was persisted can still
53+ // resume safely: their immediate parent was the only available root.
54+ rootCacheID = strings.TrimSpace(spec.ParentSessionID)
55+ if rootCacheID == "" {
56+ rootCacheID = spec.SessionID
57+ }
58+ }
59+ if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
60+ return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
52 }61 }
53 if journal != nil {62 if journal != nil {
54 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())63 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
M packages/agent/subagent_runtime.go +1 -1
@@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
338338 workspaceMode = subagents.WorkspaceShared
339339 }
340340 spec := subagents.ResidentChildSpec{
341- ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,
341+ ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
342342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
344344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
@@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
338 workspaceMode = subagents.WorkspaceShared338 workspaceMode = subagents.WorkspaceShared
339 }339 }
340 spec := subagents.ResidentChildSpec{340 spec := subagents.ResidentChildSpec{
341- ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,341+ ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
M packages/agent/subagents/resident_journal.go +6 -2
@@ -65,8 +65,11 @@ const (
6565 // provider endpoint and TLS mode are retained so explicit resume reconstructs
6666 // the same transport contract.
6767 type ResidentChildSpec struct {
68- ID string `json:"id"`
68+ ID string `json:"id"`
69+ // SessionID identifies the child's conversation thread. RootCacheID is the
70+ // root cache affinity shared by every child of the same parent session.
6971 SessionID string `json:"session_id"`
72+ RootCacheID string `json:"root_cache_id,omitempty"`
7073 InitialTurnID string `json:"initial_turn_id,omitempty"`
7174 ParentSessionID string `json:"parent_session_id,omitempty"`
7275 Profile string `json:"profile,omitempty"`
@@ -193,6 +196,7 @@ type ResidentMetadata struct {
193196 Version int `json:"version"`
194197 ID string `json:"id"`
195198 SessionID string `json:"session_id"`
199+ RootCacheID string `json:"root_cache_id,omitempty"`
196200 ParentSessionID string `json:"parent_session_id,omitempty"`
197201 State ResidentState `json:"state"`
198202 UpdatedAt time.Time `json:"updated_at"`
@@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
491495 usage := j.usageSnapshot()
492496 return ResidentMetadata{
493497 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
498+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495499 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496500 }
497501 }
@@ -65,8 +65,11 @@ const (
65 // provider endpoint and TLS mode are retained so explicit resume reconstructs65 // provider endpoint and TLS mode are retained so explicit resume reconstructs
66 // the same transport contract.66 // the same transport contract.
67 type ResidentChildSpec struct {67 type ResidentChildSpec struct {
68- ID string `json:"id"`68+ ID string `json:"id"`
69+ // SessionID identifies the child's conversation thread. RootCacheID is the
70+ // root cache affinity shared by every child of the same parent session.
69 SessionID string `json:"session_id"`71 SessionID string `json:"session_id"`
72+ RootCacheID string `json:"root_cache_id,omitempty"`
70 InitialTurnID string `json:"initial_turn_id,omitempty"`73 InitialTurnID string `json:"initial_turn_id,omitempty"`
71 ParentSessionID string `json:"parent_session_id,omitempty"`74 ParentSessionID string `json:"parent_session_id,omitempty"`
72 Profile string `json:"profile,omitempty"`75 Profile string `json:"profile,omitempty"`
@@ -193,6 +196,7 @@ type ResidentMetadata struct {
193 Version int `json:"version"`196 Version int `json:"version"`
194 ID string `json:"id"`197 ID string `json:"id"`
195 SessionID string `json:"session_id"`198 SessionID string `json:"session_id"`
199+ RootCacheID string `json:"root_cache_id,omitempty"`
196 ParentSessionID string `json:"parent_session_id,omitempty"`200 ParentSessionID string `json:"parent_session_id,omitempty"`
197 State ResidentState `json:"state"`201 State ResidentState `json:"state"`
198 UpdatedAt time.Time `json:"updated_at"`202 UpdatedAt time.Time `json:"updated_at"`
@@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
491 usage := j.usageSnapshot()495 usage := j.usageSnapshot()
492 return ResidentMetadata{496 return ResidentMetadata{
493 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,497 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,498+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,499 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496 }500 }
497 }501 }
M packages/agent/subagents/resident_journal_test.go +3 -2
@@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
2525 spec := ResidentChildSpec{
2626 ID: "child-1",
2727 SessionID: "child-session-1",
28+ RootCacheID: "root-cache-1",
2829 ParentSessionID: "parent-session-1",
2930 Provider: "openai-codex",
3031 Model: "gpt-5.6-terra",
@@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
4243 if len(records) != 1 || records[0].Type != residentRecordAccepted {
4344 t.Fatalf("records = %#v, want one accepted record", records)
4445 }
45- if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" {
46+ if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
4647 t.Fatalf("accepted record = %#v", records[0])
4748 }
4849
@@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
5051 if err != nil {
5152 t.Fatalf("ReadResidentMetadata: %v", err)
5253 }
53- if meta.State != ResidentQueued || meta.SessionID != spec.SessionID {
54+ if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
5455 t.Fatalf("metadata = %#v", meta)
5556 }
5657 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
@@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
25 spec := ResidentChildSpec{25 spec := ResidentChildSpec{
26 ID: "child-1",26 ID: "child-1",
27 SessionID: "child-session-1",27 SessionID: "child-session-1",
28+ RootCacheID: "root-cache-1",
28 ParentSessionID: "parent-session-1",29 ParentSessionID: "parent-session-1",
29 Provider: "openai-codex",30 Provider: "openai-codex",
30 Model: "gpt-5.6-terra",31 Model: "gpt-5.6-terra",
@@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
42 if len(records) != 1 || records[0].Type != residentRecordAccepted {43 if len(records) != 1 || records[0].Type != residentRecordAccepted {
43 t.Fatalf("records = %#v, want one accepted record", records)44 t.Fatalf("records = %#v, want one accepted record", records)
44 }45 }
45- if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" {46+ if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
46 t.Fatalf("accepted record = %#v", records[0])47 t.Fatalf("accepted record = %#v", records[0])
47 }48 }
48 49
@@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
50 if err != nil {51 if err != nil {
51 t.Fatalf("ReadResidentMetadata: %v", err)52 t.Fatalf("ReadResidentMetadata: %v", err)
52 }53 }
53- if meta.State != ResidentQueued || meta.SessionID != spec.SessionID {54+ if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
54 t.Fatalf("metadata = %#v", meta)55 t.Fatalf("metadata = %#v", meta)
55 }56 }
56 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))57 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
M packages/core/agent.go +44 -14
@@ -166,10 +166,12 @@ type Agent struct {
166166 // a running tool or cancels an in-flight provider request.
167167 queued []queuedMessage
168168 timeContext agentTimeContext
169- // sessionID is assigned by the host from the durable session before the
170- // first provider request. Agents without persistence allocate one lazily at
171- // their first accepted turn and retain it for the process lifetime.
169+ // sessionID identifies this conversation thread. cacheSessionID identifies
170+ // the root prompt-cache affinity and may be shared by resident child
171+ // threads. Hosts bind both before the first provider request; unbound
172+ // agents allocate one shared ephemeral value for the process lifetime.
172173 sessionID string
174+ cacheSessionID string
173175 providerRequestStarted bool
174176 }
175177
@@ -188,37 +190,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
188190 }
189191 }
190192
191-// BindSessionID assigns the stable logical session identity before the first
192-// provider request. A durable host session must bind its SessionMeta.ID here;
193-// no-session hosts may leave it unset and the agent will allocate one lazily.
193+// BindSessionID binds a root conversation's durable session as both its
194+// thread identity and prompt-cache affinity. Resident children use
195+// BindRequestIdentity to retain their own thread while sharing their root's
196+// cache affinity.
194197 func (a *Agent) BindSessionID(id string) error {
195- id = strings.TrimSpace(id)
196- if id == "" {
197- return errors.New("session ID is empty")
198+ return a.BindRequestIdentity(id, id)
199+}
200+
201+// BindRequestIdentity assigns the stable root cache and thread identities
202+// before the first provider request.
203+func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error {
204+ cacheSessionID = strings.TrimSpace(cacheSessionID)
205+ threadID = strings.TrimSpace(threadID)
206+ if cacheSessionID == "" || threadID == "" {
207+ return errors.New("cache session ID and thread ID are required")
198208 }
199209 a.mu.Lock()
200210 defer a.mu.Unlock()
201211 if a.providerRequestStarted {
202- return errors.New("cannot change session ID after the first provider request")
212+ return errors.New("cannot change request identity after the first provider request")
203213 }
204- a.sessionID = id
214+ a.cacheSessionID = cacheSessionID
215+ a.sessionID = threadID
205216 return nil
206217 }
207218
208-// SessionID returns the stable logical session identity, allocating an
219+// SessionID returns the stable conversation-thread identity, allocating an
209220 // ephemeral identity when persistence is disabled and the host did not bind
210221 // one during runtime assembly.
211222 func (a *Agent) SessionID() string {
212223 a.mu.Lock()
213224 defer a.mu.Unlock()
225+ a.ensureRequestIdentityLocked()
226+ return a.sessionID
227+}
228+
229+// CacheSessionID returns the stable root cache-affinity identity.
230+func (a *Agent) CacheSessionID() string {
231+ a.mu.Lock()
232+ defer a.mu.Unlock()
233+ a.ensureRequestIdentityLocked()
234+ return a.cacheSessionID
235+}
236+
237+func (a *Agent) ensureRequestIdentityLocked() {
214238 if a.sessionID == "" {
215239 a.sessionID = uuid.NewString()
216240 }
217- return a.sessionID
241+ if a.cacheSessionID == "" {
242+ a.cacheSessionID = a.sessionID
243+ }
218244 }
219245
220246 func (a *Agent) beginTurn() provider.RequestContext {
221- return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()}
247+ a.mu.Lock()
248+ a.ensureRequestIdentityLocked()
249+ context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()}
250+ a.mu.Unlock()
251+ return context
222252 }
223253
224254 func (a *Agent) beginProviderRequest() {
@@ -166,10 +166,12 @@ type Agent struct {
166 // a running tool or cancels an in-flight provider request.166 // a running tool or cancels an in-flight provider request.
167 queued []queuedMessage167 queued []queuedMessage
168 timeContext agentTimeContext168 timeContext agentTimeContext
169- // sessionID is assigned by the host from the durable session before the169+ // sessionID identifies this conversation thread. cacheSessionID identifies
170- // first provider request. Agents without persistence allocate one lazily at170+ // the root prompt-cache affinity and may be shared by resident child
171- // their first accepted turn and retain it for the process lifetime.171+ // threads. Hosts bind both before the first provider request; unbound
172+ // agents allocate one shared ephemeral value for the process lifetime.
172 sessionID string173 sessionID string
174+ cacheSessionID string
173 providerRequestStarted bool175 providerRequestStarted bool
174 }176 }
175 177
@@ -188,37 +190,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
188 }190 }
189 }191 }
190 192
191-// BindSessionID assigns the stable logical session identity before the first193+// BindSessionID binds a root conversation's durable session as both its
192-// provider request. A durable host session must bind its SessionMeta.ID here;194+// thread identity and prompt-cache affinity. Resident children use
193-// no-session hosts may leave it unset and the agent will allocate one lazily.195+// BindRequestIdentity to retain their own thread while sharing their root's
196+// cache affinity.
194 func (a *Agent) BindSessionID(id string) error {197 func (a *Agent) BindSessionID(id string) error {
195- id = strings.TrimSpace(id)198+ return a.BindRequestIdentity(id, id)
196- if id == "" {199+}
197- return errors.New("session ID is empty")200+
201+// BindRequestIdentity assigns the stable root cache and thread identities
202+// before the first provider request.
203+func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error {
204+ cacheSessionID = strings.TrimSpace(cacheSessionID)
205+ threadID = strings.TrimSpace(threadID)
206+ if cacheSessionID == "" || threadID == "" {
207+ return errors.New("cache session ID and thread ID are required")
198 }208 }
199 a.mu.Lock()209 a.mu.Lock()
200 defer a.mu.Unlock()210 defer a.mu.Unlock()
201 if a.providerRequestStarted {211 if a.providerRequestStarted {
202- return errors.New("cannot change session ID after the first provider request")212+ return errors.New("cannot change request identity after the first provider request")
203 }213 }
204- a.sessionID = id214+ a.cacheSessionID = cacheSessionID
215+ a.sessionID = threadID
205 return nil216 return nil
206 }217 }
207 218
208-// SessionID returns the stable logical session identity, allocating an219+// SessionID returns the stable conversation-thread identity, allocating an
209 // ephemeral identity when persistence is disabled and the host did not bind220 // ephemeral identity when persistence is disabled and the host did not bind
210 // one during runtime assembly.221 // one during runtime assembly.
211 func (a *Agent) SessionID() string {222 func (a *Agent) SessionID() string {
212 a.mu.Lock()223 a.mu.Lock()
213 defer a.mu.Unlock()224 defer a.mu.Unlock()
225+ a.ensureRequestIdentityLocked()
226+ return a.sessionID
227+}
228+
229+// CacheSessionID returns the stable root cache-affinity identity.
230+func (a *Agent) CacheSessionID() string {
231+ a.mu.Lock()
232+ defer a.mu.Unlock()
233+ a.ensureRequestIdentityLocked()
234+ return a.cacheSessionID
235+}
236+
237+func (a *Agent) ensureRequestIdentityLocked() {
214 if a.sessionID == "" {238 if a.sessionID == "" {
215 a.sessionID = uuid.NewString()239 a.sessionID = uuid.NewString()
216 }240 }
217- return a.sessionID241+ if a.cacheSessionID == "" {
242+ a.cacheSessionID = a.sessionID
243+ }
218 }244 }
219 245
220 func (a *Agent) beginTurn() provider.RequestContext {246 func (a *Agent) beginTurn() provider.RequestContext {
221- return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()}247+ a.mu.Lock()
248+ a.ensureRequestIdentityLocked()
249+ context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()}
250+ a.mu.Unlock()
251+ return context
222 }252 }
223 253
224 func (a *Agent) beginProviderRequest() {254 func (a *Agent) beginProviderRequest() {
M packages/core/agent_identity_test.go +9 -9
@@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request {
4949 return requests
5050 }
5151
52-func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
52+func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) {
5353 client := &identityRecordingClient{failOnce: true}
5454 agent := NewAgent(client, "test-model", "system", Registry{})
5555 agent.MaxRetries = 1
5656 agent.RetryBaseDelay = time.Millisecond
57- if err := agent.BindSessionID("session-1"); err != nil {
58- t.Fatalf("BindSessionID: %v", err)
57+ if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil {
58+ t.Fatalf("BindRequestIdentity: %v", err)
5959 }
6060
6161 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
@@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
7070 t.Fatalf("request count = %d, want 3", len(requests))
7171 }
7272 for i, request := range requests {
73- if request.Context.SessionID != "session-1" {
74- t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID)
73+ if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" {
74+ t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context)
7575 }
7676 if request.Context.TurnID == "" {
7777 t.Fatalf("request %d has empty turn ID", i)
@@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
8383 if requests[1].Context.TurnID == requests[2].Context.TurnID {
8484 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)
8585 }
86- if err := agent.BindSessionID("other-session"); err == nil {
87- t.Fatal("BindSessionID succeeded after the first provider request")
86+ if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil {
87+ t.Fatal("BindRequestIdentity succeeded after the first provider request")
8888 }
8989 }
9090
@@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) {
102102 if len(requests) != 2 {
103103 t.Fatalf("request count = %d, want 2", len(requests))
104104 }
105- if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID {
106- t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID)
105+ if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID {
106+ t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context)
107107 }
108108 }
@@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request {
49 return requests49 return requests
50 }50 }
51 51
52-func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {52+func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) {
53 client := &identityRecordingClient{failOnce: true}53 client := &identityRecordingClient{failOnce: true}
54 agent := NewAgent(client, "test-model", "system", Registry{})54 agent := NewAgent(client, "test-model", "system", Registry{})
55 agent.MaxRetries = 155 agent.MaxRetries = 1
56 agent.RetryBaseDelay = time.Millisecond56 agent.RetryBaseDelay = time.Millisecond
57- if err := agent.BindSessionID("session-1"); err != nil {57+ if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil {
58- t.Fatalf("BindSessionID: %v", err)58+ t.Fatalf("BindRequestIdentity: %v", err)
59 }59 }
60 60
61 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {61 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
@@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
70 t.Fatalf("request count = %d, want 3", len(requests))70 t.Fatalf("request count = %d, want 3", len(requests))
71 }71 }
72 for i, request := range requests {72 for i, request := range requests {
73- if request.Context.SessionID != "session-1" {73+ if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" {
74- t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID)74+ t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context)
75 }75 }
76 if request.Context.TurnID == "" {76 if request.Context.TurnID == "" {
77 t.Fatalf("request %d has empty turn ID", i)77 t.Fatalf("request %d has empty turn ID", i)
@@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
83 if requests[1].Context.TurnID == requests[2].Context.TurnID {83 if requests[1].Context.TurnID == requests[2].Context.TurnID {
84 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)84 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)
85 }85 }
86- if err := agent.BindSessionID("other-session"); err == nil {86+ if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil {
87- t.Fatal("BindSessionID succeeded after the first provider request")87+ t.Fatal("BindRequestIdentity succeeded after the first provider request")
88 }88 }
89 }89 }
90 90
@@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) {
102 if len(requests) != 2 {102 if len(requests) != 2 {
103 t.Fatalf("request count = %d, want 2", len(requests))103 t.Fatalf("request count = %d, want 2", len(requests))
104 }104 }
105- if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID {105+ if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID {
106- t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID)106+ t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context)
107 }107 }
108 }108 }
M packages/core/cost.go +1 -1
@@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage {
3131 // Seed replaces the running total with usage restored from a session.
3232 func (c *CostTracker) Seed(u provider.Usage) {
3333 c.Total = u
34- c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 0
34+ c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0
3535 }
@@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage {
31 // Seed replaces the running total with usage restored from a session.31 // Seed replaces the running total with usage restored from a session.
32 func (c *CostTracker) Seed(u provider.Usage) {32 func (c *CostTracker) Seed(u provider.Usage) {
33 c.Total = u33 c.Total = u
34- c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 034+ c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0
35 }35 }
M packages/core/session.go +2 -0
@@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
367367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
368368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
369369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
370+ lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens)
371+ lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens)
370372 lastTurn.CostUSD = last.CostUSD - prev.CostUSD
371373 if lastTurn.CostUSD < 0 {
372374 lastTurn.CostUSD = 0
@@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
370+ lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens)
371+ lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens)
370 lastTurn.CostUSD = last.CostUSD - prev.CostUSD372 lastTurn.CostUSD = last.CostUSD - prev.CostUSD
371 if lastTurn.CostUSD < 0 {373 if lastTurn.CostUSD < 0 {
372 lastTurn.CostUSD = 0374 lastTurn.CostUSD = 0
M packages/core/session_usage_test.go +39 -0
@@ -7,6 +7,45 @@ import (
77 "github.com/bnema/zut/packages/provider"
88 )
99
10+func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) {
11+ sess := newUsageTestSession(t)
12+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil {
13+ t.Fatal(err)
14+ }
15+ first := provider.Usage{
16+ InputTokens: 60,
17+ CacheReadTokens: 40,
18+ CacheMeasuredPromptTokens: 100,
19+ CacheMeasuredReadTokens: 40,
20+ }
21+ if err := sess.AppendUsage(first, first); err != nil {
22+ t.Fatal(err)
23+ }
24+ second := provider.Usage{
25+ InputTokens: 80,
26+ CacheReadTokens: 70,
27+ CacheMeasuredPromptTokens: 150,
28+ CacheMeasuredReadTokens: 70,
29+ }
30+ if err := sess.AppendUsage(provider.Usage{}, second); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := sess.Close(); err != nil {
34+ t.Fatal(err)
35+ }
36+
37+ cumulative, last, err := SessionUsageDetail(sess.Path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 {
42+ t.Fatalf("cumulative measured cache usage = %+v", cumulative)
43+ }
44+ if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 {
45+ t.Fatalf("last measured cache usage = %+v", last)
46+ }
47+}
48+
1049 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
1150 t.Run("compaction is latest row", func(t *testing.T) {
1251 sess := newUsageTestSession(t)
@@ -7,6 +7,45 @@ import (
7 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
8 )8 )
9 9
10+func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) {
11+ sess := newUsageTestSession(t)
12+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil {
13+ t.Fatal(err)
14+ }
15+ first := provider.Usage{
16+ InputTokens: 60,
17+ CacheReadTokens: 40,
18+ CacheMeasuredPromptTokens: 100,
19+ CacheMeasuredReadTokens: 40,
20+ }
21+ if err := sess.AppendUsage(first, first); err != nil {
22+ t.Fatal(err)
23+ }
24+ second := provider.Usage{
25+ InputTokens: 80,
26+ CacheReadTokens: 70,
27+ CacheMeasuredPromptTokens: 150,
28+ CacheMeasuredReadTokens: 70,
29+ }
30+ if err := sess.AppendUsage(provider.Usage{}, second); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := sess.Close(); err != nil {
34+ t.Fatal(err)
35+ }
36+
37+ cumulative, last, err := SessionUsageDetail(sess.Path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 {
42+ t.Fatalf("cumulative measured cache usage = %+v", cumulative)
43+ }
44+ if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 {
45+ t.Fatalf("last measured cache usage = %+v", last)
46+ }
47+}
48+
10 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {49 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
11 t.Run("compaction is latest row", func(t *testing.T) {50 t.Run("compaction is latest row", func(t *testing.T) {
12 sess := newUsageTestSession(t)51 sess := newUsageTestSession(t)
M packages/provider/openai.go +4 -11
@@ -656,11 +656,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
656656 FinishReason string `json:"finish_reason"`
657657 } `json:"choices"`
658658 Usage *struct {
659- PromptTokens int `json:"prompt_tokens"`
660- CompletionTokens int `json:"completion_tokens"`
661- PromptTokensDetails struct {
662- CachedTokens int `json:"cached_tokens"`
663- } `json:"prompt_tokens_details"`
659+ PromptTokens int `json:"prompt_tokens"`
660+ CompletionTokens int `json:"completion_tokens"`
661+ PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"`
664662 CompletionTokensDetails *struct {
665663 ReasoningTokens int `json:"reasoning_tokens"`
666664 } `json:"completion_tokens_details"`
@@ -680,12 +678,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
680678 return
681679 }
682680 if chunk.Usage != nil {
683- usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens
684- if usage.InputTokens < 0 {
685- usage.InputTokens = chunk.Usage.PromptTokens
686- }
687- usage.OutputTokens = chunk.Usage.CompletionTokens
688- usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
681+ usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails)
689682 if details := chunk.Usage.CompletionTokensDetails; details != nil {
690683 usage.ReasoningTokens = details.ReasoningTokens
691684 usage.ReasoningTokensKnown = true
@@ -656,11 +656,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
656 FinishReason string `json:"finish_reason"`656 FinishReason string `json:"finish_reason"`
657 } `json:"choices"`657 } `json:"choices"`
658 Usage *struct {658 Usage *struct {
659- PromptTokens int `json:"prompt_tokens"`659+ PromptTokens int `json:"prompt_tokens"`
660- CompletionTokens int `json:"completion_tokens"`660+ CompletionTokens int `json:"completion_tokens"`
661- PromptTokensDetails struct {661+ PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"`
662- CachedTokens int `json:"cached_tokens"`
663- } `json:"prompt_tokens_details"`
664 CompletionTokensDetails *struct {662 CompletionTokensDetails *struct {
665 ReasoningTokens int `json:"reasoning_tokens"`663 ReasoningTokens int `json:"reasoning_tokens"`
666 } `json:"completion_tokens_details"`664 } `json:"completion_tokens_details"`
@@ -680,12 +678,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
680 return678 return
681 }679 }
682 if chunk.Usage != nil {680 if chunk.Usage != nil {
683- usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens681+ usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails)
684- if usage.InputTokens < 0 {
685- usage.InputTokens = chunk.Usage.PromptTokens
686- }
687- usage.OutputTokens = chunk.Usage.CompletionTokens
688- usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
689 if details := chunk.Usage.CompletionTokensDetails; details != nil {682 if details := chunk.Usage.CompletionTokensDetails; details != nil {
690 usage.ReasoningTokens = details.ReasoningTokens683 usage.ReasoningTokens = details.ReasoningTokens
691 usage.ReasoningTokensKnown = true684 usage.ReasoningTokensKnown = true
M packages/provider/openai_codex.go +20 -16
@@ -53,6 +53,8 @@ type codexClient struct {
5353 type responsesCapabilities struct {
5454 StablePromptCacheKey bool
5555 SessionHeader bool
56+ ThreadHeader bool
57+ ClientRequestHeader bool
5658 }
5759
5860 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -78,6 +80,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
7880 capabilities: responsesCapabilities{
7981 StablePromptCacheKey: true,
8082 SessionHeader: true,
83+ ThreadHeader: true,
84+ ClientRequestHeader: true,
8185 },
8286 http: &http.Client{Timeout: 0},
8387 }
@@ -383,9 +387,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
383387 wire.Model = c.modelName(wire.Model)
384388 }
385389 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
386- logicalSessionID := strings.TrimSpace(req.Context.SessionID)
387- if c.capabilities.StablePromptCacheKey && logicalSessionID != "" {
388- wire.PromptCacheKey = logicalSessionID
390+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
391+ threadID := strings.TrimSpace(req.Context.ThreadID)
392+ if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
393+ wire.PromptCacheKey = cacheSessionID
389394 }
390395 body, err := json.Marshal(wire)
391396 if err != nil {
@@ -408,8 +413,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
408413 // identities are load-shed with "servers are currently
409414 // overloaded" errors even when capacity is fine.
410415 httpReq.Header.Set("originator", "codex_cli_rs")
411- if c.capabilities.SessionHeader && logicalSessionID != "" {
412- httpReq.Header.Set("session-id", logicalSessionID)
416+ if c.capabilities.SessionHeader && cacheSessionID != "" {
417+ httpReq.Header.Set("session-id", cacheSessionID)
418+ }
419+ if c.capabilities.ThreadHeader && threadID != "" {
420+ httpReq.Header.Set("thread-id", threadID)
421+ }
422+ if c.capabilities.ClientRequestHeader && threadID != "" {
423+ httpReq.Header.Set("x-client-request-id", threadID)
413424 }
414425 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
415426 } else {
@@ -659,11 +670,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
659670 Response struct {
660671 ID string `json:"id"`
661672 Usage struct {
662- InputTokens int `json:"input_tokens"`
663- OutputTokens int `json:"output_tokens"`
664- InputTokensDetails struct {
665- CachedTokens int `json:"cached_tokens"`
666- } `json:"input_tokens_details"`
673+ InputTokens int `json:"input_tokens"`
674+ OutputTokens int `json:"output_tokens"`
675+ InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"`
667676 OutputTokensDetails *struct {
668677 ReasoningTokens int `json:"reasoning_tokens"`
669678 } `json:"output_tokens_details"`
@@ -672,12 +681,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
672681 } `json:"response"`
673682 }
674683 _ = json.Unmarshal([]byte(ev.Data), &p)
675- usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
676- if usage.InputTokens < 0 {
677- usage.InputTokens = p.Response.Usage.InputTokens
678- }
679- usage.OutputTokens = p.Response.Usage.OutputTokens
680- usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
684+ usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
681685 if details := p.Response.Usage.OutputTokensDetails; details != nil {
682686 usage.ReasoningTokens = details.ReasoningTokens
683687 usage.ReasoningTokensKnown = true
@@ -53,6 +53,8 @@ type codexClient struct {
53 type responsesCapabilities struct {53 type responsesCapabilities struct {
54 StablePromptCacheKey bool54 StablePromptCacheKey bool
55 SessionHeader bool55 SessionHeader bool
56+ ThreadHeader bool
57+ ClientRequestHeader bool
56 }58 }
57 59
58 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint60 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -78,6 +80,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
78 capabilities: responsesCapabilities{80 capabilities: responsesCapabilities{
79 StablePromptCacheKey: true,81 StablePromptCacheKey: true,
80 SessionHeader: true,82 SessionHeader: true,
83+ ThreadHeader: true,
84+ ClientRequestHeader: true,
81 },85 },
82 http: &http.Client{Timeout: 0},86 http: &http.Client{Timeout: 0},
83 }87 }
@@ -383,9 +387,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
383 wire.Model = c.modelName(wire.Model)387 wire.Model = c.modelName(wire.Model)
384 }388 }
385 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))389 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
386- logicalSessionID := strings.TrimSpace(req.Context.SessionID)390+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
387- if c.capabilities.StablePromptCacheKey && logicalSessionID != "" {391+ threadID := strings.TrimSpace(req.Context.ThreadID)
388- wire.PromptCacheKey = logicalSessionID392+ if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
393+ wire.PromptCacheKey = cacheSessionID
389 }394 }
390 body, err := json.Marshal(wire)395 body, err := json.Marshal(wire)
391 if err != nil {396 if err != nil {
@@ -408,8 +413,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
408 // identities are load-shed with "servers are currently413 // identities are load-shed with "servers are currently
409 // overloaded" errors even when capacity is fine.414 // overloaded" errors even when capacity is fine.
410 httpReq.Header.Set("originator", "codex_cli_rs")415 httpReq.Header.Set("originator", "codex_cli_rs")
411- if c.capabilities.SessionHeader && logicalSessionID != "" {416+ if c.capabilities.SessionHeader && cacheSessionID != "" {
412- httpReq.Header.Set("session-id", logicalSessionID)417+ httpReq.Header.Set("session-id", cacheSessionID)
418+ }
419+ if c.capabilities.ThreadHeader && threadID != "" {
420+ httpReq.Header.Set("thread-id", threadID)
421+ }
422+ if c.capabilities.ClientRequestHeader && threadID != "" {
423+ httpReq.Header.Set("x-client-request-id", threadID)
413 }424 }
414 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")425 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
415 } else {426 } else {
@@ -659,11 +670,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
659 Response struct {670 Response struct {
660 ID string `json:"id"`671 ID string `json:"id"`
661 Usage struct {672 Usage struct {
662- InputTokens int `json:"input_tokens"`673+ InputTokens int `json:"input_tokens"`
663- OutputTokens int `json:"output_tokens"`674+ OutputTokens int `json:"output_tokens"`
664- InputTokensDetails struct {675+ InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"`
665- CachedTokens int `json:"cached_tokens"`
666- } `json:"input_tokens_details"`
667 OutputTokensDetails *struct {676 OutputTokensDetails *struct {
668 ReasoningTokens int `json:"reasoning_tokens"`677 ReasoningTokens int `json:"reasoning_tokens"`
669 } `json:"output_tokens_details"`678 } `json:"output_tokens_details"`
@@ -672,12 +681,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
672 } `json:"response"`681 } `json:"response"`
673 }682 }
674 _ = json.Unmarshal([]byte(ev.Data), &p)683 _ = json.Unmarshal([]byte(ev.Data), &p)
675- usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens684+ usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
676- if usage.InputTokens < 0 {
677- usage.InputTokens = p.Response.Usage.InputTokens
678- }
679- usage.OutputTokens = p.Response.Usage.OutputTokens
680- usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
681 if details := p.Response.Usage.OutputTokensDetails; details != nil {685 if details := p.Response.Usage.OutputTokensDetails; details != nil {
682 usage.ReasoningTokens = details.ReasoningTokens686 usage.ReasoningTokens = details.ReasoningTokens
683 usage.ReasoningTokensKnown = true687 usage.ReasoningTokensKnown = true
M packages/provider/openai_codex_test.go +12 -12
@@ -92,7 +92,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
9292
9393 events, err := c.Stream(context.Background(), Request{
9494 Model: "gpt-5.6-terra",
95- Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"},
95+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
9696 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
9797 })
9898 if err != nil {
@@ -110,11 +110,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
110110 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
111111 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
112112 }
113- if gotBody.PromptCacheKey != "logical-session-1" {
114- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
113+ if gotBody.PromptCacheKey != "cache-root-1" {
114+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
115115 }
116- if gotReq.Header.Get("session-id") != "logical-session-1" {
117- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
116+ if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" {
117+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
118118 }
119119 }
120120
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187187
188188 events, err := ws.Stream(context.Background(), Request{
189189 Model: "gpt-5.6-sol",
190- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
190+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
191191 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
192192 })
193193 if err != nil {
@@ -202,8 +202,8 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
202202 if gotReq.Header.Get("session-id") != "" {
203203 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
204204 }
205- if gotBody.PromptCacheKey != "logical-session-2" {
206- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
205+ if gotBody.PromptCacheKey != "cache-root-2" {
206+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
207207 }
208208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209209 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
@@ -220,7 +220,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
220220 }
221221 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
222222 })
223- events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}})
223+ events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
224224 if err != nil {
225225 t.Fatal(err)
226226 }
@@ -272,7 +272,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
272272
273273 events, err := c.Stream(context.Background(), Request{
274274 Model: "gpt-5.6-sol",
275- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
275+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
276276 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
277277 })
278278 if err != nil {
@@ -290,8 +290,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
290290 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
291291 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
292292 }
293- if gotReq.Header.Get("session-id") != "logical-session-2" {
294- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
293+ if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" {
294+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
295295 }
296296 if !strings.Contains(body.String(), "prompt_cache_key") {
297297 t.Fatalf("request missing prompt_cache_key: %s", body.String())
@@ -92,7 +92,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
92 92
93 events, err := c.Stream(context.Background(), Request{93 events, err := c.Stream(context.Background(), Request{
94 Model: "gpt-5.6-terra",94 Model: "gpt-5.6-terra",
95- Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"},95+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
96 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},96 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
97 })97 })
98 if err != nil {98 if err != nil {
@@ -110,11 +110,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
110 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {110 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
111 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))111 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
112 }112 }
113- if gotBody.PromptCacheKey != "logical-session-1" {113+ if gotBody.PromptCacheKey != "cache-root-1" {
114- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)114+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
115 }115 }
116- if gotReq.Header.Get("session-id") != "logical-session-1" {116+ if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" {
117- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))117+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
118 }118 }
119 }119 }
120 120
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187 187
188 events, err := ws.Stream(context.Background(), Request{188 events, err := ws.Stream(context.Background(), Request{
189 Model: "gpt-5.6-sol",189 Model: "gpt-5.6-sol",
190- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},190+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
191 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},191 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
192 })192 })
193 if err != nil {193 if err != nil {
@@ -202,8 +202,8 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
202 if gotReq.Header.Get("session-id") != "" {202 if gotReq.Header.Get("session-id") != "" {
203 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))203 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
204 }204 }
205- if gotBody.PromptCacheKey != "logical-session-2" {205+ if gotBody.PromptCacheKey != "cache-root-2" {
206- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)206+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
207 }207 }
208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)209 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
@@ -220,7 +220,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
220 }220 }
221 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil221 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
222 })222 })
223- events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}})223+ events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
224 if err != nil {224 if err != nil {
225 t.Fatal(err)225 t.Fatal(err)
226 }226 }
@@ -272,7 +272,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
272 272
273 events, err := c.Stream(context.Background(), Request{273 events, err := c.Stream(context.Background(), Request{
274 Model: "gpt-5.6-sol",274 Model: "gpt-5.6-sol",
275- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},275+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
276 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},276 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
277 })277 })
278 if err != nil {278 if err != nil {
@@ -290,8 +290,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
290 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {290 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
291 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))291 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
292 }292 }
293- if gotReq.Header.Get("session-id") != "logical-session-2" {293+ if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" {
294- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))294+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
295 }295 }
296 if !strings.Contains(body.String(), "prompt_cache_key") {296 if !strings.Contains(body.String(), "prompt_cache_key") {
297 t.Fatalf("request missing prompt_cache_key: %s", body.String())297 t.Fatalf("request missing prompt_cache_key: %s", body.String())
M packages/provider/openai_responses_websocket.go +14 -13
@@ -152,15 +152,16 @@ func responsesWebSocketURL(endpoint string) (string, error) {
152152 }
153153
154154 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
155- // WebSocket continuation state is keyed by the durable core session ID.
156- // Requests without that identity retain HTTP/SSE semantics rather than
157- // accidentally pooling unrelated conversations.
158- sessionID := strings.TrimSpace(req.Context.SessionID)
159- if sessionID == "" {
155+ // WebSocket continuation state is isolated per conversation thread. Cache
156+ // affinity may be shared by root and resident children, but their server
157+ // response chains must never be pooled.
158+ threadID := strings.TrimSpace(req.Context.ThreadID)
159+ if threadID == "" {
160160 return c.http.Stream(ctx, req)
161161 }
162+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
162163
163- session := c.session(sessionID)
164+ session := c.session(threadID)
164165 select {
165166 case <-ctx.Done():
166167 return nil, ctx.Err()
@@ -175,7 +176,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
175176 }()
176177
177178 streamReq, previousResponseID := session.incrementalRequest(req)
178- payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID)
179+ payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
179180 if err != nil {
180181 return nil, err
181182 }
@@ -215,7 +216,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
215216 // HTTP/SSE. That keeps the stable cache key and logical IDs intact
216217 // without ever reusing the invalid previous_response_id.
217218 session.invalidate()
218- reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID)
219+ reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID)
219220 if err != nil {
220221 c.forwardResponsesHTTP(ctx, req, out)
221222 return
@@ -254,7 +255,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
254255 return out, nil
255256 }
256257
257-func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) {
258+func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) {
258259 wire, err := c.http.buildRequest(req)
259260 if err != nil {
260261 return nil, err
@@ -262,8 +263,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
262263 if c.http.modelName != nil {
263264 wire.Model = c.http.modelName(wire.Model)
264265 }
265- if c.http.capabilities.StablePromptCacheKey {
266- wire.PromptCacheKey = sessionID
266+ if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" {
267+ wire.PromptCacheKey = cacheSessionID
267268 }
268269 wire.Stream = false // WebSocket mode does not send HTTP stream controls.
269270 wire.PreviousResponseID = previousResponseID
@@ -276,8 +277,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
276277 // reconnectFull establishes one replacement WebSocket turn after a cached
277278 // continuation failed. A fresh connection cannot rely on the previous server
278279 // response, so it always sends the complete local transcript.
279-func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
280- payload, err := c.websocketPayload(req, sessionID, "")
280+func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
281+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281282 if err != nil {
282283 return nil, nil, sseEvent{}, "", err
283284 }
@@ -152,15 +152,16 @@ func responsesWebSocketURL(endpoint string) (string, error) {
152 }152 }
153 153
154 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {154 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
155- // WebSocket continuation state is keyed by the durable core session ID.155+ // WebSocket continuation state is isolated per conversation thread. Cache
156- // Requests without that identity retain HTTP/SSE semantics rather than156+ // affinity may be shared by root and resident children, but their server
157- // accidentally pooling unrelated conversations.157+ // response chains must never be pooled.
158- sessionID := strings.TrimSpace(req.Context.SessionID)158+ threadID := strings.TrimSpace(req.Context.ThreadID)
159- if sessionID == "" {159+ if threadID == "" {
160 return c.http.Stream(ctx, req)160 return c.http.Stream(ctx, req)
161 }161 }
162+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
162 163
163- session := c.session(sessionID)164+ session := c.session(threadID)
164 select {165 select {
165 case <-ctx.Done():166 case <-ctx.Done():
166 return nil, ctx.Err()167 return nil, ctx.Err()
@@ -175,7 +176,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
175 }()176 }()
176 177
177 streamReq, previousResponseID := session.incrementalRequest(req)178 streamReq, previousResponseID := session.incrementalRequest(req)
178- payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID)179+ payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
179 if err != nil {180 if err != nil {
180 return nil, err181 return nil, err
181 }182 }
@@ -215,7 +216,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
215 // HTTP/SSE. That keeps the stable cache key and logical IDs intact216 // HTTP/SSE. That keeps the stable cache key and logical IDs intact
216 // without ever reusing the invalid previous_response_id.217 // without ever reusing the invalid previous_response_id.
217 session.invalidate()218 session.invalidate()
218- reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID)219+ reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID)
219 if err != nil {220 if err != nil {
220 c.forwardResponsesHTTP(ctx, req, out)221 c.forwardResponsesHTTP(ctx, req, out)
221 return222 return
@@ -254,7 +255,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
254 return out, nil255 return out, nil
255 }256 }
256 257
257-func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) {258+func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) {
258 wire, err := c.http.buildRequest(req)259 wire, err := c.http.buildRequest(req)
259 if err != nil {260 if err != nil {
260 return nil, err261 return nil, err
@@ -262,8 +263,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
262 if c.http.modelName != nil {263 if c.http.modelName != nil {
263 wire.Model = c.http.modelName(wire.Model)264 wire.Model = c.http.modelName(wire.Model)
264 }265 }
265- if c.http.capabilities.StablePromptCacheKey {266+ if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" {
266- wire.PromptCacheKey = sessionID267+ wire.PromptCacheKey = cacheSessionID
267 }268 }
268 wire.Stream = false // WebSocket mode does not send HTTP stream controls.269 wire.Stream = false // WebSocket mode does not send HTTP stream controls.
269 wire.PreviousResponseID = previousResponseID270 wire.PreviousResponseID = previousResponseID
@@ -276,8 +277,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
276 // reconnectFull establishes one replacement WebSocket turn after a cached277 // reconnectFull establishes one replacement WebSocket turn after a cached
277 // continuation failed. A fresh connection cannot rely on the previous server278 // continuation failed. A fresh connection cannot rely on the previous server
278 // response, so it always sends the complete local transcript.279 // response, so it always sends the complete local transcript.
279-func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {280+func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
280- payload, err := c.websocketPayload(req, sessionID, "")281+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281 if err != nil {282 if err != nil {
282 return nil, nil, sseEvent{}, "", err283 return nil, nil, sseEvent{}, "", err
283 }284 }
M packages/provider/openai_responses_websocket_test.go +8 -8
@@ -80,10 +80,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t
8080 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
8181 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
8282 })
83- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
83+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
8484 assertCompletedResponse(t, client, first)
8585 lifecycle := &recordingRequestLifecycle{}
86- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
86+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
8787 assertCompletedResponseText(t, client, second, "reconnected")
8888 mu.Lock()
8989 defer mu.Unlock()
@@ -163,14 +163,14 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
163163 first := Request{
164164 Model: "gpt-5.6-sol",
165165 FastMode: true,
166- Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"},
166+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
167167 Lifecycle: lifecycle,
168168 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
169169 }
170170 assertCompletedResponse(t, client, first)
171171 second := Request{
172172 Model: "gpt-5.6-sol",
173- Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"},
173+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174174 Messages: append(append([]Message(nil), first.Messages...),
175175 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176176 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
@@ -195,7 +195,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
195195 if _, ok := requests[0]["stream"]; ok {
196196 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])
197197 }
198- if got := requests[0]["prompt_cache_key"]; got != "session-1" {
198+ if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" {
199199 t.Fatalf("prompt_cache_key = %#v", got)
200200 }
201201 if got := requests[0]["service_tier"]; got != fastModeServiceTier {
@@ -242,7 +242,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T)
242242 capabilities: responsesCapabilities{StablePromptCacheKey: true},
243243 })
244244 ctx, cancel := context.WithCancel(context.Background())
245- stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}})
245+ stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}})
246246 if err != nil {
247247 t.Fatal(err)
248248 }
@@ -290,9 +290,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin
290290 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
291291 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
292292 })
293- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
293+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
294294 assertCompletedResponseText(t, client, first, "first")
295- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
295+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
296296 assertCompletedResponseText(t, client, second, "second")
297297 }
298298
@@ -80,10 +80,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t
80 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",80 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
81 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},81 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
82 })82 })
83- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}83+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
84 assertCompletedResponse(t, client, first)84 assertCompletedResponse(t, client, first)
85 lifecycle := &recordingRequestLifecycle{}85 lifecycle := &recordingRequestLifecycle{}
86- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}86+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
87 assertCompletedResponseText(t, client, second, "reconnected")87 assertCompletedResponseText(t, client, second, "reconnected")
88 mu.Lock()88 mu.Lock()
89 defer mu.Unlock()89 defer mu.Unlock()
@@ -163,14 +163,14 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
163 first := Request{163 first := Request{
164 Model: "gpt-5.6-sol",164 Model: "gpt-5.6-sol",
165 FastMode: true,165 FastMode: true,
166- Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"},166+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
167 Lifecycle: lifecycle,167 Lifecycle: lifecycle,
168 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},168 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
169 }169 }
170 assertCompletedResponse(t, client, first)170 assertCompletedResponse(t, client, first)
171 second := Request{171 second := Request{
172 Model: "gpt-5.6-sol",172 Model: "gpt-5.6-sol",
173- Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"},173+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174 Messages: append(append([]Message(nil), first.Messages...),174 Messages: append(append([]Message(nil), first.Messages...),
175 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},175 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},176 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
@@ -195,7 +195,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
195 if _, ok := requests[0]["stream"]; ok {195 if _, ok := requests[0]["stream"]; ok {
196 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])196 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])
197 }197 }
198- if got := requests[0]["prompt_cache_key"]; got != "session-1" {198+ if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" {
199 t.Fatalf("prompt_cache_key = %#v", got)199 t.Fatalf("prompt_cache_key = %#v", got)
200 }200 }
201 if got := requests[0]["service_tier"]; got != fastModeServiceTier {201 if got := requests[0]["service_tier"]; got != fastModeServiceTier {
@@ -242,7 +242,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T)
242 capabilities: responsesCapabilities{StablePromptCacheKey: true},242 capabilities: responsesCapabilities{StablePromptCacheKey: true},
243 })243 })
244 ctx, cancel := context.WithCancel(context.Background())244 ctx, cancel := context.WithCancel(context.Background())
245- stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}})245+ stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}})
246 if err != nil {246 if err != nil {
247 t.Fatal(err)247 t.Fatal(err)
248 }248 }
@@ -290,9 +290,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin
290 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",290 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
291 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},291 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
292 })292 })
293- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}293+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
294 assertCompletedResponseText(t, client, first, "first")294 assertCompletedResponseText(t, client, first, "first")
295- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}295+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
296 assertCompletedResponseText(t, client, second, "second")296 assertCompletedResponseText(t, client, second, "second")
297 }297 }
298 298
A packages/provider/openai_usage.go +37 -0
@@ -0,0 +1,37 @@
1+package provider
2+
3+// openAIInputTokensDetails is shared by the Chat Completions and Responses
4+// streams. The details object is optional; its presence is the availability
5+// signal for cache accounting, including a known cache miss.
6+type openAIInputTokensDetails struct {
7+ CachedTokens int `json:"cached_tokens"`
8+ CacheWriteTokens int `json:"cache_write_tokens"`
9+}
10+
11+// normalizeOpenAIUsage converts OpenAI's total input count and optional cache
12+// detail into disjoint usage buckets. Malformed detail must not create a
13+// negative ordinary-input count or an unsupported cache metric, so it falls
14+// back to treating the total as ordinary input.
15+func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage {
16+ if inputTokens < 0 {
17+ inputTokens = 0
18+ }
19+ if outputTokens < 0 {
20+ outputTokens = 0
21+ }
22+
23+ usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens}
24+ if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 {
25+ return usage
26+ }
27+ if details.CachedTokens+details.CacheWriteTokens > inputTokens {
28+ return usage
29+ }
30+
31+ usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens
32+ usage.CacheReadTokens = details.CachedTokens
33+ usage.CacheWriteTokens = details.CacheWriteTokens
34+ usage.CacheMeasuredPromptTokens = inputTokens
35+ usage.CacheMeasuredReadTokens = details.CachedTokens
36+ return usage
37+}
@@ -0,0 +1,37 @@
1+package provider
2+
3+// openAIInputTokensDetails is shared by the Chat Completions and Responses
4+// streams. The details object is optional; its presence is the availability
5+// signal for cache accounting, including a known cache miss.
6+type openAIInputTokensDetails struct {
7+ CachedTokens int `json:"cached_tokens"`
8+ CacheWriteTokens int `json:"cache_write_tokens"`
9+}
10+
11+// normalizeOpenAIUsage converts OpenAI's total input count and optional cache
12+// detail into disjoint usage buckets. Malformed detail must not create a
13+// negative ordinary-input count or an unsupported cache metric, so it falls
14+// back to treating the total as ordinary input.
15+func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage {
16+ if inputTokens < 0 {
17+ inputTokens = 0
18+ }
19+ if outputTokens < 0 {
20+ outputTokens = 0
21+ }
22+
23+ usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens}
24+ if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 {
25+ return usage
26+ }
27+ if details.CachedTokens+details.CacheWriteTokens > inputTokens {
28+ return usage
29+ }
30+
31+ usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens
32+ usage.CacheReadTokens = details.CachedTokens
33+ usage.CacheWriteTokens = details.CacheWriteTokens
34+ usage.CacheMeasuredPromptTokens = inputTokens
35+ usage.CacheMeasuredReadTokens = details.CachedTokens
36+ return usage
37+}
M packages/provider/provider.go +36 -26
@@ -171,13 +171,19 @@ func activatedToolNames(messages []Message) map[string]bool {
171171
172172 // Usage aggregates token counts and cost for a turn.
173173 type Usage struct {
174- InputTokens int `json:"input_tokens"`
175- OutputTokens int `json:"output_tokens"`
176- ReasoningTokens int `json:"reasoning_tokens"`
177- ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178- CacheReadTokens int `json:"cache_read_tokens"`
179- CacheWriteTokens int `json:"cache_write_tokens"`
180- CostUSD float64 `json:"cost_usd"`
174+ InputTokens int `json:"input_tokens"`
175+ OutputTokens int `json:"output_tokens"`
176+ ReasoningTokens int `json:"reasoning_tokens"`
177+ ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178+ CacheReadTokens int `json:"cache_read_tokens"`
179+ CacheWriteTokens int `json:"cache_write_tokens"`
180+ // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the
181+ // portion of the prompt for which the provider explicitly reported cache
182+ // details. Zero values mean cache details were unavailable, including in
183+ // historical session rows.
184+ CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"`
185+ CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"`
186+ CostUSD float64 `json:"cost_usd"`
181187 }
182188
183189 // PromptTokens returns all input tokens, regardless of cache disposition.
@@ -185,28 +191,30 @@ func (u Usage) PromptTokens() int {
185191 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186192 }
187193
188-// CacheHitRatio returns the share of prompt tokens served from cache. The
189-// boolean is false when the provider reported no cache reads, which avoids
190-// presenting an unsupported cache metric as a zero-percent hit rate.
194+// CacheHitRatio returns the share of provider-measured prompt tokens served
195+// from cache. The boolean is false when cache details were unavailable, which
196+// keeps historical or unsupported usage distinct from a known zero-percent
197+// cache hit rate.
191198 func (u Usage) CacheHitRatio() (float64, bool) {
192- total := u.PromptTokens()
193- if total <= 0 || u.CacheReadTokens <= 0 {
199+ if u.CacheMeasuredPromptTokens <= 0 {
194200 return 0, false
195201 }
196- return float64(u.CacheReadTokens) / float64(total), true
202+ return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true
197203 }
198204
199205 // Add returns u plus v. A reasoning total is known only when both
200206 // component usage reports include a separate reasoning-token count.
201207 func (u Usage) Add(v Usage) Usage {
202208 return Usage{
203- InputTokens: u.InputTokens + v.InputTokens,
204- OutputTokens: u.OutputTokens + v.OutputTokens,
205- ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
206- ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
207- CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
208- CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
209- CostUSD: u.CostUSD + v.CostUSD,
209+ InputTokens: u.InputTokens + v.InputTokens,
210+ OutputTokens: u.OutputTokens + v.OutputTokens,
211+ ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
212+ ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
213+ CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
214+ CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
215+ CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens,
216+ CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens,
217+ CostUSD: u.CostUSD + v.CostUSD,
210218 }
211219 }
212220
@@ -312,13 +320,15 @@ type RequestFailureLifecycle interface {
312320 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)
313321 }
314322
315-// RequestContext identifies one logical agent session and one accepted turn.
316-// Values are opaque provider-neutral correlation IDs. Providers may translate
317-// them into cache controls or session-affinity fields, but must not replace
318-// them with transport-attempt IDs.
323+// RequestContext identifies the cache affinity, conversation thread, and
324+// accepted turn for one logical agent request. Values are opaque
325+// provider-neutral correlation IDs. Providers may translate them into cache
326+// controls or routing fields, but must not replace them with transport-attempt
327+// IDs.
319328 type RequestContext struct {
320- SessionID string
321- TurnID string
329+ CacheSessionID string
330+ ThreadID string
331+ TurnID string
322332 }
323333
324334 // Request is a single LLM call.
@@ -171,13 +171,19 @@ func activatedToolNames(messages []Message) map[string]bool {
171 171
172 // Usage aggregates token counts and cost for a turn.172 // Usage aggregates token counts and cost for a turn.
173 type Usage struct {173 type Usage struct {
174- InputTokens int `json:"input_tokens"`174+ InputTokens int `json:"input_tokens"`
175- OutputTokens int `json:"output_tokens"`175+ OutputTokens int `json:"output_tokens"`
176- ReasoningTokens int `json:"reasoning_tokens"`176+ ReasoningTokens int `json:"reasoning_tokens"`
177- ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`177+ ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178- CacheReadTokens int `json:"cache_read_tokens"`178+ CacheReadTokens int `json:"cache_read_tokens"`
179- CacheWriteTokens int `json:"cache_write_tokens"`179+ CacheWriteTokens int `json:"cache_write_tokens"`
180- CostUSD float64 `json:"cost_usd"`180+ // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the
181+ // portion of the prompt for which the provider explicitly reported cache
182+ // details. Zero values mean cache details were unavailable, including in
183+ // historical session rows.
184+ CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"`
185+ CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"`
186+ CostUSD float64 `json:"cost_usd"`
181 }187 }
182 188
183 // PromptTokens returns all input tokens, regardless of cache disposition.189 // PromptTokens returns all input tokens, regardless of cache disposition.
@@ -185,28 +191,30 @@ func (u Usage) PromptTokens() int {
185 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens191 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186 }192 }
187 193
188-// CacheHitRatio returns the share of prompt tokens served from cache. The194+// CacheHitRatio returns the share of provider-measured prompt tokens served
189-// boolean is false when the provider reported no cache reads, which avoids195+// from cache. The boolean is false when cache details were unavailable, which
190-// presenting an unsupported cache metric as a zero-percent hit rate.196+// keeps historical or unsupported usage distinct from a known zero-percent
197+// cache hit rate.
191 func (u Usage) CacheHitRatio() (float64, bool) {198 func (u Usage) CacheHitRatio() (float64, bool) {
192- total := u.PromptTokens()199+ if u.CacheMeasuredPromptTokens <= 0 {
193- if total <= 0 || u.CacheReadTokens <= 0 {
194 return 0, false200 return 0, false
195 }201 }
196- return float64(u.CacheReadTokens) / float64(total), true202+ return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true
197 }203 }
198 204
199 // Add returns u plus v. A reasoning total is known only when both205 // Add returns u plus v. A reasoning total is known only when both
200 // component usage reports include a separate reasoning-token count.206 // component usage reports include a separate reasoning-token count.
201 func (u Usage) Add(v Usage) Usage {207 func (u Usage) Add(v Usage) Usage {
202 return Usage{208 return Usage{
203- InputTokens: u.InputTokens + v.InputTokens,209+ InputTokens: u.InputTokens + v.InputTokens,
204- OutputTokens: u.OutputTokens + v.OutputTokens,210+ OutputTokens: u.OutputTokens + v.OutputTokens,
205- ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,211+ ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
206- ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,212+ ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
207- CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,213+ CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
208- CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,214+ CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
209- CostUSD: u.CostUSD + v.CostUSD,215+ CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens,
216+ CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens,
217+ CostUSD: u.CostUSD + v.CostUSD,
210 }218 }
211 }219 }
212 220
@@ -312,13 +320,15 @@ type RequestFailureLifecycle interface {
312 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)320 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)
313 }321 }
314 322
315-// RequestContext identifies one logical agent session and one accepted turn.323+// RequestContext identifies the cache affinity, conversation thread, and
316-// Values are opaque provider-neutral correlation IDs. Providers may translate324+// accepted turn for one logical agent request. Values are opaque
317-// them into cache controls or session-affinity fields, but must not replace325+// provider-neutral correlation IDs. Providers may translate them into cache
318-// them with transport-attempt IDs.326+// controls or routing fields, but must not replace them with transport-attempt
327+// IDs.
319 type RequestContext struct {328 type RequestContext struct {
320- SessionID string329+ CacheSessionID string
321- TurnID string330+ ThreadID string
331+ TurnID string
322 }332 }
323 333
324 // Request is a single LLM call.334 // Request is a single LLM call.
M packages/provider/provider_test.go +23 -4
@@ -12,7 +12,7 @@ import (
1212 )
1313
1414 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15- u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}
15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000}
1616 if got := u.PromptTokens(); got != 210_000 {
1717 t.Fatalf("prompt tokens = %d, want 210000", got)
1818 }
@@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
2222 }
2323 }
2424
25-func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {
26- if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {
27- t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)
25+func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) {
26+ knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000}
27+ if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 {
28+ t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok)
29+ }
30+
31+ historicalUnknown := Usage{InputTokens: 84_000}
32+ if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 {
33+ t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok)
34+ }
35+}
36+
37+func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) {
38+ measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40}
39+ unknown := Usage{InputTokens: 200, CacheReadTokens: 100}
40+
41+ got := measured.Add(unknown)
42+ if got.CacheMeasuredPromptTokens != 100 {
43+ t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens)
44+ }
45+ if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 {
46+ t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok)
2847 }
2948 }
3049
@@ -12,7 +12,7 @@ import (
12 )12 )
13 13
14 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {14 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15- u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000}
16 if got := u.PromptTokens(); got != 210_000 {16 if got := u.PromptTokens(); got != 210_000 {
17 t.Fatalf("prompt tokens = %d, want 210000", got)17 t.Fatalf("prompt tokens = %d, want 210000", got)
18 }18 }
@@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
22 }22 }
23 }23 }
24 24
25-func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {25+func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) {
26- if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {26+ knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000}
27- t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)27+ if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 {
28+ t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok)
29+ }
30+
31+ historicalUnknown := Usage{InputTokens: 84_000}
32+ if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 {
33+ t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok)
34+ }
35+}
36+
37+func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) {
38+ measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40}
39+ unknown := Usage{InputTokens: 200, CacheReadTokens: 100}
40+
41+ got := measured.Add(unknown)
42+ if got.CacheMeasuredPromptTokens != 100 {
43+ t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens)
44+ }
45+ if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 {
46+ t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok)
28 }47 }
29 }48 }
30 49
M packages/provider/reasoning_usage_test.go +58 -0
@@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) {
2828 }
2929 }
3030
31+func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) {
32+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ w.Header().Set("content-type", "text/event-stream")
34+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`)
35+ fmt.Fprintln(w)
36+ fmt.Fprintln(w, "data: [DONE]")
37+ fmt.Fprintln(w)
38+ }))
39+ defer srv.Close()
40+
41+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ usage := usageFromEvents(events)
46+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
47+ t.Fatalf("usage = %+v", usage)
48+ }
49+}
50+
51+func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ w.Header().Set("content-type", "text/event-stream")
54+ fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`)
55+ fmt.Fprintln(w)
56+ }))
57+ defer srv.Close()
58+
59+ events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"})
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ usage := usageFromEvents(events)
64+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
65+ t.Fatalf("usage = %+v", usage)
66+ }
67+}
68+
69+func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("content-type", "text/event-stream")
72+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`)
73+ fmt.Fprintln(w)
74+ fmt.Fprintln(w, "data: [DONE]")
75+ fmt.Fprintln(w)
76+ }))
77+ defer srv.Close()
78+
79+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ usage := usageFromEvents(events)
84+ if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 {
85+ t.Fatalf("malformed cache usage = %+v", usage)
86+ }
87+}
88+
3189 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {
3290 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3391 w.Header().Set("content-type", "text/event-stream")
@@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) {
28 }28 }
29 }29 }
30 30
31+func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) {
32+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ w.Header().Set("content-type", "text/event-stream")
34+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`)
35+ fmt.Fprintln(w)
36+ fmt.Fprintln(w, "data: [DONE]")
37+ fmt.Fprintln(w)
38+ }))
39+ defer srv.Close()
40+
41+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ usage := usageFromEvents(events)
46+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
47+ t.Fatalf("usage = %+v", usage)
48+ }
49+}
50+
51+func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ w.Header().Set("content-type", "text/event-stream")
54+ fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`)
55+ fmt.Fprintln(w)
56+ }))
57+ defer srv.Close()
58+
59+ events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"})
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ usage := usageFromEvents(events)
64+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
65+ t.Fatalf("usage = %+v", usage)
66+ }
67+}
68+
69+func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("content-type", "text/event-stream")
72+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`)
73+ fmt.Fprintln(w)
74+ fmt.Fprintln(w, "data: [DONE]")
75+ fmt.Fprintln(w)
76+ }))
77+ defer srv.Close()
78+
79+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ usage := usageFromEvents(events)
84+ if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 {
85+ t.Fatalf("malformed cache usage = %+v", usage)
86+ }
87+}
88+
31 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {89 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {
32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {90 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 w.Header().Set("content-type", "text/event-stream")91 w.Header().Set("content-type", "text/event-stream")
M packages/tui/statusbar_test.go +13 -2
@@ -10,7 +10,7 @@ import (
1010 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
1111 lines := StatusBar(StatusBarParams{
1212 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},
13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
1414 ContextUsed: 45_152, ContextMax: 272_000,
1515 })
1616 plain := stripANSI(strings.Join(lines, "\n"))
@@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) {
1919 }
2020 }
2121
22+func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
23+ lines := StatusBar(StatusBarParams{
24+ Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
25+ Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
26+ })
27+ plain := stripANSI(strings.Join(lines, "\n"))
28+ if strings.Contains(plain, "C") {
29+ t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
30+ }
31+}
32+
2233 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
2334 const cols = 30
2435 lines := StatusBar(StatusBarParams{
2536 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},
37+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
2738 ContextUsed: 209_000, ContextMax: 272_000,
2839 })
2940 plain := stripANSI(strings.Join(lines, "\n"))
@@ -10,7 +10,7 @@ import (
10 func TestStatusBarShowsCacheHitRatio(t *testing.T) {10 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11 lines := StatusBar(StatusBarParams{11 lines := StatusBar(StatusBarParams{
12 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,12 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
14 ContextUsed: 45_152, ContextMax: 272_000,14 ContextUsed: 45_152, ContextMax: 272_000,
15 })15 })
16 plain := stripANSI(strings.Join(lines, "\n"))16 plain := stripANSI(strings.Join(lines, "\n"))
@@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) {
19 }19 }
20 }20 }
21 21
22+func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
23+ lines := StatusBar(StatusBarParams{
24+ Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
25+ Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
26+ })
27+ plain := stripANSI(strings.Join(lines, "\n"))
28+ if strings.Contains(plain, "C") {
29+ t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
30+ }
31+}
32+
22 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {33 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
23 const cols = 3034 const cols = 30
24 lines := StatusBar(StatusBarParams{35 lines := StatusBar(StatusBarParams{
25 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,36 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},37+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
27 ContextUsed: 209_000, ContextMax: 272_000,38 ContextUsed: 209_000, ContextMax: 272_000,
28 })39 })
29 plain := stripANSI(strings.Join(lines, "\n"))40 plain := stripANSI(strings.Join(lines, "\n"))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close