Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/runtime/executor/codex_executor_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions internal/runtime/executor/codex_executor_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slug>" plus ";tier=<service_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 {
Expand Down
234 changes: 234 additions & 0 deletions internal/runtime/executor/codex_executor_routing_hint_test.go
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


internal/runtime/executor/codex_executor_execute.go

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")
}
})
}
1 change: 1 addition & 0 deletions internal/runtime/executor/codex_executor_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/runtime/executor/codex_websockets_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading