fix(codex): 升级 CPA 并固定上游版本声明 (#619)
This commit is contained in:
1 parent
f091528bdb
commit
abc6483d0e
19 files changed
+410
-89
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+27
-14
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -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=
|
||||
|
||||
Reference in new issue
Block a user