diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a9f6ff33..812a5180 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 diff --git a/go.mod b/go.mod index f5455715..cb8b1645 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 37b8a48c..a45de718 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/execution/cpa/codex_compatibility_test.go b/internal/execution/cpa/codex_compatibility_test.go new file mode 100644 index 00000000..bb3001db --- /dev/null +++ b/internal/execution/cpa/codex_compatibility_test.go @@ -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) + } +} diff --git a/internal/execution/cpa/codex_headers_test.go b/internal/execution/cpa/codex_headers_test.go index 8ce6a801..b0249e03 100644 --- a/internal/execution/cpa/codex_headers_test.go +++ b/internal/execution/cpa/codex_headers_test.go @@ -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} { diff --git a/internal/execution/cpa/codex_provider.go b/internal/execution/cpa/codex_provider.go index 69a42cf4..01d6ad3f 100644 --- a/internal/execution/cpa/codex_provider.go +++ b/internal/execution/cpa/codex_provider.go @@ -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 } } diff --git a/internal/execution/cpa/websocket.go b/internal/execution/cpa/websocket.go index 6c59640d..998a2d7f 100644 --- a/internal/execution/cpa/websocket.go +++ b/internal/execution/cpa/websocket.go @@ -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 diff --git a/internal/execution/cpa/websocket_test.go b/internal/execution/cpa/websocket_test.go index 2549c242..e8d6bf1d 100644 --- a/internal/execution/cpa/websocket_test.go +++ b/internal/execution/cpa/websocket_test.go @@ -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()) diff --git a/internal/gateway/execution_forward_test.go b/internal/gateway/execution_forward_test.go index 3dcec56f..82474a3c 100644 --- a/internal/gateway/execution_forward_test.go +++ b/internal/gateway/execution_forward_test.go @@ -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 diff --git a/third_party/cpaembedded/README.md b/third_party/cpaembedded/README.md index 12886707..4a6e08aa 100644 --- a/third_party/cpaembedded/README.md +++ b/third_party/cpaembedded/README.md @@ -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 diff --git a/third_party/cpaembedded/embedded/antigravity_test.go b/third_party/cpaembedded/embedded/antigravity_test.go index 7237e4da..a689c3d5 100644 --- a/third_party/cpaembedded/embedded/antigravity_test.go +++ b/third_party/cpaembedded/embedded/antigravity_test.go @@ -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) { diff --git a/third_party/cpaembedded/embedded/codex_headers.go b/third_party/cpaembedded/embedded/codex_headers.go index 052ee783..efcfc384 100644 --- a/third_party/cpaembedded/embedded/codex_headers.go +++ b/third_party/cpaembedded/embedded/codex_headers.go @@ -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 } diff --git a/third_party/cpaembedded/embedded/codex_headers_test.go b/third_party/cpaembedded/embedded/codex_headers_test.go index 14be387c..4a5b01f2 100644 --- a/third_party/cpaembedded/embedded/codex_headers_test.go +++ b/third_party/cpaembedded/embedded/codex_headers_test.go @@ -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") diff --git a/third_party/cpaembedded/embedded/codex_websocket.go b/third_party/cpaembedded/embedded/codex_websocket.go index 5d685f3b..7d03581a 100644 --- a/third_party/cpaembedded/embedded/codex_websocket.go +++ b/third_party/cpaembedded/embedded/codex_websocket.go @@ -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, }) diff --git a/third_party/cpaembedded/embedded/codex_websocket_test.go b/third_party/cpaembedded/embedded/codex_websocket_test.go index 04416f6c..118074f7 100644 --- a/third_party/cpaembedded/embedded/codex_websocket_test.go +++ b/third_party/cpaembedded/embedded/codex_websocket_test.go @@ -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()) + } + }) + } + } +} diff --git a/third_party/cpaembedded/embedded/embedded.go b/third_party/cpaembedded/embedded/embedded.go index fcb1f4e1..b2812cd2 100644 --- a/third_party/cpaembedded/embedded/embedded.go +++ b/third_party/cpaembedded/embedded/embedded.go @@ -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( diff --git a/third_party/cpaembedded/embedded/embedded_test.go b/third_party/cpaembedded/embedded/embedded_test.go index 5adc02cd..96cc9489 100644 --- a/third_party/cpaembedded/embedded/embedded_test.go +++ b/third_party/cpaembedded/embedded/embedded_test.go @@ -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) diff --git a/third_party/cpaembedded/go.mod b/third_party/cpaembedded/go.mod index 49b2b745..1df3707f 100644 --- a/third_party/cpaembedded/go.mod +++ b/third_party/cpaembedded/go.mod @@ -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 diff --git a/third_party/cpaembedded/go.sum b/third_party/cpaembedded/go.sum index 5b19fe8c..6c84dbfc 100644 --- a/third_party/cpaembedded/go.sum +++ b/third_party/cpaembedded/go.sum @@ -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=