fix(codex): 升级 CPA 并固定上游版本声明 (#619)

This commit is contained in:
tbphp authored and GitHub committed 2026-09-11 00:08:04 +08:00
1 parent f091528bdb
commit abc6483d0e
19 files changed
+410 -89

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ The complete Apache License 2.0 text is distributed in
## CLIProxyAPI
- Module: `github.com/router-for-me/CLIProxyAPI/v7`
- Version: `v7.2.151`
- Version: `v7.2.157`
- Copyright: 2025-2005.9 Luis Pater; 2025.9-present Router-For.ME
- License: MIT License
+1 -1
View File
@@ -98,7 +98,7 @@ require (
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151 // indirect
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
+2 -2
View File
@@ -207,8 +207,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151 h1:b713YWa1uaEFmM3cgXiUpbdV1nC9NMmyfnkjUXFrCeg=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157 h1:ctp0AJ+QmK9SEcYVOue5AOtGQsXdDw2d/PJvYjBW2rg=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@@ -0,0 +1,124 @@
package cpa
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"gpt-load/internal/execution"
"gpt-load/internal/health"
)
func TestCodexUpgradePreservesQuotaAndRejectionPolicy(t *testing.T) {
for _, test := range []struct {
name string
stream bool
status int
errorJSON string
wantStatus int
wantHint execution.FailureHint
wantScope execution.ErrorScope
wantReplay bool
wantEffect health.Effect
}{
{
name: "unary quota remains credential model scoped", status: http.StatusTooManyRequests,
errorJSON: `{"type":"usage_limit_reached","resets_in_seconds":60}`,
wantStatus: http.StatusTooManyRequests, wantHint: execution.FailureHintRateLimited,
wantScope: execution.ErrorScopeModel, wantReplay: true, wantEffect: health.EffectCooldownModel,
},
{
name: "stream quota remains credential model scoped", stream: true, status: http.StatusTooManyRequests,
errorJSON: `{"type":"usage_limit_reached","resets_in_seconds":60}`,
wantStatus: http.StatusTooManyRequests, wantHint: execution.FailureHintRateLimited,
wantScope: execution.ErrorScopeModel, wantReplay: true, wantEffect: health.EffectCooldownModel,
},
{
name: "model capacity code is not quota exhaustion", stream: true, status: http.StatusOK,
errorJSON: `{"type":"server_error","code":"model_at_capacity","message":"model_at_capacity"}`,
wantStatus: http.StatusTooManyRequests, wantHint: execution.FailureHintCandidateUnavailable,
wantScope: execution.ErrorScopeModel, wantReplay: true, wantEffect: health.EffectNone,
},
{
name: "explicit retryable bootstrap error", stream: true, status: http.StatusOK,
errorJSON: `{"type":"server_error","code":"server_error","message":"An error occurred. You can retry your request."}`,
wantStatus: http.StatusServiceUnavailable, wantHint: execution.FailureHintHostError,
wantScope: execution.ErrorScopeGroup, wantReplay: true, wantEffect: health.EffectSkipGroup,
},
{
name: "ordinary server error does not gain replay proof", stream: true, status: http.StatusOK,
errorJSON: `{"type":"server_error","code":"server_error","message":"Unknown server failure"}`,
wantStatus: http.StatusBadGateway, wantHint: execution.FailureHintHostError,
wantScope: execution.ErrorScopeGroup, wantEffect: health.EffectSkipGroup,
},
} {
t.Run(test.name, func(t *testing.T) {
adapter, _, _, service, row := newAdapterFixture(t, credentialJSON("access", "refresh", time.Now().Add(time.Hour)))
body := `{"error":` + test.errorJSON + `}`
contentType := "application/json"
if test.status == http.StatusOK {
body = "data: " + `{"type":"response.failed","response":{"error":` + test.errorJSON + `}}` + "\n\n"
contentType = "text/event-stream"
}
transport := roundTripperFunc(func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: test.status, Header: http.Header{"Content-Type": {contentType}},
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})
ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport))
spec := validSpec(t, row, service)
var evidence *execution.ErrorEvidence
var status int
var dispatch execution.DispatchState
if test.stream {
events := 0
result := adapter.ExecuteStream(ctx, spec, func(execution.StreamEvent) error { events++; return nil })
evidence, status, dispatch = result.Error, result.StatusCode, result.DispatchState
if test.wantReplay && events != 0 {
t.Fatalf("rejection emitted %d downstream events", events)
}
} else {
result := adapter.Execute(ctx, spec)
evidence, status, dispatch = result.Error, result.StatusCode, result.DispatchState
}
if evidence == nil || status != test.wantStatus || evidence.Hint != test.wantHint || evidence.ScopeHint != test.wantScope {
t.Fatalf("status=%d evidence=%+v", status, evidence)
}
if got := evidence.ReplaySafety == execution.ReplaySafetyRejectedBeforeProcessing; got != test.wantReplay {
t.Fatalf("replay proof=%t, want %t; evidence=%+v", got, test.wantReplay, evidence)
}
decision := health.JudgeExecution(health.ExecutionAttempt{
DispatchState: dispatch, StatusCode: status, Evidence: evidence, Now: time.Now(),
}, health.DecisionContext{Method: http.MethodPost, Operation: spec.Operation})
if decision.Effect != test.wantEffect {
t.Fatalf("health decision=%+v, want effect %s", decision, test.wantEffect)
}
if got := decision.Retry == health.RetryNextCandidate; got != test.wantReplay {
t.Fatalf("health retry=%s, want retryable=%t", decision.Retry, test.wantReplay)
}
})
}
}
func TestCodexRetryableErrorAfterOutputDoesNotGainReplayProof(t *testing.T) {
adapter, _, _, service, row := newAdapterFixture(t, credentialJSON("access", "refresh", time.Now().Add(time.Hour)))
body := "data: " + `{"type":"response.created","response":{"id":"resp_partial","status":"in_progress"}}` + "\n\n" +
"data: " + `{"type":"response.output_text.delta","response_id":"resp_partial","output_index":0,"content_index":0,"delta":"partial"}` + "\n\n" +
"data: " + `{"type":"response.failed","response":{"id":"resp_partial","error":{"type":"server_error","code":"server_error","message":"You can retry your request."}}}` + "\n\n"
transport := roundTripperFunc(func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(body)), Request: request,
}, nil
})
ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport))
events := 0
result := adapter.ExecuteStream(ctx, validSpec(t, row, service), func(execution.StreamEvent) error { events++; return nil })
if events == 0 || result.Error == nil || result.Error.ReplaySafety == execution.ReplaySafetyRejectedBeforeProcessing {
t.Fatalf("events=%d evidence=%+v", events, result.Error)
}
}
+29 -19
View File
@@ -16,8 +16,7 @@ import (
)
func TestCodexGatewayRequestIdentity(t *testing.T) {
const defaultUA = "codex-tui/0.146.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.146.0)"
const lunaUA = "codex-tui/0.144.0 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.144.0)"
const defaultUA = "codex-tui/0.153.3 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.153.3)"
const configuredUA = "codex-tui/0.200.0 (Mac OS 26.5.0; arm64)"
tests := []struct {
name string
@@ -31,65 +30,76 @@ func TestCodexGatewayRequestIdentity(t *testing.T) {
wantSession string
}{
{
name: "preserve provider default and align client version",
name: "client identity cannot change fixed version",
headers: http.Header{"User-Agent": {"browser/1.0"}, "Originator": {"browser"}, "Version": {"9.9.9"}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.146.0",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "configured identity wins",
name: "configured UA cannot override CPA identity",
headers: http.Header{"User-Agent": {"browser/1.0"}, "Version": {"9.9.9"}},
rules: state.HeaderRules{Set: map[string]string{"user-agent": configuredUA, "originator": "configured-client"}},
wantUA: configuredUA, wantOriginator: "configured-client", wantVersion: "0.200.0",
wantUA: defaultUA, wantOriginator: "configured-client", wantVersion: "0.153.3",
},
{
name: "preserve model default and align version", model: "gpt-5.6-luna",
headers: http.Header{"Version": {"9.9.9"}},
wantUA: lunaUA, wantOriginator: "codex-tui", wantVersion: "0.144.0",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "configured identity wins over model default", model: "gpt-5.6-luna",
name: "configured UA cannot override model default", model: "gpt-5.6-luna",
rules: state.HeaderRules{Set: map[string]string{"User-Agent": configuredUA}},
wantUA: configuredUA, wantOriginator: "codex-tui", wantVersion: "0.200.0",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "custom UA does not inherit unrelated version",
name: "non Codex configured UA is ignored",
headers: http.Header{"Version": {"9.9.9"}},
rules: state.HeaderRules{Set: map[string]string{"User-Agent": "custom-client/1.0"}},
wantUA: "custom-client/1.0", wantOriginator: "codex-tui",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "explicit version remains authoritative",
name: "configured version cannot override fixed version",
rules: state.HeaderRules{Set: map[string]string{"User-Agent": "custom-client/1.0", "Version": "custom-version"}},
wantUA: "custom-client/1.0", wantOriginator: "codex-tui", wantVersion: "custom-version",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "explicit removal survives SDK defaults",
name: "removal cannot remove fixed identity",
headers: http.Header{"User-Agent": {"browser/1.0"}, "Originator": {"browser"}, "Version": {"9.9.9"}},
rules: state.HeaderRules{Remove: []string{"User-Agent", "Originator", "Version"}},
wantUA: defaultUA, wantVersion: "0.153.3",
},
{
name: "empty configured identity is ignored",
rules: state.HeaderRules{Set: map[string]string{"User-Agent": "", "Version": ""}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "client UA version is ignored",
headers: http.Header{"User-Agent": {"codex_cli_rs/0.999.0"}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3",
},
{
name: "underscore session is preserved", model: "gpt-5.6-luna",
headers: http.Header{"Session_id": {"client-session"}},
wantUA: lunaUA, wantOriginator: "codex-tui", wantVersion: "0.144.0", wantSession: "client-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3", wantSession: "client-session",
},
{
name: "hyphen session wins over alias",
headers: http.Header{"Session_id": {"alias-session"}, "Session-Id": {"canonical-session"}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.146.0", wantSession: "canonical-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3", wantSession: "canonical-session",
},
{
name: "blank canonical session falls back to alias",
headers: http.Header{"Session-Id": {" "}, "Session_id": {" alias-session "}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.146.0", wantSession: "alias-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3", wantSession: "alias-session",
},
{
name: "prompt cache remains the session fallback", promptCacheKey: "cache-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.146.0", wantSession: "cache-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3", wantSession: "cache-session",
},
{
name: "session alias has the same precedence as canonical header", promptCacheKey: "cache-session",
headers: http.Header{"Session_id": {"client-session"}},
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.146.0", wantSession: "client-session",
wantUA: defaultUA, wantOriginator: "codex-tui", wantVersion: "0.153.3", wantSession: "client-session",
},
}
for _, stream := range []bool{false, true} {
+14 -9
View File
@@ -439,8 +439,13 @@ func (*codexProviderBridge) ClassifyError(
}
func codexBootstrapCapacityRejection(err error) bool {
_, codeValue := codexErrorTypeCode(err)
return codexBootstrapOverload(codeValue) || codexBootstrapRateLimit(codeValue)
typeValue, codeValue := codexErrorTypeCode(err)
if codexBootstrapOverload(codeValue) || codexBootstrapRateLimit(codeValue) || codexModelCapacityError(err) {
return true
}
// 仅在 ExecuteStream 返回首包前错误时调用;普通 server_error 不提供重试证据。
return (strings.EqualFold(typeValue, "server_error") || strings.EqualFold(codeValue, "server_error")) &&
strings.Contains(strings.ToLower(err.Error()), "you can retry your request")
}
func codexBootstrapOverload(codeValue string) bool {
@@ -468,6 +473,10 @@ func codexErrorTypeCode(err error) (string, string) {
}
func codexModelCapacityError(err error) bool {
_, code := codexErrorTypeCode(err)
if strings.EqualFold(code, "model_at_capacity") || strings.EqualFold(code, "model_is_at_capacity") {
return true
}
for current := err; current != nil; current = errors.Unwrap(current) {
message := strings.TrimSpace(current.Error())
var payload struct {
@@ -483,13 +492,9 @@ func codexModelCapacityError(err error) bool {
message = payload.Message
}
}
switch strings.ToLower(strings.TrimSpace(message)) {
case "selected model is at capacity",
"selected model is at capacity. please try a different model",
"selected model is at capacity. please try a different model.",
"the selected model is at capacity. please try a different model.",
"model is at capacity. please try a different model",
"model is at capacity. please try a different model.":
lower := strings.ToLower(message)
if strings.Contains(lower, "model_at_capacity") || strings.Contains(lower, "model_is_at_capacity") ||
strings.Contains(lower, "model") && strings.Contains(lower, "at capacity") {
return true
}
}
+6
View File
@@ -162,6 +162,12 @@ func codexWebsocketEvidence(ctx context.Context, err error) *execution.ErrorEvid
e.Kind = execution.ErrorKindInvalidRequest
e.OriginHint = execution.ErrorOriginInternal
}
if strings.EqualFold(failure.UpstreamCode, "model_at_capacity") || strings.EqualFold(failure.UpstreamCode, "model_is_at_capacity") {
e.Hint = execution.FailureHintCandidateUnavailable
e.ScopeHint = execution.ErrorScopeModel
// WS 错误未携带生成阶段证据;容量不足不计为额度耗尽,也不据此重放。
e.ReplaySafety = execution.ReplaySafetyUnknown
}
}
if ctx.Err() != nil && e.StatusCode == 0 && e.Kind != execution.ErrorKindProvider {
e.Kind = execution.ErrorKindCanceled
+21
View File
@@ -2,14 +2,35 @@ package cpa
import (
"context"
"net/http"
"testing"
"time"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/subscription/providers/codex"
)
func TestWebsocketModelCapacityDoesNotApplyQuotaCooldown(t *testing.T) {
for _, code := range []string{"model_at_capacity", "model_is_at_capacity"} {
evidence := codexWebsocketEvidence(t.Context(), &codex.WSError{
Code: "upstream_error", UpstreamCode: code, HTTPStatus: http.StatusTooManyRequests,
DispatchState: codex.WSMaybeSent,
})
if evidence.Hint != execution.FailureHintCandidateUnavailable || evidence.ScopeHint != execution.ErrorScopeModel ||
evidence.ReplaySafety != execution.ReplaySafetyUnknown {
t.Fatalf("capacity evidence=%+v", evidence)
}
decision := health.JudgeExecution(health.ExecutionAttempt{
DispatchState: execution.DispatchMaybeSent, StatusCode: http.StatusTooManyRequests, Evidence: evidence,
}, health.DecisionContext{Method: http.MethodPost, Operation: execution.OperationResponsesCreate})
if decision.Effect != health.EffectNone || decision.Retry != health.RetryNone {
t.Fatalf("capacity rejection caused cooldown or unsafe replay: %+v", decision)
}
}
}
func TestWebsocketHTTPErrorEvidenceSurvivesCancellation(t *testing.T) {
for _, status := range []int{401, 429} {
ctx, cancel := context.WithCancel(context.Background())
@@ -1821,6 +1821,37 @@ func TestNewExecutionAttemptSpecKeepsContinuityPrivate(t *testing.T) {
}
}
func TestOtherChannelsRetainIdentityHeaderRules(t *testing.T) {
for _, test := range []struct {
name string
rules state.HeaderRules
wantUA string
wantVersion string
}{
{name: "client headers", wantUA: "client/1.0", wantVersion: "1.0"},
{
name: "configured headers",
rules: state.HeaderRules{Set: map[string]string{"User-Agent": "configured/2.0", "Version": "2.0"}},
wantUA: "configured/2.0", wantVersion: "2.0",
},
{name: "removed headers", rules: state.HeaderRules{Remove: []string{"User-Agent", "Version"}}},
} {
t.Run(test.name, func(t *testing.T) {
input := executionForwardInput()
input.Request.Header.Set("User-Agent", "client/1.0")
input.Request.Header.Set("Version", "1.0")
input.Group.HeaderRules = test.rules
spec, err := newExecutionAttemptSpec(input)
if err != nil {
t.Fatal(err)
}
if spec.Header.Get("User-Agent") != test.wantUA || spec.Header.Get("Version") != test.wantVersion {
t.Fatalf("non-Codex headers changed: UA=%q Version=%q", spec.Header.Get("User-Agent"), spec.Header.Get("Version"))
}
})
}
}
func TestNewExecutionAttemptSpecCarriesResponsesStoreDowngrade(t *testing.T) {
input := responsesExecutionForwardInput()
input.RouteRequirement = execution.RouteRequirementAny
+27 -14
View File
@@ -38,21 +38,34 @@ watcher, and Auto executors. The Codex WS facade blocks HTTP fallback and busine
request replay. The gateway explicitly wires this facade into its native WS route;
the existing HTTP executor remains separate.
## Codex HTTP request identity
## Codex request identity
The HTTP bridge preserves CPA's default and model-specific User-Agent values.
Explicit GPT-Load request-header rules for `User-Agent`, `Originator`, and
`Version` take precedence after CPA constructs the upstream request, including
explicit removal. Downstream headers alone do not override CPA's default identity.
Unless `Version` is explicitly configured, it follows the final `codex-tui` or
`codex_cli_rs` User-Agent version; an unrecognized custom UA drops the unrelated
client version.
Codex HTTP inference (including streaming and images) and WebSocket handshakes
use the pinned CPA default User-Agent. `Version` is fixed to the matching
`codexClientVersion` constant, currently `0.153.3`. Downstream and GPT-Load group
header rules cannot override, clear, or remove these two identity headers.
This restriction applies only to Codex; other providers retain their header rules.
HTTP continues to honor explicit `Originator` rules, including empty values and
removal. WebSocket retains the SDK's existing originator handling.
Model and account observation requests use the same version for their User-Agent,
Version header, and models `client_version` query parameter. CPA's default UA
constant is private, so dependency updates must keep our one version constant in
sync; HTTP, image, WebSocket, and observation tests check the outgoing values.
Both `Session-Id` and `Session_id` are accepted, with `Session-Id` taking precedence
if both exist. The upstream receives one `Session-Id`; explicit client sessions
keep CPA's existing precedence over its prompt-cache fallback. This applies to
HTTP inference, including streaming and images; account queries and the independent
WebSocket facade keep their existing behavior.
if both exist. HTTP sends one `Session-Id`, retaining the existing precedence over
CPA's prompt-cache fallback. WebSocket keeps CPA's wire spelling and connection
reuse behavior.
Both Codex executors explicitly enable CPA's `ModelLevelCooling`. This keeps
`usage_limit_reached` from acquiring CPA's new credential-wide scope, preserving
GPT-Load's credential-plus-model cooldown policy. GPT-Load still owns scheduling,
retries, and health state. Pre-generation capacity rejections and explicitly
retryable `server_error` responses are classified in the HTTP bridge; ordinary
server failures do not acquire safe-replay evidence. WebSocket model-capacity
error codes do not trigger quota cooldowns; without generation-stage proof, the
existing conservative replay policy remains in effect.
## Codex WebSocket Session
@@ -104,7 +117,7 @@ capability to GPT-Load callers. The existing `NewExecutor` remains HTTP-only.
use updated timeout settings. `Done` closes when the Session is invalidated.
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
**after SDK reading**: CPA v7.2.157 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.
@@ -135,7 +148,7 @@ sent only in the first. `CPA_LIVE_CODEX_WS_PROXY_URL` defaults to `direct`;
## Pinned upstream
- Module: `github.com/router-for-me/CLIProxyAPI/v7`
- Version: `v7.2.151`
- Version: `v7.2.157`
The root module consumes this bridge through a local `replace`; releases still
resolve CPA itself at the exact version recorded in both `go.mod` files and
+31
View File
@@ -760,6 +760,37 @@ func TestAntigravityExecutionOnlyBridgeScopesConnectionsByCredential(t *testing.
}
}
func TestAntigravityRejectsForeignCompactionBeforeDispatch(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
executor := newAntigravityHTTPExecutor(server.URL)
credential := AntigravityCredential{
Type: ProviderAntigravity, AccessToken: "access-secret", RefreshToken: "refresh-secret",
AccountID: "google-account-one", Email: "owner@example.com", ProjectID: "project-one",
Expire: "2030-01-01T00:00:00Z",
}
request := ExecuteRequest{
Model: "gemini-live", Format: "openai-response",
Payload: []byte(`{"model":"gemini-live","input":[{"type":"compaction","encrypted_content":"foreign-capsule"}]}`),
}
for _, stream := range []bool{false, true} {
var err error
if stream {
_, err = executor.ExecuteStreamCanonical(t.Context(), "credential-one", credential, request)
} else {
_, err = executor.ExecuteCanonical(t.Context(), "credential-one", credential, request)
}
var failure *AntigravityExecutionError
if !errors.As(err, &failure) || failure.StatusCode() != http.StatusBadRequest || calls.Load() != 0 {
t.Fatalf("stream=%t error=%v upstream calls=%d", stream, err, calls.Load())
}
}
}
func TestAntigravityExecutionOnlyBridgeRejectsRedirects(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
+17 -27
View File
@@ -5,7 +5,10 @@ import (
"strings"
)
// codexHeadersRoundTripper 在 CPA 默认值与模型覆盖之后应用显式身份规则。
// codexClientVersion 必须与固定 CPA 依赖的默认及模型 UA 版本一致,由出站请求测试校验。
const codexClientVersion = "0.153.3"
// codexHeadersRoundTripper 保留 CPA 的 UA,并固定版本及 HTTP 会话头。
type codexHeadersRoundTripper struct {
base http.RoundTripper
source http.Header
@@ -14,30 +17,19 @@ type codexHeadersRoundTripper struct {
func (transport codexHeadersRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
request = request.Clone(request.Context())
versionConfigured := false
for _, name := range transport.configured {
name = http.CanonicalHeaderKey(name)
switch name {
case "User-Agent", "Originator", "Version":
case "Originator":
value, present := codexHeaderValue(transport.source, name)
if present || name == "User-Agent" {
// 空 UA 显式禁止 net/http 重新补入 Go 默认 UA。
if present {
request.Header.Set(name, value)
} else {
request.Header.Del(name)
}
if name == "Version" {
versionConfigured = true
}
}
}
if !versionConfigured {
if version := codexUserAgentVersion(request.Header.Get("User-Agent")); version != "" {
request.Header.Set("Version", version)
} else {
request.Header.Del("Version")
}
}
request.Header.Set("Version", codexClientVersion)
normalizeCodexSessionHeader(request.Header)
// CPA 的直接图片路径不读取 opts.Headers,补回调用者显式提供的会话。
if request.Header.Get("Session-Id") == "" {
@@ -48,20 +40,18 @@ func (transport codexHeadersRoundTripper) RoundTrip(request *http.Request) (*htt
return transport.base.RoundTrip(request)
}
func codexUserAgentVersion(userAgent string) string {
fields := strings.Fields(userAgent)
if len(fields) == 0 {
return ""
}
product, version, _ := strings.Cut(fields[0], "/")
if product == "codex-tui" || product == "codex_cli_rs" {
return version
}
return ""
}
func normalizedCodexHeaders(headers http.Header) http.Header {
cloned := headers.Clone()
if cloned == nil {
cloned = make(http.Header)
}
// 仅 Codex 忽略客户端及分组规则提供的版本身份,UA 由 CPA 生成。
for name := range cloned {
if strings.EqualFold(name, "User-Agent") || strings.EqualFold(name, "Version") {
delete(cloned, name)
}
}
cloned.Set("Version", codexClientVersion)
normalizeCodexSessionHeader(cloned)
return cloned
}
+22 -6
View File
@@ -8,7 +8,8 @@ import (
"testing"
)
func TestCodexHTTPIdentityOnImagesAndWireRemoval(t *testing.T) {
func TestCodexHTTPFixedIdentityOnImagesAndWire(t *testing.T) {
const defaultUA = "codex-tui/0.153.3 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.153.3)"
const customUA = "codex-tui/0.200.0 (Mac OS 26.5.0; arm64)"
for _, test := range []struct {
name string
@@ -17,7 +18,7 @@ func TestCodexHTTPIdentityOnImagesAndWireRemoval(t *testing.T) {
want http.Header
}{
{
name: "images honor identity and session headers",
name: "images keep fixed identity and explicit session",
request: ExecuteRequest{
Model: "gpt-image-2", Format: "openai-image", RequestPath: "/v1/images/generations",
Payload: []byte(`{"model":"gpt-image-2","prompt":"draw a circle"}`),
@@ -25,30 +26,45 @@ func TestCodexHTTPIdentityOnImagesAndWireRemoval(t *testing.T) {
ConfiguredHeaders: []string{"User-Agent", "Originator"},
},
response: `{"created":1,"data":[{"b64_json":"aA=="}]}`,
want: http.Header{"User-Agent": {customUA}, "Originator": {"custom-client"}, "Version": {"0.200.0"}, "Session-Id": {"image-session"}},
want: http.Header{"User-Agent": {defaultUA}, "Originator": {"custom-client"}, "Version": {"0.153.3"}, "Session-Id": {"image-session"}},
},
{
name: "explicit empty identity headers remain present",
name: "empty version rules preserve fixed identity and empty originator",
request: ExecuteRequest{
Model: "gpt-5", Format: "openai-response", Payload: []byte(`{"model":"gpt-5","input":"hello"}`),
Headers: http.Header{"User-Agent": {customUA}, "Originator": {""}, "Version": {""}},
ConfiguredHeaders: []string{"User-Agent", "Originator", "Version"},
},
response: "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\",\"output\":[]}}\n\n",
want: http.Header{"User-Agent": {customUA}, "Originator": {""}, "Version": {""}},
want: http.Header{"User-Agent": {defaultUA}, "Originator": {""}, "Version": {"0.153.3"}},
},
{
name: "removed UA is not replaced by Go transport",
name: "removed identity remains fixed",
request: ExecuteRequest{
Model: "gpt-5", Format: "openai-response", Payload: []byte(`{"model":"gpt-5","input":"hello"}`),
ConfiguredHeaders: []string{"User-Agent", "Originator", "Version"},
},
response: "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\",\"output\":[]}}\n\n",
want: http.Header{"User-Agent": {defaultUA}, "Version": {"0.153.3"}},
},
{
name: "image 2.5 uses existing direct image execution",
request: ExecuteRequest{
Model: "gpt-image-2.5", Format: "openai-image", RequestPath: "/v1/images/generations",
Payload: []byte(`{"model":"gpt-image-2.5","prompt":"draw a circle"}`),
Headers: http.Header{"User-Agent": {customUA}, "Version": {"9.9.9"}},
ConfiguredHeaders: []string{"User-Agent", "Version"},
},
response: `{"created":1,"data":[{"b64_json":"aA=="}]}`,
want: http.Header{"User-Agent": {defaultUA}, "Originator": {"codex-tui"}, "Version": {"0.153.3"}},
},
} {
t.Run(test.name, func(t *testing.T) {
capturedHeaders := make(chan http.Header, 1)
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if test.request.Format == "openai-image" && r.URL.Path != "/backend-api/codex/images/generations" {
t.Errorf("image path = %q", r.URL.Path)
}
capturedHeaders <- r.Header.Clone()
if test.request.Format == "openai-image" {
w.Header().Set("Content-Type", "application/json")
+4 -2
View File
@@ -121,7 +121,9 @@ func NewCodexWSSession(options CodexWSSessionOptions) (*CodexWSSession, error) {
options.Headers = options.Headers.Clone()
session := &CodexWSSession{
auth: auth, id: codexWSSessionIDPrefix + uuid.NewString(), options: options, closeDone: make(chan struct{}),
inner: internalexecutor.NewCodexWebsocketsExecutor(&internalconfig.Config{}),
inner: internalexecutor.NewCodexWebsocketsExecutor(&internalconfig.Config{
Codex: internalconfig.CodexConfig{ModelLevelCooling: true},
}),
}
session.resource = &codexWSResource{session: session}
return session, nil
@@ -203,7 +205,7 @@ func (s *CodexWSSession) ExecuteTurn(ctx context.Context, payload json.RawMessag
stream, executionErr := s.inner.ExecuteStream(turnCtx, s.auth, cliproxyexecutor.Request{
Model: model, Payload: append([]byte(nil), payload...), Format: sdktranslator.FormatOpenAIResponse,
}, cliproxyexecutor.Options{
Stream: true, Headers: s.options.Headers.Clone(), SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse,
Stream: true, Headers: normalizedCodexHeaders(s.options.Headers), SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse,
Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: s.id},
ExecutionLifecycle: s.resource, WebSocketResponseObserver: observation.observe,
})
@@ -935,3 +935,60 @@ func TestCodexWSSessionPreservesDoneErrorCode(t *testing.T) {
})
}
}
func TestCodexWSSessionFixedIdentity(t *testing.T) {
const wantUA = "codex-tui/0.153.3 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.153.3)"
for _, model := range []string{"gpt-6-astra", "gpt-5.6-luna"} {
for _, test := range []struct {
name string
headers http.Header
}{
{name: "absent"},
{name: "supplied", headers: http.Header{"Version": {"9.9.9"}, "User-Agent": {"codex_cli_rs/0.200.0"}}},
{name: "empty", headers: http.Header{"Version": {""}, "User-Agent": {""}}},
{name: "case variants", headers: http.Header{"version": {"9.9.9"}, "user-agent": {"custom-client/1.0"}}},
} {
t.Run(model+"/"+test.name, func(t *testing.T) {
var handshakes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handshakes.Add(1)
if r.Header.Get("Version") != "0.153.3" || r.Header.Get("User-Agent") != wantUA {
t.Errorf("handshake identity: version=%q UA=%q", r.Header.Get("Version"), r.Header.Get("User-Agent"))
}
if r.Header.Get("Authorization") != "Bearer test-access" {
t.Error("identity normalization changed credential")
}
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for turn := 0; turn < 2; turn++ {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
if err := conn.WriteMessage(websocket.TextMessage, wsCompleted(fmt.Sprintf("resp_fixed_%d", turn))); err != nil {
return
}
}
_, _, _ = conn.ReadMessage()
}))
defer server.Close()
session := wsTestSession(t, server.URL)
session.options.Headers = test.headers.Clone()
for turn := 0; turn < 2; turn++ {
payload := json.RawMessage(fmt.Sprintf(`{"model":%q,"input":"hello"}`, model))
if turn == 1 {
payload = json.RawMessage(fmt.Sprintf(`{"model":%q,"input":"next","previous_response_id":"resp_fixed_0"}`, model))
}
if _, err := session.ExecuteTurn(t.Context(), payload, nil); err != nil {
t.Fatal(err)
}
}
if handshakes.Load() != 1 {
t.Errorf("handshakes = %d, want one reused connection", handshakes.Load())
}
})
}
}
}
+7 -4
View File
@@ -38,7 +38,6 @@ const (
defaultLoginTimeout = 5 * time.Minute
defaultCodexBaseURL = "https://chatgpt.com/backend-api/codex"
defaultCodexAPIBase = "https://chatgpt.com/backend-api"
defaultModelsVersion = "0.144.1"
maxObservedBodyBytes = 32 << 20
)
@@ -381,7 +380,10 @@ type CodexHTTPExecutor struct {
// NewCodexHTTPExecutor constructs an HTTP-only executor with no CPA manager.
func NewCodexHTTPExecutor() *CodexHTTPExecutor {
cfg := &internalconfig.Config{Codex: internalconfig.CodexConfig{StreamBootstrapBuffering: true}}
cfg := &internalconfig.Config{Codex: internalconfig.CodexConfig{
StreamBootstrapBuffering: true,
ModelLevelCooling: true,
}}
return &CodexHTTPExecutor{cfg: cfg, inner: internalexecutor.NewCodexExecutor(cfg)}
}
@@ -588,7 +590,7 @@ func ListCodexModels(ctx context.Context, credential CodexCredential, baseURL st
return nil, err
}
query := target.Query()
query.Set("client_version", defaultModelsVersion)
query.Set("client_version", codexClientVersion)
target.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
@@ -743,7 +745,8 @@ func applyCodexReadHeaders(req *http.Request, credential CodexCredential) {
req.Header.Set("Authorization", "Bearer "+credential.AccessToken)
req.Header.Set("Chatgpt-Account-Id", credential.AccountID)
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("User-Agent", "codex_cli_rs/"+defaultModelsVersion)
req.Header.Set("User-Agent", "codex_cli_rs/"+codexClientVersion)
req.Header.Set("Version", codexClientVersion)
}
func (e *CodexHTTPExecutor) executionContext(
+13 -1
View File
@@ -747,7 +747,10 @@ func TestListCodexModelsRequestsOnceAndNormalizesIDs(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.URL.Path != "/models" || r.URL.Query().Get("client_version") == "" ||
if r.Header.Get("Version") != "0.153.3" || r.Header.Get("User-Agent") != "codex_cli_rs/0.153.3" {
t.Errorf("Codex version headers = %q / %q", r.Header.Get("Version"), r.Header.Get("User-Agent"))
}
if r.URL.Path != "/models" || r.URL.Query().Get("client_version") != "0.153.3" ||
r.Header.Get("Authorization") != "Bearer access" || r.Header.Get("Chatgpt-Account-Id") != "account-123" {
t.Errorf("request = %s %s %#v", r.Method, r.URL.String(), r.Header)
}
@@ -772,6 +775,9 @@ func TestObserveCodexAccountUsesFixedUsagePathOnce(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Version") != "0.153.3" || r.Header.Get("User-Agent") != "codex_cli_rs/0.153.3" {
t.Errorf("Codex version headers = %q / %q", r.Header.Get("Version"), r.Header.Get("User-Agent"))
}
if r.URL.Path != "/wham/usage" {
t.Errorf("path = %q", r.URL.Path)
}
@@ -797,6 +803,9 @@ func TestObserveCodexResetCreditsUsesFixedDetailsPathOnce(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Version") != "0.153.3" || r.Header.Get("User-Agent") != "codex_cli_rs/0.153.3" {
t.Errorf("Codex version headers = %q / %q", r.Header.Get("Version"), r.Header.Get("User-Agent"))
}
if r.Method != http.MethodGet || r.URL.Path != "/wham/rate-limit-reset-credits" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
@@ -825,6 +834,9 @@ func TestConsumeCodexResetCreditUsesStableRedeemRequestIDOnce(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Version") != "0.153.3" || r.Header.Get("User-Agent") != "codex_cli_rs/0.153.3" {
t.Errorf("Codex version headers = %q / %q", r.Header.Get("Version"), r.Header.Get("User-Agent"))
}
if r.Method != http.MethodPost || r.URL.Path != "/wham/rate-limit-reset-credits/consume" ||
r.Header.Get("Content-Type") != "application/json" {
t.Errorf("request = %s %s %#v", r.Method, r.URL.Path, r.Header)
+1 -1
View File
@@ -6,7 +6,7 @@ require (
github.com/andybalholm/brotli v1.2.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157
github.com/sirupsen/logrus v1.9.3
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
+2 -2
View File
@@ -71,8 +71,8 @@ github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthO
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151 h1:b713YWa1uaEFmM3cgXiUpbdV1nC9NMmyfnkjUXFrCeg=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.151/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157 h1:ctp0AJ+QmK9SEcYVOue5AOtGQsXdDw2d/PJvYjBW2rg=
github.com/router-for-me/CLIProxyAPI/v7 v7.2.157/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=