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
2 changes: 1 addition & 1 deletion internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (

const (
codexUserAgent = "codex-tui/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9 (codex-tui; 0.118.0)"
codexOriginator = "codex-tui"
codexOriginator = "codex-toy-app-server"
)

var dataTag = []byte("data:")
Expand Down
79 changes: 59 additions & 20 deletions internal/runtime/executor/codex_websockets_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,9 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut

requestedModel := helps.PayloadRequestedModel(opts, req.Model)
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
if !gjson.GetBytes(body, "instructions").Exists() {
body, _ = sjson.SetBytes(body, "instructions", "")
}
originalExecutionSessionID := executionSessionIDFromOptions(opts)
var effectiveSessionID, turnMeta string
body, effectiveSessionID, turnMeta = prepareCodexWebsocketRequestBody(body, baseModel, originalExecutionSessionID, true)

httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
Expand All @@ -201,6 +196,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}

body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
if strings.TrimSpace(effectiveSessionID) != "" {
wsHeaders.Set("Session_id", effectiveSessionID)
wsHeaders.Set("x-client-request-id", effectiveSessionID)
}
if strings.TrimSpace(turnMeta) != "" {
wsHeaders.Set("x-codex-turn-metadata", turnMeta)
}
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)

var authID, authLabel, authType, authValue string
Expand All @@ -210,10 +212,9 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
authType, authValue = auth.AccountInfo()
}

executionSessionID := executionSessionIDFromOptions(opts)
var sess *codexWebsocketSession
if executionSessionID != "" {
sess = e.getOrCreateSession(executionSessionID)
if originalExecutionSessionID != "" {
sess = e.getOrCreateSession(originalExecutionSessionID)
sess.reqMu.Lock()
defer sess.reqMu.Unlock()
}
Expand Down Expand Up @@ -249,13 +250,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
recordAPIWebsocketHandshake(ctx, e.cfg, respHS)
if sess == nil {
logCodexWebsocketConnected(executionSessionID, authID, wsURL)
logCodexWebsocketConnected(effectiveSessionID, authID, wsURL)
defer func() {
reason := "completed"
if err != nil {
reason = "error"
}
logCodexWebsocketDisconnected(executionSessionID, authID, wsURL, reason, err)
logCodexWebsocketDisconnected(effectiveSessionID, authID, wsURL, reason, err)
if errClose := conn.Close(); errClose != nil {
log.Errorf("codex websockets executor: close websocket error: %v", errClose)
}
Expand Down Expand Up @@ -388,6 +389,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr

requestedModel := helps.PayloadRequestedModel(opts, req.Model)
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, body, requestedModel)
originalExecutionSessionID := executionSessionIDFromOptions(opts)
var effectiveSessionID, turnMeta string
body, effectiveSessionID, turnMeta = prepareCodexWebsocketRequestBody(body, baseModel, originalExecutionSessionID, true)

httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
Expand All @@ -396,17 +400,23 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}

body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
if strings.TrimSpace(effectiveSessionID) != "" {
wsHeaders.Set("Session_id", effectiveSessionID)
wsHeaders.Set("x-client-request-id", effectiveSessionID)
}
if strings.TrimSpace(turnMeta) != "" {
wsHeaders.Set("x-codex-turn-metadata", turnMeta)
}
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)

var authID, authLabel, authType, authValue string
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()

executionSessionID := executionSessionIDFromOptions(opts)
var sess *codexWebsocketSession
if executionSessionID != "" {
sess = e.getOrCreateSession(executionSessionID)
if originalExecutionSessionID != "" {
sess = e.getOrCreateSession(originalExecutionSessionID)
if sess != nil {
sess.reqMu.Lock()
}
Expand Down Expand Up @@ -451,7 +461,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
recordAPIWebsocketHandshake(ctx, e.cfg, respHS)

if sess == nil {
logCodexWebsocketConnected(executionSessionID, authID, wsURL)
logCodexWebsocketConnected(effectiveSessionID, authID, wsURL)
}

var readCh chan codexWebsocketRead
Expand Down Expand Up @@ -497,7 +507,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
conn = connRetry
wsReqBody = wsReqBodyRetry
} else {
logCodexWebsocketDisconnected(executionSessionID, authID, wsURL, "send_error", errSend)
logCodexWebsocketDisconnected(effectiveSessionID, authID, wsURL, "send_error", errSend)
if errClose := conn.Close(); errClose != nil {
log.Errorf("codex websockets executor: close websocket error: %v", errClose)
}
Expand All @@ -517,7 +527,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
sess.reqMu.Unlock()
return
}
logCodexWebsocketDisconnected(executionSessionID, authID, wsURL, terminateReason, terminateErr)
logCodexWebsocketDisconnected(effectiveSessionID, authID, wsURL, terminateReason, terminateErr)
if errClose := conn.Close(); errClose != nil {
log.Errorf("codex websockets executor: close websocket error: %v", errClose)
}
Expand Down Expand Up @@ -802,7 +812,6 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto

if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
headers.Set("Conversation_id", cache.ID)
}

return rawJSON, headers
Expand Down Expand Up @@ -1062,6 +1071,36 @@ func websocketHandshakeBody(resp *http.Response) []byte {
return body
}

func normalizeCodexWebsocketInstructions(body []byte) []byte {
return normalizeCodexInstructions(body)
}

func enrichCodexWebsocketSessionMetadata(body []byte, sessionID string) ([]byte, string, string) {
effectiveSessionID := strings.TrimSpace(sessionID)
if effectiveSessionID == "" {
effectiveSessionID = uuid.NewString()
}
if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); !promptCacheKey.Exists() || strings.TrimSpace(promptCacheKey.String()) == "" {
body, _ = sjson.SetBytes(body, "prompt_cache_key", effectiveSessionID)
}
clientMetadata := gjson.GetBytes(body, "client_metadata")
if !clientMetadata.Exists() || clientMetadata.Type == gjson.Null {
body, _ = sjson.SetRawBytes(body, "client_metadata", []byte(`{}`))
}
turnMeta := fmt.Sprintf(`{"session_id":"%s","turn_id":"","workspaces":{},"sandbox":"seccomp"}`, effectiveSessionID)
body, _ = sjson.SetBytes(body, "client_metadata.x-codex-turn-metadata", turnMeta)
return body, effectiveSessionID, turnMeta
}

func prepareCodexWebsocketRequestBody(body []byte, baseModel string, sessionID string, stream bool) ([]byte, string, string) {
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", stream)
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body = normalizeCodexWebsocketInstructions(body)
return enrichCodexWebsocketSessionMetadata(body, sessionID)
}

func closeHTTPResponseBody(resp *http.Response, logPrefix string) {
if resp == nil || resp.Body == nil {
return
Expand Down
52 changes: 52 additions & 0 deletions internal/runtime/executor/codex_websockets_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T)
}
}

func TestPrepareCodexWebsocketRequestBodyNormalizesMissingInstructionsToEmptyString(t *testing.T) {
body, effectiveSessionID, turnMeta := prepareCodexWebsocketRequestBody([]byte(`{"input":[]}`), "gpt-5-codex", "session-123", true)

if got := gjson.GetBytes(body, "instructions").String(); got != "" {
t.Fatalf("instructions = %q, want empty string", got)
}
if got := gjson.GetBytes(body, "model").String(); got != "gpt-5-codex" {
t.Fatalf("model = %q, want gpt-5-codex", got)
}
if !gjson.GetBytes(body, "stream").Bool() {
t.Fatal("stream = false, want true")
}
if effectiveSessionID != "session-123" {
t.Fatalf("effectiveSessionID = %q, want session-123", effectiveSessionID)
}
if got := gjson.Get(turnMeta, "session_id").String(); got != "session-123" {
t.Fatalf("turnMeta.session_id = %q, want session-123", got)
}
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "session-123" {
t.Fatalf("prompt_cache_key = %q, want session-123", got)
}
if got := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); got != turnMeta {
t.Fatalf("embedded turn metadata mismatch: got %q want %q", got, turnMeta)
}
}

func TestPrepareCodexWebsocketRequestBodyPreservesBlankInstructionsAndGeneratedSessionMetadata(t *testing.T) {
body, effectiveSessionID, turnMeta := prepareCodexWebsocketRequestBody([]byte(`{"instructions":" ","prompt_cache_retention":"ephemeral","safety_identifier":"sid"}`), "gpt-5-codex", "", true)

if got := gjson.GetBytes(body, "instructions").String(); got != " " {
t.Fatalf("instructions = %q, want blank string preserved", got)
}
if effectiveSessionID == "" {
t.Fatal("effectiveSessionID is empty")
}
if got := gjson.Get(turnMeta, "session_id").String(); got != effectiveSessionID {
t.Fatalf("turnMeta.session_id = %q, want %q", got, effectiveSessionID)
}
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != effectiveSessionID {
t.Fatalf("prompt_cache_key = %q, want %q", got, effectiveSessionID)
}
if got := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); got != turnMeta {
t.Fatalf("embedded turn metadata mismatch: got %q want %q", got, turnMeta)
}
if gjson.GetBytes(body, "prompt_cache_retention").Exists() {
t.Fatalf("prompt_cache_retention should be removed: %s", body)
}
if gjson.GetBytes(body, "safety_identifier").Exists() {
t.Fatalf("safety_identifier should be removed: %s", body)
}
}

func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)

Expand Down
6 changes: 5 additions & 1 deletion internal/watcher/synthesizer/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
sdkauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)

Expand Down Expand Up @@ -159,8 +160,11 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) []
}
coreauth.ApplyCustomHeadersFromMetadata(a)
ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth")
// For codex auth files, extract plan_type from the JWT id_token.
// For codex auth files, extract plan_type from the JWT id_token and default websocket support on.
if provider == "codex" {
if websockets, ok := sdkauth.CodexWebsocketsAttributeValue(metadata); ok {
a.Attributes["websockets"] = websockets
}
if idTokenRaw, ok := metadata["id_token"].(string); ok && strings.TrimSpace(idTokenRaw) != "" {
if claims, errParse := codex.ParseJWTToken(idTokenRaw); errParse == nil && claims != nil {
if pt := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); pt != "" {
Expand Down
59 changes: 59 additions & 0 deletions internal/watcher/synthesizer/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,65 @@ func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) {
}
}

func TestFileSynthesizer_Synthesize_CodexAuthDefaultsWebsocketsTrue(t *testing.T) {
tempDir := t.TempDir()

authData := map[string]any{
"type": "codex",
"email": "codex@example.com",
}
data, _ := json.Marshal(authData)
if err := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644); err != nil {
t.Fatalf("failed to write auth file: %v", err)
}

auths, err := NewFileSynthesizer().Synthesize(&SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
if got := auths[0].Attributes["websockets"]; got != "true" {
t.Fatalf("websockets = %q, want true", got)
}
}

func TestFileSynthesizer_Synthesize_CodexAuthHonorsExplicitWebsocketsOverride(t *testing.T) {
tempDir := t.TempDir()

authData := map[string]any{
"type": "codex",
"email": "codex@example.com",
"websockets": false,
}
data, _ := json.Marshal(authData)
if err := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644); err != nil {
t.Fatalf("failed to write auth file: %v", err)
}

auths, err := NewFileSynthesizer().Synthesize(&SynthesisContext{
Config: &config.Config{},
AuthDir: tempDir,
Now: time.Now(),
IDGenerator: NewStableIDGenerator(),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auths) != 1 {
t.Fatalf("expected 1 auth, got %d", len(auths))
}
if got := auths[0].Attributes["websockets"]; got != "false" {
t.Fatalf("websockets = %q, want false", got)
}
}

func TestFileSynthesizer_Synthesize_GeminiProviderMapping(t *testing.T) {
tempDir := t.TempDir()

Expand Down
22 changes: 22 additions & 0 deletions sdk/auth/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ func NewCodexAuthenticator() *CodexAuthenticator {
return &CodexAuthenticator{CallbackPort: 1455}
}

func CodexWebsocketsAttributeValue(metadata map[string]any) (string, bool) {
if metadata == nil {
return "true", true
}
raw, ok := metadata["websockets"]
if !ok || raw == nil {
return "true", true
}
switch v := raw.(type) {
case bool:
if v {
return "true", true
}
return "false", true
case string:
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed, true
}
}
return "", false
}

func (a *CodexAuthenticator) Provider() string {
return "codex"
}
Expand Down
5 changes: 5 additions & 0 deletions sdk/auth/filestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
LastRefreshedAt: time.Time{},
NextRefreshAfter: time.Time{},
}
if provider == "codex" {
if websockets, ok := CodexWebsocketsAttributeValue(metadata); ok {
auth.Attributes["websockets"] = websockets
}
}
if email, ok := metadata["email"].(string); ok && email != "" {
auth.Attributes["email"] = email
}
Expand Down
Loading