feat(provider): account for OpenAI cache usage
- commit
- 5d2b75626f6decbad0b2e94d3f386b32f11d2d89
- author
- brice · · 2026-08-18 08:09 UTC
- parent
- 29be61c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 31 | 31 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 32 | 32 | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |
| 33 | 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 | 35 | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |
| 36 | 36 | }}, 100, now) | |
| 37 | 37 | 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) { | |||
| 35 | 35 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 36 | 36 | journal.ConfigureUsage(272_000, true) | |
| 37 | 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 | 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | |
| 40 | 40 | }, nil | |
| 41 | 41 | }) | |
| @@ -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 | }, nil | 40 | }, nil | ||
| 41 | }) | 41 | }) | ||
M packages/agent/subagent_factory.go +11 -2
| @@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 47 | 47 | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 48 | 48 | agent.SeedCost(baseline.Usage) | |
| 49 | 49 | } | |
| 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) | |
| 52 | 61 | } | |
| 53 | 62 | if journal != nil { | |
| 54 | 63 | 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 | |||
| 338 | 338 | workspaceMode = subagents.WorkspaceShared | |
| 339 | 339 | } | |
| 340 | 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 | 342 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 343 | 343 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |
| 344 | 344 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| @@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 338 | workspaceMode = subagents.WorkspaceShared | 338 | 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 ( | |||
| 65 | 65 | // provider endpoint and TLS mode are retained so explicit resume reconstructs | |
| 66 | 66 | // the same transport contract. | |
| 67 | 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 | 71 | SessionID string `json:"session_id"` | |
| 72 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |
| 70 | 73 | InitialTurnID string `json:"initial_turn_id,omitempty"` | |
| 71 | 74 | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 72 | 75 | Profile string `json:"profile,omitempty"` | |
| @@ -193,6 +196,7 @@ type ResidentMetadata struct { | |||
| 193 | 196 | Version int `json:"version"` | |
| 194 | 197 | ID string `json:"id"` | |
| 195 | 198 | SessionID string `json:"session_id"` | |
| 199 | + | RootCacheID string `json:"root_cache_id,omitempty"` | |
| 196 | 200 | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 197 | 201 | State ResidentState `json:"state"` | |
| 198 | 202 | UpdatedAt time.Time `json:"updated_at"` | |
| @@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, | |||
| 491 | 495 | usage := j.usageSnapshot() | |
| 492 | 496 | return ResidentMetadata{ | |
| 493 | 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 | 499 | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 496 | 500 | } | |
| 497 | 501 | } | |
| @@ -65,8 +65,11 @@ const ( | |||||
| 65 | // provider endpoint and TLS mode are retained so explicit resume reconstructs | 65 | // 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 | |||
| 25 | 25 | spec := ResidentChildSpec{ | |
| 26 | 26 | ID: "child-1", | |
| 27 | 27 | SessionID: "child-session-1", | |
| 28 | + | RootCacheID: "root-cache-1", | |
| 28 | 29 | ParentSessionID: "parent-session-1", | |
| 29 | 30 | Provider: "openai-codex", | |
| 30 | 31 | Model: "gpt-5.6-terra", | |
| @@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 42 | 43 | if len(records) != 1 || records[0].Type != residentRecordAccepted { | |
| 43 | 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 | 47 | t.Fatalf("accepted record = %#v", records[0]) | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 50 | 51 | if err != nil { | |
| 51 | 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 | 55 | t.Fatalf("metadata = %#v", meta) | |
| 55 | 56 | } | |
| 56 | 57 | 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 { | |||
| 166 | 166 | // a running tool or cancels an in-flight provider request. | |
| 167 | 167 | queued []queuedMessage | |
| 168 | 168 | 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. | |
| 172 | 173 | sessionID string | |
| 174 | + | cacheSessionID string | |
| 173 | 175 | 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 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. | |
| 194 | 197 | 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") | |
| 198 | 208 | } | |
| 199 | 209 | a.mu.Lock() | |
| 200 | 210 | defer a.mu.Unlock() | |
| 201 | 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 = id | |
| 214 | + | a.cacheSessionID = cacheSessionID | |
| 215 | + | a.sessionID = threadID | |
| 205 | 216 | return nil | |
| 206 | 217 | } | |
| 207 | 218 | ||
| 208 | - | // SessionID returns the stable logical session identity, allocating an | |
| 219 | + | // SessionID returns the stable conversation-thread identity, allocating an | |
| 209 | 220 | // ephemeral identity when persistence is disabled and the host did not bind | |
| 210 | 221 | // one during runtime assembly. | |
| 211 | 222 | func (a *Agent) SessionID() string { | |
| 212 | 223 | a.mu.Lock() | |
| 213 | 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 | 238 | if a.sessionID == "" { | |
| 215 | 239 | a.sessionID = uuid.NewString() | |
| 216 | 240 | } | |
| 217 | - | return a.sessionID | |
| 241 | + | if a.cacheSessionID == "" { | |
| 242 | + | a.cacheSessionID = a.sessionID | |
| 243 | + | } | |
| 218 | 244 | } | |
| 219 | 245 | ||
| 220 | 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 | 254 | 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 []queuedMessage | 167 | queued []queuedMessage | ||
| 168 | timeContext agentTimeContext | 168 | timeContext agentTimeContext | ||
| 169 | - | // sessionID is assigned by the host from the durable session before the | 169 | + | // sessionID identifies this conversation thread. cacheSessionID identifies |
| 170 | - | // first provider request. Agents without persistence allocate one lazily at | 170 | + | // 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 string | 173 | sessionID string | ||
| 174 | + | cacheSessionID string | |||
| 173 | providerRequestStarted bool | 175 | 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 first | 193 | + | // 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 = id | 214 | + | a.cacheSessionID = cacheSessionID |
| 215 | + | a.sessionID = threadID | |||
| 205 | return nil | 216 | return nil | ||
| 206 | } | 217 | } | ||
| 207 | 218 | ||||
| 208 | - | // SessionID returns the stable logical session identity, allocating an | 219 | + | // SessionID returns the stable conversation-thread identity, allocating an |
| 209 | // ephemeral identity when persistence is disabled and the host did not bind | 220 | // 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.sessionID | 241 | + | 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 { | |||
| 49 | 49 | return requests | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |
| 52 | + | func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) { | |
| 53 | 53 | client := &identityRecordingClient{failOnce: true} | |
| 54 | 54 | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| 55 | 55 | agent.MaxRetries = 1 | |
| 56 | 56 | 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) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil { | |
| @@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||
| 70 | 70 | t.Fatalf("request count = %d, want 3", len(requests)) | |
| 71 | 71 | } | |
| 72 | 72 | 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) | |
| 75 | 75 | } | |
| 76 | 76 | if request.Context.TurnID == "" { | |
| 77 | 77 | t.Fatalf("request %d has empty turn ID", i) | |
| @@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) { | |||
| 83 | 83 | if requests[1].Context.TurnID == requests[2].Context.TurnID { | |
| 84 | 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 { | |
| 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") | |
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | ||
| @@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) { | |||
| 102 | 102 | if len(requests) != 2 { | |
| 103 | 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 { | |
| 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) | |
| 107 | 107 | } | |
| 108 | 108 | } | |
| @@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request { | |||||
| 49 | return requests | 49 | 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 = 1 | 55 | agent.MaxRetries = 1 | ||
| 56 | agent.RetryBaseDelay = time.Millisecond | 56 | 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 { | |||
| 31 | 31 | // Seed replaces the running total with usage restored from a session. | |
| 32 | 32 | func (c *CostTracker) Seed(u provider.Usage) { | |
| 33 | 33 | 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 | |
| 35 | 35 | } | |
| @@ -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 = u | 33 | 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 |
| 35 | } | 35 | } | ||
M packages/core/session.go +2 -0
| @@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e | |||
| 367 | 367 | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | |
| 368 | 368 | lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens) | |
| 369 | 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 | 372 | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | |
| 371 | 373 | if lastTurn.CostUSD < 0 { | |
| 372 | 374 | lastTurn.CostUSD = 0 | |
| @@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e | |||||
| 367 | lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown | 367 | 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.CostUSD | 372 | lastTurn.CostUSD = last.CostUSD - prev.CostUSD | ||
| 371 | if lastTurn.CostUSD < 0 { | 373 | if lastTurn.CostUSD < 0 { | ||
| 372 | lastTurn.CostUSD = 0 | 374 | lastTurn.CostUSD = 0 | ||
M packages/core/session_usage_test.go +39 -0
| @@ -7,6 +7,45 @@ import ( | |||
| 7 | 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 | 49 | func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) { | |
| 11 | 50 | t.Run("compaction is latest row", func(t *testing.T) { | |
| 12 | 51 | 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 | |||
| 656 | 656 | FinishReason string `json:"finish_reason"` | |
| 657 | 657 | } `json:"choices"` | |
| 658 | 658 | 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"` | |
| 664 | 662 | CompletionTokensDetails *struct { | |
| 665 | 663 | ReasoningTokens int `json:"reasoning_tokens"` | |
| 666 | 664 | } `json:"completion_tokens_details"` | |
| @@ -680,12 +678,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 680 | 678 | return | |
| 681 | 679 | } | |
| 682 | 680 | 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) | |
| 689 | 682 | if details := chunk.Usage.CompletionTokensDetails; details != nil { | |
| 690 | 683 | usage.ReasoningTokens = details.ReasoningTokens | |
| 691 | 684 | 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 | return | 678 | return | ||
| 681 | } | 679 | } | ||
| 682 | if chunk.Usage != nil { | 680 | if chunk.Usage != nil { | ||
| 683 | - | usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens | 681 | + | 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.ReasoningTokens | 683 | usage.ReasoningTokens = details.ReasoningTokens | ||
| 691 | usage.ReasoningTokensKnown = true | 684 | usage.ReasoningTokensKnown = true | ||
M packages/provider/openai_codex.go +20 -16
| @@ -53,6 +53,8 @@ type codexClient struct { | |||
| 53 | 53 | type responsesCapabilities struct { | |
| 54 | 54 | StablePromptCacheKey bool | |
| 55 | 55 | SessionHeader bool | |
| 56 | + | ThreadHeader bool | |
| 57 | + | ClientRequestHeader bool | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | |
| @@ -78,6 +80,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||
| 78 | 80 | capabilities: responsesCapabilities{ | |
| 79 | 81 | StablePromptCacheKey: true, | |
| 80 | 82 | SessionHeader: true, | |
| 83 | + | ThreadHeader: true, | |
| 84 | + | ClientRequestHeader: true, | |
| 81 | 85 | }, | |
| 82 | 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 | 387 | wire.Model = c.modelName(wire.Model) | |
| 384 | 388 | } | |
| 385 | 389 | 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 | |
| 389 | 394 | } | |
| 390 | 395 | body, err := json.Marshal(wire) | |
| 391 | 396 | if err != nil { | |
| @@ -408,8 +413,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 408 | 413 | // identities are load-shed with "servers are currently | |
| 409 | 414 | // overloaded" errors even when capacity is fine. | |
| 410 | 415 | 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) | |
| 413 | 424 | } | |
| 414 | 425 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | |
| 415 | 426 | } else { | |
| @@ -659,11 +670,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 659 | 670 | Response struct { | |
| 660 | 671 | ID string `json:"id"` | |
| 661 | 672 | 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"` | |
| 667 | 676 | OutputTokensDetails *struct { | |
| 668 | 677 | ReasoningTokens int `json:"reasoning_tokens"` | |
| 669 | 678 | } `json:"output_tokens_details"` | |
| @@ -672,12 +681,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 672 | 681 | } `json:"response"` | |
| 673 | 682 | } | |
| 674 | 683 | _ = 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) | |
| 681 | 685 | if details := p.Response.Usage.OutputTokensDetails; details != nil { | |
| 682 | 686 | usage.ReasoningTokens = details.ReasoningTokens | |
| 683 | 687 | usage.ReasoningTokensKnown = true | |
| @@ -53,6 +53,8 @@ type codexClient struct { | |||||
| 53 | type responsesCapabilities struct { | 53 | type responsesCapabilities struct { | ||
| 54 | StablePromptCacheKey bool | 54 | StablePromptCacheKey bool | ||
| 55 | SessionHeader bool | 55 | SessionHeader bool | ||
| 56 | + | ThreadHeader bool | |||
| 57 | + | ClientRequestHeader bool | |||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 58 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | 60 | // 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 = logicalSessionID | 392 | + | 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 currently | 413 | // 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.CachedTokens | 684 | + | 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.ReasoningTokens | 686 | usage.ReasoningTokens = details.ReasoningTokens | ||
| 683 | usage.ReasoningTokensKnown = true | 687 | usage.ReasoningTokensKnown = true | ||
M packages/provider/openai_codex_test.go +12 -12
| @@ -92,7 +92,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 92 | 92 | ||
| 93 | 93 | events, err := c.Stream(context.Background(), Request{ | |
| 94 | 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 | 96 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 97 | 97 | }) | |
| 98 | 98 | if err != nil { | |
| @@ -110,11 +110,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 110 | 110 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |
| 111 | 111 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 112 | 112 | } | |
| 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) | |
| 115 | 115 | } | |
| 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")) | |
| 118 | 118 | } | |
| 119 | 119 | } | |
| 120 | 120 | ||
| @@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 187 | 187 | ||
| 188 | 188 | events, err := ws.Stream(context.Background(), Request{ | |
| 189 | 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 | 191 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 192 | 192 | }) | |
| 193 | 193 | if err != nil { | |
| @@ -202,8 +202,8 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 202 | 202 | if gotReq.Header.Get("session-id") != "" { | |
| 203 | 203 | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |
| 204 | 204 | } | |
| 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) | |
| 207 | 207 | } | |
| 208 | 208 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | |
| 209 | 209 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | |
| @@ -220,7 +220,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T | |||
| 220 | 220 | } | |
| 221 | 221 | 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 | 224 | if err != nil { | |
| 225 | 225 | t.Fatal(err) | |
| 226 | 226 | } | |
| @@ -272,7 +272,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||
| 272 | 272 | ||
| 273 | 273 | events, err := c.Stream(context.Background(), Request{ | |
| 274 | 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 | 276 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 277 | 277 | }) | |
| 278 | 278 | if err != nil { | |
| @@ -290,8 +290,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { | |||
| 290 | 290 | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |
| 291 | 291 | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 292 | 292 | } | |
| 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")) | |
| 295 | 295 | } | |
| 296 | 296 | if !strings.Contains(body.String(), "prompt_cache_key") { | |
| 297 | 297 | 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(""))}, nil | 221 | 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) { | |||
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | 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. | |
| 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 == "" { | |
| 160 | 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 | 165 | select { | |
| 165 | 166 | case <-ctx.Done(): | |
| 166 | 167 | return nil, ctx.Err() | |
| @@ -175,7 +176,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 175 | 176 | }() | |
| 176 | 177 | ||
| 177 | 178 | streamReq, previousResponseID := session.incrementalRequest(req) | |
| 178 | - | payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID) | |
| 179 | + | payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID) | |
| 179 | 180 | if err != nil { | |
| 180 | 181 | return nil, err | |
| 181 | 182 | } | |
| @@ -215,7 +216,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 215 | 216 | // HTTP/SSE. That keeps the stable cache key and logical IDs intact | |
| 216 | 217 | // without ever reusing the invalid previous_response_id. | |
| 217 | 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 | 220 | if err != nil { | |
| 220 | 221 | c.forwardResponsesHTTP(ctx, req, out) | |
| 221 | 222 | return | |
| @@ -254,7 +255,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 254 | 255 | 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 | 259 | wire, err := c.http.buildRequest(req) | |
| 259 | 260 | if err != nil { | |
| 260 | 261 | return nil, err | |
| @@ -262,8 +263,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||
| 262 | 263 | if c.http.modelName != nil { | |
| 263 | 264 | wire.Model = c.http.modelName(wire.Model) | |
| 264 | 265 | } | |
| 265 | - | if c.http.capabilities.StablePromptCacheKey { | |
| 266 | - | wire.PromptCacheKey = sessionID | |
| 266 | + | if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" { | |
| 267 | + | wire.PromptCacheKey = cacheSessionID | |
| 267 | 268 | } | |
| 268 | 269 | wire.Stream = false // WebSocket mode does not send HTTP stream controls. | |
| 269 | 270 | wire.PreviousResponseID = previousResponseID | |
| @@ -276,8 +277,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||
| 276 | 277 | // reconnectFull establishes one replacement WebSocket turn after a cached | |
| 277 | 278 | // continuation failed. A fresh connection cannot rely on the previous server | |
| 278 | 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 | - | 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, "") | |
| 281 | 282 | if err != nil { | |
| 282 | 283 | return nil, nil, sseEvent{}, "", err | |
| 283 | 284 | } | |
| @@ -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 than | 156 | + | // 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, err | 181 | 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 intact | 216 | // 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 | return | 222 | return | ||
| @@ -254,7 +255,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 254 | return out, nil | 255 | 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, err | 261 | 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 = sessionID | 267 | + | 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 = previousResponseID | 270 | wire.PreviousResponseID = previousResponseID | ||
| @@ -276,8 +277,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev | |||||
| 276 | // reconnectFull establishes one replacement WebSocket turn after a cached | 277 | // reconnectFull establishes one replacement WebSocket turn after a cached | ||
| 277 | // continuation failed. A fresh connection cannot rely on the previous server | 278 | // 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{}, "", err | 283 | 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 | |||
| 80 | 80 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | |
| 81 | 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 | 84 | assertCompletedResponse(t, client, first) | |
| 85 | 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 | 87 | assertCompletedResponseText(t, client, second, "reconnected") | |
| 88 | 88 | mu.Lock() | |
| 89 | 89 | defer mu.Unlock() | |
| @@ -163,14 +163,14 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 163 | 163 | first := Request{ | |
| 164 | 164 | Model: "gpt-5.6-sol", | |
| 165 | 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 | 167 | Lifecycle: lifecycle, | |
| 168 | 168 | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}, | |
| 169 | 169 | } | |
| 170 | 170 | assertCompletedResponse(t, client, first) | |
| 171 | 171 | second := Request{ | |
| 172 | 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 | 174 | Messages: append(append([]Message(nil), first.Messages...), | |
| 175 | 175 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | |
| 176 | 176 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| @@ -195,7 +195,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 195 | 195 | if _, ok := requests[0]["stream"]; ok { | |
| 196 | 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 | 199 | t.Fatalf("prompt_cache_key = %#v", got) | |
| 200 | 200 | } | |
| 201 | 201 | if got := requests[0]["service_tier"]; got != fastModeServiceTier { | |
| @@ -242,7 +242,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) | |||
| 242 | 242 | capabilities: responsesCapabilities{StablePromptCacheKey: true}, | |
| 243 | 243 | }) | |
| 244 | 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 | 246 | if err != nil { | |
| 247 | 247 | t.Fatal(err) | |
| 248 | 248 | } | |
| @@ -290,9 +290,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin | |||
| 290 | 290 | token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai", | |
| 291 | 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 | 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 | 296 | assertCompletedResponseText(t, client, second, "second") | |
| 297 | 297 | } | |
| 298 | 298 | ||
| @@ -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 { | |||
| 171 | 171 | ||
| 172 | 172 | // Usage aggregates token counts and cost for a turn. | |
| 173 | 173 | 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"` | |
| 181 | 187 | } | |
| 182 | 188 | ||
| 183 | 189 | // PromptTokens returns all input tokens, regardless of cache disposition. | |
| @@ -185,28 +191,30 @@ func (u Usage) PromptTokens() int { | |||
| 185 | 191 | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | |
| 186 | 192 | } | |
| 187 | 193 | ||
| 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. | |
| 191 | 198 | func (u Usage) CacheHitRatio() (float64, bool) { | |
| 192 | - | total := u.PromptTokens() | |
| 193 | - | if total <= 0 || u.CacheReadTokens <= 0 { | |
| 199 | + | if u.CacheMeasuredPromptTokens <= 0 { | |
| 194 | 200 | return 0, false | |
| 195 | 201 | } | |
| 196 | - | return float64(u.CacheReadTokens) / float64(total), true | |
| 202 | + | return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true | |
| 197 | 203 | } | |
| 198 | 204 | ||
| 199 | 205 | // Add returns u plus v. A reasoning total is known only when both | |
| 200 | 206 | // component usage reports include a separate reasoning-token count. | |
| 201 | 207 | func (u Usage) Add(v Usage) Usage { | |
| 202 | 208 | 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, | |
| 210 | 218 | } | |
| 211 | 219 | } | |
| 212 | 220 | ||
| @@ -312,13 +320,15 @@ type RequestFailureLifecycle interface { | |||
| 312 | 320 | RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool) | |
| 313 | 321 | } | |
| 314 | 322 | ||
| 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. | |
| 319 | 328 | type RequestContext struct { | |
| 320 | - | SessionID string | |
| 321 | - | TurnID string | |
| 329 | + | CacheSessionID string | |
| 330 | + | ThreadID string | |
| 331 | + | TurnID string | |
| 322 | 332 | } | |
| 323 | 333 | ||
| 324 | 334 | // 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.CacheWriteTokens | 191 | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | ||
| 186 | } | 192 | } | ||
| 187 | 193 | ||||
| 188 | - | // CacheHitRatio returns the share of prompt tokens served from cache. The | 194 | + | // CacheHitRatio returns the share of provider-measured prompt tokens served |
| 189 | - | // boolean is false when the provider reported no cache reads, which avoids | 195 | + | // 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, false | 200 | return 0, false | ||
| 195 | } | 201 | } | ||
| 196 | - | return float64(u.CacheReadTokens) / float64(total), true | 202 | + | 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 both | 205 | // 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 translate | 324 | + | // accepted turn for one logical agent request. Values are opaque |
| 317 | - | // them into cache controls or session-affinity fields, but must not replace | 325 | + | // 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 string | 329 | + | CacheSessionID string |
| 321 | - | TurnID string | 330 | + | 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 ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 16 | if got := u.PromptTokens(); got != 210_000 { | |
| 17 | 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) { | |
| 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) | |
| 28 | 47 | } | |
| 29 | 48 | } | |
| 30 | 49 | ||
| @@ -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) { | |||
| 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 | 89 | func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) { | |
| 32 | 90 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 33 | 91 | 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 ( | |||
| 10 | 10 | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |
| 11 | 11 | lines := StatusBar(StatusBarParams{ | |
| 12 | 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 | 14 | ContextUsed: 45_152, ContextMax: 272_000, | |
| 15 | 15 | }) | |
| 16 | 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 | 33 | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | |
| 23 | 34 | const cols = 30 | |
| 24 | 35 | lines := StatusBar(StatusBarParams{ | |
| 25 | 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 | 38 | ContextUsed: 209_000, ContextMax: 272_000, | |
| 28 | 39 | }) | |
| 29 | 40 | 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 = 30 | 34 | 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")) | ||