detached b3121d6

Retry Codex requests with unsupported cache retention

commit
b3121d6c418164c5450a0b6cf7d8ab751223c8ce
author
patriceckhart · · 2026-08-21 08:29 UTC
parent
b464209
signature
unsigned
refs
tag: v0.3.46
2 files · +128 -29
M packages/provider/openai_codex.go +65 -29
@@ -374,49 +374,67 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
374374 if c.modelName != nil {
375375 wire.Model = c.modelName(wire.Model)
376376 }
377- var codexCLISessionID string
378- if !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) {
379- codexCLISessionID = newCodexSessionID()
380- wire.PromptCacheKey = codexCLISessionID
377+ useCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
378+ var codexSessionID string
379+ if useCLIRouting {
380+ codexSessionID = newCodexSessionID()
381+ wire.PromptCacheKey = codexSessionID
381382 }
382383 body, err := json.Marshal(wire)
383384 if err != nil {
384385 return nil, err
385386 }
386387
387- newReq := func() (*http.Request, error) {
388- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
389- if err != nil {
390- return nil, err
391- }
392- httpReq.Header.Set("content-type", "application/json")
393- httpReq.Header.Set("accept", "text/event-stream")
394- httpReq.Header.Set("authorization", "Bearer "+c.token)
395- httpReq.Header.Set("chatgpt-account-id", c.accountID)
396- httpReq.Header.Set("openai-beta", "responses=experimental")
397- if codexCLISessionID != "" {
398- // The ChatGPT Codex backend only admits (and reliably serves)
399- // requests that follow Codex CLI routing metadata; other client
400- // identities are load-shed with "servers are currently
401- // overloaded" errors even when capacity is fine.
402- httpReq.Header.Set("originator", "codex_cli_rs")
403- httpReq.Header.Set("session-id", codexCLISessionID)
404- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
405- } else {
406- httpReq.Header.Set("originator", "zot")
407- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
388+ newReq := func(cliRouting bool) func() (*http.Request, error) {
389+ return func() (*http.Request, error) {
390+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
391+ if err != nil {
392+ return nil, err
393+ }
394+ httpReq.Header.Set("content-type", "application/json")
395+ httpReq.Header.Set("accept", "text/event-stream")
396+ httpReq.Header.Set("authorization", "Bearer "+c.token)
397+ httpReq.Header.Set("chatgpt-account-id", c.accountID)
398+ httpReq.Header.Set("openai-beta", "responses=experimental")
399+ if codexSessionID != "" {
400+ httpReq.Header.Set("session-id", codexSessionID)
401+ httpReq.Header.Set("x-client-request-id", codexSessionID)
402+ }
403+ if cliRouting {
404+ // The ChatGPT Codex backend normally requires the Codex CLI
405+ // request identity. A narrow fallback below uses zot's identity
406+ // when that compatibility shape selects unsupported cache policy.
407+ httpReq.Header.Set("originator", "codex_cli_rs")
408+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
409+ } else {
410+ httpReq.Header.Set("originator", "zot")
411+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
412+ }
413+ return httpReq, nil
408414 }
409- return httpReq, nil
410415 }
411416
412- resp, err := doStreamWithRetry(ctx, c.http, newReq)
417+ resp, err := doStreamWithRetry(ctx, c.http, newReq(useCLIRouting))
413418 if err != nil {
414419 return nil, fmt.Errorf("openai-codex: %w", err)
415420 }
416421 if resp.StatusCode != http.StatusOK {
417- b, _ := io.ReadAll(resp.Body)
422+ status := resp.StatusCode
423+ responseBody, _ := io.ReadAll(resp.Body)
418424 resp.Body.Close()
419- return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
425+ if !useCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) {
426+ return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
427+ }
428+ resp, err = doStreamWithRetry(ctx, c.http, newReq(false))
429+ if err != nil {
430+ return nil, fmt.Errorf("openai-codex: cache compatibility retry: %w", err)
431+ }
432+ if resp.StatusCode != http.StatusOK {
433+ status = resp.StatusCode
434+ responseBody, _ = io.ReadAll(resp.Body)
435+ resp.Body.Close()
436+ return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
437+ }
420438 }
421439
422440 out := make(chan Event, 16)
@@ -424,6 +442,24 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
424442 return out, nil
425443 }
426444
445+func unsupportedPromptCacheRetention(status int, body []byte) bool {
446+ if status != http.StatusBadRequest {
447+ return false
448+ }
449+ var payload struct {
450+ Error struct {
451+ Message string `json:"message"`
452+ Param string `json:"param"`
453+ Code string `json:"code"`
454+ } `json:"error"`
455+ }
456+ if json.Unmarshal(body, &payload) != nil || payload.Error.Param != "prompt_cache_retention" {
457+ return false
458+ }
459+ message := strings.ToLower(payload.Error.Message)
460+ return payload.Error.Code == "invalid_parameter" || strings.Contains(message, "not supported")
461+}
462+
427463 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
428464 defer close(out)
429465 defer resp.Body.Close()
@@ -374,49 +374,67 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
374 if c.modelName != nil {374 if c.modelName != nil {
375 wire.Model = c.modelName(wire.Model)375 wire.Model = c.modelName(wire.Model)
376 }376 }
377- var codexCLISessionID string377+ useCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
378- if !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model)) {378+ var codexSessionID string
379- codexCLISessionID = newCodexSessionID()379+ if useCLIRouting {
380- wire.PromptCacheKey = codexCLISessionID380+ codexSessionID = newCodexSessionID()
381+ wire.PromptCacheKey = codexSessionID
381 }382 }
382 body, err := json.Marshal(wire)383 body, err := json.Marshal(wire)
383 if err != nil {384 if err != nil {
384 return nil, err385 return nil, err
385 }386 }
386 387
387- newReq := func() (*http.Request, error) {388+ newReq := func(cliRouting bool) func() (*http.Request, error) {
388- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))389+ return func() (*http.Request, error) {
389- if err != nil {390+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
390- return nil, err391+ if err != nil {
391- }392+ return nil, err
392- httpReq.Header.Set("content-type", "application/json")393+ }
393- httpReq.Header.Set("accept", "text/event-stream")394+ httpReq.Header.Set("content-type", "application/json")
394- httpReq.Header.Set("authorization", "Bearer "+c.token)395+ httpReq.Header.Set("accept", "text/event-stream")
395- httpReq.Header.Set("chatgpt-account-id", c.accountID)396+ httpReq.Header.Set("authorization", "Bearer "+c.token)
396- httpReq.Header.Set("openai-beta", "responses=experimental")397+ httpReq.Header.Set("chatgpt-account-id", c.accountID)
397- if codexCLISessionID != "" {398+ httpReq.Header.Set("openai-beta", "responses=experimental")
398- // The ChatGPT Codex backend only admits (and reliably serves)399+ if codexSessionID != "" {
399- // requests that follow Codex CLI routing metadata; other client400+ httpReq.Header.Set("session-id", codexSessionID)
400- // identities are load-shed with "servers are currently401+ httpReq.Header.Set("x-client-request-id", codexSessionID)
401- // overloaded" errors even when capacity is fine.402+ }
402- httpReq.Header.Set("originator", "codex_cli_rs")403+ if cliRouting {
403- httpReq.Header.Set("session-id", codexCLISessionID)404+ // The ChatGPT Codex backend normally requires the Codex CLI
404- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")405+ // request identity. A narrow fallback below uses zot's identity
405- } else {406+ // when that compatibility shape selects unsupported cache policy.
406- httpReq.Header.Set("originator", "zot")407+ httpReq.Header.Set("originator", "codex_cli_rs")
407- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))408+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
409+ } else {
410+ httpReq.Header.Set("originator", "zot")
411+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
412+ }
413+ return httpReq, nil
408 }414 }
409- return httpReq, nil
410 }415 }
411 416
412- resp, err := doStreamWithRetry(ctx, c.http, newReq)417+ resp, err := doStreamWithRetry(ctx, c.http, newReq(useCLIRouting))
413 if err != nil {418 if err != nil {
414 return nil, fmt.Errorf("openai-codex: %w", err)419 return nil, fmt.Errorf("openai-codex: %w", err)
415 }420 }
416 if resp.StatusCode != http.StatusOK {421 if resp.StatusCode != http.StatusOK {
417- b, _ := io.ReadAll(resp.Body)422+ status := resp.StatusCode
423+ responseBody, _ := io.ReadAll(resp.Body)
418 resp.Body.Close()424 resp.Body.Close()
419- return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))425+ if !useCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) {
426+ return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
427+ }
428+ resp, err = doStreamWithRetry(ctx, c.http, newReq(false))
429+ if err != nil {
430+ return nil, fmt.Errorf("openai-codex: cache compatibility retry: %w", err)
431+ }
432+ if resp.StatusCode != http.StatusOK {
433+ status = resp.StatusCode
434+ responseBody, _ = io.ReadAll(resp.Body)
435+ resp.Body.Close()
436+ return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
437+ }
420 }438 }
421 439
422 out := make(chan Event, 16)440 out := make(chan Event, 16)
@@ -424,6 +442,24 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
424 return out, nil442 return out, nil
425 }443 }
426 444
445+func unsupportedPromptCacheRetention(status int, body []byte) bool {
446+ if status != http.StatusBadRequest {
447+ return false
448+ }
449+ var payload struct {
450+ Error struct {
451+ Message string `json:"message"`
452+ Param string `json:"param"`
453+ Code string `json:"code"`
454+ } `json:"error"`
455+ }
456+ if json.Unmarshal(body, &payload) != nil || payload.Error.Param != "prompt_cache_retention" {
457+ return false
458+ }
459+ message := strings.ToLower(payload.Error.Message)
460+ return payload.Error.Code == "invalid_parameter" || strings.Contains(message, "not supported")
461+}
462+
427 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {463 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
428 defer close(out)464 defer close(out)
429 defer resp.Body.Close()465 defer resp.Body.Close()
M packages/provider/openai_codex_test.go +63 -0
@@ -263,3 +263,66 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
263263 t.Fatalf("request missing prompt_cache_key: %s", body.String())
264264 }
265265 }
266+
267+func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) {
268+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
269+ var headers []http.Header
270+ var bodies []string
271+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
272+ headers = append(headers, r.Header.Clone())
273+ body, err := io.ReadAll(r.Body)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ bodies = append(bodies, string(body))
278+ if len(headers) == 1 {
279+ return &http.Response{
280+ StatusCode: http.StatusBadRequest,
281+ Header: make(http.Header),
282+ Body: io.NopCloser(strings.NewReader(`{
283+ "error": {
284+ "message": "prompt_cache_retention is not supported on this model",
285+ "type": "invalid_request_error",
286+ "param": "prompt_cache_retention",
287+ "code": "invalid_parameter"
288+ }
289+ }`)),
290+ }, nil
291+ }
292+ return &http.Response{
293+ StatusCode: http.StatusOK,
294+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
295+ Body: io.NopCloser(strings.NewReader("")),
296+ }, nil
297+ })
298+
299+ events, err := c.Stream(context.Background(), Request{
300+ Model: "gpt-5.6-sol",
301+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
302+ })
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ for event := range events {
307+ if done, ok := event.(EventDone); ok && done.Err != nil {
308+ t.Fatalf("retry stream failed: %v", done.Err)
309+ }
310+ }
311+
312+ if len(headers) != 2 {
313+ t.Fatalf("requests = %d, want 2", len(headers))
314+ }
315+ if got := headers[0].Get("originator"); got != "codex_cli_rs" {
316+ t.Fatalf("initial originator = %q", got)
317+ }
318+ if got := headers[1].Get("originator"); got != "zot" {
319+ t.Fatalf("retry originator = %q", got)
320+ }
321+ sessionID := headers[1].Get("session-id")
322+ if sessionID == "" || headers[1].Get("x-client-request-id") != sessionID {
323+ t.Fatalf("retry session headers = %#v", headers[1])
324+ }
325+ if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key"`) {
326+ t.Fatalf("retry body did not preserve prompt cache key: %v", bodies)
327+ }
328+}
@@ -263,3 +263,66 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
263 t.Fatalf("request missing prompt_cache_key: %s", body.String())263 t.Fatalf("request missing prompt_cache_key: %s", body.String())
264 }264 }
265 }265 }
266+
267+func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) {
268+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
269+ var headers []http.Header
270+ var bodies []string
271+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
272+ headers = append(headers, r.Header.Clone())
273+ body, err := io.ReadAll(r.Body)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ bodies = append(bodies, string(body))
278+ if len(headers) == 1 {
279+ return &http.Response{
280+ StatusCode: http.StatusBadRequest,
281+ Header: make(http.Header),
282+ Body: io.NopCloser(strings.NewReader(`{
283+ "error": {
284+ "message": "prompt_cache_retention is not supported on this model",
285+ "type": "invalid_request_error",
286+ "param": "prompt_cache_retention",
287+ "code": "invalid_parameter"
288+ }
289+ }`)),
290+ }, nil
291+ }
292+ return &http.Response{
293+ StatusCode: http.StatusOK,
294+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
295+ Body: io.NopCloser(strings.NewReader("")),
296+ }, nil
297+ })
298+
299+ events, err := c.Stream(context.Background(), Request{
300+ Model: "gpt-5.6-sol",
301+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
302+ })
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ for event := range events {
307+ if done, ok := event.(EventDone); ok && done.Err != nil {
308+ t.Fatalf("retry stream failed: %v", done.Err)
309+ }
310+ }
311+
312+ if len(headers) != 2 {
313+ t.Fatalf("requests = %d, want 2", len(headers))
314+ }
315+ if got := headers[0].Get("originator"); got != "codex_cli_rs" {
316+ t.Fatalf("initial originator = %q", got)
317+ }
318+ if got := headers[1].Get("originator"); got != "zot" {
319+ t.Fatalf("retry originator = %q", got)
320+ }
321+ sessionID := headers[1].Get("session-id")
322+ if sessionID == "" || headers[1].Get("x-client-request-id") != sessionID {
323+ t.Fatalf("retry session headers = %#v", headers[1])
324+ }
325+ if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key"`) {
326+ t.Fatalf("retry body did not preserve prompt cache key: %v", bodies)
327+ }
328+}

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