feat(codex): 添加独立上游 WebSocket Session 封装 (#612)
* feat(codex): 添加独立上游 WebSocket 会话封装 * fix(codex): 放开 SOCKS5 代理并修正超时分类 * fix(codex): 修正 WS 终态并脱敏断连日志 * fix(codex): 保留失败终态的上游错误码 * fix(codex): 提前注册 WebSocket 日志脱敏
This commit is contained in:
1 parent
7cbd2e672d
commit
7d80a981d1
12 files changed
+1825
-8
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+81
-3
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user