fix(provider): use websocket for codex subscriptions
- commit
- 775d96b62a547aa298746705af61f46c68173e3f
- author
- brice · · 2026-08-18 13:30 UTC
- parent
- 1a6392e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +177 -58
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{ | ||