Merge pull request #141 from bnema/fix/stable-tool-result-history
fix(openai): preserve prompt cache prefixes
11 files · +232 -152
M packages/core/context_budget.go +13 -31
| @@ -9,48 +9,30 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | 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]" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // 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. | |
| 22 | 21 | func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |
| 23 | 22 | projected := copyToolResultMessages(msgs) | |
| 24 | - | remaining := maxToolResultTotalTextBytes | |
| 25 | 23 | ||
| 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) | |
| 29 | 27 | if !ok { | |
| 30 | 28 | continue | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 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) | |
| 51 | 33 | } | |
| 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)}) | |
| 54 | 36 | } | |
| 55 | 37 | projected[i].Content[j] = result | |
| 56 | 38 | } | |
| @@ -9,48 +9,30 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
| 12 | - | maxToolResultTextBytes = 32 * 1024 | 12 | + | maxToolResultTextBytes = 32 * 1024 |
| 13 | - | maxToolResultTotalTextBytes = 128 * 1024 | 13 | + | 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 the | 17 | + | // 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 so | 19 | + | // 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 | continue | 28 | 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] = result | 37 | projected[i].Content[j] = result | ||
| 56 | } | 38 | } | ||
M packages/core/context_budget_test.go +36 -62
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| @@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) { | |||
| 93 | 94 | } | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 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 | + | }}) | |
| 124 | 106 | } | |
| 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") | |
| 127 | 119 | } | |
| 128 | 120 | } | |
| 129 | 121 | ||
| @@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||
| 147 | 139 | } | |
| 148 | 140 | ||
| 149 | 141 | projected := projectToolResultMessages(msgs) | |
| 150 | - | total := 0 | |
| 151 | 142 | for _, message := range projected { | |
| 152 | 143 | for _, content := range message.Content { | |
| 153 | 144 | result, ok := content.(provider.ToolResultBlock) | |
| @@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||
| 166 | 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 | 165 | msgs := make([]provider.Message, 0, 10) | |
| 179 | 166 | for i := 1; i <= 5; i++ { | |
| 180 | 167 | id := "call-" + string(rune('0'+i)) | |
| @@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) { | |||
| 192 | 179 | } | |
| 193 | 180 | ||
| 194 | 181 | 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 | + | } | |
| 202 | 188 | } | |
| 203 | 189 | } | |
| 204 | 190 | ||
| 205 | - | func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) { | |
| 206 | - | const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1 | |
| 191 | + | func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) { | |
| 192 | + | const resultCount = 5 | |
| 207 | 193 | msgs := make([]provider.Message, 0, resultCount) | |
| 208 | 194 | for i := 0; i < resultCount; i++ { | |
| 209 | 195 | id := "call-" + string(rune('1'+i)) | |
| @@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t | |||
| 230 | 216 | retainedTotal += retainedToolResultTextBytes(result.Content) | |
| 231 | 217 | } | |
| 232 | 218 | } | |
| 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) | |
| 235 | 221 | } | |
| 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) | |
| 238 | 224 | } | |
| 239 | 225 | } | |
| 240 | 226 | ||
| @@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) { | |||
| 291 | 277 | t.Fatalf("Continue: %v", err) | |
| 292 | 278 | } | |
| 293 | 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 | 281 | t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)]) | |
| 296 | 282 | } | |
| 297 | 283 | if !strings.Contains(projectedText, formatToolTiming(timing)) { | |
| @@ -373,18 +359,6 @@ func hasToolResultOmissionMarker(content []provider.Content) bool { | |||
| 373 | 359 | 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 | 362 | func toolResultTextForCall(messages []provider.Message, callID string) string { | |
| 389 | 363 | var text strings.Builder | |
| 390 | 364 | 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 + 1 | 192 | + | 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 false | 359 | 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.Builder | 363 | 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) { | |||
| 21 | 21 | if err := session.Close(); err != nil { | |
| 22 | 22 | t.Fatal(err) | |
| 23 | 23 | } | |
| 24 | - | _, messages, err := OpenSession(path) | |
| 24 | + | opened, messages, err := OpenSession(path) | |
| 25 | 25 | if err != nil { | |
| 26 | 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 | 33 | if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper { | |
| 29 | 34 | t.Fatalf("messages = %#v, want one developer message", messages) | |
| 30 | 35 | } | |
| @@ -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) { | |||
| 97 | 97 | }, | |
| 98 | 98 | "openai-codex": func(req Request) ([]string, error) { | |
| 99 | 99 | req.Model = "gpt-5.5" | |
| 100 | - | wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req) | |
| 100 | + | wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req) | |
| 101 | 101 | if err != nil { | |
| 102 | 102 | return nil, err | |
| 103 | 103 | } | |
| @@ -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, err | 102 | return nil, err | ||
| 103 | } | 103 | } | ||
M packages/provider/fast_mode_test.go +1 -1
| @@ -27,7 +27,7 @@ func TestOpenAIFastModeAddsServiceTier(t *testing.T) { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) { | |
| 30 | - | client := NewOpenAICodex("token", "account", "").(*codexClient) | |
| 30 | + | client := newOpenAICodexClient("token", "account", "") | |
| 31 | 31 | ||
| 32 | 32 | wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true}) | |
| 33 | 33 | 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 { | |||
| 46 | 46 | case *codexClient: | |
| 47 | 47 | v.http = httpClient | |
| 48 | 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 | 54 | case *renamedClient: | |
| 51 | 55 | if codex, ok := v.inner.(*codexClient); ok { | |
| 52 | 56 | 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 = httpClient | 47 | 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 { | |||
| 58 | 58 | ClientRequestHeader bool | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 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. | |
| 64 | 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 | 70 | if baseURL == "" { | |
| 66 | 71 | baseURL = codexDefaultBaseURL | |
| 67 | 72 | } | |
| @@ -444,7 +449,6 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 444 | 449 | } | |
| 445 | 450 | useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) | |
| 446 | 451 | cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID) | |
| 447 | - | threadID := strings.TrimSpace(req.Context.ThreadID) | |
| 448 | 452 | if c.capabilities.StablePromptCacheKey && cacheSessionID != "" { | |
| 449 | 453 | wire.PromptCacheKey = cacheSessionID | |
| 450 | 454 | } | |
| @@ -458,33 +462,9 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 458 | 462 | if err != nil { | |
| 459 | 463 | return nil, err | |
| 460 | 464 | } | |
| 465 | + | httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false) | |
| 461 | 466 | httpReq.Header.Set("content-type", "application/json") | |
| 462 | 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 | 468 | return httpReq, nil | |
| 489 | 469 | } | |
| 490 | 470 | ||
| @@ -503,6 +483,45 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 503 | 483 | 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 | 525 | func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { | |
| 507 | 526 | defer close(out) | |
| 508 | 527 | defer resp.Body.Close() | |
| @@ -58,10 +58,15 @@ type responsesCapabilities struct { | |||||
| 58 | ClientRequestHeader bool | 58 | ClientRequestHeader bool | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | - | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | 61 | + | // NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint |
| 62 | - | // using a subscription OAuth access token and the user's ChatGPT | 62 | + | // 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 = codexDefaultBaseURL | 71 | 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 = cacheSessionID | 453 | 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, err | 463 | 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, nil | 468 | 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, nil | 483 | 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) { | |||
| 35 | 35 | // following user-message image must serialize as input_image so the | |
| 36 | 36 | // model actually receives the bytes. | |
| 37 | 37 | func TestCodexImageToolResultMirror(t *testing.T) { | |
| 38 | - | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |
| 38 | + | c := newOpenAICodexClient("token", "acct", "") | |
| 39 | 39 | ||
| 40 | 40 | wire, err := c.buildRequest(Request{ | |
| 41 | 41 | Model: "gpt-5.5", | |
| @@ -92,7 +92,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error) | |||
| 92 | 92 | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } | |
| 93 | 93 | ||
| 94 | 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 | 96 | var gotReq *http.Request | |
| 97 | 97 | var gotBody codexRequest | |
| 98 | 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 | 138 | func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |
| 139 | - | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |
| 139 | + | c := newOpenAICodexClient("token", "acct", "") | |
| 140 | 140 | wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"}) | |
| 141 | 141 | if err != nil { | |
| 142 | 142 | t.Fatal(err) | |
| @@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 187 | 187 | c := ws.http | |
| 188 | 188 | var gotReq *http.Request | |
| 189 | 189 | var gotBody codexRequest | |
| 190 | - | ws.dial = func(context.Context) (*websocket.Conn, error) { | |
| 190 | + | ws.dial = func(context.Context, Request) (*websocket.Conn, error) { | |
| 191 | 191 | return nil, errors.New("test websocket unavailable") | |
| 192 | 192 | } | |
| 193 | 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 | 270 | func TestCodexNestedStreamError(t *testing.T) { | |
| 271 | - | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |
| 271 | + | c := newOpenAICodexClient("token", "acct", "") | |
| 272 | 272 | resp := &http.Response{ | |
| 273 | 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 | 293 | // even when capacity is fine, so the CLI shape is required for | |
| 294 | 294 | // reliable service, not just for preview-model admission. | |
| 295 | 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 | 297 | var gotReq *http.Request | |
| 298 | 298 | var body bytes.Buffer | |
| 299 | 299 | 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 the | 35 | // 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.Request | 96 | var gotReq *http.Request | ||
| 97 | var gotBody codexRequest | 97 | 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.http | 187 | c := ws.http | ||
| 188 | var gotReq *http.Request | 188 | var gotReq *http.Request | ||
| 189 | var gotBody codexRequest | 189 | 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 for | 293 | // 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.Request | 297 | var gotReq *http.Request | ||
| 298 | var body bytes.Buffer | 298 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | // OpenAI Responses API client (api.openai.com/v1/responses). | |
| 4 | 4 | // | |
| 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. | |
| 11 | 9 | // | |
| 12 | 10 | // This is a separate provider from `openai` (which is Chat Completions); | |
| 13 | 11 | // 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 the | 5 | + | // Strategy: reuse codexClient, which already speaks the Responses wire |
| 6 | - | // Responses wire format) via a header-rewriting RoundTripper. The codex | 6 | + | // format, via a header-rewriting RoundTripper. The public Responses API on |
| 7 | - | // client always sends `chatgpt-account-id` and `openai-beta`; the public | 7 | + | // 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 a | 11 | // 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 ( | |||
| 17 | 17 | "golang.org/x/net/websocket" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 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. | |
| 25 | 24 | type responsesWebSocketClient struct { | |
| 26 | 25 | http *codexClient | |
| 27 | 26 | ||
| 28 | 27 | mu sync.Mutex | |
| 29 | 28 | 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 | 32 | type responsesWebSocketSession struct { | |
| @@ -85,17 +84,25 @@ func (c *responsesWebSocketClient) session(id string) *responsesWebSocketSession | |||
| 85 | 84 | 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 | 88 | endpoint, err := responsesWebSocketURL(c.http.baseURL) | |
| 90 | 89 | if err != nil { | |
| 91 | 90 | return nil, err | |
| 92 | 91 | } | |
| 93 | - | config, err := websocket.NewConfig(endpoint, "https://api.openai.com") | |
| 92 | + | origin, err := responsesWebSocketOrigin(c.http.baseURL) | |
| 94 | 93 | if err != nil { | |
| 95 | 94 | return nil, err | |
| 96 | 95 | } | |
| 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) | |
| 99 | 106 | if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil { | |
| 100 | 107 | config.TlsConfig = tlsConfig | |
| 101 | 108 | } | |
| @@ -104,6 +111,26 @@ func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocke | |||
| 104 | 111 | ||
| 105 | 112 | // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by | |
| 106 | 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 | 134 | func responsesWebSocketTLSConfig(client *http.Client) *tls.Config { | |
| 108 | 135 | if client == nil { | |
| 109 | 136 | return nil | |
| @@ -203,7 +230,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 203 | 230 | ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation)) | |
| 204 | 231 | } | |
| 205 | 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 | 234 | // A connection that never became usable has no server-side turn. The | |
| 208 | 235 | // SSE fallback therefore receives the complete request and preserves | |
| 209 | 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 | 340 | return nil, nil, sseEvent{}, "", err | |
| 314 | 341 | } | |
| 315 | 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 | 344 | return nil, nil, sseEvent{}, "", err | |
| 318 | 345 | } | |
| 319 | 346 | conn, raw := session.connection() | |
| @@ -494,14 +521,14 @@ func sameResponsesContinuationConfig(previous, current Request) bool { | |||
| 494 | 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 | 525 | s.mu.Lock() | |
| 499 | 526 | if s.conn != nil && s.raw != nil { | |
| 500 | 527 | s.mu.Unlock() | |
| 501 | 528 | return nil | |
| 502 | 529 | } | |
| 503 | 530 | s.mu.Unlock() | |
| 504 | - | conn, err := dial(ctx) | |
| 531 | + | conn, err := dial(ctx, req) | |
| 505 | 532 | if err != nil { | |
| 506 | 533 | return err | |
| 507 | 534 | } | |
| @@ -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 one | 20 | + | // responsesWebSocketClient uses Responses WebSocket mode for one logical |
| 21 | - | // logical session at a time. It deliberately wraps only the public OpenAI | 21 | + | // thread at a time. Official OpenAI API-key and ChatGPT subscription clients |
| 22 | - | // Responses client: ChatGPT Codex and OpenAI-compatible endpoints have | 22 | + | // both use it; each supplies its own authenticated handshake headers and keeps |
| 23 | - | // different authentication and cache contracts and must retain their HTTP/SSE | 23 | + | // 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 *codexClient | 25 | http *codexClient | ||
| 27 | 26 | ||||
| 28 | mu sync.Mutex | 27 | mu sync.Mutex | ||
| 29 | sessions map[string]*responsesWebSocketSession | 28 | 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 session | 84 | 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, err | 90 | 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, err | 94 | 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 = tlsConfig | 107 | 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 by | 112 | // 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 nil | 136 | 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. The | 234 | // A connection that never became usable has no server-side turn. The | ||
| 208 | // SSE fallback therefore receives the complete request and preserves | 235 | // 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{}, "", err | 340 | 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{}, "", err | 344 | 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 nil | 528 | 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 err | 533 | return err | ||
| 507 | } | 534 | } | ||
M packages/provider/openai_responses_websocket_test.go +71 -0
| @@ -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 | 299 | func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) { | |
| 229 | 300 | mappedModel := "gpt-5.6-sol-a" | |
| 230 | 301 | 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{ | ||