diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 89333d30..a9f6ff33 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -30,8 +30,12 @@ The complete Apache License 2.0 text is distributed in GPT-Load uses a pinned, execution-only embedded adapter around CLIProxyAPI's Codex, Claude, Antigravity, and xAI OAuth and HTTP executor code. GPT-Load retains ownership of credential storage, account selection, retry, health, affinity, logging, and -usage policy; the embedded adapter does not use CLIProxyAPI's manager, pool, -file store, WebSocket executor, fallback, or automatic retry. +usage policy; the embedded adapter does not use CLIProxyAPI's manager, account pool, +or file store. A separate, explicitly called Codex WebSocket session facade reuses +the pinned WS executor with HTTP fallback and business-request replay blocked. +The SDK can still attempt an extra handshake after a failed send; the facade +rejects replacement connection binding before another business request is sent. +This facade is not connected to the existing HTTP data plane. The complete MIT License text is distributed in `LICENSES/MIT.txt`. diff --git a/internal/subscription/providers/codex/websocket.go b/internal/subscription/providers/codex/websocket.go new file mode 100644 index 00000000..01211ffa --- /dev/null +++ b/internal/subscription/providers/codex/websocket.go @@ -0,0 +1,102 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded" +) + +const ( + WSNotSent = "not_sent" + WSMaybeSent = "maybe_sent" +) + +// WSSessionOptions 固定调用者已选定的凭据、API 代理根地址和出站代理。 +// ProxyURL 复用现有代理策略选择的 direct 或代理 URL,环境代理须由调用者先解析。 +type WSSessionOptions struct { + CredentialID string + Credential Credential + BaseURL string + ProxyURL string + TurnTimeout time.Duration + MaxRequestBytes int + MaxEventBytes int +} + +// WSTurnResult 的 Usage 保留上游 JSON,缺失时为 nil,不伪造零用量。 +type WSTurnResult struct { + ResponseID string + Status string + Usage json.RawMessage + Headers http.Header + DispatchState string +} + +// WSError 暴露稳定分类和发送证据,错误文本不包含上游正文与凭据。 +type WSError struct { + Code string + UpstreamCode string + HTTPStatus int + DispatchState string + cause error +} + +func (err *WSError) Error() string { return "codex websocket: " + err.Code } +func (err *WSError) Unwrap() error { return err.cause } + +// WSSession 独立于既有 HTTP Executor,不注册到数据面或全局生命周期。 +type WSSession struct{ bridge *cpaembedded.CodexWSSession } + +// NewWSSession 创建独立句柄,首轮 ExecuteTurn 才建立上游连接。 +func NewWSSession(options WSSessionOptions) (*WSSession, error) { + bridge, err := cpaembedded.NewCodexWSSession(cpaembedded.CodexWSSessionOptions{ + CredentialID: options.CredentialID, Credential: credentialToBridge(options.Credential), + BaseURL: options.BaseURL, ProxyURL: options.ProxyURL, + TurnTimeout: options.TurnTimeout, MaxRequestBytes: options.MaxRequestBytes, MaxEventBytes: options.MaxEventBytes, + }) + if err != nil { + return nil, wsErrorFromBridge(err) + } + return &WSSession{bridge: bridge}, nil +} + +// ExecuteTurn 同步执行原生 Responses Create,首轮不引用上一响应。 +// emit 顺序接收原生 JSON 事件,须及时返回并响应 ctx;nil 表示忽略事件。 +// 调用者负责保存响应 ID,并通过同一 Session 发起串行续接。 +func (s *WSSession) ExecuteTurn(ctx context.Context, payload json.RawMessage, emit func(context.Context, json.RawMessage) error) (WSTurnResult, error) { + var bridge *cpaembedded.CodexWSSession + if s != nil { + bridge = s.bridge + } + result, err := bridge.ExecuteTurn(ctx, payload, emit) + return WSTurnResult{ + ResponseID: result.ResponseID, Status: result.Status, Usage: result.Usage, + Headers: result.Headers, DispatchState: result.DispatchState, + }, wsErrorFromBridge(err) +} + +// Close 幂等关闭本 Session,不影响其他 Session;活动 ExecuteTurn 随后退出。 +func (s *WSSession) Close() error { + if s == nil { + return nil + } + return wsErrorFromBridge(s.bridge.Close()) +} + +func wsErrorFromBridge(err error) error { + if err == nil { + return nil + } + var failure *cpaembedded.CodexWSError + if errors.As(err, &failure) { + return &WSError{ + Code: failure.Code, UpstreamCode: failure.UpstreamCode, HTTPStatus: failure.HTTPStatus, + DispatchState: failure.DispatchState, cause: failure.Unwrap(), + } + } + return &WSError{Code: "internal_error", DispatchState: WSMaybeSent} +} diff --git a/internal/subscription/providers/codex/websocket_test.go b/internal/subscription/providers/codex/websocket_test.go new file mode 100644 index 00000000..83c5a595 --- /dev/null +++ b/internal/subscription/providers/codex/websocket_test.go @@ -0,0 +1,41 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +func TestWSSessionIsExplicitAndHTTPExecutorIsUnchanged(t *testing.T) { + session, err := NewWSSession(WSSessionOptions{ + CredentialID: "test", Credential: Credential{Type: Provider, AccessToken: "test-access", RefreshToken: "test-refresh", AccountID: "test-account"}, + ProxyURL: "direct", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := session.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result, err := session.ExecuteTurn(ctx, json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + var failure *WSError + if !errors.Is(err, context.Canceled) || !errors.As(err, &failure) || result.DispatchState != "not_sent" { + t.Fatalf("cancellation contract lost: %v", err) + } + httpExecutor := NewExecutor().(*executor) + if httpExecutor.bridge == nil { + t.Fatal("existing HTTP executor changed") + } + if err := session.Close(); err != nil { + t.Fatal(err) + } + _, err = session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if !errors.As(err, &failure) || failure.Code != "session_closed" { + t.Fatalf("closed session reused: %v", err) + } +} diff --git a/runtime_logging_test.go b/runtime_logging_test.go new file mode 100644 index 00000000..fa09626c --- /dev/null +++ b/runtime_logging_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "maps" + "strings" + "sync" + "testing" + + "github.com/sirupsen/logrus" + + "gpt-load/internal/platform/redact" + "gpt-load/internal/subscription/providers/codex" +) + +type runtimeLogCapture struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (capture *runtimeLogCapture) Write(body []byte) (int, error) { + capture.mu.Lock() + defer capture.mu.Unlock() + return capture.buf.Write(body) +} + +func (capture *runtimeLogCapture) String() string { + capture.mu.Lock() + defer capture.mu.Unlock() + return capture.buf.String() +} + +func (*runtimeLogCapture) Levels() []logrus.Level { return logrus.AllLevels } + +func (capture *runtimeLogCapture) Fire(entry *logrus.Entry) error { + // 与 Windows Event Log 一样,在 Hook 内格式化并写入,而非等待最终输出。 + formatted, err := entry.Logger.Formatter.Format(entry) + if err != nil { + return err + } + _, err = capture.Write(formatted) + return err +} + +func TestCodexWSRedactionPrecedesRuntimeLogHooks(t *testing.T) { + // 非并行测试保留包初始化安装的 Hook,只临时追加运行时日志链。 + logger := logrus.StandardLogger() + originalOutput, originalHooks := logger.Out, maps.Clone(logger.Hooks) + t.Cleanup(func() { + logger.SetOutput(originalOutput) + logger.ReplaceHooks(originalHooks) + }) + var output, sink runtimeLogCapture + logger.SetOutput(&output) + logger.AddHook(redact.NewHook(redact.New())) + logger.AddHook(&sink) + + // 复现服务启动后才首次创建 Session 的顺序;创建句柄不会建立连接。 + session, err := codex.NewWSSession(codex.WSSessionOptions{ + CredentialID: "test", Credential: codex.Credential{ + Type: codex.Provider, AccessToken: "test-access", RefreshToken: "test-refresh", AccountID: "test-account", + }, ProxyURL: "direct", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := session.Close(); err != nil { + t.Error(err) + } + }) + const marker = "private-close-reason-marker" + // 使用固定 SDK 的断连日志格式,验证桥接封装与项目日志链的组合。 + logger.Infof("codex websockets: upstream disconnected session=gptload-codex-ws-log-order-test auth=test url=wss://example.test/responses reason=read_error err=websocket: close 1008 (policy violation): %s", marker) + for name, capture := range map[string]*runtimeLogCapture{"output": &output, "sink": &sink} { + message := capture.String() + if strings.Contains(message, marker) { + t.Errorf("close reason leaked into %s", name) + } + if !strings.Contains(message, "session=[REDACTED]") || + !strings.Contains(message, "ws_close_code=1008") || + !strings.Contains(message, "error_class=websocket_closed") { + t.Errorf("missing redacted session or safe close diagnostics in %s", name) + } + } +} diff --git a/third_party/cpaembedded/README.md b/third_party/cpaembedded/README.md index 1944608d..208d91d3 100644 --- a/third_party/cpaembedded/README.md +++ b/third_party/cpaembedded/README.md @@ -14,6 +14,7 @@ quota policy. This bridge only exposes: - strict CPA Codex JSON parsing; - one-shot, context-aware token refresh; - the stateless Codex HTTP executor and its explicit local CountTokens estimator; +- an explicitly called Codex WebSocket Session facade for serial Responses turns; - one-shot model and usage observation requests. - Claude browser OAuth challenge creation and one-shot code exchange; - strict CPA Claude JSON parsing and stable device identity normalization; @@ -32,8 +33,83 @@ quota policy. This bridge only exposes: translators, without CPA manager, refresh, retry, fallback, WebSocket, image, or video execution paths. -It intentionally excludes CPA Manager, selector, pool, file store, server, -watcher, WebSocket/Auto executors, fallback, and internal retry loops. +It intentionally excludes CPA Manager, selector, account pool, file store, server, +watcher, and Auto executors. The Codex WS facade blocks HTTP fallback and business +request replay; it does not enable WS in the HTTP data plane. + +## Codex WebSocket Session + +`internal/subscription/providers/codex.NewWSSession` exposes this independent +capability to GPT-Load callers. The existing `NewExecutor` remains HTTP-only. + +- Supply an already selected credential, optional HTTPS API proxy root (with the + same native-path mapping as HTTP), and the proxy URL selected by the existing + proxy policy (HTTP or SOCKS5), or `direct`. There is no additional WS-specific + proxy scheme restriction; dialing uses the pinned SDK's proxy implementation. + There is no environment proxy lookup, credential selection, or token refresh. + HTTP proxies can tunnel TLS/WSS upstreams with CONNECT. The existing business + proxy configuration and HTTP execution paths are unchanged. +- `NewWSSession` creates a handle; the first `ExecuteTurn` opens the connection. + Pass a Responses create body, without the WS event `type`. The first turn must + not reference a prior response. Retain its response ID and use the same Session + for subsequent `previous_response_id` turns. IDs are not automatically added, + removed, looked up globally, or persisted. CPA enforces `store:false` upstream. +- `ExecuteTurn(ctx, body, emit)` is synchronous. The callback receives native JSON + events in order and must return promptly and honor `ctx`; nil discards events. + It may return an error or call `Close` to stop. The result contains response ID, + terminal status, raw usage (nil if absent), handshake headers when available, + and `not_sent` / `maybe_sent` business-dispatch evidence. Reused connections do + not provide new handshake headers; old quota headers are not carried forward. + A generic `response.done` preserves `response.status`; only `completed` succeeds. +- One turn runs at a time; overlapping calls fail with `session_busy`. Local + validation errors leave the Session usable. Cancellation, timeout, transport + loss and failed/protocol-invalid responses close it. A closed Session cannot + reconnect. `Close` is idempotent and affects only that Session; active execution + then unwinds. Callers own the eventual `Close`, including after successful use. + Cancellation also closes an in-progress TLS/WS upgrade via the SDK's HTTP trace + connection hook. Before an HTTP proxy CONNECT tunnel is established that hook + is not yet available: cancellation may wait for proxy negotiation to end or its + deadline (the smaller of the turn deadline and SDK's 30-second handshake limit). + SOCKS5 is accepted with a known SDK limitation: its initial dial/negotiation + ignores the request context. `TurnTimeout`, cancellation and `Close` cannot + guarantee prompt termination or resource release while that operation is stuck; + the goroutine and connection can remain until the proxy/network returns. Once + the SOCKS5 tunnel is established, the existing TLS/WS cancellation and Session + cleanup apply. The facade does not modify or fork the SDK to change this behavior. +- A lifetime-bound CPA `ExecutionLifecycle` blocks HTTP fallback and rejects + replacement connections. CPA can still perform an extra handshake after a send + failure, but cannot send the business request again. This is not a guarantee of + exactly one network connection attempt. +- Default per-turn timeout is five minutes; request and forwarded-event limits + default to 10 MiB each. All three are configurable when creating the Session. + The facade buffers no conversation history or output queue. Event checks occur + **after SDK reading**: CPA v7.2.151 has no exposed raw-frame size limit and has + its own internal buffers. These checks do not bound all SDK memory. CPA also + retains its upstream read-idle timeout; idle connection loss invalidates the + Session and is not transparently recovered. +- A narrowly scoped logrus hook is registered during package initialization, + before the application's runtime redaction and log-sink hooks. It removes raw + errors from the pinned SDK's disconnect logs for facade-owned Sessions, + retaining safe error classes and WS close codes. It does not change log levels, + outputs, or other Sessions' logs. Revalidate this log-format contract when + upgrading CPA. +- No multi-lane `stream_id`, background mode, other protocols, Responses resource + operations, global session routing, application shutdown integration, or + business health/quota/logging policy is introduced here. + +Deterministic tests use local fake WS upstreams. They prove connection reuse and +wire contracts, not real Codex account availability or recovery compatibility. +Real-account checks require a separately authorized credential and are opt-in. + +```bash +CPA_LIVE_CODEX_WS_CREDENTIAL_FILE=/absolute/path/to/codex.json \ +CPA_LIVE_CODEX_WS_MODEL=authorized-model-id \ + go test -count=1 -run '^TestLiveCodexWSSessionContract$' ./embedded +``` + +This makes two real model requests and checks that the second can recall a marker +sent only in the first. `CPA_LIVE_CODEX_WS_PROXY_URL` defaults to `direct`; +`CPA_LIVE_CODEX_WS_BASE_URL` optionally selects an authorized HTTPS API proxy root. ## Pinned upstream @@ -53,7 +129,9 @@ bumps: executor, translation, headers, identity, model discovery, and usage observation code. 2. Update the CPA version in this module and run `go mod tidy` here. 3. Fix only bridge compatibility issues; keep the execution-only boundary and - do not adopt CPA Manager, retry, WebSocket, fallback, or file persistence. + do not adopt CPA Manager, business-request retry, Auto, fallback, or file persistence. + Revalidate the explicit WS facade's lifecycle, continuation, proxy and cancellation + contracts when changing the pinned SDK. 4. Run `go test -count=1 ./...` in this module, then GPT-Load's full `make check` from the repository root. 5. With authorized disposable CPA credentials, run the applicable opt-in live diff --git a/third_party/cpaembedded/embedded/codex_websocket.go b/third_party/cpaembedded/embedded/codex_websocket.go new file mode 100644 index 00000000..ae63b74e --- /dev/null +++ b/third_party/cpaembedded/embedded/codex_websocket.go @@ -0,0 +1,459 @@ +package embedded + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptrace" + "strings" + "sync" + "time" + + "github.com/google/uuid" + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + internalexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +const ( + CodexWSNotSent = "not_sent" + CodexWSMaybeSent = "maybe_sent" + defaultCodexWSTurnTimeout = 5 * time.Minute + defaultCodexWSMaxBytes = 10 << 20 +) + +// CodexWSSessionOptions 固定调用者已选择的身份、API 代理根地址和出站代理。 +// ProxyURL 传入现有代理策略选定的 direct 或代理 URL,不读取环境代理。 +type CodexWSSessionOptions struct { + CredentialID string + Credential CodexCredential + BaseURL string + ProxyURL string + TurnTimeout time.Duration + MaxRequestBytes int + MaxEventBytes int +} + +// CodexWSTurnResult 只描述本轮执行,不执行健康、额度或日志记账。 +type CodexWSTurnResult struct { + ResponseID string + Status string + Usage json.RawMessage + Headers http.Header + DispatchState string +} + +// CodexWSError 的文本不包含上游响应、凭据、地址或代理密码。 +// UpstreamCode 和 HTTPStatus 仅提供调用者所需的错误分类证据。 +type CodexWSError struct { + Code string + UpstreamCode string + HTTPStatus int + DispatchState string + cause error +} + +func (err *CodexWSError) Error() string { return "codex websocket: " + err.Code } +func (err *CodexWSError) Unwrap() error { return err.cause } + +func codexWSError(code string) *CodexWSError { + return &CodexWSError{Code: code, DispatchState: CodexWSNotSent} +} + +// CodexWSSession 是尚未接入数据面的独立 Codex 上游会话。 +type CodexWSSession struct { + auth *cliproxyauth.Auth + inner *internalexecutor.CodexWebsocketsExecutor + id string + options CodexWSSessionOptions + resource *codexWSResource + mu sync.Mutex + running bool + started bool + closed bool + bound bool + cancel context.CancelFunc + closeConnection func() error + pendingConnection net.Conn + closeDone chan struct{} + closeErr error +} + +// NewCodexWSSession 只创建句柄,不建连、不刷新 token,也不保留 refresh token。 +func NewCodexWSSession(options CodexWSSessionOptions) (*CodexWSSession, error) { + if strings.TrimSpace(options.CredentialID) == "" || validateCredential(options.Credential) != nil { + return nil, codexWSError("invalid_session_options") + } + endpoints, err := ResolveCodexAPIEndpoints(options.BaseURL) + if err != nil { + return nil, codexWSError("invalid_session_options") + } + proxy, err := proxyutil.Parse(options.ProxyURL) + if err != nil || (proxy.Mode != proxyutil.ModeDirect && proxy.Mode != proxyutil.ModeProxy) { + return nil, codexWSError("invalid_proxy") + } + if proxy.URL != nil && (proxy.URL.RawQuery != "" || proxy.URL.Fragment != "" || (proxy.URL.Path != "" && proxy.URL.Path != "/")) { + return nil, codexWSError("invalid_proxy") + } + if options.TurnTimeout < 0 || options.MaxRequestBytes < 0 || options.MaxEventBytes < 0 { + return nil, codexWSError("invalid_session_options") + } + if options.TurnTimeout == 0 { + options.TurnTimeout = defaultCodexWSTurnTimeout + } + if options.MaxRequestBytes == 0 { + options.MaxRequestBytes = defaultCodexWSMaxBytes + } + if options.MaxEventBytes == 0 { + options.MaxEventBytes = defaultCodexWSMaxBytes + } + auth := NewCodexAuth(options.CredentialID, options.Credential, endpoints.ExecutionBase) + delete(auth.Metadata, "refresh_token") + auth.ProxyURL = proxy.Raw + options.Credential = CodexCredential{} + session := &CodexWSSession{ + auth: auth, id: codexWSSessionIDPrefix + uuid.NewString(), options: options, closeDone: make(chan struct{}), + inner: internalexecutor.NewCodexWebsocketsExecutor(&internalconfig.Config{}), + } + session.resource = &codexWSResource{session: session} + return session, nil +} + +// ExecuteTurn 同步执行一轮;emit 按原始上游事件顺序调用,不积累聊天历史。 +// emit 可为 nil;非空回调须及时返回并响应 ctx,不能在回调中等待本轮结束。 +// 事件大小检查发生在 SDK 读取之后,不是 SDK 原始帧读取的内存上限。 +func (s *CodexWSSession) ExecuteTurn(ctx context.Context, payload json.RawMessage, emit func(context.Context, json.RawMessage) error) (CodexWSTurnResult, error) { + result := CodexWSTurnResult{DispatchState: CodexWSNotSent} + if s == nil { + return result, codexWSError("session_closed") + } + model, previous, err := s.validateRequest(payload) + if err != nil { + return result, err + } + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return result, codexWSContextError(ctx.Err(), CodexWSNotSent) + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return result, codexWSError("session_closed") + } + if s.running { + s.mu.Unlock() + return result, codexWSError("session_busy") + } + if !s.started && previous != "" { + s.mu.Unlock() + return result, codexWSError("continuation_requires_session") + } + turnCtx, cancel := context.WithTimeout(ctx, s.options.TurnTimeout) + s.cancel, s.running = cancel, true + reuse := s.started + s.mu.Unlock() + cleanupDone := make(chan struct{}) + stop := context.AfterFunc(turnCtx, func() { s.invalidate(true); close(cleanupDone) }) + defer func() { + if !stop() { + <-cleanupDone + } + cancel() + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + // 关闭与 SDK 创建会话竞态时,仍清理本调用留下的空登记。 + if closed { + s.inner.CloseExecutionSession(s.id) + } + s.mu.Lock() + s.cancel, s.running = nil, false + s.mu.Unlock() + }() + turnCtx = cliproxyexecutor.WithUpstreamAttemptTracker(turnCtx) + // Gorilla 的 Upgrade 读取只设置 deadline;捕获尚未绑定 lifecycle 的连接, + // 使主动取消也能中止 TLS/WS 握手,而不必等待握手超时。 + turnCtx = httptrace.WithClientTrace(turnCtx, &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { s.trackDialConnection(info.Conn) }, + }) + if reuse { + turnCtx = cliproxyexecutor.WithRequiredUpstreamWebsocket(turnCtx) + } + observation := codexWSTurnObservation{ + result: &result, maxBytes: s.options.MaxEventBytes, emit: emit, cancel: cancel, + failSession: func() { s.invalidate(false) }, + } + stream, executionErr := s.inner.ExecuteStream(turnCtx, s.auth, cliproxyexecutor.Request{ + Model: model, Payload: append([]byte(nil), payload...), Format: sdktranslator.FormatOpenAIResponse, + }, cliproxyexecutor.Options{ + Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: s.id}, + ExecutionLifecycle: s.resource, WebSocketResponseObserver: observation.observe, + }) + if stream != nil { + result.Headers = stream.Headers.Clone() + // 原生 JSON 由 observer 交付。排空 SDK 的转换输出,确保单轮收尾完成。 + for chunk := range stream.Chunks { + if chunk.Err != nil && executionErr == nil { + executionErr = chunk.Err + } + } + } + s.mu.Lock() + bound := s.bound + s.mu.Unlock() + if bound && cliproxyexecutor.UpstreamAttempted(turnCtx) { + result.DispatchState = CodexWSMaybeSent + } + if observation.err != nil { + s.invalidate(true) + observation.err.DispatchState = result.DispatchState + return result, observation.err + } + if turnCtx.Err() != nil { + s.invalidate(true) + return result, codexWSContextError(turnCtx.Err(), result.DispatchState) + } + if executionErr != nil { + // 连接 deadline 可能先于 context 定时器触发,仍须保留超时分类。 + var timeout net.Error + if errors.As(executionErr, &timeout) && timeout.Timeout() { + s.invalidate(true) + return result, codexWSContextError(context.DeadlineExceeded, result.DispatchState) + } + failure := codexWSError("upstream_error") + failure.DispatchState, failure.UpstreamCode = result.DispatchState, observation.upstreamCode + var status interface{ StatusCode() int } + if errors.As(executionErr, &status) { + failure.HTTPStatus = status.StatusCode() + } + var headers interface{ Headers() http.Header } + if errors.As(executionErr, &headers) { + result.Headers = headers.Headers().Clone() + } + // SDK 请求预处理失败且未接触上游时,不破坏已有连接。 + if cliproxyexecutor.UpstreamAttempted(turnCtx) || cliproxyexecutor.IsUpstreamWebsocketReplayRequired(executionErr) { + s.invalidate(true) + } + return result, failure + } + if result.Status != "completed" || result.ResponseID == "" { + s.invalidate(true) + failure := codexWSError("incomplete_response") + failure.DispatchState, failure.UpstreamCode = result.DispatchState, observation.upstreamCode + return result, failure + } + s.mu.Lock() + s.started = true + s.mu.Unlock() + return result, nil +} + +func (s *CodexWSSession) validateRequest(payload []byte) (string, string, error) { + if len(payload) > s.options.MaxRequestBytes { + return "", "", codexWSError("request_too_large") + } + var root map[string]json.RawMessage + if json.Unmarshal(payload, &root) != nil || root == nil { + return "", "", codexWSError("invalid_request") + } + var model, previous string + if json.Unmarshal(root["model"], &model) != nil || strings.TrimSpace(model) == "" { + return "", "", codexWSError("invalid_request") + } + if raw, ok := root["previous_response_id"]; ok && json.Unmarshal(raw, &previous) != nil { + return "", "", codexWSError("invalid_request") + } + // 首版仅支持原生单轮生成,不静默丢弃多流或后台执行语义。 + for _, key := range []string{"type", "stream_id", "conversation"} { + if _, ok := root[key]; ok { + return "", "", codexWSError("unsupported_request") + } + } + if raw, ok := root["background"]; ok { + var background bool + if json.Unmarshal(raw, &background) != nil || background { + return "", "", codexWSError("unsupported_request") + } + } + return model, previous, nil +} + +// Close 可从事件回调调用;它关闭连接并取消本轮,ExecuteTurn 随后退出。 +func (s *CodexWSSession) Close() error { + if s == nil { + return nil + } + s.invalidate(true) + <-s.closeDone + s.mu.Lock() + defer s.mu.Unlock() + return s.closeErr +} + +func (s *CodexWSSession) trackDialConnection(connection net.Conn) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + s.recordCloseError(connection.Close()) + return + } + previous := s.pendingConnection + s.pendingConnection = connection + s.mu.Unlock() + if previous != nil { + s.recordCloseError(previous.Close()) + } +} + +func (s *CodexWSSession) recordCloseError(err error) { + if err == nil || errors.Is(err, net.ErrClosed) { + return + } + s.mu.Lock() + s.closeErr = codexWSError("close_failed") + s.mu.Unlock() +} + +func (s *CodexWSSession) invalidate(cancelActive bool) { + s.mu.Lock() + if s.closed { + cancel := s.cancel + s.mu.Unlock() + if cancelActive && cancel != nil { + cancel() + } + return + } + s.closed = true + cancel, closeConnection := s.cancel, s.closeConnection + pendingConnection := s.pendingConnection + s.closeConnection = nil + s.pendingConnection = nil + s.mu.Unlock() + if cancelActive && cancel != nil { + cancel() + } + if pendingConnection != nil { + s.recordCloseError(pendingConnection.Close()) + } + if closeConnection != nil { + s.recordCloseError(closeConnection()) + } + s.inner.CloseExecutionSession(s.id) + close(s.closeDone) +} + +type codexWSResource struct{ session *CodexWSSession } + +// Bind 只接受第一条连接;SDK 重拨后再次绑定时,在第二次业务发送前拒绝。 +func (resource *codexWSResource) Bind(closeConnection func() error) error { + s := resource.session + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.bound { + return codexWSError("session_closed") + } + s.bound, s.closeConnection = true, closeConnection + s.pendingConnection = nil + return nil +} + +func (resource *codexWSResource) End(string) { resource.session.invalidate(false) } + +func codexWSContextError(err error, dispatch string) *CodexWSError { + code := "canceled" + if errors.Is(err, context.DeadlineExceeded) { + code = "timeout" + } + return &CodexWSError{Code: code, DispatchState: dispatch, cause: err} +} + +type codexWSTurnObservation struct { + result *CodexWSTurnResult + maxBytes int + emit func(context.Context, json.RawMessage) error + cancel context.CancelFunc + err *CodexWSError + upstreamCode string + failSession func() +} + +func (o *codexWSTurnObservation) observe(ctx context.Context, event cliproxyexecutor.WebSocketResponseEvent) { + if o.err != nil || ctx.Err() != nil { + return + } + fail := func(code string) { o.err = codexWSError(code); o.cancel() } + if len(event.Payload) > o.maxBytes { + fail("event_too_large") + return + } + var envelope struct { + Type string `json:"type"` + Response struct { + ID string `json:"id"` + Status string `json:"status"` + Usage json.RawMessage `json:"usage"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } `json:"response"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal(event.Payload, &envelope) != nil || envelope.Type == "" { + fail("invalid_event") + return + } + if envelope.Response.ID != "" { + if o.result.ResponseID != "" && o.result.ResponseID != envelope.Response.ID { + fail("invalid_event") + return + } + o.result.ResponseID = envelope.Response.ID + } + switch envelope.Type { + case "response.completed": + o.result.Status, o.result.Usage = "completed", envelope.Response.Usage + case "response.done": + o.result.Status, o.result.Usage = envelope.Response.Status, envelope.Response.Usage + o.upstreamCode = safeCodexWSCode(envelope.Response.Error.Code) + case "response.incomplete": + o.result.Status, o.result.Usage = "incomplete", envelope.Response.Usage + case "response.failed", "error": + o.result.Status, o.result.Usage = "failed", envelope.Response.Usage + o.upstreamCode = safeCodexWSCode(envelope.Error.Code) + if o.upstreamCode == "" { + o.upstreamCode = safeCodexWSCode(envelope.Response.Error.Code) + } + } + if o.emit != nil { + if err := o.emit(ctx, append(json.RawMessage(nil), event.Payload...)); err != nil { + fail("event_consumer_failed") + } + } + // 先释放已失败的会话;让 SDK 继续返回原始错误分类,而非在断连日志里输出错误正文。 + if o.result.Status == "failed" || o.result.Status == "incomplete" || (envelope.Type == "response.done" && o.result.Status != "completed") { + o.failSession() + } +} + +func safeCodexWSCode(code string) string { + if len(code) > 128 { + return "" + } + for _, char := range code { + if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' || char == '-' || char == '.') { + return "" + } + } + return code +} diff --git a/third_party/cpaembedded/embedded/codex_websocket_live_test.go b/third_party/cpaembedded/embedded/codex_websocket_live_test.go new file mode 100644 index 00000000..744cf64f --- /dev/null +++ b/third_party/cpaembedded/embedded/codex_websocket_live_test.go @@ -0,0 +1,82 @@ +package embedded + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +// 此合同需要单独授权的真实账号和模型;默认跳过,不刷新 token、不输出凭据。 +func TestLiveCodexWSSessionContract(t *testing.T) { + path := strings.TrimSpace(os.Getenv("CPA_LIVE_CODEX_WS_CREDENTIAL_FILE")) + if path == "" { + t.Skip("CPA_LIVE_CODEX_WS_CREDENTIAL_FILE is not set") + } + model := strings.TrimSpace(os.Getenv("CPA_LIVE_CODEX_WS_MODEL")) + if model == "" { + t.Fatal("CPA_LIVE_CODEX_WS_MODEL is required") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal("read live WS credential") + } + credential, err := ParseCodexCredentialJSON(raw) + clear(raw) + if err != nil { + t.Fatal("parse live WS credential") + } + proxy := strings.TrimSpace(os.Getenv("CPA_LIVE_CODEX_WS_PROXY_URL")) + if proxy == "" { + proxy = "direct" + } + session, err := NewCodexWSSession(CodexWSSessionOptions{ + CredentialID: "live-ws-contract", Credential: credential, + BaseURL: os.Getenv("CPA_LIVE_CODEX_WS_BASE_URL"), ProxyURL: proxy, TurnTimeout: 90 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := session.Close(); err != nil { + t.Error(err) + } + }() + marker := uuid.NewString() + firstBody, err := json.Marshal(map[string]any{"model": model, "store": false, "input": "Remember this marker for the next turn: " + marker + ". Reply OK."}) + if err != nil { + t.Fatal(err) + } + first, err := session.ExecuteTurn(context.Background(), firstBody, nil) + if err != nil { + t.Fatal(err) + } + secondBody, err := json.Marshal(map[string]any{"model": model, "store": false, "previous_response_id": first.ResponseID, "input": "Return only the marker supplied in the previous request."}) + if err != nil { + t.Fatal(err) + } + var answer strings.Builder + second, err := session.ExecuteTurn(context.Background(), secondBody, func(_ context.Context, event json.RawMessage) error { + var payload struct { + Type string `json:"type"` + Delta string `json:"delta"` + } + if err := json.Unmarshal(event, &payload); err != nil { + return err + } + if payload.Type == "response.output_text.delta" { + answer.WriteString(payload.Delta) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if first.ResponseID == "" || second.ResponseID == first.ResponseID || !strings.Contains(answer.String(), marker) { + t.Fatal("live WS continuation did not retain the first turn context") + } +} diff --git a/third_party/cpaembedded/embedded/codex_websocket_log.go b/third_party/cpaembedded/embedded/codex_websocket_log.go new file mode 100644 index 00000000..48fad6cd --- /dev/null +++ b/third_party/cpaembedded/embedded/codex_websocket_log.go @@ -0,0 +1,45 @@ +package embedded + +import ( + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +const codexWSSessionIDPrefix = "gptload-codex-ws-" + +// 先于应用运行时的通用脱敏和日志写入 Hook 注册,避免 session 前缀先被 +// 遮蔽而无法匹配,或原始错误正文先被其他 Hook 持久化。 +func init() { + logrus.AddHook(codexWSLogHook{}) +} + +// CPA v7.2.151 在通知 lifecycle 前将 CloseError 正文写入默认 logger。 +// 仅处理本封装会话的该条日志,不修改全局级别、输出或其他执行器的日志。 +type codexWSLogHook struct{} + +func (codexWSLogHook) Levels() []logrus.Level { return []logrus.Level{logrus.InfoLevel} } + +func (codexWSLogHook) Fire(entry *logrus.Entry) error { + if !strings.HasPrefix(entry.Message, "codex websockets: upstream disconnected session="+codexWSSessionIDPrefix) { + return nil + } + message, rawError, found := strings.Cut(entry.Message, " err=") + if !found { + return nil + } + entry.Message = message + entry.Data["error_class"] = "upstream_error" + // 只提取 Gorilla CloseError 的数值关闭码;未知格式也不保留错误正文。 + if detail, ok := strings.CutPrefix(rawError, "websocket: close "); ok { + if end := strings.IndexAny(detail, " :"); end >= 0 { + detail = detail[:end] + } + if code, err := strconv.Atoi(detail); err == nil && code >= 1000 && code < 5000 { + entry.Data["ws_close_code"] = code + entry.Data["error_class"] = "websocket_closed" + } + } + return nil +} diff --git a/third_party/cpaembedded/embedded/codex_websocket_log_test.go b/third_party/cpaembedded/embedded/codex_websocket_log_test.go new file mode 100644 index 00000000..5a604eab --- /dev/null +++ b/third_party/cpaembedded/embedded/codex_websocket_log_test.go @@ -0,0 +1,105 @@ +package embedded + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gorilla/websocket" + "github.com/sirupsen/logrus" +) + +type codexWSLogBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *codexWSLogBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *codexWSLogBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestCodexWSSessionRedactsCloseReason(t *testing.T) { + // 仅非并行测试捕获默认日志;生产封装不修改全局输出或日志级别。 + output := logrus.StandardLogger().Out + defer logrus.SetOutput(output) + for _, code := range []int{websocket.ClosePolicyViolation, 4001} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + var logs codexWSLogBuffer + logrus.SetOutput(&logs) + const marker = "private-close-reason-marker" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + if err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, marker)); err != nil { + t.Error(err) + } + })) + defer server.Close() + session := wsTestSession(t, server.URL) + _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if err == nil { + t.Fatal("upstream close must fail the turn") + } + if strings.Contains(logs.String(), marker) { + t.Fatal("upstream close reason leaked into default logs") + } + if !strings.Contains(logs.String(), fmt.Sprintf("ws_close_code=%d", code)) || !strings.Contains(logs.String(), "error_class=websocket_closed") { + t.Fatalf("missing safe close diagnostics: %s", logs.String()) + } + }) + } +} + +func TestCodexWSLogHookScope(t *testing.T) { + owned := "codex websockets: upstream disconnected session=" + codexWSSessionIDPrefix + "test auth=test url=ws://test reason=read_error" + for _, test := range []struct { + name string + message string + want string + }{ + {"unknown error", owned + " err=private upstream error", owned}, + {"no error", owned, owned}, + {"other session", "codex websockets: upstream disconnected session=other err=retained", "codex websockets: upstream disconnected session=other err=retained"}, + {"application log", "application err=retained", "application err=retained"}, + } { + t.Run(test.name, func(t *testing.T) { + var output bytes.Buffer + logger := logrus.New() + logger.SetOutput(&output) + logger.SetFormatter(&logrus.JSONFormatter{}) + logger.AddHook(codexWSLogHook{}) + logger.Info(test.message) + var entry struct { + Message string `json:"msg"` + ErrorClass string `json:"error_class"` + } + if err := json.Unmarshal(output.Bytes(), &entry); err != nil { + t.Fatal(err) + } + if entry.Message != test.want || (entry.ErrorClass == "upstream_error") != (test.name == "unknown error") { + t.Fatalf("unexpected log: %s", output.String()) + } + }) + } +} diff --git a/third_party/cpaembedded/embedded/codex_websocket_test.go b/third_party/cpaembedded/embedded/codex_websocket_test.go new file mode 100644 index 00000000..2a7d3b44 --- /dev/null +++ b/third_party/cpaembedded/embedded/codex_websocket_test.go @@ -0,0 +1,814 @@ +package embedded + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +func wsTestSession(t *testing.T, target string, proxyURLs ...string) *CodexWSSession { + t.Helper() + proxyURL := "direct" + if len(proxyURLs) != 0 { + proxyURL = proxyURLs[0] + } + session, err := NewCodexWSSession(CodexWSSessionOptions{ + CredentialID: "ws-test", Credential: CodexCredential{ + Type: ProviderCodex, AccessToken: "test-access", RefreshToken: "test-refresh", AccountID: "test-account", + }, ProxyURL: proxyURL, TurnTimeout: 3 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + // 生产入口保持 HTTPS 校验;测试仅将已固定的执行地址指向本地假上游。 + session.auth.Attributes["base_url"] = target + t.Cleanup(func() { + if err := session.Close(); err != nil { + t.Error(err) + } + }) + return session +} + +func wsCompleted(id string) []byte { + return []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":%q,"object":"response","status":"completed","store":false,"output":[],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, id)) +} + +func TestCodexWSSessionContinuationAndIsolation(t *testing.T) { + var connections, turns atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" || r.Header.Get("Authorization") != "Bearer test-access" { + t.Error("unexpected request identity or path") + w.WriteHeader(http.StatusBadRequest) + return + } + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, http.Header{"X-Codex-Test": {"handshake"}}) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + n := connections.Add(1) + previous := "" + for { + _, body, err := conn.ReadMessage() + if err != nil { + return + } + var request struct { + Type string `json:"type"` + Previous string `json:"previous_response_id"` + Store bool `json:"store"` + } + if err := json.Unmarshal(body, &request); err != nil { + t.Error(err) + return + } + if request.Type != "response.create" || request.Previous != previous || request.Store { + t.Error("request lost continuation or stateless semantics") + return + } + previous = fmt.Sprintf("resp_%d_%d", n, turns.Add(1)) + if err := conn.WriteMessage(websocket.TextMessage, wsCompleted(previous)); err != nil { + return + } + } + })) + defer server.Close() + session := wsTestSession(t, server.URL) + events := 0 + consume := func(ctx context.Context, event json.RawMessage) error { + if !json.Valid(event) { + t.Error("event is not native JSON") + } + events++ + return nil + } + firstCtx, cancelFirst := context.WithCancel(context.Background()) + defer cancelFirst() + first, err := session.ExecuteTurn(firstCtx, json.RawMessage(`{"model":"gpt-5","input":"hello","store":false}`), consume) + if err != nil { + t.Fatal(err) + } + if first.ResponseID == "" || first.Status != "completed" || !json.Valid(first.Usage) { + t.Fatalf("missing terminal result: %+v", first) + } + cancelFirst() // 成功请求的 context 结束不应关闭已空闲的会话。 + second, err := session.ExecuteTurn(context.Background(), json.RawMessage(fmt.Sprintf(`{"model":"gpt-5","input":"continue","previous_response_id":%q}`, first.ResponseID)), consume) + if err != nil { + t.Fatal(err) + } + if second.ResponseID == first.ResponseID || events != 2 || connections.Load() != 1 { + t.Fatal("turns did not reuse one connection") + } + if first.Headers.Get("X-Codex-Test") == "" || second.Headers.Get("X-Codex-Test") != "" { + t.Fatal("handshake headers were lost or reused as fresh observation") + } + other := wsTestSession(t, server.URL) + if _, err := other.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"another"}`), consume); err != nil { + t.Fatal(err) + } + if connections.Load() != 2 { + t.Fatal("independent sessions shared a connection") + } +} + +func TestCodexWSSessionDoesNotFallbackHTTP(t *testing.T) { + var upgrades, posts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if websocket.IsWebSocketUpgrade(r) { + upgrades.Add(1) + } else { + posts.Add(1) + } + w.WriteHeader(http.StatusUpgradeRequired) + })) + defer server.Close() + session := wsTestSession(t, server.URL) + _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if err == nil { + t.Fatal("expected handshake rejection") + } + if upgrades.Load() != 1 || posts.Load() != 0 { + t.Fatalf("upgrades=%d posts=%d", upgrades.Load(), posts.Load()) + } +} + +func TestCodexWSSessionPreservesUpstreamFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","status":429,"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"test-access"}}`)); err != nil { + t.Error(err) + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + var received bool + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), func(ctx context.Context, event json.RawMessage) error { + received = strings.Contains(string(event), `"type":"error"`) + return nil + }) + var failure *CodexWSError + if !errors.As(err, &failure) || failure.Code != "upstream_error" || failure.HTTPStatus != 429 || failure.UpstreamCode != "rate_limit_exceeded" { + t.Fatalf("upstream failure was lost: %v (%+v)", err, failure) + } + if !received || result.DispatchState != CodexWSMaybeSent || strings.Contains(err.Error(), "test-access") { + t.Fatal("missing event/dispatch evidence or unsafe error text") + } + _, err = session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"again"}`), nil) + if !errors.As(err, &failure) || failure.Code != "session_closed" { + t.Fatalf("failed session reused: %v", err) + } +} + +func TestCodexWSSessionCancellationAndBusy(t *testing.T) { + for _, mode := range []string{"cancel", "timeout", "close"} { + t.Run(mode, func(t *testing.T) { + received, disconnected := make(chan struct{}), make(chan struct{}) + var turns atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + turns.Add(1) + close(received) + _, _, _ = conn.ReadMessage() + close(disconnected) + })) + defer server.Close() + session := wsTestSession(t, server.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if mode == "timeout" { + session.options.TurnTimeout = 200 * time.Millisecond + } + finished := make(chan error, 1) + go func() { + _, err := session.ExecuteTurn(ctx, json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + finished <- err + }() + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("first turn not received") + } + _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"overlap"}`), nil) + var failure *CodexWSError + if !errors.As(err, &failure) || failure.Code != "session_busy" { + t.Fatalf("overlap was not rejected: %v", err) + } + switch mode { + case "cancel": + cancel() + case "close": + if err := session.Close(); err != nil { + t.Fatal(err) + } + } + select { + case err := <-finished: + want := context.Canceled + if mode == "timeout" { + want = context.DeadlineExceeded + } + if !errors.Is(err, want) { + t.Fatalf("execution error=%v, want %v", err, want) + } + case <-time.After(2 * time.Second): + t.Fatal("cancellation did not release execution") + } + select { + case <-disconnected: + case <-time.After(time.Second): + t.Fatal("upstream connection leaked") + } + if turns.Load() != 1 { + t.Fatal("canceled request was replayed") + } + if err := session.Close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCodexWSSessionValidationKeepsSession(t *testing.T) { + session := wsTestSession(t, "http://127.0.0.1:1") + for _, payload := range []string{ + `{}`, `null`, `{"model":"gpt-5","previous_response_id":12}`, + `{"model":"gpt-5","previous_response_id":"foreign"}`, + `{"model":"gpt-5","stream_id":"other"}`, `{"model":"gpt-5","background":true}`, + } { + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(payload), nil) + if err == nil || result.DispatchState != CodexWSNotSent { + t.Fatalf("invalid request accepted: %s", payload) + } + if session.closed || session.bound || session.running { + t.Fatal("local rejection changed session state") + } + } + session.options.MaxRequestBytes = 2 + if result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5"}`), nil); err == nil || result.DispatchState != CodexWSNotSent { + t.Fatal("oversized request accepted") + } +} + +func TestCodexWSSessionRejectsInvalidOptions(t *testing.T) { + for _, proxy := range []string{"", "ftp://proxy.invalid", "http://proxy.invalid?password=secret"} { + _, err := NewCodexWSSession(CodexWSSessionOptions{ + CredentialID: "test", Credential: CodexCredential{Type: ProviderCodex, AccessToken: "access", RefreshToken: "refresh", AccountID: "account"}, ProxyURL: proxy, + }) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("invalid proxy was accepted or leaked: %v", err) + } + } +} + +func TestCodexWSSessionEventFailureClosesConnection(t *testing.T) { + for _, mode := range []string{"invalid", "oversize", "consumer", "disconnect"} { + t.Run(mode, func(t *testing.T) { + disconnected := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + defer close(disconnected) + if _, _, err := conn.ReadMessage(); err != nil { + return + } + if mode == "disconnect" { + return + } + payload := wsCompleted("resp_test") + if mode == "invalid" { + payload = []byte(`{"invalid"`) + } + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + t.Error(err) + return + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + if mode == "oversize" { + session.options.MaxEventBytes = 16 + } + _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), func(context.Context, json.RawMessage) error { + if mode == "consumer" { + return errors.New("consumer stopped") + } + if mode == "oversize" || mode == "invalid" { + t.Error("invalid event forwarded") + } + return nil + }) + if err == nil { + t.Fatal("expected failed turn") + } + select { + case <-disconnected: + case <-time.After(time.Second): + t.Fatal("failed session leaked connection") + } + if !session.closed { + t.Fatal("failed session was retained") + } + }) + } +} + +// 在真实 SDK 的首次 Bind 后关闭连接,确定性制造业务写入失败。 +// 仍使用生产 resource 的 Bind/End,验证 SDK 重拨不能再次发送业务帧。 +type closeFirstWSBinding struct { + resource *codexWSResource + binds atomic.Int32 +} + +func (lifecycle *closeFirstWSBinding) Bind(closeConnection func() error) error { + lifecycle.binds.Add(1) + if err := lifecycle.resource.Bind(closeConnection); err != nil { + return err + } + return closeConnection() +} + +func (lifecycle *closeFirstWSBinding) End(reason string) { lifecycle.resource.End(reason) } + +func TestCodexWSSessionGuardsSDKSendRetry(t *testing.T) { + var frames, handshakes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + handshakes.Add(1) + if _, _, err := conn.ReadMessage(); err == nil { + frames.Add(1) + } + })) + defer server.Close() + session := wsTestSession(t, server.URL) + lifecycle := &closeFirstWSBinding{resource: session.resource} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := session.inner.ExecuteStream(ctx, session.auth, cliproxyexecutor.Request{ + Model: "gpt-5", Payload: []byte(`{"model":"gpt-5","input":"hello"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: session.id}, + ExecutionLifecycle: lifecycle, + }) + if err == nil { + t.Fatal("failed send succeeded") + } + if frames.Load() != 0 || handshakes.Load() < 1 || handshakes.Load() > 2 { + t.Fatal("SDK send failure escaped the lifecycle guard") + } + if lifecycle.binds.Load() < 1 || lifecycle.binds.Load() > 2 { + t.Fatal("unexpected SDK binding attempts") + } +} + +func TestCodexWSSessionRejectsSDKReplacementConnection(t *testing.T) { + var frames, handshakes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + handshakes.Add(1) + if _, _, err := conn.ReadMessage(); err != nil { + return + } + frames.Add(1) + if err := conn.WriteMessage(websocket.TextMessage, wsCompleted("resp_first")); err != nil { + return + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + if _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil); err != nil { + t.Fatal(err) + } + session.inner.CloseExecutionSession(session.id) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + // 模拟 SDK 已经重拨,并再次要求接管资源;它必须在写入业务帧之前失败。 + _, err := session.inner.ExecuteStream(ctx, session.auth, cliproxyexecutor.Request{ + Model: "gpt-5", Payload: []byte(`{"model":"gpt-5","input":"hello"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: session.id}, + ExecutionLifecycle: session.resource, + }) + if err == nil || frames.Load() != 1 || handshakes.Load() != 2 { + t.Fatalf("replacement sent a request: frames=%d handshakes=%d error=%v", frames.Load(), handshakes.Load(), err) + } +} + +func TestCodexWSSessionPreservesIncompleteStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.incomplete","response":{"id":"resp_partial","status":"incomplete","usage":{"input_tokens":5,"output_tokens":1},"incomplete_details":{"reason":"max_output_tokens"}}}`)); err != nil { + t.Error(err) + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if err == nil || result.Status != "incomplete" || result.ResponseID != "resp_partial" || !json.Valid(result.Usage) { + t.Fatalf("incomplete status/usage lost: result=%+v error=%v", result, err) + } +} + +func TestCodexWSSessionCancelDuringHandshake(t *testing.T) { + started, disconnected := make(chan struct{}), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + close(disconnected) + })) + defer server.Close() + session := wsTestSession(t, server.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { + _, err := session.ExecuteTurn(ctx, json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + finished <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("handshake did not start") + } + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("handshake error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("handshake ignored cancellation") + } + select { + case <-disconnected: + case <-time.After(time.Second): + t.Fatal("handshake connection leaked") + } +} + +func TestCodexWSSessionUsesExplicitHTTPProxy(t *testing.T) { + var tunnels atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + if err := conn.WriteMessage(websocket.TextMessage, wsCompleted("resp_proxy")); err != nil { + t.Error(err) + } + _, _, _ = conn.ReadMessage() + })) + defer upstream.Close() + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect || r.Host != "codex.invalid:80" { + t.Error("unexpected proxy destination") + w.WriteHeader(http.StatusBadRequest) + return + } + remote, err := net.Dial("tcp", upstream.Listener.Addr().String()) + if err != nil { + t.Error(err) + return + } + defer remote.Close() + client, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer client.Close() + tunnels.Add(1) + if _, err := rw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + t.Error(err) + return + } + if err := rw.Flush(); err != nil { + t.Error(err) + return + } + copied := make(chan struct{}) + go func() { _, _ = io.Copy(remote, rw); _ = remote.Close(); close(copied) }() + _, _ = io.Copy(client, remote) + _ = client.Close() + <-copied + })) + defer proxy.Close() + session := wsTestSession(t, "http://codex.invalid") + session.auth.ProxyURL = proxy.URL + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if err != nil { + t.Fatal(err) + } + if result.ResponseID != "resp_proxy" || tunnels.Load() != 1 { + t.Fatal("explicit proxy was not used") + } + if err := session.Close(); err != nil { + t.Fatal(err) + } +} + +func TestCodexWSSessionHTTPProxyNegotiationHasDeadline(t *testing.T) { + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer proxy.Close() + session := wsTestSession(t, "http://codex.invalid") + session.auth.ProxyURL = proxy.URL + session.options.TurnTimeout = 200 * time.Millisecond + finished := make(chan error, 1) + go func() { + _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + finished <- err + }() + select { + case err := <-finished: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("proxy deadline not classified: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("proxy CONNECT has no deadline") + } +} + +func TestCodexWSSessionContinuesThroughSOCKS5(t *testing.T) { + var turns atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + previous := "" + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + var request struct { + Previous string `json:"previous_response_id"` + } + if json.Unmarshal(payload, &request) != nil || request.Previous != previous { + t.Error("SOCKS5 continuation changed") + return + } + previous = fmt.Sprintf("resp_socks_%d", turns.Add(1)) + if err := conn.WriteMessage(websocket.TextMessage, wsCompleted(previous)); err != nil { + return + } + } + })) + defer upstream.Close() + proxy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + proxyDone := make(chan struct{}) + go func() { + defer close(proxyDone) + conn, err := proxy.Accept() + if err != nil { + if !errors.Is(err, net.ErrClosed) { + t.Error(err) + } + return + } + // 后续轮次必须复用此连接;额外拨号直接失败,避免坏实现卡在 SOCKS 协商。 + _ = proxy.Close() + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Error(err) + return + } + read := func(size int) []byte { + buffer := make([]byte, size) + if _, err := io.ReadFull(conn, buffer); err != nil { + t.Error(err) + return nil + } + return buffer + } + greeting := read(2) + if greeting == nil || greeting[0] != 5 || read(int(greeting[1])) == nil { + return + } + if _, err := conn.Write([]byte{5, 2}); err != nil { + t.Error(err) + return + } + auth := read(2) + if auth == nil || auth[0] != 1 { + t.Error("invalid SOCKS5 authentication") + return + } + username := read(int(auth[1])) + passwordLength := read(1) + if passwordLength == nil { + return + } + password := read(int(passwordLength[0])) + if string(username) != "test-user" || string(password) != "test-password" { + t.Error("SOCKS5 credentials were not forwarded") + return + } + if _, err := conn.Write([]byte{1, 0}); err != nil { + t.Error(err) + return + } + command := read(4) + if command == nil || command[0] != 5 || command[1] != 1 || command[3] != 3 { + t.Error("invalid SOCKS5 CONNECT") + return + } + length := read(1) + if length == nil { + return + } + hostname := read(int(length[0])) + port := read(2) + if string(hostname) != "codex.invalid" || len(port) != 2 || port[0] != 0 || port[1] != 80 { + t.Error("SOCKS5 target changed") + return + } + remote, err := net.Dial("tcp", upstream.Listener.Addr().String()) + if err != nil { + t.Error(err) + return + } + defer remote.Close() + if _, err := conn.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 80}); err != nil { + t.Error(err) + return + } + copied := make(chan struct{}) + go func() { _, _ = io.Copy(remote, conn); _ = remote.Close(); close(copied) }() + _, _ = io.Copy(conn, remote) + _ = conn.Close() + <-copied + }() + t.Cleanup(func() { + _ = proxy.Close() + select { + case <-proxyDone: + case <-time.After(6 * time.Second): + t.Error("SOCKS5 proxy did not close") + } + }) + session := wsTestSession(t, "http://codex.invalid", "socks5://test-user:test-password@"+proxy.Addr().String()) + first, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + if err != nil { + t.Fatal(err) + } + second, err := session.ExecuteTurn(context.Background(), json.RawMessage(fmt.Sprintf(`{"model":"gpt-5","input":"continue","previous_response_id":%q}`, first.ResponseID)), nil) + if err != nil { + t.Fatal(err) + } + if second.ResponseID != "resp_socks_2" || turns.Load() != 2 { + t.Fatal("SOCKS5 session did not complete two turns") + } + if err := session.Close(); err != nil { + t.Fatal(err) + } +} + +func TestCodexWSSessionPreservesDoneStatus(t *testing.T) { + for _, status := range []string{"completed", "failed", "incomplete", "cancelled", "in_progress", ""} { + t.Run(status, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + body := fmt.Sprintf(`{"type":"response.done","response":{"id":"resp_done","object":"response","status":%q,"output":[],"usage":{"input_tokens":3,"output_tokens":1}}}`, status) + if err := conn.WriteMessage(websocket.TextMessage, []byte(body)); err != nil { + t.Error(err) + return + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + wantFailure := status != "completed" + if result.Status != status || (err != nil) != wantFailure || result.ResponseID != "resp_done" || !json.Valid(result.Usage) { + t.Fatalf("done status=%q result=%+v error=%v", status, result, err) + } + session.mu.Lock() + closed := session.closed + session.mu.Unlock() + if closed != wantFailure { + t.Fatalf("done status=%q closed=%v", status, closed) + } + }) + } +} + +func TestCodexWSSessionPreservesDoneErrorCode(t *testing.T) { + for _, test := range []struct { + name string + code string + want string + }{ + {"safe", "rate_limit_exceeded", "rate_limit_exceeded"}, + {"unsafe", "invalid code\nprivate detail", ""}, + {"empty", "", ""}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close() + if _, _, err := conn.ReadMessage(); err != nil { + return + } + body := fmt.Sprintf(`{"type":"response.done","response":{"id":"resp_failed","object":"response","status":"failed","output":[],"error":{"code":%q,"message":"synthetic-upstream-detail"}}}`, test.code) + if err := conn.WriteMessage(websocket.TextMessage, []byte(body)); err != nil { + t.Error(err) + return + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + session := wsTestSession(t, server.URL) + result, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) + var failure *CodexWSError + if !errors.As(err, &failure) || result.Status != "failed" || failure.UpstreamCode != test.want { + t.Fatalf("failed done error code lost or unsafe: status=%q error=%+v", result.Status, failure) + } + if strings.Contains(err.Error(), "synthetic-upstream-detail") { + t.Fatal("upstream error message leaked") + } + }) + } +} diff --git a/third_party/cpaembedded/embedded/embedded.go b/third_party/cpaembedded/embedded/embedded.go index da8b6bf5..fe973151 100644 --- a/third_party/cpaembedded/embedded/embedded.go +++ b/third_party/cpaembedded/embedded/embedded.go @@ -1,6 +1,7 @@ // Package embedded exposes the smallest CPA surface required by GPT-Load. // It deliberately excludes CPA's manager, selector, retry loop, server, watcher, -// file store, websocket executor, and automatic credential refresh. +// file store and automatic credential refresh. The separate Codex WS session +// facade is explicit-only and does not replace the HTTP executor. package embedded import ( diff --git a/third_party/cpaembedded/go.mod b/third_party/cpaembedded/go.mod index cd3a9488..49b2b745 100644 --- a/third_party/cpaembedded/go.mod +++ b/third_party/cpaembedded/go.mod @@ -5,7 +5,9 @@ go 1.26.6 require ( github.com/andybalholm/brotli v1.2.3 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/router-for-me/CLIProxyAPI/v7 v7.2.151 + github.com/sirupsen/logrus v1.9.3 github.com/tidwall/gjson v1.19.0 github.com/tidwall/sjson v1.2.5 ) @@ -25,7 +27,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -37,7 +38,6 @@ require ( github.com/pierrec/xxHash v0.1.5 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect