Skip to content
Open
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
12 changes: 8 additions & 4 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,8 @@ upstream:
# wait. Neither is made worse by this option - with it off the same stalled line stalls the
# conductor instead, which waits on the same context - but a client-side request timeout, not
# proxy_read_timeout, is what bounds them.
# Default: false
stream-bootstrap-buffering: false
# Default: true
stream-bootstrap-buffering: true
# Optional maximum duration to hold back uncommitted response headers during bootstrap buffering.
# When set (e.g. "20s"), if the time ceiling is reached before the first generated token,
# the stream is released to the client and further in-stream overloads are delivered rather
Expand All @@ -366,8 +366,8 @@ upstream:
# commit when that first token arrives).
# When unset or set to "0", "0s", "none", "unlimited", "disabled", "off", or "never", no time ceiling
# is applied, and buffering relies purely on the 48-frame and 1MB byte budget.
# Default: "0" (unlimited)
stream-bootstrap-timeout: "0"
# Default: "30s"
stream-bootstrap-timeout: "30s"
# When true, enable opt-in compatibility for orphan Codex delegation outputs.
# Converts orphan function_call_output items from codex_app/create_thread and
# codex_app/send_message_to_thread (which lack a valid call_id or matching function_call)
Expand Down Expand Up @@ -999,6 +999,9 @@ oauth:

# Codex provider behavior.
codex:
# Preserve a coherent first-party Codex client's User-Agent and Originator.
# Other requests still use the configured Codex identity headers.
preserve-native-client-identity: true
# Terminate and relay Codex Live WebRTC audio and DataChannel traffic in this process.
# This requires inbound UDP reachability. Keep disabled to preserve direct media behavior.
live-media-relay:
Expand Down Expand Up @@ -1030,6 +1033,7 @@ oauth:
# These are used only for file-backed/OAuth Codex requests when the client
# does not send the header. `user-agent` applies to HTTP and websocket requests;
# `beta-features` only applies to websocket requests. They do not apply to api-keys.codex entries.
# A native Codex User-Agent takes precedence when preserve-native-client-identity is enabled.
# header-defaults:
# user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
# beta-features: "multi_agent"
Expand Down
38 changes: 38 additions & 0 deletions internal/config/codex_websocket_header_defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,46 @@ import (
"os"
"path/filepath"
"testing"
"time"
)

func TestParseConfigBytes_CodexRuntimeDefaults(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`{}`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if !cfg.Codex.StreamBootstrapBuffering {
t.Fatal("default StreamBootstrapBuffering = false, want true")
}
if got := cfg.Codex.StreamBootstrapTimeoutDuration(); got != 30*time.Second {
t.Fatalf("default StreamBootstrapTimeoutDuration() = %v, want 30s", got)
}
if got := cfg.DisableImageGeneration; got != DisableImageGenerationPassthrough {
t.Fatalf("default DisableImageGeneration = %v, want passthrough", got)
}
}

func TestParseConfigBytes_CodexRuntimeDefaultsCanBeOverridden(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
disable-image-generation: false
codex:
stream-bootstrap-buffering: false
stream-bootstrap-timeout: "0"
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if cfg.Codex.StreamBootstrapBuffering {
t.Fatal("StreamBootstrapBuffering = true, want explicit false")
}
if got := cfg.Codex.StreamBootstrapTimeoutDuration(); got != 0 {
t.Fatalf("StreamBootstrapTimeoutDuration() = %v, want 0", got)
}
if got := cfg.DisableImageGeneration; got != DisableImageGenerationOff {
t.Fatalf("DisableImageGeneration = %v, want false", got)
}
}

func TestLoadConfigOptional_CodexHeaderDefaults(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
Expand Down
19 changes: 19 additions & 0 deletions internal/config/config_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,23 @@ const (
DefaultPprofAddr = "127.0.0.1:8316"
DefaultAuthDir = "~/.cli-proxy-api"
DefaultDiscoveryServiceType = "_ai-gateway._tcp"
DefaultCodexBootstrapTimeout = "30s"
)

func applyCodexRuntimeDefaults(cfg *Config) {
if cfg == nil {
return
}
cfg.DisableImageGeneration = DisableImageGenerationPassthrough
cfg.Codex.StreamBootstrapBuffering = true
cfg.Codex.StreamBootstrapTimeout = DefaultCodexBootstrapTimeout
Comment on lines +16 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid enabling buffering without an enforceable deadline

When a Codex SSE upstream emits an initial bufferable event such as response.created and then sends no further complete line, enabling buffering by default with a 30-second timeout can leave downstream headers uncommitted indefinitely. The timeout in codex_executor_stream.go is checked only after scanner.Scan() returns another line, so it cannot release the already buffered event while the scan is blocked; native Responses clients that previously received response.created immediately can therefore hit reverse-proxy/client read timeouts despite this configured ceiling. Use an asynchronous release mechanism or keep buffering opt-in for this path.

Useful? React with 👍 / 👎.

// Preserving a coherent first-party Codex identity is the default behavior.
preserveNativeClientIdentity := true
cfg.Codex.PreserveNativeClientIdentity = &preserveNativeClientIdentity
}

func newOptionalFallbackConfig() *Config {
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
applyCodexRuntimeDefaults(cfg)
return cfg
}
10 changes: 5 additions & 5 deletions internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if optional {
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
// Missing and optional: return empty config (cloud deploy standby).
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg := newOptionalFallbackConfig()
cfg.NormalizePluginsConfig()
return cfg, nil
}
Expand All @@ -46,14 +46,14 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {

// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
if optional && len(bytes.TrimSpace(data)) == 0 {
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg := newOptionalFallbackConfig()
cfg.NormalizePluginsConfig()
return cfg, nil
}

if errValidate := validateCredentialWeightYAML(data); errValidate != nil {
if optional {
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional := newOptionalFallbackConfig()
cfgOptional.NormalizePluginsConfig()
return cfgOptional, nil
}
Expand All @@ -72,7 +72,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.DisableImageGeneration = DisableImageGenerationOff
applyCodexRuntimeDefaults(&cfg)
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
Expand All @@ -84,7 +84,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional := newOptionalFallbackConfig()
cfgOptional.NormalizePluginsConfig()
return cfgOptional, nil
}
Expand Down
14 changes: 10 additions & 4 deletions internal/config/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ type AntigravityConnectionPoolConfig struct {
type CodexConfig struct {
// DisableCodexCloaking disables forcing the official Codex identity headers on HTTP/SSE and WebSocket requests.
DisableCodexCloaking bool `yaml:"disable-codex-cloaking" json:"disable-codex-cloaking"`
// PreserveNativeClientIdentity keeps the downstream User-Agent and Originator untouched when the
// request already presents a coherent first-party Codex identity, instead of overwriting both with
// the built-in Codex identity. Every other request is still cloaked as before. Ignored when
// DisableCodexCloaking is true, because that flag already skips cloaking entirely.
// Default is true; a nil pointer means enabled.
PreserveNativeClientIdentity *bool `yaml:"preserve-native-client-identity,omitempty" json:"preserve-native-client-identity,omitempty"`
// StreamBootstrapBuffering holds back the frames that arrive before generation starts, none of
// which the client has seen anything from - the handshake (response.created, response.in_progress,
// the websocket metadata frames), keepalive heartbeats, and the *.added announcements of an item
Expand All @@ -199,11 +205,11 @@ type CodexConfig struct {
// reasoning phase instead of ending at the first keepalive: a clean end with no terminal event
// is request-scoped on SSE and stops there, while a websocket close or a transport error on
// either transport is not, so the request may be retried on another credential.
// Default is false.
// Default is true.
StreamBootstrapBuffering bool `yaml:"stream-bootstrap-buffering" json:"stream-bootstrap-buffering"`
// StreamBootstrapTimeout specifies an optional maximum duration to hold back uncommitted response
// headers during bootstrap buffering before releasing the stream to the client.
// Defaults to "0" (unlimited time, relying purely on the 48-frame and 1MB byte bounds).
// Config loaders default this to 30 seconds. A zero-value CodexConfig remains unlimited.
// When set (e.g. "20s"), the stream is released once the time ceiling is reached, avoiding
// reverse-proxy timeouts (e.g. Nginx 60s proxy_read_timeout).
StreamBootstrapTimeout string `yaml:"stream-bootstrap-timeout,omitempty" json:"stream-bootstrap-timeout,omitempty"`
Expand All @@ -220,13 +226,13 @@ type CodexConfig struct {
}

// DefaultCodexStreamBootstrapTimeout is the default maximum duration to buffer bootstrap events.
// By default, it is 0 (unlimited time, relying purely on the 48-frame and 1MB byte bounds).
// A loaded runtime config initializes the field to 30 seconds before reaching this fallback.
const DefaultCodexStreamBootstrapTimeout = 0

const maxBootstrapTimeoutSeconds = int64(math.MaxInt64 / time.Second)

// StreamBootstrapTimeoutDuration returns the maximum duration to buffer bootstrap events.
// Defaults to 0 (unlimited time, relying purely on the 48-frame and 1MB byte bounds).
// Defaults to 0 for a zero-value CodexConfig; loaded runtime configs initialize the field to 30 seconds.
// If explicitly set to a positive duration (e.g. "10s", "500ms", "15"), returns that duration.
// If set to "0", "0s", "none", "unlimited", "disabled", "off", "never", or invalid strings, returns 0.
func (c *CodexConfig) StreamBootstrapTimeoutDuration() time.Duration {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.DisableImageGeneration = DisableImageGenerationOff
applyCodexRuntimeDefaults(&cfg)
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
Expand Down
4 changes: 2 additions & 2 deletions internal/config/sdk_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ type SDKConfig struct {
// DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed.
//
// Supported values:
// - false (default): image_generation is enabled everywhere (normal behavior).
// - false: image_generation is enabled everywhere and may be injected automatically.
// - true: image_generation is disabled everywhere. The server stops injecting it, removes it from request payloads,
// and returns 404 for /v1/images/generations and /v1/images/edits.
// - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions),
// while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there.
// - "passthrough": do not modify the tool list on non-images endpoints — keep image_generation if the client
// - "passthrough" (default): do not modify the tool list on non-images endpoints — keep image_generation if the client
// sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat".
DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"`

Expand Down
17 changes: 13 additions & 4 deletions internal/runtime/executor/codex_executor_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) {
for key, value := range overrides {
headers.Set(key, value)
}
if strings.Contains(headers.Get("User-Agent"), "Mac OS") && codexSessionHeaderValue(headers) == "" {
headers.Set("Session_id", uuid.NewString())
if helps.IsFirstPartyCodexIdentity(headers.Get("User-Agent"), headers.Get("Originator")) && codexSessionHeaderValue(headers) == "" {
headers.Set("Session-Id", uuid.NewString())
}
}

Expand Down Expand Up @@ -208,14 +208,17 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke
misc.EnsureHeader(r.Header, ginHeaders, "X-Openai-Internal-Codex-Responses-Lite", "")

cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
if nativeUserAgent := helps.NativeCodexUserAgent(cfg, ginHeaders); nativeUserAgent != "" {
r.Header.Set("User-Agent", nativeUserAgent)
} else {
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
}

if stream {
r.Header.Set("Accept", "text/event-stream")
} else {
r.Header.Set("Accept", "application/json")
}
r.Header.Set("Connection", "Keep-Alive")

isAPIKey := codexAuthUsesAPIKey(auth)
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
Expand Down Expand Up @@ -310,10 +313,16 @@ func isCodexCloakingDisabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
return false
}

// applyCodexCloakingHeaders forces the built-in Codex identity headers as a fallback.
// When identity preservation is enabled and the request already presents a coherent first-party
// Codex identity, both User-Agent and Originator are left untouched; anything else is cloaked.
func applyCodexCloakingHeaders(headers http.Header, cfg *config.Config, auth *cliproxyauth.Auth) {
if headers == nil || cfg == nil || isCodexCloakingDisabled(cfg, auth) {
return
}
if helps.PreserveNativeCodexIdentity(cfg) && helps.IsFirstPartyCodexIdentity(headers.Get("User-Agent"), headers.Get("Originator")) {
return
}
headers.Set("User-Agent", codexUserAgent)
headers.Set("Originator", codexOriginator)
}
Expand Down
59 changes: 57 additions & 2 deletions internal/runtime/executor/codex_native_fidelity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,11 @@ func testCodexNativeStreamFidelity(t *testing.T, source sdktranslator.Format) {
}
alias := headerValueCaseInsensitive(upstreamHeaders, "session_id")
t.Logf("upstream session alias: %q", alias)
if (alias == "") != native {
t.Errorf("session alias = %q, native = %t", alias, native)
if alias != "" {
t.Errorf("unexpected underscore session alias = %q", alias)
}
if got := upstreamHeaders.Get("Session-Id"); got != "session-1" {
t.Errorf("Session-Id = %q, want session-1 (native = %t)", got, native)
}
}
t.Logf("downstream metadata: %q", metadataEvents)
Expand Down Expand Up @@ -154,3 +157,55 @@ func TestCodexWebsocketLiteHeaderWithoutSessionHeaders(t *testing.T) {
}
}
}

func TestApplyCodexCloakingHeadersPreservesNativeIdentity(t *testing.T) {
const nativeUA = "codex-tui/0.154.0 (Mac OS 15.7.9; arm64) Apple_Terminal (codex-tui; 0.154.0)"
disabled := false
cases := []struct {
name string
cfg *config.Config
userAgent string
originator string
wantUserAgent string
wantOriginator string
}{
{
name: "coherent identity preserved by default",
cfg: &config.Config{},
userAgent: nativeUA,
originator: "codex-tui",
wantUserAgent: nativeUA,
wantOriginator: "codex-tui",
},
{
name: "preservation disabled falls back to cloaking",
cfg: &config.Config{Codex: config.CodexConfig{PreserveNativeClientIdentity: &disabled}},
userAgent: nativeUA,
originator: "codex-tui",
wantUserAgent: codexUserAgent,
wantOriginator: codexOriginator,
},
{
name: "incoherent pair still cloaked",
cfg: &config.Config{},
userAgent: nativeUA,
originator: "my-proxy",
wantUserAgent: codexUserAgent,
wantOriginator: codexOriginator,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
headers := http.Header{}
headers.Set("User-Agent", tc.userAgent)
headers.Set("Originator", tc.originator)
applyCodexCloakingHeaders(headers, tc.cfg, nil)
if got := headers.Get("User-Agent"); got != tc.wantUserAgent {
t.Errorf("User-Agent = %q, want %q", got, tc.wantUserAgent)
}
if got := headers.Get("Originator"); got != tc.wantOriginator {
t.Errorf("Originator = %q, want %q", got, tc.wantOriginator)
}
})
}
}
Loading
Loading