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 | |||
| 374 | 374 | if c.modelName != nil { | |
| 375 | 375 | wire.Model = c.modelName(wire.Model) | |
| 376 | 376 | } | |
| 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 | |
| 381 | 382 | } | |
| 382 | 383 | body, err := json.Marshal(wire) | |
| 383 | 384 | if err != nil { | |
| 384 | 385 | return nil, err | |
| 385 | 386 | } | |
| 386 | 387 | ||
| 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 | |
| 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 | 418 | if err != nil { | |
| 414 | 419 | return nil, fmt.Errorf("openai-codex: %w", err) | |
| 415 | 420 | } | |
| 416 | 421 | if resp.StatusCode != http.StatusOK { | |
| 417 | - | b, _ := io.ReadAll(resp.Body) | |
| 422 | + | status := resp.StatusCode | |
| 423 | + | responseBody, _ := io.ReadAll(resp.Body) | |
| 418 | 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 | 440 | out := make(chan Event, 16) | |
| @@ -424,6 +442,24 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 424 | 442 | 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 | 463 | func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { | |
| 428 | 464 | defer close(out) | |
| 429 | 465 | 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 string | 377 | + | 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 = codexCLISessionID | 380 | + | 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, err | 385 | 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, err | 391 | + | 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 client | 400 | + | httpReq.Header.Set("session-id", codexSessionID) |
| 400 | - | // identities are load-shed with "servers are currently | 401 | + | 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, nil | 442 | 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) { | |||
| 263 | 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 | + | } | |
| @@ -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 | + | } | |||