feat(codex): 添加独立上游 WebSocket Session 封装 (#612)

* feat(codex): 添加独立上游 WebSocket 会话封装

* fix(codex): 放开 SOCKS5 代理并修正超时分类

* fix(codex): 修正 WS 终态并脱敏断连日志

* fix(codex): 保留失败终态的上游错误码

* fix(codex): 提前注册 WebSocket 日志脱敏
This commit is contained in:
tbphp authored and GitHub committed 2026-09-10 14:18:36 +08:00
1 parent 7cbd2e672d
commit 7d80a981d1
12 files changed
+1825 -8

No files matched your search

+6 -2
View File
@@ -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 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 Codex, Claude, Antigravity, and xAI OAuth and HTTP executor code. GPT-Load retains ownership of
credential storage, account selection, retry, health, affinity, logging, and credential storage, account selection, retry, health, affinity, logging, and
usage policy; the embedded adapter does not use CLIProxyAPI's manager, pool, usage policy; the embedded adapter does not use CLIProxyAPI's manager, account pool,
file store, WebSocket executor, fallback, or automatic retry. 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`. The complete MIT License text is distributed in `LICENSES/MIT.txt`.
@@ -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}
}
@@ -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)
}
}
+86
View File
@@ -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)
}
}
}
+81 -3
View File
@@ -14,6 +14,7 @@ quota policy. This bridge only exposes:
- strict CPA Codex JSON parsing; - strict CPA Codex JSON parsing;
- one-shot, context-aware token refresh; - one-shot, context-aware token refresh;
- the stateless Codex HTTP executor and its explicit local CountTokens estimator; - 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. - one-shot model and usage observation requests.
- Claude browser OAuth challenge creation and one-shot code exchange; - Claude browser OAuth challenge creation and one-shot code exchange;
- strict CPA Claude JSON parsing and stable device identity normalization; - 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, translators, without CPA manager, refresh, retry, fallback,
WebSocket, image, or video execution paths. WebSocket, image, or video execution paths.
It intentionally excludes CPA Manager, selector, pool, file store, server, It intentionally excludes CPA Manager, selector, account pool, file store, server,
watcher, WebSocket/Auto executors, fallback, and internal retry loops. 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 ## Pinned upstream
@@ -53,7 +129,9 @@ bumps:
executor, translation, headers, identity, model discovery, and usage observation code. executor, translation, headers, identity, model discovery, and usage observation code.
2. Update the CPA version in this module and run `go mod tidy` here. 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 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 4. Run `go test -count=1 ./...` in this module, then GPT-Load's full
`make check` from the repository root. `make check` from the repository root.
5. With authorized disposable CPA credentials, run the applicable opt-in live 5. With authorized disposable CPA credentials, run the applicable opt-in live
+459
View File
@@ -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
}
@@ -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")
}
}
+45
View File
@@ -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
}
@@ -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())
}
})
}
}
+814
View File
@@ -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")
}
})
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
// Package embedded exposes the smallest CPA surface required by GPT-Load. // Package embedded exposes the smallest CPA surface required by GPT-Load.
// It deliberately excludes CPA's manager, selector, retry loop, server, watcher, // 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 package embedded
import ( import (
+2 -2
View File
@@ -5,7 +5,9 @@ go 1.26.6
require ( require (
github.com/andybalholm/brotli v1.2.3 github.com/andybalholm/brotli v1.2.3
github.com/google/uuid v1.6.0 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/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/gjson v1.19.0
github.com/tidwall/sjson v1.2.5 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/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // 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/pierrec/xxHash v0.1.5 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/refraction-networking/utls v1.8.2 // 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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect