From b97f71da0cf0cadac8b565d02ef9f567e0a52c6e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 23 Sep 2026 19:56:53 -0500 Subject: [PATCH] fix(codex): send the ChatGPT routing hint native Codex sends - Send X-Codex-Routing-Hint ("model=" plus ";tier=") on ChatGPT-backend requests over HTTP, compact and websocket handshakes, matching openai/codex rust-v0.155.0 build_routing_hint_header. Translated requests previously carried service_tier=priority only in the body. - Derive the hint from the final upstream body, replacing a hint a native client forwarded, so it names the model and tier actually sent. - Keep operator precedence: an auth header rule that resolves to a value wins, a dynamic rule that resolves to nothing falls back to the derived hint, and models.json override_header still applies last. - Leave API-key requests unchanged, and keep websocket reuse as native Codex does: an open connection keeps its handshake hint when the tier changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../executor/codex_executor_execute.go | 2 + .../executor/codex_executor_request.go | 52 ++++ .../codex_executor_routing_hint_test.go | 234 ++++++++++++++++++ .../runtime/executor/codex_executor_stream.go | 1 + .../executor/codex_websockets_execute.go | 1 + .../codex_websockets_routing_hint_test.go | 210 ++++++++++++++++ .../executor/codex_websockets_stream.go | 1 + 7 files changed, 501 insertions(+) create mode 100644 internal/runtime/executor/codex_executor_routing_hint_test.go create mode 100644 internal/runtime/executor/codex_websockets_routing_hint_test.go diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index fa988c10d75..6f501af41d2 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -83,6 +83,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, err } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, httpReq.Header, auth, upstreamBody, opts.Headers) applyModelHeaderOverrides(httpReq.Header, baseModel) applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState) var authID, authLabel, authType, authValue string @@ -256,6 +257,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A return resp, err } applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, httpReq.Header, auth, upstreamBody, opts.Headers) applyModelHeaderOverrides(httpReq.Header, baseModel) applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState) var authID, authLabel, authType, authValue string diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 49f6ccbce56..2688fe5e18c 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -370,6 +370,58 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke applyCodexCloakingHeaders(r.Header, cfg, auth) } +const codexRoutingHintHeader = "X-Codex-Routing-Hint" + +// applyCodexRoutingHint sends the routing hint native Codex attaches to every +// ChatGPT-backend Responses request: "model=" plus ";tier=" +// when the body requests a tier (openai/codex rust-v0.155.0, +// codex-rs/core/src/client.rs build_routing_hint_header). Without it, a +// translated request carries service_tier=priority only in the body. Whether +// the backend needs the header to grant priority is undocumented. +// +// The hint is derived from the final upstream body so it cannot disagree with +// the model and tier actually sent after aliasing and payload rules; a hint a +// native client forwarded names what the client asked for and is replaced. +// Operator configuration keeps precedence: when an auth "header:" rule for the +// hint resolves to a value (static, or a "$Header" reference the request +// carries), that value is sent, and callers apply models.json override_header +// afterwards. A rule that resolves to nothing falls back to the derived hint. +// API-key requests are not touched, matching native Codex, which sends no hint +// to API-key providers. +func applyCodexRoutingHint(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, upstreamBody []byte, clientHeaders http.Header) { + if codexAuthUsesAPIKey(auth) { + return + } + deleteHeaderCaseInsensitive(headers, codexRoutingHintHeader) + if operatorHint := codexOperatorHeaderValue(ctx, auth, clientHeaders, codexRoutingHintHeader); operatorHint != "" { + headers.Set(codexRoutingHintHeader, operatorHint) + return + } + model := strings.TrimSpace(gjson.GetBytes(upstreamBody, "model").String()) + if model == "" { + return + } + hint := "model=" + model + if tier := gjson.GetBytes(upstreamBody, "service_tier"); tier.Type == gjson.String { + if value := strings.TrimSpace(tier.String()); value != "" { + hint += ";tier=" + value + } + } + headers.Set(codexRoutingHintHeader, hint) +} + +// codexOperatorHeaderValue returns the value the auth's "header:" rules +// resolve to for name, using the same resolver that applied them to the +// request, so dynamic references that resolve to nothing report "". +func codexOperatorHeaderValue(ctx context.Context, auth *cliproxyauth.Auth, clientHeaders http.Header, name string) string { + if auth == nil || len(auth.Attributes) == 0 { + return "" + } + resolved := (&http.Request{Header: http.Header{}}).WithContext(ctx) + util.ApplyCustomHeadersFromAttrs(resolved, auth.Attributes, clientHeaders) + return strings.TrimSpace(resolved.Header.Get(name)) +} + func isCodexCloakingDisabled(cfg *config.Config, auth *cliproxyauth.Auth) bool { if auth != nil && len(auth.Attributes) > 0 { if val, ok := auth.Attributes[cliproxyauth.AttributeCodexDisableCloaking]; ok { diff --git a/internal/runtime/executor/codex_executor_routing_hint_test.go b/internal/runtime/executor/codex_executor_routing_hint_test.go new file mode 100644 index 00000000000..16032c0f881 --- /dev/null +++ b/internal/runtime/executor/codex_executor_routing_hint_test.go @@ -0,0 +1,234 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +type capturedCodexRequest struct { + path string + routingHint string + hasHint bool + serviceTier gjson.Result +} + +func newCodexRoutingHintServer(t *testing.T, captured *capturedCodexRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + captured.path = r.URL.Path + _, captured.hasHint = r.Header[http.CanonicalHeaderKey(codexRoutingHintHeader)] + captured.routingHint = r.Header.Get(codexRoutingHintHeader) + captured.serviceTier = gjson.GetBytes(body, "service_tier") + if r.URL.Path == "/responses/compact" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cmp_1","object":"response.compaction","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}` + "\n\n")) + })) +} + +func codexOAuthTestAuth(baseURL string) *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{"base_url": baseURL}, + Metadata: map[string]any{"type": "codex", "access_token": "oauth-token", "account_id": "acct"}, + } +} + +func codexAPIKeyTestAuth(baseURL string) *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{"base_url": baseURL, "api_key": "sk-test"}, + } +} + +// A Claude Messages request with speed=fast (Claude Code Fast Mode) must reach +// the ChatGPT Codex backend the way native Codex 0.155 sends Fast: the body +// carries service_tier=priority and the routing hint names the same tier. +func TestCodexExecutorRoutingHintCarriesRequestedTier(t *testing.T) { + const fastClaude = `{"model":"gpt-5.5","max_tokens":64,"speed":"fast","messages":[{"role":"user","content":"hi"}]}` + const plainClaude = `{"model":"gpt-5.5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}` + + cases := []struct { + name string + apiKey bool + stream bool + payload string + wantHint string + wantTier string + }{ + {name: "oauth stream fast", stream: true, payload: fastClaude, wantHint: "model=gpt-5.5;tier=priority", wantTier: "priority"}, + {name: "oauth non-stream fast", stream: false, payload: fastClaude, wantHint: "model=gpt-5.5;tier=priority", wantTier: "priority"}, + {name: "oauth stream standard", stream: true, payload: plainClaude, wantHint: "model=gpt-5.5"}, + {name: "api key keeps body tier without hint", apiKey: true, stream: true, payload: fastClaude, wantTier: "priority"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var captured capturedCodexRequest + server := newCodexRoutingHintServer(t, &captured) + defer server.Close() + + auth := codexOAuthTestAuth(server.URL) + if tc.apiKey { + auth = codexAPIKeyTestAuth(server.URL) + } + executor := NewCodexExecutor(&config.Config{}) + req := cliproxyexecutor.Request{Model: "gpt-5.5", Payload: []byte(tc.payload)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), Stream: tc.stream} + + if tc.stream { + result, err := executor.ExecuteStream(context.Background(), auth, req, opts) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + } + } else if _, err := executor.Execute(context.Background(), auth, req, opts); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if tc.wantHint == "" { + if captured.hasHint { + t.Fatalf("routing hint = %q, want header absent", captured.routingHint) + } + } else if captured.routingHint != tc.wantHint { + t.Fatalf("routing hint = %q, want %q", captured.routingHint, tc.wantHint) + } + if tc.wantTier == "" { + if captured.serviceTier.Exists() { + t.Fatalf("body service_tier = %s, want absent", captured.serviceTier.Raw) + } + } else if captured.serviceTier.String() != tc.wantTier { + t.Fatalf("body service_tier = %q, want %q", captured.serviceTier.String(), tc.wantTier) + } + }) + } +} + +func TestCodexExecutorCompactRoutingHintCarriesRequestedTier(t *testing.T) { + var captured capturedCodexRequest + server := newCodexRoutingHintServer(t, &captured) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + _, err := executor.Execute(context.Background(), codexOAuthTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-5.5", + Payload: []byte(`{"model":"gpt-5.5","service_tier":"priority","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Alt: "responses/compact", + }) + if err != nil { + t.Fatalf("compact Execute error: %v", err) + } + if captured.path != "/responses/compact" { + t.Fatalf("upstream path = %q, want /responses/compact", captured.path) + } + if captured.routingHint != "model=gpt-5.5;tier=priority" { + t.Fatalf("routing hint = %q, want %q", captured.routingHint, "model=gpt-5.5;tier=priority") + } +} + +func TestCodexExecutorOperatorRoutingHintRuleWins(t *testing.T) { + var captured capturedCodexRequest + server := newCodexRoutingHintServer(t, &captured) + defer server.Close() + + auth := codexOAuthTestAuth(server.URL) + auth.Attributes["header:X-Codex-Routing-Hint"] = "model=operator-pinned" + clientHeaders := http.Header{} + clientHeaders.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + executor := NewCodexExecutor(&config.Config{}) + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.5", + Payload: []byte(`{"model":"gpt-5.5","max_tokens":64,"speed":"fast","messages":[{"role":"user","content":"hi"}]}`), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), Stream: true, Headers: clientHeaders}) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + } + if captured.routingHint != "model=operator-pinned" { + t.Fatalf("routing hint = %q, want operator rule value", captured.routingHint) + } +} + +func TestApplyCodexRoutingHint(t *testing.T) { + oauth := codexOAuthTestAuth("") + + t.Run("replaces a hint that did not come from an operator rule", func(t *testing.T) { + headers := http.Header{} + headers.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + applyCodexRoutingHint(context.Background(), headers, oauth, []byte(`{"model":"gpt-5.5","service_tier":"priority"}`), nil) + if got := headers.Get(codexRoutingHintHeader); got != "model=gpt-5.5;tier=priority" { + t.Fatalf("routing hint = %q, want %q", got, "model=gpt-5.5;tier=priority") + } + }) + + t.Run("keeps the value from an operator auth header rule", func(t *testing.T) { + operator := codexOAuthTestAuth("") + operator.Attributes["header:x-codex-routing-hint"] = "model=operator" + headers := http.Header{} + headers.Set(codexRoutingHintHeader, "model=operator") + applyCodexRoutingHint(context.Background(), headers, operator, []byte(`{"model":"gpt-5.5","service_tier":"priority"}`), nil) + if got := headers.Get(codexRoutingHintHeader); got != "model=operator" { + t.Fatalf("routing hint = %q, want operator value kept", got) + } + }) + + t.Run("derives the hint when a dynamic operator rule resolves to nothing", func(t *testing.T) { + operator := codexOAuthTestAuth("") + operator.Attributes["header:X-Codex-Routing-Hint"] = "$X-Operator-Hint" + headers := http.Header{} + headers.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + applyCodexRoutingHint(context.Background(), headers, operator, []byte(`{"model":"gpt-5.5","service_tier":"priority"}`), nil) + if got := headers.Get(codexRoutingHintHeader); got != "model=gpt-5.5;tier=priority" { + t.Fatalf("routing hint = %q, want %q", got, "model=gpt-5.5;tier=priority") + } + }) + + t.Run("leaves API-key requests untouched", func(t *testing.T) { + headers := http.Header{} + headers.Set(codexRoutingHintHeader, "model=client") + applyCodexRoutingHint(context.Background(), headers, codexAPIKeyTestAuth(""), []byte(`{"model":"gpt-5.5","service_tier":"priority"}`), nil) + if got := headers.Get(codexRoutingHintHeader); got != "model=client" { + t.Fatalf("routing hint = %q, want API-key headers unchanged", got) + } + }) + + t.Run("drops a forwarded hint when the body has no model", func(t *testing.T) { + headers := http.Header{} + headers.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + applyCodexRoutingHint(context.Background(), headers, oauth, []byte(`{"service_tier":"priority"}`), nil) + if _, ok := headers[http.CanonicalHeaderKey(codexRoutingHintHeader)]; ok { + t.Fatalf("routing hint = %q, want header absent", headers.Get(codexRoutingHintHeader)) + } + }) + + t.Run("ignores a non-string tier", func(t *testing.T) { + headers := http.Header{} + applyCodexRoutingHint(context.Background(), headers, oauth, []byte(`{"model":"gpt-5.5","service_tier":null}`), nil) + if got := headers.Get(codexRoutingHintHeader); got != "model=gpt-5.5" { + t.Fatalf("routing hint = %q, want %q", got, "model=gpt-5.5") + } + }) +} diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index e70e88eae10..165b30c789c 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -91,6 +91,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers) + applyCodexRoutingHint(ctx, httpReq.Header, auth, upstreamBody, opts.Headers) applyModelHeaderOverrides(httpReq.Header, baseModel) applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState) var authID, authLabel, authType, authValue string diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 94829bd089b..c6aafc6c759 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -88,6 +88,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body) reporter.SetTranslatedReasoningEffort(clientBody, to.String()) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, nativeRequest, opts.Headers) + applyCodexRoutingHint(ctx, wsHeaders, auth, upstreamBody, opts.Headers) applyModelHeaderOverrides(wsHeaders, baseModel) applyCodexIdentityConfuseHeaders(wsHeaders, &identityState) diff --git a/internal/runtime/executor/codex_websockets_routing_hint_test.go b/internal/runtime/executor/codex_websockets_routing_hint_test.go new file mode 100644 index 00000000000..5e8db31a6bf --- /dev/null +++ b/internal/runtime/executor/codex_websockets_routing_hint_test.go @@ -0,0 +1,210 @@ +package executor + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +type codexWebsocketHintFrame struct { + conn int + tier string + previousResponseID string +} + +type codexWebsocketHintRecorder struct { + mu sync.Mutex + handshakes []string + frames []codexWebsocketHintFrame +} + +func newCodexWebsocketHintServer(t *testing.T, rec *codexWebsocketHintRecorder) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.handshakes = append(rec.handshakes, r.Header.Get(codexRoutingHintHeader)) + connIndex := len(rec.handshakes) - 1 + rec.mu.Unlock() + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade websocket: %v", err) + return + } + defer func() { _ = conn.Close() }() + for { + _, payload, errRead := conn.ReadMessage() + if errRead != nil { + return + } + rec.mu.Lock() + rec.frames = append(rec.frames, codexWebsocketHintFrame{ + conn: connIndex, + tier: gjson.GetBytes(payload, "service_tier").String(), + previousResponseID: gjson.GetBytes(payload, "previous_response_id").String(), + }) + rec.mu.Unlock() + completed := []byte(`{"type":"response.completed","response":{"id":"resp-1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`) + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + return + } + } + })) +} + +// Native Codex sends the routing hint only in the websocket handshake and keeps +// an open connection when the tier changes. The gateway mirrors that: an +// incremental request that turns on Fast continues on the same socket with the +// original handshake hint (its body carries the new tier), and the next fresh +// connection is dialed with the current hint. Whether the backend honors a +// mid-connection tier change is not established by this test. +func TestCodexWebsocketsFastToggleKeepsIncrementalConnection(t *testing.T) { + var rec codexWebsocketHintRecorder + server := newCodexWebsocketHintServer(t, &rec) + defer server.Close() + + exec := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}}) + const executionSessionID = "ws-routing-hint-toggle" + t.Cleanup(func() { exec.CloseExecutionSession(executionSessionID) }) + auth := codexOAuthTestAuth(server.URL) + auth.ID = "codex-oauth-test" + opts := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: executionSessionID}, + } + execute := func(payload string) { + t.Helper() + if _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: "gpt-5.5", Payload: []byte(payload)}, opts); err != nil { + t.Fatalf("Execute(%s) error: %v", payload, err) + } + } + + execute(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`) + execute(`{"model":"gpt-5.5","service_tier":"priority","previous_response_id":"resp-1","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"again"}]}]}`) + exec.CloseExecutionSession(executionSessionID) + execute(`{"model":"gpt-5.5","service_tier":"priority","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"fresh"}]}]}`) + + rec.mu.Lock() + defer rec.mu.Unlock() + wantHandshakes := []string{"model=gpt-5.5", "model=gpt-5.5;tier=priority"} + if len(rec.handshakes) != len(wantHandshakes) || rec.handshakes[0] != wantHandshakes[0] || rec.handshakes[1] != wantHandshakes[1] { + t.Fatalf("handshakes = %q, want %q", rec.handshakes, wantHandshakes) + } + wantFrames := []codexWebsocketHintFrame{ + {conn: 0}, + {conn: 0, tier: "priority", previousResponseID: "resp-1"}, + {conn: 1, tier: "priority"}, + } + if len(rec.frames) != len(wantFrames) { + t.Fatalf("frames = %+v, want %+v", rec.frames, wantFrames) + } + for i := range wantFrames { + if rec.frames[i] != wantFrames[i] { + t.Fatalf("frames = %+v, want %+v", rec.frames, wantFrames) + } + } +} + +// An operator rule that points at a client header the request does not carry +// is skipped by the header resolver. It must not stop the gateway from +// replacing the forwarded client hint with the one derived from the body, and +// a rule that does resolve must win over the derived hint. +func TestCodexWebsocketsRoutingHintDynamicOperatorRule(t *testing.T) { + cases := []struct { + name string + operatorHint string + want string + }{ + {name: "missing reference falls back to derived hint", want: "model=gpt-5.5;tier=priority"}, + {name: "resolved reference wins", operatorHint: "model=operator-dynamic", want: "model=operator-dynamic"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var rec codexWebsocketHintRecorder + server := newCodexWebsocketHintServer(t, &rec) + defer server.Close() + + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, + Codex: config.CodexConfig{DisableCodexCloaking: true}, + } + exec := NewCodexWebsocketsExecutor(cfg) + auth := codexOAuthTestAuth(server.URL) + auth.ID = "codex-oauth-test" + auth.Attributes["header:X-Codex-Routing-Hint"] = "$X-Operator-Hint" + clientHeaders := http.Header{} + clientHeaders.Set("X-OpenAI-Internal-Codex-Responses-Lite", "true") + clientHeaders.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + if tc.operatorHint != "" { + clientHeaders.Set("X-Operator-Hint", tc.operatorHint) + } + + _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.5", + Payload: []byte(`{"model":"gpt-5.4-client","service_tier":"priority","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response"), Headers: clientHeaders}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + rec.mu.Lock() + defer rec.mu.Unlock() + if len(rec.handshakes) != 1 || rec.handshakes[0] != tc.want { + t.Fatalf("handshakes = %q, want [%q]", rec.handshakes, tc.want) + } + }) + } +} + +// A native Codex client forwards its own routing hint when cloaking is +// disabled. That hint names the model and tier the client asked for, not the +// ones the gateway resolved, so the handshake must carry the hint derived from +// the final upstream body. +func TestCodexWebsocketsRoutingHintOverridesForwardedClientHint(t *testing.T) { + var rec codexWebsocketHintRecorder + server := newCodexWebsocketHintServer(t, &rec) + defer server.Close() + + cfg := &config.Config{ + SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}, + Codex: config.CodexConfig{DisableCodexCloaking: true}, + Payload: config.PayloadConfig{Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "gpt-5.5"}}, + Params: map[string]any{"service_tier": "priority"}, + }}}, + } + exec := NewCodexWebsocketsExecutor(cfg) + auth := codexOAuthTestAuth(server.URL) + auth.ID = "codex-oauth-test" + clientHeaders := http.Header{} + clientHeaders.Set("X-OpenAI-Internal-Codex-Responses-Lite", "true") + clientHeaders.Set(codexRoutingHintHeader, "model=gpt-5.4-client") + + _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.5", + Payload: []byte(`{"model":"gpt-5.4-client","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Headers: clientHeaders, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + rec.mu.Lock() + defer rec.mu.Unlock() + if len(rec.handshakes) != 1 || rec.handshakes[0] != "model=gpt-5.5;tier=priority" { + t.Fatalf("handshakes = %q, want [\"model=gpt-5.5;tier=priority\"]", rec.handshakes) + } + if len(rec.frames) != 1 || rec.frames[0].tier != "priority" { + t.Fatalf("frames = %+v, want one frame with service_tier=priority", rec.frames) + } +} diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index e42cfe119f9..f3f3eb6b09d 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -816,6 +816,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex var identityState codexIdentityConfuseState upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, preserveNativeOutput, opts.Headers) + applyCodexRoutingHint(ctx, wsHeaders, auth, upstreamBody, opts.Headers) applyModelHeaderOverrides(wsHeaders, baseModel) applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)