diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index e48a4ac351..a24bd04483 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -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:") diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 2041cebc64..e14d65e4f9 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -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) @@ -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 @@ -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() } @@ -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) } @@ -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) @@ -396,6 +400,13 @@ 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 @@ -403,10 +414,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr 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() } @@ -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 @@ -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) } @@ -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) } @@ -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 @@ -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 diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index dec356de4c..8e16255d51 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -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) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 49a635e7e8..0144d63763 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -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" ) @@ -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 != "" { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index f3e4497923..7d0a73b3ff 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -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() diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go index 269e3d8b21..cc0bcc2b03 100644 --- a/sdk/auth/codex.go +++ b/sdk/auth/codex.go @@ -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" } diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index f8f49f44ba..f4b79ba4e0 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -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 }