detached cabc432

Merge pull request #141 from bnema/fix/stable-tool-result-history

fix(openai): preserve prompt cache prefixes
commit
cabc432a9aedea5849c0838d2680c3fc5d414807
author
bnema · · 2026-08-18 13:41 UTC
committer
GitHub · · 2026-08-18 13:41 UTC
merge of
aa7307c + 7aad900 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +232 -152
M packages/core/context_budget.go +13 -31
@@ -9,48 +9,30 @@ import (
99 )
1010
1111 const (
12- maxToolResultTextBytes = 32 * 1024
13- maxToolResultTotalTextBytes = 128 * 1024
14- toolResultOmissionMarker = "[tool result content omitted]"
12+ maxToolResultTextBytes = 32 * 1024
13+ toolResultOmissionMarker = "[tool result content omitted]"
1514 )
1615
1716 // projectToolResultMessages returns a copied provider-input view of msgs.
18-// Text inside each tool result is bounded independently and then against the
19-// total budget, with newer results receiving the remaining budget first.
20-// Tool-result blocks and all non-text content remain in the projection so
21-// tool-call/result pairing stays valid.
17+// Each tool result is bounded independently. Its projection must depend only
18+// on that result: retroactively shrinking older results when a new one arrives
19+// mutates the prompt prefix and defeats provider prompt caching. Compaction
20+// owns the aggregate transcript budget.
2221 func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2322 projected := copyToolResultMessages(msgs)
24- remaining := maxToolResultTotalTextBytes
2523
26- for i := len(projected) - 1; i >= 0; i-- {
27- for j := len(projected[i].Content) - 1; j >= 0; j-- {
28- result, ok := projected[i].Content[j].(provider.ToolResultBlock)
24+ for i := range projected {
25+ for j, content := range projected[i].Content {
26+ result, ok := content.(provider.ToolResultBlock)
2927 if !ok {
3028 continue
3129 }
3230
33- timingText := ""
34- if result.Timing != nil {
35- candidate := formatToolTiming(result.Timing)
36- if len(candidate) <= remaining {
37- timingText = candidate
38- remaining -= len(candidate)
39- }
40- }
41-
42- textBytes := toolResultTextBytes(result.Content)
43- if textBytes > 0 {
44- limit := maxToolResultTextBytes
45- if remaining < limit {
46- limit = remaining
47- }
48- var retained int
49- result.Content, retained = projectToolResultContent(result.Content, limit)
50- remaining -= retained
31+ if toolResultTextBytes(result.Content) > 0 {
32+ result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
5133 }
52- if timingText != "" {
53- result.Content = append(result.Content, provider.TextBlock{Text: timingText})
34+ if result.Timing != nil {
35+ result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
5436 }
5537 projected[i].Content[j] = result
5638 }
@@ -9,48 +9,30 @@ import (
9 )9 )
10 10
11 const (11 const (
12- maxToolResultTextBytes = 32 * 102412+ maxToolResultTextBytes = 32 * 1024
13- maxToolResultTotalTextBytes = 128 * 102413+ toolResultOmissionMarker = "[tool result content omitted]"
14- toolResultOmissionMarker = "[tool result content omitted]"
15 )14 )
16 15
17 // projectToolResultMessages returns a copied provider-input view of msgs.16 // projectToolResultMessages returns a copied provider-input view of msgs.
18-// Text inside each tool result is bounded independently and then against the17+// Each tool result is bounded independently. Its projection must depend only
19-// total budget, with newer results receiving the remaining budget first.18+// on that result: retroactively shrinking older results when a new one arrives
20-// Tool-result blocks and all non-text content remain in the projection so19+// mutates the prompt prefix and defeats provider prompt caching. Compaction
21-// tool-call/result pairing stays valid.20+// owns the aggregate transcript budget.
22 func projectToolResultMessages(msgs []provider.Message) []provider.Message {21 func projectToolResultMessages(msgs []provider.Message) []provider.Message {
23 projected := copyToolResultMessages(msgs)22 projected := copyToolResultMessages(msgs)
24- remaining := maxToolResultTotalTextBytes
25 23
26- for i := len(projected) - 1; i >= 0; i-- {24+ for i := range projected {
27- for j := len(projected[i].Content) - 1; j >= 0; j-- {25+ for j, content := range projected[i].Content {
28- result, ok := projected[i].Content[j].(provider.ToolResultBlock)26+ result, ok := content.(provider.ToolResultBlock)
29 if !ok {27 if !ok {
30 continue28 continue
31 }29 }
32 30
33- timingText := ""31+ if toolResultTextBytes(result.Content) > 0 {
34- if result.Timing != nil {32+ result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
35- candidate := formatToolTiming(result.Timing)
36- if len(candidate) <= remaining {
37- timingText = candidate
38- remaining -= len(candidate)
39- }
40- }
41-
42- textBytes := toolResultTextBytes(result.Content)
43- if textBytes > 0 {
44- limit := maxToolResultTextBytes
45- if remaining < limit {
46- limit = remaining
47- }
48- var retained int
49- result.Content, retained = projectToolResultContent(result.Content, limit)
50- remaining -= retained
51 }33 }
52- if timingText != "" {34+ if result.Timing != nil {
53- result.Content = append(result.Content, provider.TextBlock{Text: timingText})35+ result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
54 }36 }
55 projected[i].Content[j] = result37 projected[i].Content[j] = result
56 }38 }
M packages/core/context_budget_test.go +36 -62
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "fmt"
56 "reflect"
67 "strings"
78 "testing"
@@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
9394 }
9495 }
9596
96-func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) {
97- started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
98- timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second}
99- msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1)
100- for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ {
101- msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
102- CallID: string(rune('a' + i)),
103- Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}},
104- }}})
105- }
106- msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
107- CallID: "timed-empty", Timing: timing,
108- }}})
109-
110- projected := projectToolResultMessages(msgs)
111- if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) {
112- t.Fatalf("empty timed result = %q, want timing annotation", got)
113- }
114- var total int
115- for _, message := range projected {
116- for _, content := range message.Content {
117- if result, ok := content.(provider.ToolResultBlock); ok {
118- total += retainedToolResultTextBytes(result.Content)
119- }
120- }
121- }
122- if total > maxToolResultTotalTextBytes {
123- t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
97+func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {
98+ messages := make([]provider.Message, 0, 4)
99+ for i := 0; i < 4; i++ {
100+ messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
101+ provider.ToolResultBlock{
102+ CallID: fmt.Sprintf("call-%d", i),
103+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat(string(rune('a'+i)), maxToolResultTextBytes)}},
104+ },
105+ }})
124106 }
125- if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) {
126- t.Fatalf("oldest result = %q, want truncation after timing reservation", got)
107+
108+ before := projectToolResultMessages(messages)
109+ messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
110+ provider.ToolResultBlock{
111+ CallID: "new-call",
112+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("new", maxToolResultTextBytes)}},
113+ },
114+ }})
115+ after := projectToolResultMessages(messages)
116+
117+ if !reflect.DeepEqual(after[:len(before)], before) {
118+ t.Fatal("appending a tool result changed the provider-visible historical prefix")
127119 }
128120 }
129121
@@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
147139 }
148140
149141 projected := projectToolResultMessages(msgs)
150- total := 0
151142 for _, message := range projected {
152143 for _, content := range message.Content {
153144 result, ok := content.(provider.ToolResultBlock)
@@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
166157 t.Fatalf("result %q contains invalid UTF-8", result.CallID)
167158 }
168159 }
169- total += retainedBytes
170160 }
171161 }
172- if total > maxToolResultTotalTextBytes {
173- t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
174- }
175162 }
176163
177-func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
164+func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) {
178165 msgs := make([]provider.Message, 0, 10)
179166 for i := 1; i <= 5; i++ {
180167 id := "call-" + string(rune('0'+i))
@@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
192179 }
193180
194181 projected := projectToolResultMessages(msgs)
195- oldest := toolResultTextForCall(projected, "call-1")
196- newest := toolResultTextForCall(projected, "call-5")
197- if oldest != toolResultOmissionMarker {
198- t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest)
199- }
200- if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) {
201- t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)])
182+ for i := 1; i <= 5; i++ {
183+ id := "call-" + string(rune('0'+i))
184+ got := toolResultTextForCall(projected, id)
185+ if !strings.HasPrefix(got, "result-"+id+" ") || !strings.Contains(got, toolResultOmissionMarker) {
186+ t.Fatalf("result %s was not independently retained and truncated: %q", id, got[:min(len(got), 80)])
187+ }
202188 }
203189 }
204190
205-func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) {
206- const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1
191+func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) {
192+ const resultCount = 5
207193 msgs := make([]provider.Message, 0, resultCount)
208194 for i := 0; i < resultCount; i++ {
209195 id := "call-" + string(rune('1'+i))
@@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t
230216 retainedTotal += retainedToolResultTextBytes(result.Content)
231217 }
232218 }
233- if retainedTotal != maxToolResultTotalTextBytes {
234- t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes)
219+ if retainedTotal != resultCount*maxToolResultTextBytes {
220+ t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes)
235221 }
236- if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker {
237- t.Fatalf("aggregate-exhausted result = %q, want omission marker", got)
222+ if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker {
223+ t.Fatalf("old result was discarded after later results: %q", got)
238224 }
239225 }
240226
@@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
291277 t.Fatalf("Continue: %v", err)
292278 }
293279 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
294- if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
280+ if !strings.Contains(projectedText, toolResultOmissionMarker) {
295281 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
296282 }
297283 if !strings.Contains(projectedText, formatToolTiming(timing)) {
@@ -373,18 +359,6 @@ func hasToolResultOmissionMarker(content []provider.Content) bool {
373359 return false
374360 }
375361
376-func retainedToolResultTextBytesForCall(messages []provider.Message, callID string) int {
377- for _, message := range messages {
378- for _, content := range message.Content {
379- result, ok := content.(provider.ToolResultBlock)
380- if ok && result.CallID == callID {
381- return retainedToolResultTextBytes(result.Content)
382- }
383- }
384- }
385- return 0
386-}
387-
388362 func toolResultTextForCall(messages []provider.Message, callID string) string {
389363 var text strings.Builder
390364 for _, message := range messages {
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "reflect"6 "reflect"
6 "strings"7 "strings"
7 "testing"8 "testing"
@@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
93 }94 }
94 }95 }
95 96
96-func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) {97+func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {
97- started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)98+ messages := make([]provider.Message, 0, 4)
98- timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second}99+ for i := 0; i < 4; i++ {
99- msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1)100+ messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
100- for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ {101+ provider.ToolResultBlock{
101- msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{102+ CallID: fmt.Sprintf("call-%d", i),
102- CallID: string(rune('a' + i)),103+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat(string(rune('a'+i)), maxToolResultTextBytes)}},
103- Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}},104+ },
104- }}})105+ }})
105- }
106- msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
107- CallID: "timed-empty", Timing: timing,
108- }}})
109-
110- projected := projectToolResultMessages(msgs)
111- if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) {
112- t.Fatalf("empty timed result = %q, want timing annotation", got)
113- }
114- var total int
115- for _, message := range projected {
116- for _, content := range message.Content {
117- if result, ok := content.(provider.ToolResultBlock); ok {
118- total += retainedToolResultTextBytes(result.Content)
119- }
120- }
121- }
122- if total > maxToolResultTotalTextBytes {
123- t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
124 }106 }
125- if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) {107+
126- t.Fatalf("oldest result = %q, want truncation after timing reservation", got)108+ before := projectToolResultMessages(messages)
109+ messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
110+ provider.ToolResultBlock{
111+ CallID: "new-call",
112+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("new", maxToolResultTextBytes)}},
113+ },
114+ }})
115+ after := projectToolResultMessages(messages)
116+
117+ if !reflect.DeepEqual(after[:len(before)], before) {
118+ t.Fatal("appending a tool result changed the provider-visible historical prefix")
127 }119 }
128 }120 }
129 121
@@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
147 }139 }
148 140
149 projected := projectToolResultMessages(msgs)141 projected := projectToolResultMessages(msgs)
150- total := 0
151 for _, message := range projected {142 for _, message := range projected {
152 for _, content := range message.Content {143 for _, content := range message.Content {
153 result, ok := content.(provider.ToolResultBlock)144 result, ok := content.(provider.ToolResultBlock)
@@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
166 t.Fatalf("result %q contains invalid UTF-8", result.CallID)157 t.Fatalf("result %q contains invalid UTF-8", result.CallID)
167 }158 }
168 }159 }
169- total += retainedBytes
170 }160 }
171 }161 }
172- if total > maxToolResultTotalTextBytes {
173- t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
174- }
175 }162 }
176 163
177-func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {164+func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) {
178 msgs := make([]provider.Message, 0, 10)165 msgs := make([]provider.Message, 0, 10)
179 for i := 1; i <= 5; i++ {166 for i := 1; i <= 5; i++ {
180 id := "call-" + string(rune('0'+i))167 id := "call-" + string(rune('0'+i))
@@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
192 }179 }
193 180
194 projected := projectToolResultMessages(msgs)181 projected := projectToolResultMessages(msgs)
195- oldest := toolResultTextForCall(projected, "call-1")182+ for i := 1; i <= 5; i++ {
196- newest := toolResultTextForCall(projected, "call-5")183+ id := "call-" + string(rune('0'+i))
197- if oldest != toolResultOmissionMarker {184+ got := toolResultTextForCall(projected, id)
198- t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest)185+ if !strings.HasPrefix(got, "result-"+id+" ") || !strings.Contains(got, toolResultOmissionMarker) {
199- }186+ t.Fatalf("result %s was not independently retained and truncated: %q", id, got[:min(len(got), 80)])
200- if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) {187+ }
201- t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)])
202 }188 }
203 }189 }
204 190
205-func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) {191+func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) {
206- const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1192+ const resultCount = 5
207 msgs := make([]provider.Message, 0, resultCount)193 msgs := make([]provider.Message, 0, resultCount)
208 for i := 0; i < resultCount; i++ {194 for i := 0; i < resultCount; i++ {
209 id := "call-" + string(rune('1'+i))195 id := "call-" + string(rune('1'+i))
@@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t
230 retainedTotal += retainedToolResultTextBytes(result.Content)216 retainedTotal += retainedToolResultTextBytes(result.Content)
231 }217 }
232 }218 }
233- if retainedTotal != maxToolResultTotalTextBytes {219+ if retainedTotal != resultCount*maxToolResultTextBytes {
234- t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes)220+ t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes)
235 }221 }
236- if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker {222+ if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker {
237- t.Fatalf("aggregate-exhausted result = %q, want omission marker", got)223+ t.Fatalf("old result was discarded after later results: %q", got)
238 }224 }
239 }225 }
240 226
@@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
291 t.Fatalf("Continue: %v", err)277 t.Fatalf("Continue: %v", err)
292 }278 }
293 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")279 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
294- if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {280+ if !strings.Contains(projectedText, toolResultOmissionMarker) {
295 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])281 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
296 }282 }
297 if !strings.Contains(projectedText, formatToolTiming(timing)) {283 if !strings.Contains(projectedText, formatToolTiming(timing)) {
@@ -373,18 +359,6 @@ func hasToolResultOmissionMarker(content []provider.Content) bool {
373 return false359 return false
374 }360 }
375 361
376-func retainedToolResultTextBytesForCall(messages []provider.Message, callID string) int {
377- for _, message := range messages {
378- for _, content := range message.Content {
379- result, ok := content.(provider.ToolResultBlock)
380- if ok && result.CallID == callID {
381- return retainedToolResultTextBytes(result.Content)
382- }
383- }
384- }
385- return 0
386-}
387-
388 func toolResultTextForCall(messages []provider.Message, callID string) string {362 func toolResultTextForCall(messages []provider.Message, callID string) string {
389 var text strings.Builder363 var text strings.Builder
390 for _, message := range messages {364 for _, message := range messages {
M packages/core/session_developer_test.go +6 -1
@@ -21,10 +21,15 @@ func TestOpenSessionHydratesDeveloperMessage(t *testing.T) {
2121 if err := session.Close(); err != nil {
2222 t.Fatal(err)
2323 }
24- _, messages, err := OpenSession(path)
24+ opened, messages, err := OpenSession(path)
2525 if err != nil {
2626 t.Fatalf("OpenSession: %v", err)
2727 }
28+ t.Cleanup(func() {
29+ if err := opened.Close(); err != nil {
30+ t.Errorf("close opened session: %v", err)
31+ }
32+ })
2833 if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper {
2934 t.Fatalf("messages = %#v, want one developer message", messages)
3035 }
@@ -21,10 +21,15 @@ func TestOpenSessionHydratesDeveloperMessage(t *testing.T) {
21 if err := session.Close(); err != nil {21 if err := session.Close(); err != nil {
22 t.Fatal(err)22 t.Fatal(err)
23 }23 }
24- _, messages, err := OpenSession(path)24+ opened, messages, err := OpenSession(path)
25 if err != nil {25 if err != nil {
26 t.Fatalf("OpenSession: %v", err)26 t.Fatalf("OpenSession: %v", err)
27 }27 }
28+ t.Cleanup(func() {
29+ if err := opened.Close(); err != nil {
30+ t.Errorf("close opened session: %v", err)
31+ }
32+ })
28 if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper {33 if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper {
29 t.Fatalf("messages = %#v, want one developer message", messages)34 t.Fatalf("messages = %#v, want one developer message", messages)
30 }35 }
M packages/provider/deferred_tools_test.go +1 -1
@@ -97,7 +97,7 @@ func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
9797 },
9898 "openai-codex": func(req Request) ([]string, error) {
9999 req.Model = "gpt-5.5"
100- wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
100+ wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req)
101101 if err != nil {
102102 return nil, err
103103 }
@@ -97,7 +97,7 @@ func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
97 },97 },
98 "openai-codex": func(req Request) ([]string, error) {98 "openai-codex": func(req Request) ([]string, error) {
99 req.Model = "gpt-5.5"99 req.Model = "gpt-5.5"
100- wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)100+ wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req)
101 if err != nil {101 if err != nil {
102 return nil, err102 return nil, err
103 }103 }
M packages/provider/fast_mode_test.go +1 -1
@@ -27,7 +27,7 @@ func TestOpenAIFastModeAddsServiceTier(t *testing.T) {
2727 }
2828
2929 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {
30- client := NewOpenAICodex("token", "account", "").(*codexClient)
30+ client := newOpenAICodexClient("token", "account", "")
3131
3232 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})
3333 if err != nil {
@@ -27,7 +27,7 @@ func TestOpenAIFastModeAddsServiceTier(t *testing.T) {
27 }27 }
28 28
29 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {29 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {
30- client := NewOpenAICodex("token", "account", "").(*codexClient)30+ client := newOpenAICodexClient("token", "account", "")
31 31
32 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})32 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})
33 if err != nil {33 if err != nil {
M packages/provider/httpclient.go +5 -1
@@ -46,7 +46,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
4646 case *codexClient:
4747 v.http = httpClient
4848 case *responsesWebSocketClient:
49- v.http.http = openaiResponsesHTTPClient(httpClient)
49+ if v.http.accountID == "" {
50+ v.http.http = openaiResponsesHTTPClient(httpClient)
51+ } else {
52+ v.http.http = httpClient
53+ }
5054 case *renamedClient:
5155 if codex, ok := v.inner.(*codexClient); ok {
5256 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {
@@ -46,7 +46,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
46 case *codexClient:46 case *codexClient:
47 v.http = httpClient47 v.http = httpClient
48 case *responsesWebSocketClient:48 case *responsesWebSocketClient:
49- v.http.http = openaiResponsesHTTPClient(httpClient)49+ if v.http.accountID == "" {
50+ v.http.http = openaiResponsesHTTPClient(httpClient)
51+ } else {
52+ v.http.http = httpClient
53+ }
50 case *renamedClient:54 case *renamedClient:
51 if codex, ok := v.inner.(*codexClient); ok {55 if codex, ok := v.inner.(*codexClient); ok {
52 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {56 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {
M packages/provider/openai_codex.go +48 -29
@@ -58,10 +58,15 @@ type responsesCapabilities struct {
5858 ClientRequestHeader bool
5959 }
6060
61-// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
62-// using a subscription OAuth access token and the user's ChatGPT
63-// account id. baseURL may be empty to use the default.
61+// NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint
62+// using a subscription OAuth access token and the user's ChatGPT account ID.
63+// It prefers WebSocket mode and falls back to HTTP/SSE before visible output.
64+// baseURL may be empty to use the default.
6465 func NewOpenAICodex(token, accountID, baseURL string) Client {
66+ return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL))
67+}
68+
69+func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
6570 if baseURL == "" {
6671 baseURL = codexDefaultBaseURL
6772 }
@@ -444,7 +449,6 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
444449 }
445450 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
446451 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
447- threadID := strings.TrimSpace(req.Context.ThreadID)
448452 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449453 wire.PromptCacheKey = cacheSessionID
450454 }
@@ -458,33 +462,9 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
458462 if err != nil {
459463 return nil, err
460464 }
465+ httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
461466 httpReq.Header.Set("content-type", "application/json")
462467 httpReq.Header.Set("accept", "text/event-stream")
463- httpReq.Header.Set("authorization", "Bearer "+c.token)
464- httpReq.Header.Set("chatgpt-account-id", c.accountID)
465- httpReq.Header.Set("openai-beta", "responses=experimental")
466- if useCodexCLIRouting {
467- // The ChatGPT Codex backend only admits (and reliably serves)
468- // requests that follow Codex CLI routing metadata; other client
469- // identities are load-shed with "servers are currently
470- // overloaded" errors even when capacity is fine.
471- httpReq.Header.Set("originator", "codex_cli_rs")
472- if c.capabilities.SessionHeader && cacheSessionID != "" {
473- httpReq.Header.Set("session-id", cacheSessionID)
474- }
475- if c.capabilities.ThreadHeader && threadID != "" {
476- httpReq.Header.Set("thread-id", threadID)
477- }
478- if c.capabilities.ClientRequestHeader && threadID != "" {
479- httpReq.Header.Set("x-client-request-id", threadID)
480- }
481- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
482- } else {
483- // ChatGPT's backend recognizes these established values. They are
484- // external provider metadata, not zut product identifiers.
485- httpReq.Header.Set("originator", "zot")
486- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
487- }
488468 return httpReq, nil
489469 }
490470
@@ -503,6 +483,45 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
503483 return out, nil
504484 }
505485
486+func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header {
487+ headers := make(http.Header)
488+ headers.Set("authorization", "Bearer "+c.token)
489+ if !websocketMode || c.accountID != "" {
490+ headers.Set("openai-beta", "responses=experimental")
491+ }
492+ if c.accountID != "" {
493+ headers.Set("chatgpt-account-id", c.accountID)
494+ }
495+
496+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
497+ threadID := strings.TrimSpace(req.Context.ThreadID)
498+ if useCodexCLIRouting {
499+ // The ChatGPT Codex backend only admits (and reliably serves)
500+ // requests that follow Codex CLI routing metadata; other client
501+ // identities are load-shed even when capacity is available.
502+ headers.Set("originator", "codex_cli_rs")
503+ if c.capabilities.SessionHeader && cacheSessionID != "" {
504+ headers.Set("session-id", cacheSessionID)
505+ }
506+ if c.capabilities.ThreadHeader && threadID != "" {
507+ headers.Set("thread-id", threadID)
508+ }
509+ if c.capabilities.ClientRequestHeader && threadID != "" {
510+ headers.Set("x-client-request-id", threadID)
511+ }
512+ headers.Set("user-agent", "codex_cli_rs/0.0.0")
513+ return headers
514+ }
515+
516+ // ChatGPT's backend recognizes these established values. They are
517+ // external provider metadata, not zut product identifiers.
518+ if c.accountID != "" {
519+ headers.Set("originator", "zot")
520+ headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
521+ }
522+ return headers
523+}
524+
506525 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
507526 defer close(out)
508527 defer resp.Body.Close()
@@ -58,10 +58,15 @@ type responsesCapabilities struct {
58 ClientRequestHeader bool58 ClientRequestHeader bool
59 }59 }
60 60
61-// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint61+// NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint
62-// using a subscription OAuth access token and the user's ChatGPT62+// using a subscription OAuth access token and the user's ChatGPT account ID.
63-// account id. baseURL may be empty to use the default.63+// It prefers WebSocket mode and falls back to HTTP/SSE before visible output.
64+// baseURL may be empty to use the default.
64 func NewOpenAICodex(token, accountID, baseURL string) Client {65 func NewOpenAICodex(token, accountID, baseURL string) Client {
66+ return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL))
67+}
68+
69+func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
65 if baseURL == "" {70 if baseURL == "" {
66 baseURL = codexDefaultBaseURL71 baseURL = codexDefaultBaseURL
67 }72 }
@@ -444,7 +449,6 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
444 }449 }
445 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))450 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
446 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)451 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
447- threadID := strings.TrimSpace(req.Context.ThreadID)
448 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {452 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449 wire.PromptCacheKey = cacheSessionID453 wire.PromptCacheKey = cacheSessionID
450 }454 }
@@ -458,33 +462,9 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
458 if err != nil {462 if err != nil {
459 return nil, err463 return nil, err
460 }464 }
465+ httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
461 httpReq.Header.Set("content-type", "application/json")466 httpReq.Header.Set("content-type", "application/json")
462 httpReq.Header.Set("accept", "text/event-stream")467 httpReq.Header.Set("accept", "text/event-stream")
463- httpReq.Header.Set("authorization", "Bearer "+c.token)
464- httpReq.Header.Set("chatgpt-account-id", c.accountID)
465- httpReq.Header.Set("openai-beta", "responses=experimental")
466- if useCodexCLIRouting {
467- // The ChatGPT Codex backend only admits (and reliably serves)
468- // requests that follow Codex CLI routing metadata; other client
469- // identities are load-shed with "servers are currently
470- // overloaded" errors even when capacity is fine.
471- httpReq.Header.Set("originator", "codex_cli_rs")
472- if c.capabilities.SessionHeader && cacheSessionID != "" {
473- httpReq.Header.Set("session-id", cacheSessionID)
474- }
475- if c.capabilities.ThreadHeader && threadID != "" {
476- httpReq.Header.Set("thread-id", threadID)
477- }
478- if c.capabilities.ClientRequestHeader && threadID != "" {
479- httpReq.Header.Set("x-client-request-id", threadID)
480- }
481- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
482- } else {
483- // ChatGPT's backend recognizes these established values. They are
484- // external provider metadata, not zut product identifiers.
485- httpReq.Header.Set("originator", "zot")
486- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
487- }
488 return httpReq, nil468 return httpReq, nil
489 }469 }
490 470
@@ -503,6 +483,45 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
503 return out, nil483 return out, nil
504 }484 }
505 485
486+func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header {
487+ headers := make(http.Header)
488+ headers.Set("authorization", "Bearer "+c.token)
489+ if !websocketMode || c.accountID != "" {
490+ headers.Set("openai-beta", "responses=experimental")
491+ }
492+ if c.accountID != "" {
493+ headers.Set("chatgpt-account-id", c.accountID)
494+ }
495+
496+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
497+ threadID := strings.TrimSpace(req.Context.ThreadID)
498+ if useCodexCLIRouting {
499+ // The ChatGPT Codex backend only admits (and reliably serves)
500+ // requests that follow Codex CLI routing metadata; other client
501+ // identities are load-shed even when capacity is available.
502+ headers.Set("originator", "codex_cli_rs")
503+ if c.capabilities.SessionHeader && cacheSessionID != "" {
504+ headers.Set("session-id", cacheSessionID)
505+ }
506+ if c.capabilities.ThreadHeader && threadID != "" {
507+ headers.Set("thread-id", threadID)
508+ }
509+ if c.capabilities.ClientRequestHeader && threadID != "" {
510+ headers.Set("x-client-request-id", threadID)
511+ }
512+ headers.Set("user-agent", "codex_cli_rs/0.0.0")
513+ return headers
514+ }
515+
516+ // ChatGPT's backend recognizes these established values. They are
517+ // external provider metadata, not zut product identifiers.
518+ if c.accountID != "" {
519+ headers.Set("originator", "zot")
520+ headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
521+ }
522+ return headers
523+}
524+
506 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {525 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
507 defer close(out)526 defer close(out)
508 defer resp.Body.Close()527 defer resp.Body.Close()
M packages/provider/openai_codex_test.go +6 -6
@@ -35,7 +35,7 @@ func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
3535 // following user-message image must serialize as input_image so the
3636 // model actually receives the bytes.
3737 func TestCodexImageToolResultMirror(t *testing.T) {
38- c := NewOpenAICodex("token", "acct", "").(*codexClient)
38+ c := newOpenAICodexClient("token", "acct", "")
3939
4040 wire, err := c.buildRequest(Request{
4141 Model: "gpt-5.5",
@@ -92,7 +92,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
9292 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
9393
9494 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
95- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
95+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
9696 var gotReq *http.Request
9797 var gotBody codexRequest
9898 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -136,7 +136,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
136136 }
137137
138138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139- c := NewOpenAICodex("token", "acct", "").(*codexClient)
139+ c := newOpenAICodexClient("token", "acct", "")
140140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
141141 if err != nil {
142142 t.Fatal(err)
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187187 c := ws.http
188188 var gotReq *http.Request
189189 var gotBody codexRequest
190- ws.dial = func(context.Context) (*websocket.Conn, error) {
190+ ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
191191 return nil, errors.New("test websocket unavailable")
192192 }
193193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -268,7 +268,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
268268 }
269269
270270 func TestCodexNestedStreamError(t *testing.T) {
271- c := NewOpenAICodex("token", "acct", "").(*codexClient)
271+ c := newOpenAICodexClient("token", "acct", "")
272272 resp := &http.Response{
273273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
274274 }
@@ -293,7 +293,7 @@ func TestCodexNestedStreamError(t *testing.T) {
293293 // even when capacity is fine, so the CLI shape is required for
294294 // reliable service, not just for preview-model admission.
295295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
296- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
296+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
297297 var gotReq *http.Request
298298 var body bytes.Buffer
299299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -35,7 +35,7 @@ func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
35 // following user-message image must serialize as input_image so the35 // following user-message image must serialize as input_image so the
36 // model actually receives the bytes.36 // model actually receives the bytes.
37 func TestCodexImageToolResultMirror(t *testing.T) {37 func TestCodexImageToolResultMirror(t *testing.T) {
38- c := NewOpenAICodex("token", "acct", "").(*codexClient)38+ c := newOpenAICodexClient("token", "acct", "")
39 39
40 wire, err := c.buildRequest(Request{40 wire, err := c.buildRequest(Request{
41 Model: "gpt-5.5",41 Model: "gpt-5.5",
@@ -92,7 +92,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
92 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }92 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
93 93
94 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {94 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
95- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)95+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
96 var gotReq *http.Request96 var gotReq *http.Request
97 var gotBody codexRequest97 var gotBody codexRequest
98 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {98 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -136,7 +136,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
136 }136 }
137 137
138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139- c := NewOpenAICodex("token", "acct", "").(*codexClient)139+ c := newOpenAICodexClient("token", "acct", "")
140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
141 if err != nil {141 if err != nil {
142 t.Fatal(err)142 t.Fatal(err)
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187 c := ws.http187 c := ws.http
188 var gotReq *http.Request188 var gotReq *http.Request
189 var gotBody codexRequest189 var gotBody codexRequest
190- ws.dial = func(context.Context) (*websocket.Conn, error) {190+ ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
191 return nil, errors.New("test websocket unavailable")191 return nil, errors.New("test websocket unavailable")
192 }192 }
193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -268,7 +268,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
268 }268 }
269 269
270 func TestCodexNestedStreamError(t *testing.T) {270 func TestCodexNestedStreamError(t *testing.T) {
271- c := NewOpenAICodex("token", "acct", "").(*codexClient)271+ c := newOpenAICodexClient("token", "acct", "")
272 resp := &http.Response{272 resp := &http.Response{
273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
274 }274 }
@@ -293,7 +293,7 @@ func TestCodexNestedStreamError(t *testing.T) {
293 // even when capacity is fine, so the CLI shape is required for293 // even when capacity is fine, so the CLI shape is required for
294 // reliable service, not just for preview-model admission.294 // reliable service, not just for preview-model admission.
295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
296- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)296+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
297 var gotReq *http.Request297 var gotReq *http.Request
298 var body bytes.Buffer298 var body bytes.Buffer
299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
M packages/provider/openai_responses.go +4 -6
@@ -2,12 +2,10 @@ package provider
22
33 // OpenAI Responses API client (api.openai.com/v1/responses).
44 //
5-// Strategy: reuse the existing codexClient (which already speaks the
6-// Responses wire format) via a header-rewriting RoundTripper. The codex
7-// client always sends `chatgpt-account-id` and `openai-beta`; the public
8-// Responses API on api.openai.com accepts but doesn't require those —
9-// we strip the account-id header on its way out so the request looks
10-// like a normal authenticated OpenAI call.
5+// Strategy: reuse codexClient, which already speaks the Responses wire
6+// format, via a header-rewriting RoundTripper. The public Responses API on
7+// api.openai.com does not need ChatGPT subscription identity headers, so the
8+// transport strips them if a shared request path supplied any.
119 //
1210 // This is a separate provider from `openai` (which is Chat Completions);
1311 // users opt in by passing `--provider openai-responses` or by picking a
@@ -2,12 +2,10 @@ package provider
2 2
3 // OpenAI Responses API client (api.openai.com/v1/responses).3 // OpenAI Responses API client (api.openai.com/v1/responses).
4 //4 //
5-// Strategy: reuse the existing codexClient (which already speaks the5+// Strategy: reuse codexClient, which already speaks the Responses wire
6-// Responses wire format) via a header-rewriting RoundTripper. The codex6+// format, via a header-rewriting RoundTripper. The public Responses API on
7-// client always sends `chatgpt-account-id` and `openai-beta`; the public7+// api.openai.com does not need ChatGPT subscription identity headers, so the
8-// Responses API on api.openai.com accepts but doesn't require those —8+// transport strips them if a shared request path supplied any.
9-// we strip the account-id header on its way out so the request looks
10-// like a normal authenticated OpenAI call.
11 //9 //
12 // This is a separate provider from `openai` (which is Chat Completions);10 // This is a separate provider from `openai` (which is Chat Completions);
13 // users opt in by passing `--provider openai-responses` or by picking a11 // users opt in by passing `--provider openai-responses` or by picking a
M packages/provider/openai_responses_websocket.go +41 -14
@@ -17,17 +17,16 @@ import (
1717 "golang.org/x/net/websocket"
1818 )
1919
20-// responsesWebSocketClient uses the public Responses WebSocket mode for one
21-// logical session at a time. It deliberately wraps only the public OpenAI
22-// Responses client: ChatGPT Codex and OpenAI-compatible endpoints have
23-// different authentication and cache contracts and must retain their HTTP/SSE
24-// transport until they independently declare WebSocket support.
20+// responsesWebSocketClient uses Responses WebSocket mode for one logical
21+// thread at a time. Official OpenAI API-key and ChatGPT subscription clients
22+// both use it; each supplies its own authenticated handshake headers and keeps
23+// HTTP/SSE as the pre-event fallback.
2524 type responsesWebSocketClient struct {
2625 http *codexClient
2726
2827 mu sync.Mutex
2928 sessions map[string]*responsesWebSocketSession
30- dial func(context.Context) (*websocket.Conn, error)
29+ dial func(context.Context, Request) (*websocket.Conn, error)
3130 }
3231
3332 type responsesWebSocketSession struct {
@@ -85,17 +84,25 @@ func (c *responsesWebSocketClient) session(id string) *responsesWebSocketSession
8584 return session
8685 }
8786
88-func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocket.Conn, error) {
87+func (c *responsesWebSocketClient) dialResponses(ctx context.Context, req Request) (*websocket.Conn, error) {
8988 endpoint, err := responsesWebSocketURL(c.http.baseURL)
9089 if err != nil {
9190 return nil, err
9291 }
93- config, err := websocket.NewConfig(endpoint, "https://api.openai.com")
92+ origin, err := responsesWebSocketOrigin(c.http.baseURL)
9493 if err != nil {
9594 return nil, err
9695 }
97- config.Header = make(http.Header)
98- config.Header.Set("authorization", "Bearer "+c.http.token)
96+ config, err := websocket.NewConfig(endpoint, origin)
97+ if err != nil {
98+ return nil, err
99+ }
100+ model := req.Model
101+ if c.http.modelName != nil {
102+ model = c.http.modelName(model)
103+ }
104+ useCodexCLIRouting := !c.http.disableCLIRouting && (c.http.cliRoutingAll || usesCodexCLIRouting(model))
105+ config.Header = c.http.responsesHeaders(req, useCodexCLIRouting, true)
99106 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {
100107 config.TlsConfig = tlsConfig
101108 }
@@ -104,6 +111,26 @@ func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocke
104111
105112 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by
106113 // WithHTTPClient into the WebSocket dial.
114+func responsesWebSocketOrigin(endpoint string) (string, error) {
115+ u, err := url.Parse(endpoint)
116+ if err != nil {
117+ return "", fmt.Errorf("parse Responses endpoint origin: %w", err)
118+ }
119+ switch u.Scheme {
120+ case "https", "wss":
121+ u.Scheme = "https"
122+ case "http", "ws":
123+ u.Scheme = "http"
124+ default:
125+ return "", fmt.Errorf("unsupported Responses endpoint scheme %q", u.Scheme)
126+ }
127+ u.Path = ""
128+ u.RawPath = ""
129+ u.RawQuery = ""
130+ u.Fragment = ""
131+ return u.String(), nil
132+}
133+
107134 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {
108135 if client == nil {
109136 return nil
@@ -203,7 +230,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
203230 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204231 }
205232 reportRequestAttempt(req.Lifecycle, 1, 2)
206- if err := session.ensureConnected(ctx, c.dial); err != nil {
233+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
207234 // A connection that never became usable has no server-side turn. The
208235 // SSE fallback therefore receives the complete request and preserves
209236 // the same session-derived cache key without sharing WebSocket state.
@@ -313,7 +340,7 @@ func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *r
313340 return nil, nil, sseEvent{}, "", err
314341 }
315342 reportRequestAttempt(req.Lifecycle, 2, 2)
316- if err := session.ensureConnected(ctx, c.dial); err != nil {
343+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
317344 return nil, nil, sseEvent{}, "", err
318345 }
319346 conn, raw := session.connection()
@@ -494,14 +521,14 @@ func sameResponsesContinuationConfig(previous, current Request) bool {
494521 return reflect.DeepEqual(previous, current)
495522 }
496523
497-func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
524+func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, req Request, dial func(context.Context, Request) (*websocket.Conn, error)) error {
498525 s.mu.Lock()
499526 if s.conn != nil && s.raw != nil {
500527 s.mu.Unlock()
501528 return nil
502529 }
503530 s.mu.Unlock()
504- conn, err := dial(ctx)
531+ conn, err := dial(ctx, req)
505532 if err != nil {
506533 return err
507534 }
@@ -17,17 +17,16 @@ import (
17 "golang.org/x/net/websocket"17 "golang.org/x/net/websocket"
18 )18 )
19 19
20-// responsesWebSocketClient uses the public Responses WebSocket mode for one20+// responsesWebSocketClient uses Responses WebSocket mode for one logical
21-// logical session at a time. It deliberately wraps only the public OpenAI21+// thread at a time. Official OpenAI API-key and ChatGPT subscription clients
22-// Responses client: ChatGPT Codex and OpenAI-compatible endpoints have22+// both use it; each supplies its own authenticated handshake headers and keeps
23-// different authentication and cache contracts and must retain their HTTP/SSE23+// HTTP/SSE as the pre-event fallback.
24-// transport until they independently declare WebSocket support.
25 type responsesWebSocketClient struct {24 type responsesWebSocketClient struct {
26 http *codexClient25 http *codexClient
27 26
28 mu sync.Mutex27 mu sync.Mutex
29 sessions map[string]*responsesWebSocketSession28 sessions map[string]*responsesWebSocketSession
30- dial func(context.Context) (*websocket.Conn, error)29+ dial func(context.Context, Request) (*websocket.Conn, error)
31 }30 }
32 31
33 type responsesWebSocketSession struct {32 type responsesWebSocketSession struct {
@@ -85,17 +84,25 @@ func (c *responsesWebSocketClient) session(id string) *responsesWebSocketSession
85 return session84 return session
86 }85 }
87 86
88-func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocket.Conn, error) {87+func (c *responsesWebSocketClient) dialResponses(ctx context.Context, req Request) (*websocket.Conn, error) {
89 endpoint, err := responsesWebSocketURL(c.http.baseURL)88 endpoint, err := responsesWebSocketURL(c.http.baseURL)
90 if err != nil {89 if err != nil {
91 return nil, err90 return nil, err
92 }91 }
93- config, err := websocket.NewConfig(endpoint, "https://api.openai.com")92+ origin, err := responsesWebSocketOrigin(c.http.baseURL)
94 if err != nil {93 if err != nil {
95 return nil, err94 return nil, err
96 }95 }
97- config.Header = make(http.Header)96+ config, err := websocket.NewConfig(endpoint, origin)
98- config.Header.Set("authorization", "Bearer "+c.http.token)97+ if err != nil {
98+ return nil, err
99+ }
100+ model := req.Model
101+ if c.http.modelName != nil {
102+ model = c.http.modelName(model)
103+ }
104+ useCodexCLIRouting := !c.http.disableCLIRouting && (c.http.cliRoutingAll || usesCodexCLIRouting(model))
105+ config.Header = c.http.responsesHeaders(req, useCodexCLIRouting, true)
99 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {106 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {
100 config.TlsConfig = tlsConfig107 config.TlsConfig = tlsConfig
101 }108 }
@@ -104,6 +111,26 @@ func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocke
104 111
105 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by112 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by
106 // WithHTTPClient into the WebSocket dial.113 // WithHTTPClient into the WebSocket dial.
114+func responsesWebSocketOrigin(endpoint string) (string, error) {
115+ u, err := url.Parse(endpoint)
116+ if err != nil {
117+ return "", fmt.Errorf("parse Responses endpoint origin: %w", err)
118+ }
119+ switch u.Scheme {
120+ case "https", "wss":
121+ u.Scheme = "https"
122+ case "http", "ws":
123+ u.Scheme = "http"
124+ default:
125+ return "", fmt.Errorf("unsupported Responses endpoint scheme %q", u.Scheme)
126+ }
127+ u.Path = ""
128+ u.RawPath = ""
129+ u.RawQuery = ""
130+ u.Fragment = ""
131+ return u.String(), nil
132+}
133+
107 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {134 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {
108 if client == nil {135 if client == nil {
109 return nil136 return nil
@@ -203,7 +230,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
203 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))230 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204 }231 }
205 reportRequestAttempt(req.Lifecycle, 1, 2)232 reportRequestAttempt(req.Lifecycle, 1, 2)
206- if err := session.ensureConnected(ctx, c.dial); err != nil {233+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
207 // A connection that never became usable has no server-side turn. The234 // A connection that never became usable has no server-side turn. The
208 // SSE fallback therefore receives the complete request and preserves235 // SSE fallback therefore receives the complete request and preserves
209 // the same session-derived cache key without sharing WebSocket state.236 // the same session-derived cache key without sharing WebSocket state.
@@ -313,7 +340,7 @@ func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *r
313 return nil, nil, sseEvent{}, "", err340 return nil, nil, sseEvent{}, "", err
314 }341 }
315 reportRequestAttempt(req.Lifecycle, 2, 2)342 reportRequestAttempt(req.Lifecycle, 2, 2)
316- if err := session.ensureConnected(ctx, c.dial); err != nil {343+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
317 return nil, nil, sseEvent{}, "", err344 return nil, nil, sseEvent{}, "", err
318 }345 }
319 conn, raw := session.connection()346 conn, raw := session.connection()
@@ -494,14 +521,14 @@ func sameResponsesContinuationConfig(previous, current Request) bool {
494 return reflect.DeepEqual(previous, current)521 return reflect.DeepEqual(previous, current)
495 }522 }
496 523
497-func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {524+func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, req Request, dial func(context.Context, Request) (*websocket.Conn, error)) error {
498 s.mu.Lock()525 s.mu.Lock()
499 if s.conn != nil && s.raw != nil {526 if s.conn != nil && s.raw != nil {
500 s.mu.Unlock()527 s.mu.Unlock()
501 return nil528 return nil
502 }529 }
503 s.mu.Unlock()530 s.mu.Unlock()
504- conn, err := dial(ctx)531+ conn, err := dial(ctx, req)
505 if err != nil {532 if err != nil {
506 return err533 return err
507 }534 }
M packages/provider/openai_responses_websocket_test.go +71 -0
@@ -225,6 +225,77 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
225225 }
226226 }
227227
228+func TestOpenAICodexUsesWebSocketWithSubscriptionHeaders(t *testing.T) {
229+ headers := make(chan http.Header, 1)
230+ requests := make(chan map[string]any, 1)
231+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
232+ defer conn.Close()
233+ headers <- conn.Request().Header.Clone()
234+ var payload map[string]any
235+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
236+ t.Errorf("receive websocket request: %v", err)
237+ return
238+ }
239+ requests <- payload
240+ for _, event := range []map[string]any{
241+ {"type": "response.created", "response": map[string]any{"id": "resp_subscription"}},
242+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
243+ {"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
244+ {"type": "response.completed", "response": map[string]any{"id": "resp_subscription", "usage": map[string]any{}}},
245+ } {
246+ _ = websocket.JSON.Send(conn, event)
247+ }
248+ }))
249+ defer server.Close()
250+
251+ client := NewOpenAICodex("subscription-token", "account-1", server.URL)
252+ websocketClient, ok := client.(*responsesWebSocketClient)
253+ if !ok {
254+ t.Fatalf("NewOpenAICodex returned %T, want WebSocket Responses client", client)
255+ }
256+ defer websocketClient.Close()
257+
258+ request := Request{
259+ Model: "gpt-5.6-luna",
260+ Context: RequestContext{CacheSessionID: "root-1", ThreadID: "thread-1", TurnID: "turn-1"},
261+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
262+ }
263+ assertCompletedResponse(t, client, request)
264+
265+ gotHeaders := <-headers
266+ for name, want := range map[string]string{
267+ "authorization": "Bearer subscription-token",
268+ "chatgpt-account-id": "account-1",
269+ "openai-beta": "responses=experimental",
270+ "originator": "codex_cli_rs",
271+ "user-agent": "codex_cli_rs/0.0.0",
272+ "session-id": "root-1",
273+ "thread-id": "thread-1",
274+ "x-client-request-id": "thread-1",
275+ } {
276+ if got := gotHeaders.Get(name); got != want {
277+ t.Fatalf("%s = %q, want %q", name, got, want)
278+ }
279+ }
280+ if got := gotHeaders.Get("origin"); got != server.URL {
281+ t.Fatalf("origin = %q, want %q", got, server.URL)
282+ }
283+ if got := (<-requests)["prompt_cache_key"]; got != "root-1" {
284+ t.Fatalf("prompt_cache_key = %#v, want root cache ID", got)
285+ }
286+}
287+
288+func TestWithHTTPClientPreservesSubscriptionHeaders(t *testing.T) {
289+ client := NewOpenAICodex("subscription-token", "account-1", "https://example.test/backend-api/codex/responses")
290+ customHTTP := &http.Client{Transport: &http.Transport{}}
291+ WithHTTPClient(client, customHTTP)
292+
293+ websocketClient := client.(*responsesWebSocketClient)
294+ if websocketClient.http.http != customHTTP {
295+ t.Fatal("subscription client was wrapped in the public OpenAI header-stripping transport")
296+ }
297+}
298+
228299 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229300 mappedModel := "gpt-5.6-sol-a"
230301 client := newResponsesWebSocketClient(&codexClient{
@@ -225,6 +225,77 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
225 }225 }
226 }226 }
227 227
228+func TestOpenAICodexUsesWebSocketWithSubscriptionHeaders(t *testing.T) {
229+ headers := make(chan http.Header, 1)
230+ requests := make(chan map[string]any, 1)
231+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
232+ defer conn.Close()
233+ headers <- conn.Request().Header.Clone()
234+ var payload map[string]any
235+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
236+ t.Errorf("receive websocket request: %v", err)
237+ return
238+ }
239+ requests <- payload
240+ for _, event := range []map[string]any{
241+ {"type": "response.created", "response": map[string]any{"id": "resp_subscription"}},
242+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
243+ {"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
244+ {"type": "response.completed", "response": map[string]any{"id": "resp_subscription", "usage": map[string]any{}}},
245+ } {
246+ _ = websocket.JSON.Send(conn, event)
247+ }
248+ }))
249+ defer server.Close()
250+
251+ client := NewOpenAICodex("subscription-token", "account-1", server.URL)
252+ websocketClient, ok := client.(*responsesWebSocketClient)
253+ if !ok {
254+ t.Fatalf("NewOpenAICodex returned %T, want WebSocket Responses client", client)
255+ }
256+ defer websocketClient.Close()
257+
258+ request := Request{
259+ Model: "gpt-5.6-luna",
260+ Context: RequestContext{CacheSessionID: "root-1", ThreadID: "thread-1", TurnID: "turn-1"},
261+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
262+ }
263+ assertCompletedResponse(t, client, request)
264+
265+ gotHeaders := <-headers
266+ for name, want := range map[string]string{
267+ "authorization": "Bearer subscription-token",
268+ "chatgpt-account-id": "account-1",
269+ "openai-beta": "responses=experimental",
270+ "originator": "codex_cli_rs",
271+ "user-agent": "codex_cli_rs/0.0.0",
272+ "session-id": "root-1",
273+ "thread-id": "thread-1",
274+ "x-client-request-id": "thread-1",
275+ } {
276+ if got := gotHeaders.Get(name); got != want {
277+ t.Fatalf("%s = %q, want %q", name, got, want)
278+ }
279+ }
280+ if got := gotHeaders.Get("origin"); got != server.URL {
281+ t.Fatalf("origin = %q, want %q", got, server.URL)
282+ }
283+ if got := (<-requests)["prompt_cache_key"]; got != "root-1" {
284+ t.Fatalf("prompt_cache_key = %#v, want root cache ID", got)
285+ }
286+}
287+
288+func TestWithHTTPClientPreservesSubscriptionHeaders(t *testing.T) {
289+ client := NewOpenAICodex("subscription-token", "account-1", "https://example.test/backend-api/codex/responses")
290+ customHTTP := &http.Client{Transport: &http.Transport{}}
291+ WithHTTPClient(client, customHTTP)
292+
293+ websocketClient := client.(*responsesWebSocketClient)
294+ if websocketClient.http.http != customHTTP {
295+ t.Fatal("subscription client was wrapped in the public OpenAI header-stripping transport")
296+ }
297+}
298+
228 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {299 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229 mappedModel := "gpt-5.6-sol-a"300 mappedModel := "gpt-5.6-sol-a"
230 client := newResponsesWebSocketClient(&codexClient{301 client := newResponsesWebSocketClient(&codexClient{

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close