detached 775d96b

fix(provider): use websocket for codex subscriptions

commit
775d96b62a547aa298746705af61f46c68173e3f
author
brice · · 2026-08-18 13:30 UTC
parent
1a6392e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
9797 },
9898 "openai-codex": func(req Request) ([]string, error) {
9999 req.Model = "gpt-5.5"
100- wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
100+ wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req)
101101 if err != nil {
102102 return nil, err
103103 }
@@ -97,7 +97,7 @@ func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
97 },97 },
98 "openai-codex": func(req Request) ([]string, error) {98 "openai-codex": func(req Request) ([]string, error) {
99 req.Model = "gpt-5.5"99 req.Model = "gpt-5.5"
100- wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)100+ wire, err := newOpenAICodexClient("token", "account", "").buildRequest(req)
101 if err != nil {101 if err != nil {
102 return nil, err102 return nil, err
103 }103 }
M packages/provider/fast_mode_test.go +1 -1
@@ -27,7 +27,7 @@ func TestOpenAIFastModeAddsServiceTier(t *testing.T) {
2727 }
2828
2929 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {
30- client := NewOpenAICodex("token", "account", "").(*codexClient)
30+ client := newOpenAICodexClient("token", "account", "")
3131
3232 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})
3333 if err != nil {
@@ -27,7 +27,7 @@ func TestOpenAIFastModeAddsServiceTier(t *testing.T) {
27 }27 }
28 28
29 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {29 func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) {
30- client := NewOpenAICodex("token", "account", "").(*codexClient)30+ client := newOpenAICodexClient("token", "account", "")
31 31
32 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})32 wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true})
33 if err != nil {33 if err != nil {
M packages/provider/httpclient.go +5 -1
@@ -46,7 +46,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
4646 case *codexClient:
4747 v.http = httpClient
4848 case *responsesWebSocketClient:
49- v.http.http = openaiResponsesHTTPClient(httpClient)
49+ if v.http.accountID == "" {
50+ v.http.http = openaiResponsesHTTPClient(httpClient)
51+ } else {
52+ v.http.http = httpClient
53+ }
5054 case *renamedClient:
5155 if codex, ok := v.inner.(*codexClient); ok {
5256 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {
@@ -46,7 +46,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
46 case *codexClient:46 case *codexClient:
47 v.http = httpClient47 v.http = httpClient
48 case *responsesWebSocketClient:48 case *responsesWebSocketClient:
49- v.http.http = openaiResponsesHTTPClient(httpClient)49+ if v.http.accountID == "" {
50+ v.http.http = openaiResponsesHTTPClient(httpClient)
51+ } else {
52+ v.http.http = httpClient
53+ }
50 case *renamedClient:54 case *renamedClient:
51 if codex, ok := v.inner.(*codexClient); ok {55 if codex, ok := v.inner.(*codexClient); ok {
52 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {56 if _, isPublicResponses := codex.http.Transport.(*openaiResponsesTransport); isPublicResponses {
M packages/provider/openai_codex.go +48 -29
@@ -58,10 +58,15 @@ type responsesCapabilities struct {
5858 ClientRequestHeader bool
5959 }
6060
61-// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
62-// using a subscription OAuth access token and the user's ChatGPT
63-// account id. baseURL may be empty to use the default.
61+// NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint
62+// using a subscription OAuth access token and the user's ChatGPT account ID.
63+// It prefers WebSocket mode and falls back to HTTP/SSE before visible output.
64+// baseURL may be empty to use the default.
6465 func NewOpenAICodex(token, accountID, baseURL string) Client {
66+ return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL))
67+}
68+
69+func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
6570 if baseURL == "" {
6671 baseURL = codexDefaultBaseURL
6772 }
@@ -444,7 +449,6 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
444449 }
445450 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
446451 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
447- threadID := strings.TrimSpace(req.Context.ThreadID)
448452 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449453 wire.PromptCacheKey = cacheSessionID
450454 }
@@ -458,33 +462,9 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
458462 if err != nil {
459463 return nil, err
460464 }
465+ httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
461466 httpReq.Header.Set("content-type", "application/json")
462467 httpReq.Header.Set("accept", "text/event-stream")
463- httpReq.Header.Set("authorization", "Bearer "+c.token)
464- httpReq.Header.Set("chatgpt-account-id", c.accountID)
465- httpReq.Header.Set("openai-beta", "responses=experimental")
466- if useCodexCLIRouting {
467- // The ChatGPT Codex backend only admits (and reliably serves)
468- // requests that follow Codex CLI routing metadata; other client
469- // identities are load-shed with "servers are currently
470- // overloaded" errors even when capacity is fine.
471- httpReq.Header.Set("originator", "codex_cli_rs")
472- if c.capabilities.SessionHeader && cacheSessionID != "" {
473- httpReq.Header.Set("session-id", cacheSessionID)
474- }
475- if c.capabilities.ThreadHeader && threadID != "" {
476- httpReq.Header.Set("thread-id", threadID)
477- }
478- if c.capabilities.ClientRequestHeader && threadID != "" {
479- httpReq.Header.Set("x-client-request-id", threadID)
480- }
481- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
482- } else {
483- // ChatGPT's backend recognizes these established values. They are
484- // external provider metadata, not zut product identifiers.
485- httpReq.Header.Set("originator", "zot")
486- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
487- }
488468 return httpReq, nil
489469 }
490470
@@ -503,6 +483,45 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
503483 return out, nil
504484 }
505485
486+func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header {
487+ headers := make(http.Header)
488+ headers.Set("authorization", "Bearer "+c.token)
489+ if !websocketMode || c.accountID != "" {
490+ headers.Set("openai-beta", "responses=experimental")
491+ }
492+ if c.accountID != "" {
493+ headers.Set("chatgpt-account-id", c.accountID)
494+ }
495+
496+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
497+ threadID := strings.TrimSpace(req.Context.ThreadID)
498+ if useCodexCLIRouting {
499+ // The ChatGPT Codex backend only admits (and reliably serves)
500+ // requests that follow Codex CLI routing metadata; other client
501+ // identities are load-shed even when capacity is available.
502+ headers.Set("originator", "codex_cli_rs")
503+ if c.capabilities.SessionHeader && cacheSessionID != "" {
504+ headers.Set("session-id", cacheSessionID)
505+ }
506+ if c.capabilities.ThreadHeader && threadID != "" {
507+ headers.Set("thread-id", threadID)
508+ }
509+ if c.capabilities.ClientRequestHeader && threadID != "" {
510+ headers.Set("x-client-request-id", threadID)
511+ }
512+ headers.Set("user-agent", "codex_cli_rs/0.0.0")
513+ return headers
514+ }
515+
516+ // ChatGPT's backend recognizes these established values. They are
517+ // external provider metadata, not zut product identifiers.
518+ if c.accountID != "" {
519+ headers.Set("originator", "zot")
520+ headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
521+ }
522+ return headers
523+}
524+
506525 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
507526 defer close(out)
508527 defer resp.Body.Close()
@@ -58,10 +58,15 @@ type responsesCapabilities struct {
58 ClientRequestHeader bool58 ClientRequestHeader bool
59 }59 }
60 60
61-// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint61+// NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint
62-// using a subscription OAuth access token and the user's ChatGPT62+// using a subscription OAuth access token and the user's ChatGPT account ID.
63-// account id. baseURL may be empty to use the default.63+// It prefers WebSocket mode and falls back to HTTP/SSE before visible output.
64+// baseURL may be empty to use the default.
64 func NewOpenAICodex(token, accountID, baseURL string) Client {65 func NewOpenAICodex(token, accountID, baseURL string) Client {
66+ return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL))
67+}
68+
69+func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
65 if baseURL == "" {70 if baseURL == "" {
66 baseURL = codexDefaultBaseURL71 baseURL = codexDefaultBaseURL
67 }72 }
@@ -444,7 +449,6 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
444 }449 }
445 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))450 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
446 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)451 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
447- threadID := strings.TrimSpace(req.Context.ThreadID)
448 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {452 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449 wire.PromptCacheKey = cacheSessionID453 wire.PromptCacheKey = cacheSessionID
450 }454 }
@@ -458,33 +462,9 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
458 if err != nil {462 if err != nil {
459 return nil, err463 return nil, err
460 }464 }
465+ httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
461 httpReq.Header.Set("content-type", "application/json")466 httpReq.Header.Set("content-type", "application/json")
462 httpReq.Header.Set("accept", "text/event-stream")467 httpReq.Header.Set("accept", "text/event-stream")
463- httpReq.Header.Set("authorization", "Bearer "+c.token)
464- httpReq.Header.Set("chatgpt-account-id", c.accountID)
465- httpReq.Header.Set("openai-beta", "responses=experimental")
466- if useCodexCLIRouting {
467- // The ChatGPT Codex backend only admits (and reliably serves)
468- // requests that follow Codex CLI routing metadata; other client
469- // identities are load-shed with "servers are currently
470- // overloaded" errors even when capacity is fine.
471- httpReq.Header.Set("originator", "codex_cli_rs")
472- if c.capabilities.SessionHeader && cacheSessionID != "" {
473- httpReq.Header.Set("session-id", cacheSessionID)
474- }
475- if c.capabilities.ThreadHeader && threadID != "" {
476- httpReq.Header.Set("thread-id", threadID)
477- }
478- if c.capabilities.ClientRequestHeader && threadID != "" {
479- httpReq.Header.Set("x-client-request-id", threadID)
480- }
481- httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
482- } else {
483- // ChatGPT's backend recognizes these established values. They are
484- // external provider metadata, not zut product identifiers.
485- httpReq.Header.Set("originator", "zot")
486- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
487- }
488 return httpReq, nil468 return httpReq, nil
489 }469 }
490 470
@@ -503,6 +483,45 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
503 return out, nil483 return out, nil
504 }484 }
505 485
486+func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header {
487+ headers := make(http.Header)
488+ headers.Set("authorization", "Bearer "+c.token)
489+ if !websocketMode || c.accountID != "" {
490+ headers.Set("openai-beta", "responses=experimental")
491+ }
492+ if c.accountID != "" {
493+ headers.Set("chatgpt-account-id", c.accountID)
494+ }
495+
496+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
497+ threadID := strings.TrimSpace(req.Context.ThreadID)
498+ if useCodexCLIRouting {
499+ // The ChatGPT Codex backend only admits (and reliably serves)
500+ // requests that follow Codex CLI routing metadata; other client
501+ // identities are load-shed even when capacity is available.
502+ headers.Set("originator", "codex_cli_rs")
503+ if c.capabilities.SessionHeader && cacheSessionID != "" {
504+ headers.Set("session-id", cacheSessionID)
505+ }
506+ if c.capabilities.ThreadHeader && threadID != "" {
507+ headers.Set("thread-id", threadID)
508+ }
509+ if c.capabilities.ClientRequestHeader && threadID != "" {
510+ headers.Set("x-client-request-id", threadID)
511+ }
512+ headers.Set("user-agent", "codex_cli_rs/0.0.0")
513+ return headers
514+ }
515+
516+ // ChatGPT's backend recognizes these established values. They are
517+ // external provider metadata, not zut product identifiers.
518+ if c.accountID != "" {
519+ headers.Set("originator", "zot")
520+ headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
521+ }
522+ return headers
523+}
524+
506 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {525 func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
507 defer close(out)526 defer close(out)
508 defer resp.Body.Close()527 defer resp.Body.Close()
M packages/provider/openai_codex_test.go +6 -6
@@ -35,7 +35,7 @@ func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
3535 // following user-message image must serialize as input_image so the
3636 // model actually receives the bytes.
3737 func TestCodexImageToolResultMirror(t *testing.T) {
38- c := NewOpenAICodex("token", "acct", "").(*codexClient)
38+ c := newOpenAICodexClient("token", "acct", "")
3939
4040 wire, err := c.buildRequest(Request{
4141 Model: "gpt-5.5",
@@ -92,7 +92,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
9292 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
9393
9494 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
95- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
95+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
9696 var gotReq *http.Request
9797 var gotBody codexRequest
9898 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -136,7 +136,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
136136 }
137137
138138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139- c := NewOpenAICodex("token", "acct", "").(*codexClient)
139+ c := newOpenAICodexClient("token", "acct", "")
140140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
141141 if err != nil {
142142 t.Fatal(err)
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187187 c := ws.http
188188 var gotReq *http.Request
189189 var gotBody codexRequest
190- ws.dial = func(context.Context) (*websocket.Conn, error) {
190+ ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
191191 return nil, errors.New("test websocket unavailable")
192192 }
193193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -268,7 +268,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
268268 }
269269
270270 func TestCodexNestedStreamError(t *testing.T) {
271- c := NewOpenAICodex("token", "acct", "").(*codexClient)
271+ c := newOpenAICodexClient("token", "acct", "")
272272 resp := &http.Response{
273273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
274274 }
@@ -293,7 +293,7 @@ func TestCodexNestedStreamError(t *testing.T) {
293293 // even when capacity is fine, so the CLI shape is required for
294294 // reliable service, not just for preview-model admission.
295295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
296- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
296+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
297297 var gotReq *http.Request
298298 var body bytes.Buffer
299299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -35,7 +35,7 @@ func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
35 // following user-message image must serialize as input_image so the35 // following user-message image must serialize as input_image so the
36 // model actually receives the bytes.36 // model actually receives the bytes.
37 func TestCodexImageToolResultMirror(t *testing.T) {37 func TestCodexImageToolResultMirror(t *testing.T) {
38- c := NewOpenAICodex("token", "acct", "").(*codexClient)38+ c := newOpenAICodexClient("token", "acct", "")
39 39
40 wire, err := c.buildRequest(Request{40 wire, err := c.buildRequest(Request{
41 Model: "gpt-5.5",41 Model: "gpt-5.5",
@@ -92,7 +92,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
92 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }92 func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
93 93
94 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {94 func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
95- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)95+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
96 var gotReq *http.Request96 var gotReq *http.Request
97 var gotBody codexRequest97 var gotBody codexRequest
98 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {98 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -136,7 +136,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
136 }136 }
137 137
138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {138 func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139- c := NewOpenAICodex("token", "acct", "").(*codexClient)139+ c := newOpenAICodexClient("token", "acct", "")
140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})140 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
141 if err != nil {141 if err != nil {
142 t.Fatal(err)142 t.Fatal(err)
@@ -187,7 +187,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187 c := ws.http187 c := ws.http
188 var gotReq *http.Request188 var gotReq *http.Request
189 var gotBody codexRequest189 var gotBody codexRequest
190- ws.dial = func(context.Context) (*websocket.Conn, error) {190+ ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
191 return nil, errors.New("test websocket unavailable")191 return nil, errors.New("test websocket unavailable")
192 }192 }
193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {193 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -268,7 +268,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
268 }268 }
269 269
270 func TestCodexNestedStreamError(t *testing.T) {270 func TestCodexNestedStreamError(t *testing.T) {
271- c := NewOpenAICodex("token", "acct", "").(*codexClient)271+ c := newOpenAICodexClient("token", "acct", "")
272 resp := &http.Response{272 resp := &http.Response{
273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),273 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
274 }274 }
@@ -293,7 +293,7 @@ func TestCodexNestedStreamError(t *testing.T) {
293 // even when capacity is fine, so the CLI shape is required for293 // even when capacity is fine, so the CLI shape is required for
294 // reliable service, not just for preview-model admission.294 // reliable service, not just for preview-model admission.
295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {295 func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
296- c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)296+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
297 var gotReq *http.Request297 var gotReq *http.Request
298 var body bytes.Buffer298 var body bytes.Buffer
299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {299 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
M packages/provider/openai_responses.go +4 -6
@@ -2,12 +2,10 @@ package provider
22
33 // OpenAI Responses API client (api.openai.com/v1/responses).
44 //
5-// Strategy: reuse the existing codexClient (which already speaks the
6-// Responses wire format) via a header-rewriting RoundTripper. The codex
7-// client always sends `chatgpt-account-id` and `openai-beta`; the public
8-// Responses API on api.openai.com accepts but doesn't require those —
9-// we strip the account-id header on its way out so the request looks
10-// like a normal authenticated OpenAI call.
5+// Strategy: reuse codexClient, which already speaks the Responses wire
6+// format, via a header-rewriting RoundTripper. The public Responses API on
7+// api.openai.com does not need ChatGPT subscription identity headers, so the
8+// transport strips them if a shared request path supplied any.
119 //
1210 // This is a separate provider from `openai` (which is Chat Completions);
1311 // users opt in by passing `--provider openai-responses` or by picking a
@@ -2,12 +2,10 @@ package provider
2 2
3 // OpenAI Responses API client (api.openai.com/v1/responses).3 // OpenAI Responses API client (api.openai.com/v1/responses).
4 //4 //
5-// Strategy: reuse the existing codexClient (which already speaks the5+// Strategy: reuse codexClient, which already speaks the Responses wire
6-// Responses wire format) via a header-rewriting RoundTripper. The codex6+// format, via a header-rewriting RoundTripper. The public Responses API on
7-// client always sends `chatgpt-account-id` and `openai-beta`; the public7+// api.openai.com does not need ChatGPT subscription identity headers, so the
8-// Responses API on api.openai.com accepts but doesn't require those —8+// transport strips them if a shared request path supplied any.
9-// we strip the account-id header on its way out so the request looks
10-// like a normal authenticated OpenAI call.
11 //9 //
12 // This is a separate provider from `openai` (which is Chat Completions);10 // This is a separate provider from `openai` (which is Chat Completions);
13 // users opt in by passing `--provider openai-responses` or by picking a11 // users opt in by passing `--provider openai-responses` or by picking a
M packages/provider/openai_responses_websocket.go +41 -14
@@ -17,17 +17,16 @@ import (
1717 "golang.org/x/net/websocket"
1818 )
1919
20-// responsesWebSocketClient uses the public Responses WebSocket mode for one
21-// logical session at a time. It deliberately wraps only the public OpenAI
22-// Responses client: ChatGPT Codex and OpenAI-compatible endpoints have
23-// different authentication and cache contracts and must retain their HTTP/SSE
24-// transport until they independently declare WebSocket support.
20+// responsesWebSocketClient uses Responses WebSocket mode for one logical
21+// thread at a time. Official OpenAI API-key and ChatGPT subscription clients
22+// both use it; each supplies its own authenticated handshake headers and keeps
23+// HTTP/SSE as the pre-event fallback.
2524 type responsesWebSocketClient struct {
2625 http *codexClient
2726
2827 mu sync.Mutex
2928 sessions map[string]*responsesWebSocketSession
30- dial func(context.Context) (*websocket.Conn, error)
29+ dial func(context.Context, Request) (*websocket.Conn, error)
3130 }
3231
3332 type responsesWebSocketSession struct {
@@ -85,17 +84,25 @@ func (c *responsesWebSocketClient) session(id string) *responsesWebSocketSession
8584 return session
8685 }
8786
88-func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocket.Conn, error) {
87+func (c *responsesWebSocketClient) dialResponses(ctx context.Context, req Request) (*websocket.Conn, error) {
8988 endpoint, err := responsesWebSocketURL(c.http.baseURL)
9089 if err != nil {
9190 return nil, err
9291 }
93- config, err := websocket.NewConfig(endpoint, "https://api.openai.com")
92+ origin, err := responsesWebSocketOrigin(c.http.baseURL)
9493 if err != nil {
9594 return nil, err
9695 }
97- config.Header = make(http.Header)
98- config.Header.Set("authorization", "Bearer "+c.http.token)
96+ config, err := websocket.NewConfig(endpoint, origin)
97+ if err != nil {
98+ return nil, err
99+ }
100+ model := req.Model
101+ if c.http.modelName != nil {
102+ model = c.http.modelName(model)
103+ }
104+ useCodexCLIRouting := !c.http.disableCLIRouting && (c.http.cliRoutingAll || usesCodexCLIRouting(model))
105+ config.Header = c.http.responsesHeaders(req, useCodexCLIRouting, true)
99106 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {
100107 config.TlsConfig = tlsConfig
101108 }
@@ -104,6 +111,26 @@ func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocke
104111
105112 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by
106113 // WithHTTPClient into the WebSocket dial.
114+func responsesWebSocketOrigin(endpoint string) (string, error) {
115+ u, err := url.Parse(endpoint)
116+ if err != nil {
117+ return "", fmt.Errorf("parse Responses endpoint origin: %w", err)
118+ }
119+ switch u.Scheme {
120+ case "https", "wss":
121+ u.Scheme = "https"
122+ case "http", "ws":
123+ u.Scheme = "http"
124+ default:
125+ return "", fmt.Errorf("unsupported Responses endpoint scheme %q", u.Scheme)
126+ }
127+ u.Path = ""
128+ u.RawPath = ""
129+ u.RawQuery = ""
130+ u.Fragment = ""
131+ return u.String(), nil
132+}
133+
107134 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {
108135 if client == nil {
109136 return nil
@@ -203,7 +230,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
203230 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204231 }
205232 reportRequestAttempt(req.Lifecycle, 1, 2)
206- if err := session.ensureConnected(ctx, c.dial); err != nil {
233+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
207234 // A connection that never became usable has no server-side turn. The
208235 // SSE fallback therefore receives the complete request and preserves
209236 // the same session-derived cache key without sharing WebSocket state.
@@ -313,7 +340,7 @@ func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *r
313340 return nil, nil, sseEvent{}, "", err
314341 }
315342 reportRequestAttempt(req.Lifecycle, 2, 2)
316- if err := session.ensureConnected(ctx, c.dial); err != nil {
343+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
317344 return nil, nil, sseEvent{}, "", err
318345 }
319346 conn, raw := session.connection()
@@ -494,14 +521,14 @@ func sameResponsesContinuationConfig(previous, current Request) bool {
494521 return reflect.DeepEqual(previous, current)
495522 }
496523
497-func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
524+func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, req Request, dial func(context.Context, Request) (*websocket.Conn, error)) error {
498525 s.mu.Lock()
499526 if s.conn != nil && s.raw != nil {
500527 s.mu.Unlock()
501528 return nil
502529 }
503530 s.mu.Unlock()
504- conn, err := dial(ctx)
531+ conn, err := dial(ctx, req)
505532 if err != nil {
506533 return err
507534 }
@@ -17,17 +17,16 @@ import (
17 "golang.org/x/net/websocket"17 "golang.org/x/net/websocket"
18 )18 )
19 19
20-// responsesWebSocketClient uses the public Responses WebSocket mode for one20+// responsesWebSocketClient uses Responses WebSocket mode for one logical
21-// logical session at a time. It deliberately wraps only the public OpenAI21+// thread at a time. Official OpenAI API-key and ChatGPT subscription clients
22-// Responses client: ChatGPT Codex and OpenAI-compatible endpoints have22+// both use it; each supplies its own authenticated handshake headers and keeps
23-// different authentication and cache contracts and must retain their HTTP/SSE23+// HTTP/SSE as the pre-event fallback.
24-// transport until they independently declare WebSocket support.
25 type responsesWebSocketClient struct {24 type responsesWebSocketClient struct {
26 http *codexClient25 http *codexClient
27 26
28 mu sync.Mutex27 mu sync.Mutex
29 sessions map[string]*responsesWebSocketSession28 sessions map[string]*responsesWebSocketSession
30- dial func(context.Context) (*websocket.Conn, error)29+ dial func(context.Context, Request) (*websocket.Conn, error)
31 }30 }
32 31
33 type responsesWebSocketSession struct {32 type responsesWebSocketSession struct {
@@ -85,17 +84,25 @@ func (c *responsesWebSocketClient) session(id string) *responsesWebSocketSession
85 return session84 return session
86 }85 }
87 86
88-func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocket.Conn, error) {87+func (c *responsesWebSocketClient) dialResponses(ctx context.Context, req Request) (*websocket.Conn, error) {
89 endpoint, err := responsesWebSocketURL(c.http.baseURL)88 endpoint, err := responsesWebSocketURL(c.http.baseURL)
90 if err != nil {89 if err != nil {
91 return nil, err90 return nil, err
92 }91 }
93- config, err := websocket.NewConfig(endpoint, "https://api.openai.com")92+ origin, err := responsesWebSocketOrigin(c.http.baseURL)
94 if err != nil {93 if err != nil {
95 return nil, err94 return nil, err
96 }95 }
97- config.Header = make(http.Header)96+ config, err := websocket.NewConfig(endpoint, origin)
98- config.Header.Set("authorization", "Bearer "+c.http.token)97+ if err != nil {
98+ return nil, err
99+ }
100+ model := req.Model
101+ if c.http.modelName != nil {
102+ model = c.http.modelName(model)
103+ }
104+ useCodexCLIRouting := !c.http.disableCLIRouting && (c.http.cliRoutingAll || usesCodexCLIRouting(model))
105+ config.Header = c.http.responsesHeaders(req, useCodexCLIRouting, true)
99 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {106 if tlsConfig := responsesWebSocketTLSConfig(c.http.http); tlsConfig != nil {
100 config.TlsConfig = tlsConfig107 config.TlsConfig = tlsConfig
101 }108 }
@@ -104,6 +111,26 @@ func (c *responsesWebSocketClient) dialResponses(ctx context.Context) (*websocke
104 111
105 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by112 // responsesWebSocketTLSConfig carries the explicit TLS policy supplied by
106 // WithHTTPClient into the WebSocket dial.113 // WithHTTPClient into the WebSocket dial.
114+func responsesWebSocketOrigin(endpoint string) (string, error) {
115+ u, err := url.Parse(endpoint)
116+ if err != nil {
117+ return "", fmt.Errorf("parse Responses endpoint origin: %w", err)
118+ }
119+ switch u.Scheme {
120+ case "https", "wss":
121+ u.Scheme = "https"
122+ case "http", "ws":
123+ u.Scheme = "http"
124+ default:
125+ return "", fmt.Errorf("unsupported Responses endpoint scheme %q", u.Scheme)
126+ }
127+ u.Path = ""
128+ u.RawPath = ""
129+ u.RawQuery = ""
130+ u.Fragment = ""
131+ return u.String(), nil
132+}
133+
107 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {134 func responsesWebSocketTLSConfig(client *http.Client) *tls.Config {
108 if client == nil {135 if client == nil {
109 return nil136 return nil
@@ -203,7 +230,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
203 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))230 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204 }231 }
205 reportRequestAttempt(req.Lifecycle, 1, 2)232 reportRequestAttempt(req.Lifecycle, 1, 2)
206- if err := session.ensureConnected(ctx, c.dial); err != nil {233+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
207 // A connection that never became usable has no server-side turn. The234 // A connection that never became usable has no server-side turn. The
208 // SSE fallback therefore receives the complete request and preserves235 // SSE fallback therefore receives the complete request and preserves
209 // the same session-derived cache key without sharing WebSocket state.236 // the same session-derived cache key without sharing WebSocket state.
@@ -313,7 +340,7 @@ func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *r
313 return nil, nil, sseEvent{}, "", err340 return nil, nil, sseEvent{}, "", err
314 }341 }
315 reportRequestAttempt(req.Lifecycle, 2, 2)342 reportRequestAttempt(req.Lifecycle, 2, 2)
316- if err := session.ensureConnected(ctx, c.dial); err != nil {343+ if err := session.ensureConnected(ctx, req, c.dial); err != nil {
317 return nil, nil, sseEvent{}, "", err344 return nil, nil, sseEvent{}, "", err
318 }345 }
319 conn, raw := session.connection()346 conn, raw := session.connection()
@@ -494,14 +521,14 @@ func sameResponsesContinuationConfig(previous, current Request) bool {
494 return reflect.DeepEqual(previous, current)521 return reflect.DeepEqual(previous, current)
495 }522 }
496 523
497-func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {524+func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, req Request, dial func(context.Context, Request) (*websocket.Conn, error)) error {
498 s.mu.Lock()525 s.mu.Lock()
499 if s.conn != nil && s.raw != nil {526 if s.conn != nil && s.raw != nil {
500 s.mu.Unlock()527 s.mu.Unlock()
501 return nil528 return nil
502 }529 }
503 s.mu.Unlock()530 s.mu.Unlock()
504- conn, err := dial(ctx)531+ conn, err := dial(ctx, req)
505 if err != nil {532 if err != nil {
506 return err533 return err
507 }534 }
M packages/provider/openai_responses_websocket_test.go +71 -0
@@ -225,6 +225,77 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
225225 }
226226 }
227227
228+func TestOpenAICodexUsesWebSocketWithSubscriptionHeaders(t *testing.T) {
229+ headers := make(chan http.Header, 1)
230+ requests := make(chan map[string]any, 1)
231+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
232+ defer conn.Close()
233+ headers <- conn.Request().Header.Clone()
234+ var payload map[string]any
235+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
236+ t.Errorf("receive websocket request: %v", err)
237+ return
238+ }
239+ requests <- payload
240+ for _, event := range []map[string]any{
241+ {"type": "response.created", "response": map[string]any{"id": "resp_subscription"}},
242+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
243+ {"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
244+ {"type": "response.completed", "response": map[string]any{"id": "resp_subscription", "usage": map[string]any{}}},
245+ } {
246+ _ = websocket.JSON.Send(conn, event)
247+ }
248+ }))
249+ defer server.Close()
250+
251+ client := NewOpenAICodex("subscription-token", "account-1", server.URL)
252+ websocketClient, ok := client.(*responsesWebSocketClient)
253+ if !ok {
254+ t.Fatalf("NewOpenAICodex returned %T, want WebSocket Responses client", client)
255+ }
256+ defer websocketClient.Close()
257+
258+ request := Request{
259+ Model: "gpt-5.6-luna",
260+ Context: RequestContext{CacheSessionID: "root-1", ThreadID: "thread-1", TurnID: "turn-1"},
261+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
262+ }
263+ assertCompletedResponse(t, client, request)
264+
265+ gotHeaders := <-headers
266+ for name, want := range map[string]string{
267+ "authorization": "Bearer subscription-token",
268+ "chatgpt-account-id": "account-1",
269+ "openai-beta": "responses=experimental",
270+ "originator": "codex_cli_rs",
271+ "user-agent": "codex_cli_rs/0.0.0",
272+ "session-id": "root-1",
273+ "thread-id": "thread-1",
274+ "x-client-request-id": "thread-1",
275+ } {
276+ if got := gotHeaders.Get(name); got != want {
277+ t.Fatalf("%s = %q, want %q", name, got, want)
278+ }
279+ }
280+ if got := gotHeaders.Get("origin"); got != server.URL {
281+ t.Fatalf("origin = %q, want %q", got, server.URL)
282+ }
283+ if got := (<-requests)["prompt_cache_key"]; got != "root-1" {
284+ t.Fatalf("prompt_cache_key = %#v, want root cache ID", got)
285+ }
286+}
287+
288+func TestWithHTTPClientPreservesSubscriptionHeaders(t *testing.T) {
289+ client := NewOpenAICodex("subscription-token", "account-1", "https://example.test/backend-api/codex/responses")
290+ customHTTP := &http.Client{Transport: &http.Transport{}}
291+ WithHTTPClient(client, customHTTP)
292+
293+ websocketClient := client.(*responsesWebSocketClient)
294+ if websocketClient.http.http != customHTTP {
295+ t.Fatal("subscription client was wrapped in the public OpenAI header-stripping transport")
296+ }
297+}
298+
228299 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229300 mappedModel := "gpt-5.6-sol-a"
230301 client := newResponsesWebSocketClient(&codexClient{
@@ -225,6 +225,77 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
225 }225 }
226 }226 }
227 227
228+func TestOpenAICodexUsesWebSocketWithSubscriptionHeaders(t *testing.T) {
229+ headers := make(chan http.Header, 1)
230+ requests := make(chan map[string]any, 1)
231+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
232+ defer conn.Close()
233+ headers <- conn.Request().Header.Clone()
234+ var payload map[string]any
235+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
236+ t.Errorf("receive websocket request: %v", err)
237+ return
238+ }
239+ requests <- payload
240+ for _, event := range []map[string]any{
241+ {"type": "response.created", "response": map[string]any{"id": "resp_subscription"}},
242+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
243+ {"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
244+ {"type": "response.completed", "response": map[string]any{"id": "resp_subscription", "usage": map[string]any{}}},
245+ } {
246+ _ = websocket.JSON.Send(conn, event)
247+ }
248+ }))
249+ defer server.Close()
250+
251+ client := NewOpenAICodex("subscription-token", "account-1", server.URL)
252+ websocketClient, ok := client.(*responsesWebSocketClient)
253+ if !ok {
254+ t.Fatalf("NewOpenAICodex returned %T, want WebSocket Responses client", client)
255+ }
256+ defer websocketClient.Close()
257+
258+ request := Request{
259+ Model: "gpt-5.6-luna",
260+ Context: RequestContext{CacheSessionID: "root-1", ThreadID: "thread-1", TurnID: "turn-1"},
261+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
262+ }
263+ assertCompletedResponse(t, client, request)
264+
265+ gotHeaders := <-headers
266+ for name, want := range map[string]string{
267+ "authorization": "Bearer subscription-token",
268+ "chatgpt-account-id": "account-1",
269+ "openai-beta": "responses=experimental",
270+ "originator": "codex_cli_rs",
271+ "user-agent": "codex_cli_rs/0.0.0",
272+ "session-id": "root-1",
273+ "thread-id": "thread-1",
274+ "x-client-request-id": "thread-1",
275+ } {
276+ if got := gotHeaders.Get(name); got != want {
277+ t.Fatalf("%s = %q, want %q", name, got, want)
278+ }
279+ }
280+ if got := gotHeaders.Get("origin"); got != server.URL {
281+ t.Fatalf("origin = %q, want %q", got, server.URL)
282+ }
283+ if got := (<-requests)["prompt_cache_key"]; got != "root-1" {
284+ t.Fatalf("prompt_cache_key = %#v, want root cache ID", got)
285+ }
286+}
287+
288+func TestWithHTTPClientPreservesSubscriptionHeaders(t *testing.T) {
289+ client := NewOpenAICodex("subscription-token", "account-1", "https://example.test/backend-api/codex/responses")
290+ customHTTP := &http.Client{Transport: &http.Transport{}}
291+ WithHTTPClient(client, customHTTP)
292+
293+ websocketClient := client.(*responsesWebSocketClient)
294+ if websocketClient.http.http != customHTTP {
295+ t.Fatal("subscription client was wrapped in the public OpenAI header-stripping transport")
296+ }
297+}
298+
228 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {299 func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229 mappedModel := "gpt-5.6-sol-a"300 mappedModel := "gpt-5.6-sol-a"
230 client := newResponsesWebSocketClient(&codexClient{301 client := newResponsesWebSocketClient(&codexClient{

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close