diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 1f74540d1c04..51f07c60b868 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -109,6 +109,13 @@ stable synthetic window per account/model so cached-only generation can reuse their proof without changing actual client windows. The pinned upstream's HTTP borrowing is Astra-only: Sol is a downstream extension, independently validated against Sol rather than certified by an Astra pass. +When both completed target shots reject a candidate's STATE or the upstream +replaces its route, a request may acquire and validate one replacement through +the existing source/proxy configuration. Replacement is capped at 90 seconds and +never retires a candidate with another live successful proof or validation in +flight. Another rejection cools acquisition for 15 seconds. Receiving the same +rejected cookie cannot renew or qualify it. This remains demand-driven, with no +exit rotation, renewal timer, ordinary fallback or business replay. Source and target probes use separate HTTP/TLS pools. Business WS connections stay in the ordinary account pool, with fixed one-hour continuation anchors. @@ -163,6 +170,10 @@ Optional `service_tier` exercises the effective routing tier. Results include th specific verification (shape, actual model/tier, both completions, STATE lengths and route replacement), failure stage and tool-round-trip result. HTTP diagnostic history and STATE live only within that invocation, never in client sessions. +Complete `response.output_item.done` events rebuild tool calls and reasoning +items when the final successful envelope has empty output; a successful terminal +is still required. This matches native streaming continuation instead of assuming +all output is repeated inside `response.completed`. Ranxi reference: v2.10.2 (`d3e43f2de33af9e987cffa511d76dfabbcd749da`). Its five fingerprint, gateway-cookie, target-probe and automatic-setup core files are diff --git a/backend/internal/service/codex_gateway_borrow.go b/backend/internal/service/codex_gateway_borrow.go index 176e1b8b427a..0445f484a66f 100644 --- a/backend/internal/service/codex_gateway_borrow.go +++ b/backend/internal/service/codex_gateway_borrow.go @@ -47,12 +47,14 @@ var ( // request failing upstream. Callers must surface it before ordinary account // failover, stream-timeout or health hooks. Unwrap preserves the specific cause. type CodexGatewayBorrowFailure struct { - Cause error - Stage string - Reason string - Detail string - RetryAfter *time.Time - Verification *CodexGatewayBorrowVerification + Revision uint64 + CookieFingerprint string + Cause error + Stage string + Reason string + Detail string + RetryAfter *time.Time + Verification *CodexGatewayBorrowVerification } func (e *CodexGatewayBorrowFailure) Error() string { @@ -79,9 +81,9 @@ func IsCodexGatewayBorrowFailure(err error) bool { return errors.As(err, &failure) } -func borrowVerificationFailure(check codexGatewayBorrowTargetCheck) error { +func borrowVerificationFailure(check codexGatewayBorrowTargetCheck, revision uint64) error { result := check.result - failure := &CodexGatewayBorrowFailure{Cause: ErrCodexGatewayBorrowUnavailable, Stage: "target_validation", Reason: result.Reason, Detail: result.Error, Verification: &result} + failure := &CodexGatewayBorrowFailure{Revision: revision, Cause: ErrCodexGatewayBorrowUnavailable, Stage: "target_validation", Reason: result.Reason, Detail: result.Error, Verification: &result, CookieFingerprint: check.cookieKey} if !check.retryAfter.IsZero() { retry := check.retryAfter failure.RetryAfter = &retry @@ -274,6 +276,10 @@ type codexGatewayBorrowTargetCheck struct { } type CodexGatewayBorrowService struct { + rejectedCookie string + rejectedCause error + rejectedRoutes map[string]time.Time + validatingRoutes map[string]int validationSequence uint64 sourceSequence uint64 qualifications map[string]codexGatewayBorrowTargetCheck @@ -410,6 +416,9 @@ func (s *CodexGatewayBorrowService) publishConfig(cfg CodexGatewayBorrowConfig, s.revision++ s.config, s.loaded = cloneCodexGatewayBorrowConfig(cfg), true s.candidate = nil + s.rejectedCookie, s.rejectedCause = "", nil + s.rejectedRoutes = nil + s.validatingRoutes = nil s.setup = CodexGatewayBorrowSetup{} if saved && cfg.Enabled { s.setup = CodexGatewayBorrowSetup{State: "queued", Total: len(cfg.TargetAccountIDs) * len(cfg.Models), StartedAt: time.Now()} @@ -514,6 +523,9 @@ func (s *CodexGatewayBorrowService) Status() CodexGatewayBorrowStatus { row.State, row.Reason = "expired", "route_expired" } } + if s.config.Enabled && s.candidate != nil && s.candidate.sourceID == id && s.candidateRejectedLocked() { + row.State, row.Reason = "candidate", "source_passed_target_failed" + } out.Sources = append(out.Sources, row) } for _, id := range s.config.TargetAccountIDs { @@ -521,7 +533,7 @@ func (s *CodexGatewayBorrowService) Status() CodexGatewayBorrowStatus { row := CodexGatewayBorrowTargetStatus{CodexGatewayBorrowVerification: CodexGatewayBorrowVerification{AccountID: id, Model: model, Reason: "not_verified"}, State: "waiting"} if c, ok := s.targets[codexGatewayBorrowTargetKey{id, model}]; ok { row.CodexGatewayBorrowVerification = c.result - row.CacheValid = c.policyRevision == currentCodexFingerprintPolicyForAccount(&Account{ID: id}).revision && c.result.Success && s.candidate != nil && c.cookieKey == borrowHash(s.candidate.cookie.Value) && now.Before(c.expires) && now.Before(s.candidate.expires) + row.CacheValid = c.policyRevision == currentCodexFingerprintPolicyForAccount(&Account{ID: id}).revision && c.result.Success && s.candidate != nil && !s.candidateRejectedLocked() && c.cookieKey == borrowHash(s.candidate.cookie.Value) && now.Before(c.expires) && now.Before(s.candidate.expires) expires := c.expires if s.candidate != nil && c.cookieKey == borrowHash(s.candidate.cookie.Value) && s.candidate.expires.Before(expires) { expires = s.candidate.expires @@ -552,6 +564,10 @@ func (s *CodexGatewayBorrowService) Status() CodexGatewayBorrowStatus { if !s.config.Enabled { row.State, row.Reason, row.CacheValid = "disabled", "disabled", false } + if s.config.Enabled && !row.CacheValid && s.candidateRejectedLocked() && now.Before(s.prepareFailedUntil) { + retry := s.prepareFailedUntil + row.RetryAfter = &retry + } out.Targets = append(out.Targets, row) } } @@ -677,19 +693,30 @@ func (s *CodexGatewayBorrowService) prepareSource(ctx context.Context, rev uint6 s.mu.Unlock() return nil, ErrCodexGatewayBorrowChanged } - if s.candidate != nil && time.Until(s.candidate.expires) > codexGatewayBorrowTimeout { + if s.candidate != nil && !s.candidateRejectedLocked() && time.Until(s.candidate.expires) > codexGatewayBorrowTimeout { s.mu.Unlock() return nil, nil } + if s.accounts == nil || s.gateway == nil { + s.mu.Unlock() + return nil, ErrCodexGatewayBorrowUnavailable + } if time.Now().Before(s.prepareFailedUntil) { detail := s.prepareFailure + cause := s.rejectedCause s.mu.Unlock() + if cause != nil { + return nil, cause + } if detail != "" { return nil, errors.New(detail) } return nil, ErrCodexGatewayBorrowUnavailable } ids := append([]int64{}, s.config.SourceAccountIDs...) + if s.sources == nil { + s.sources = make(map[int64]CodexGatewayBorrowSourceStatus) + } s.preparing = true s.sourceSequence++ sequence := s.sourceSequence @@ -717,6 +744,22 @@ func (s *CodexGatewayBorrowService) prepareSource(ctx context.Context, rev uint6 return nil, ErrCodexGatewayBorrowChanged } row := CodexGatewayBorrowSourceStatus{AccountID: id, State: "rejected", Reason: "source_probe_failed", CheckedAt: &now, Error: detail} + previouslyRejected := false + if candidate != nil { + key := borrowHash(candidate.cookie.Value) + if until, found := s.rejectedRoutes[key]; found { + previouslyRejected = now.Before(until) + if !previouslyRejected { + delete(s.rejectedRoutes, key) + } + } + } + if err == nil && candidate != nil && previouslyRejected { + // Keep the old candidate and expiry: reacquiring the identical + // rejected credential must not renew its lease or qualify it. + err = errors.New("source returned the same rejected route") + row.Reason, row.Error = "source_returned_rejected_route", err.Error() + } if err == nil && candidate != nil && now.Before(candidate.expires) { // Seeing the same value while its original lease is live never // renews it, even when a different source returns that value. @@ -727,6 +770,7 @@ func (s *CodexGatewayBorrowService) prepareSource(ctx context.Context, rev uint6 s.qualifications = map[string]codexGatewayBorrowTargetCheck{} } s.candidate = candidate + s.rejectedCookie, s.rejectedCause = "", nil row.State, row.Reason, row.Error, row.ExpiresAt = "ready", "source_qualified", "", &candidate.expires s.sources[id] = row s.preparing = false @@ -744,7 +788,7 @@ func (s *CodexGatewayBorrowService) prepareSource(ctx context.Context, rev uint6 s.mu.Unlock() } s.mu.Lock() - if s.revision == rev && s.sourceSequence == sequence && !errors.Is(probeCtx.Err(), context.Canceled) { + if s.revision == rev && s.sourceSequence == sequence && !errors.Is(probeCtx.Err(), context.Canceled) && !errors.Is(lastErr, ErrCodexBorrowDiagnosticBudget) { s.preparing = false s.prepareFailedUntil = time.Now().Add(codexGatewayBorrowFailureWait) s.prepareFailure = lastErr.Error() @@ -776,7 +820,7 @@ func borrowTargetFingerprint(req *http.Request, account *Account, model, proxy s // __cflb, other cookies, body and proxy stay exactly as supplied by its caller. // cachedOnly is used by manual generation: it never acquires or validates. func (s *CodexGatewayBorrowService) Apply(req *http.Request, account *Account, model, proxy string, profile *tlsfingerprint.Profile, cachedOnly bool) (*http.Request, *CodexGatewayBorrowApplication, error) { - wire, application, err := s.apply(req, account, model, proxy, profile, cachedOnly, false) + wire, application, err := s.applyWithCandidateRetry(req, account, model, proxy, profile, cachedOnly, false) if err != nil && !IsCodexGatewayBorrowFailure(err) { err = &CodexGatewayBorrowFailure{Cause: err} } @@ -812,15 +856,19 @@ func (s *CodexGatewayBorrowService) apply(req *http.Request, account *Account, m } rev, revisionCtx := s.revision, s.revisionCtx candidate := s.candidate + rejected := s.candidateRejectedLocked() s.mu.Unlock() if !codexGatewayBorrowAccountSupported(account) { return req, nil, ErrCodexGatewayBorrowUnavailable } - if candidate == nil || !time.Now().Before(candidate.expires) { + if candidate == nil || rejected || !time.Now().Before(candidate.expires) { if cachedOnly { return req, nil, ErrCodexGatewayBorrowUnavailable } if err := s.prepareSource(req.Context(), rev, revisionCtx); err != nil { + if IsCodexGatewayBorrowFailure(err) { + return req, nil, err + } return req, nil, &CodexGatewayBorrowFailure{Cause: err, Stage: "source_preparation", Reason: "source_prepare_failed"} } s.mu.Lock() @@ -864,9 +912,11 @@ func (s *CodexGatewayBorrowService) apply(req *http.Request, account *Account, m } s.validationSequence++ validationID := s.validationSequence + validationDone := s.trackCandidateValidationLocked(rev, borrowHash(candidate.cookie.Value)) s.targets[targetKey] = codexGatewayBorrowTargetCheck{validationID: validationID, policyRevision: policyRevision, key: key, cookieKey: borrowHash(candidate.cookie.Value), validating: true, result: CodexGatewayBorrowVerification{AccountID: account.ID, Model: model, Reason: "validating", CheckedAt: time.Now()}} s.mu.Unlock() + defer validationDone() probeCtx, cancel := borrowRevisionContext(operation, revisionCtx, codexGatewayBorrowTimeout) defer cancel() credential, credentialErr := resolveCredentialAccount(probeCtx, s.accounts, account) @@ -914,7 +964,7 @@ func (s *CodexGatewayBorrowService) apply(req *http.Request, account *Account, m s.qualifications[qualificationKey] = check s.mu.Unlock() if !result.Success { - return nil, borrowVerificationFailure(check) + return nil, borrowVerificationFailure(check, rev) } return check, nil }) @@ -929,7 +979,7 @@ func (s *CodexGatewayBorrowService) apply(req *http.Request, account *Account, m } func (s *CodexGatewayBorrowService) candidateCurrentLocked(rev uint64, candidate *codexGatewayBorrowCandidate) bool { - return s.revision == rev && s.config.Enabled && s.revisionCtx.Err() == nil && s.candidate != nil && s.candidate.sourceID == candidate.sourceID && s.candidate.cookie.Value == candidate.cookie.Value && s.candidate.cookie.Path == candidate.cookie.Path && s.candidate.expires.Equal(candidate.expires) && time.Now().Before(s.candidate.expires) && time.Now().Before(candidate.expires) + return s.revision == rev && s.config.Enabled && s.revisionCtx.Err() == nil && s.candidate != nil && !s.candidateRejectedLocked() && s.candidate.sourceID == candidate.sourceID && s.candidate.cookie.Value == candidate.cookie.Value && s.candidate.cookie.Path == candidate.cookie.Path && s.candidate.expires.Equal(candidate.expires) && time.Now().Before(s.candidate.expires) && time.Now().Before(candidate.expires) } func (s *CodexGatewayBorrowService) cachedTarget(rev uint64, key codexGatewayBorrowTargetKey, fingerprint string, candidate *codexGatewayBorrowCandidate) (codexGatewayBorrowTargetCheck, bool, error) { @@ -946,6 +996,9 @@ func (s *CodexGatewayBorrowService) cachedTarget(rev uint64, key codexGatewayBor return old, false, nil } now := time.Now() + if !now.Before(old.expires) { + return old, false, nil + } if old.result.Success && now.Before(old.expires) { if candidate.expires.Before(old.expires) { old.expires = candidate.expires @@ -953,7 +1006,7 @@ func (s *CodexGatewayBorrowService) cachedTarget(rev uint64, key codexGatewayBor return old, true, nil } if !old.result.Success && now.Before(old.retryAfter) { - return old, false, borrowVerificationFailure(old) + return old, false, borrowVerificationFailure(old, rev) } return old, false, nil } @@ -1007,7 +1060,7 @@ func (s *CodexGatewayBorrowService) Verify(ctx context.Context, accountID int64, if err != nil { return result, err } - _, _, err = s.apply(req, a, model, proxy, nil, false, true) + _, _, err = s.applyWithCandidateRetry(req, a, model, proxy, nil, false, true) s.mu.Lock() if check, ok := s.targets[codexGatewayBorrowTargetKey{accountID, model}]; ok { result = check.result diff --git a/backend/internal/service/codex_gateway_borrow_candidate_retry.go b/backend/internal/service/codex_gateway_borrow_candidate_retry.go new file mode 100644 index 000000000000..9b422db36b1f --- /dev/null +++ b/backend/internal/service/codex_gateway_borrow_candidate_retry.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" +) + +func (s *CodexGatewayBorrowService) candidateRejectedLocked() bool { + return s.candidate != nil && s.rejectedCookie != "" && borrowHash(s.candidate.cookie.Value) == s.rejectedCookie +} + +func (s *CodexGatewayBorrowService) trackCandidateValidationLocked(revision uint64, cookie string) func() { + if s.validatingRoutes == nil { + s.validatingRoutes = make(map[string]int) + } + s.validatingRoutes[cookie]++ + return func() { + s.mu.Lock() + defer s.mu.Unlock() + if s.revision != revision { + return + } + s.validatingRoutes[cookie]-- + if s.validatingRoutes[cookie] == 0 { + delete(s.validatingRoutes, cookie) + } + } +} + +// Source completion produces a candidate, not proof that a target can use it. +// Retire a definitively rejected candidate only when it has no live successful +// proof and no other validation in flight. Other targets' usable routes survive. +func (s *CodexGatewayBorrowService) rejectCandidateForRetry(err error, cooling bool) bool { + var failure *CodexGatewayBorrowFailure + if s == nil || !errors.As(err, &failure) || failure.CookieFingerprint == "" || + (failure.Reason != "target_state_changed" && failure.Reason != "target_route_changed") { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.revision != failure.Revision || !s.config.Enabled || s.candidate == nil || s.revisionCtx.Err() != nil { + return false + } + if borrowHash(s.candidate.cookie.Value) != failure.CookieFingerprint { + // A concurrent waiter has already replaced this candidate. Retry the + // published replacement; never retire it using the older observation. + return !cooling + } + if failure.Verification != nil && failure.Verification.ExpiresAt != nil && !failure.Verification.ExpiresAt.Equal(s.candidate.expires) { + return !cooling // A fresh lease cannot be retired by its predecessor's result. + } + now := time.Now() + if now.Before(s.prepareFailedUntil) { + return false + } + for _, check := range s.qualifications { + if check.cookieKey == failure.CookieFingerprint && check.result.Success && now.Before(check.expires) { + return false + } + } + // The presentation index holds only the latest check per account/model; + // another request identity can still be validating the same candidate. + if s.validatingRoutes[failure.CookieFingerprint] > 0 { + return false + } + if s.rejectedRoutes == nil { + s.rejectedRoutes = make(map[string]time.Time) + } + // Remember retired credentials through their original lease, including + // A -> B -> A source responses. Never forget a live rejection to make room. + if len(s.rejectedRoutes) >= 1024 { + for key, expires := range s.rejectedRoutes { + if !now.Before(expires) { + delete(s.rejectedRoutes, key) + } + } + if _, known := s.rejectedRoutes[failure.CookieFingerprint]; !known && len(s.rejectedRoutes) >= 1024 { + return false + } + } + s.rejectedRoutes[failure.CookieFingerprint] = s.candidate.expires + s.rejectedCookie, s.rejectedCause = failure.CookieFingerprint, err + if cooling { + s.prepareFailedUntil = now.Add(codexGatewayBorrowFailureWait) + s.prepareFailure = err.Error() + } + return true +} + +// One request may try at most one replacement through the same configured +// sources/proxies. There is no background renewal or business replay. A second +// rejection cools acquisition for 15 seconds; per-shot and caller deadlines, +// shared-flight cancellation and the diagnostic's eight-request cap still apply. +func (s *CodexGatewayBorrowService) applyWithCandidateRetry(req *http.Request, account *Account, model, proxy string, profile *tlsfingerprint.Profile, cachedOnly, force bool) (*http.Request, *CodexGatewayBorrowApplication, error) { + wire, application, firstErr := s.apply(req, account, model, proxy, profile, cachedOnly, force) + if firstErr == nil || cachedOnly || req == nil || req.Context().Err() != nil || !s.rejectCandidateForRetry(firstErr, false) { + return wire, application, firstErr + } + retryCtx, cancel := context.WithTimeout(req.Context(), codexGatewayBorrowTimeout) + defer cancel() + wire, application, retryErr := s.apply(req.WithContext(retryCtx), account, model, proxy, profile, false, force) + if retryErr != nil { + s.rejectCandidateForRetry(retryErr, true) + // Preserve the original target verdict as well as the replacement's + // complete cause, including cancellation or an exhausted diagnostic cap. + return wire, nil, errors.Join(firstErr, retryErr) + } + if wire != nil && application != nil && application.Applied { + // The bounded preparation context must not cancel the business send. + wire = wire.WithContext(context.WithValue(req.Context(), codexBorrowAppliedContextKey{}, true)) + } + return wire, application, nil +} diff --git a/backend/internal/service/codex_gateway_borrow_candidate_retry_test.go b/backend/internal/service/codex_gateway_borrow_candidate_retry_test.go new file mode 100644 index 000000000000..581754969c1a --- /dev/null +++ b/backend/internal/service/codex_gateway_borrow_candidate_retry_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCodexBorrowRejectedCandidateGetsOneReplacement(t *testing.T) { + for _, replacement := range []string{"usable", "rejected", "identical"} { + t.Run(replacement, func(t *testing.T) { + var sourceCalls, targetCalls int + a := borrowCoreAccount(2) + s := newBorrowCoreTest(t, func(req *http.Request, _ string, id int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + if id == 1 { + sourceCalls++ + cookie := "replacement-cookie" + if replacement == "identical" { + cookie = "synthetic-borrowed-cookie" + } + return borrowCoreResponse("gpt-6-astra", "OK", "source-state", "__oailb="+cookie+"; Secure; Path=/; Max-Age=230"), nil + } + targetCalls++ + cookie, err := req.Cookie("__oailb") + assert.NoError(t, err) + model := gjson.GetBytes(borrowCoreBody(t, req), "model").String() + state := "first" + if req.Header.Get("X-Codex-Turn-State") != "" { + state = "changed" + if cookie != nil && cookie.Value == "replacement-cookie" && replacement == "usable" { + state = "first" + } + } + return borrowCoreResponse(model, "OK", state), nil + }, borrowCoreAccount(1), a) + originalExpiry := time.Now().Add(120 * time.Second) + borrowCoreCandidate(s, originalExpiry) + _, req, _, err := s.accountTemplate(context.Background(), 2, "gpt-6.1-sol") + require.NoError(t, err) + wire, applied, err := s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.Equal(t, 1, sourceCalls, "at most one replacement source acquisition") + if replacement == "usable" { + require.NoError(t, err) + require.True(t, applied.Applied) + require.NoError(t, wire.Context().Err(), "retry cleanup must not cancel the business request") + require.Contains(t, wire.Header.Get("Cookie"), "__oailb=replacement-cookie") + require.Equal(t, 4, targetCalls) + return + } + require.Error(t, err) + require.Nil(t, applied) + before := targetCalls + _, _, err = s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.Error(t, err) + require.Equal(t, 1, sourceCalls, "exhausted replacement cools acquisition") + require.Equal(t, before, targetCalls, "cooldown does not revalidate a known rejected candidate") + if replacement == "identical" { + require.Equal(t, originalExpiry, s.candidate.expires, "the same rejected credential never gains lifetime") + require.Equal(t, 2, targetCalls) + } else { + require.Equal(t, 4, targetCalls) + } + }) + } +} + +func TestCodexBorrowCandidateReplacementPreservesOtherProofsAndWaiters(t *testing.T) { + for _, other := range []string{"passed", "validating"} { + t.Run(other, func(t *testing.T) { + s := newBorrowCoreTest(t, nil) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + key := borrowHash(s.candidate.cookie.Value) + check := codexGatewayBorrowTargetCheck{cookieKey: key, expires: s.candidate.expires, validating: other == "validating", result: CodexGatewayBorrowVerification{AccountID: 3, Model: "gpt-6-astra", Success: other == "passed"}} + if other == "passed" { + s.qualifications["other"] = check + } else { + s.targets[codexGatewayBorrowTargetKey{3, "gpt-6-astra"}] = check + s.validatingRoutes = map[string]int{key: 1} + } + err := &CodexGatewayBorrowFailure{Revision: s.revision, Cause: ErrCodexGatewayBorrowUnavailable, Reason: "target_state_changed", CookieFingerprint: key} + require.False(t, s.rejectCandidateForRetry(err, false)) + require.Empty(t, s.rejectedCookie) + }) + } +} + +func TestCodexBorrowReplacementWaitsForHiddenRequestIdentity(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + var first atomic.Bool + a := borrowCoreAccount(2) + s := newBorrowCoreTest(t, func(req *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + if first.CompareAndSwap(false, true) { + close(started) + select { + case <-release: + case <-req.Context().Done(): + return nil, req.Context().Err() + } + } + return borrowCoreResponse("gpt-6.1-sol", "OK", "stable"), nil + }, a) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + _, req, _, err := s.accountTemplate(context.Background(), 2, "gpt-6.1-sol") + require.NoError(t, err) + done := make(chan error, 1) + go func() { _, _, runErr := s.Apply(req, a, "gpt-6.1-sol", "", nil, false); done <- runErr }() + <-started + s.mu.Lock() + key := borrowHash(s.candidate.cookie.Value) + failure := &CodexGatewayBorrowFailure{Revision: s.revision, Cause: ErrCodexGatewayBorrowUnavailable, Reason: "target_state_changed", CookieFingerprint: key} + // Another identity completed later and owns the presentation row, while + // the first identity's real validation is still using this candidate. + s.targets[codexGatewayBorrowTargetKey{2, "gpt-6.1-sol"}] = codexGatewayBorrowTargetCheck{cookieKey: key, validationID: 999} + s.mu.Unlock() + retired := s.rejectCandidateForRetry(failure, false) + close(release) + require.False(t, retired) + require.NoError(t, <-done) + s.mu.Lock() + defer s.mu.Unlock() + require.Empty(t, s.validatingRoutes) +} + +func TestCodexBorrowReplacementCannotRetireNewConfiguration(t *testing.T) { + s := newBorrowCoreTest(t, nil) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + err := &CodexGatewayBorrowFailure{Revision: s.revision, Cause: ErrCodexGatewayBorrowUnavailable, Reason: "target_state_changed", CookieFingerprint: borrowHash(s.candidate.cookie.Value)} + s.publishConfig(CodexGatewayBorrowConfig{Enabled: true, SourceAccountIDs: []int64{4}, TargetAccountIDs: []int64{2}, Models: []string{"gpt-6-astra"}}, false) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + require.False(t, s.rejectCandidateForRetry(err, false), "even an identical cookie belongs to the new config revision") + require.Empty(t, s.rejectedCookie) +} + +func TestCodexBorrowReplacementBudgetDoesNotCoolOtherCallers(t *testing.T) { + var sourceCalls int + a := borrowCoreAccount(2) + s := newBorrowCoreTest(t, func(req *http.Request, _ string, id int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + if id == 1 { + sourceCalls++ + return borrowCoreResponse("gpt-6-astra", "OK", "source", "__oailb=replacement-cookie; Secure; Path=/; Max-Age=230"), nil + } + state := "first" + cookie, cookieErr := req.Cookie("__oailb") + assert.NoError(t, cookieErr) + if req.Header.Get("X-Codex-Turn-State") != "" && cookie != nil && cookie.Value != "replacement-cookie" { + state = "changed" + } + return borrowCoreResponse("gpt-6.1-sol", "OK", state), nil + }, borrowCoreAccount(1), a) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + _, req, _, err := s.accountTemplate(context.Background(), 2, "gpt-6.1-sol") + require.NoError(t, err) + var used atomic.Int32 + limited := context.WithValue(req.Context(), codexBorrowDiagnosticContextKey{}, &codexBorrowDiagnostic{limit: 2, requests: &used, borrow: true}) + _, _, err = s.Apply(req.WithContext(limited), a, "gpt-6.1-sol", "", nil, false) + require.ErrorIs(t, err, ErrCodexBorrowDiagnosticBudget) + require.EqualValues(t, 2, used.Load()) + require.Zero(t, sourceCalls) + require.True(t, s.prepareFailedUntil.IsZero(), "a caller's cap is not a source failure") + _, applied, err := s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.NoError(t, err) + require.True(t, applied.Applied) + require.Equal(t, 1, sourceCalls) +} + +func TestCodexBorrowReplacementCannotReplayEarlierRejectedCredential(t *testing.T) { + var sources, targets int + a := borrowCoreAccount(2) + s := newBorrowCoreTest(t, func(req *http.Request, _ string, id int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + if id == 1 { + sources++ + cookie := "replacement-cookie" + if sources == 2 { + cookie = "synthetic-borrowed-cookie" + } + return borrowCoreResponse("gpt-6-astra", "OK", "source", "__oailb="+cookie+"; Secure; Path=/; Max-Age=230"), nil + } + targets++ + state := "first" + if req.Header.Get("X-Codex-Turn-State") != "" { + state = "changed" + } + return borrowCoreResponse("gpt-6.1-sol", "OK", state), nil + }, borrowCoreAccount(1), a) + borrowCoreCandidate(s, time.Now().Add(codexGatewayBorrowTTL)) + _, req, _, err := s.accountTemplate(context.Background(), 2, "gpt-6.1-sol") + require.NoError(t, err) + _, _, err = s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.Error(t, err) + require.Equal(t, 1, sources) + require.Equal(t, 4, targets) + expires := s.candidate.expires + s.prepareFailedUntil = time.Time{} + _, _, err = s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.Error(t, err) + require.Equal(t, 2, sources) + require.Equal(t, 4, targets, "an earlier rejected cookie is not qualified again") + require.Equal(t, "replacement-cookie", s.candidate.cookie.Value) + require.Equal(t, expires, s.candidate.expires) +} + +func TestCodexBorrowExpiredRejectionRequiresFreshSourceAndProof(t *testing.T) { + var sources, targets int + a := borrowCoreAccount(2) + s := newBorrowCoreTest(t, func(_ *http.Request, _ string, id int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + if id == 1 { + sources++ + return borrowCoreResponse("gpt-6-astra", "OK", "source", "__oailb=synthetic-borrowed-cookie; Secure; Path=/; Max-Age=230"), nil + } + targets++ + return borrowCoreResponse("gpt-6.1-sol", "OK", "fresh-stable-state"), nil + }, borrowCoreAccount(1), a) + oldExpiry := time.Now().Add(-time.Second) + borrowCoreCandidate(s, oldExpiry) + _, req, _, err := s.accountTemplate(context.Background(), 2, "gpt-6.1-sol") + require.NoError(t, err) + key := borrowHash(s.candidate.cookie.Value) + s.rejectedCookie = key + s.rejectedRoutes = map[string]time.Time{key: oldExpiry} + s.targets[codexGatewayBorrowTargetKey{2, "gpt-6.1-sol"}] = codexGatewayBorrowTargetCheck{ + key: borrowTargetFingerprint(req, a, "gpt-6.1-sol", "", nil, s.candidate.cookie.Value), cookieKey: key, + expires: oldExpiry, retryAfter: time.Now().Add(time.Minute), + result: CodexGatewayBorrowVerification{Reason: "target_state_changed", ExpiresAt: &oldExpiry}, + } + _, applied, err := s.Apply(req, a, "gpt-6.1-sol", "", nil, false) + require.NoError(t, err) + require.True(t, applied.Applied) + require.Equal(t, 1, sources, "expiration requires a new source observation") + require.Equal(t, 2, targets, "an old failure cooldown cannot certify or reject the new lease") + require.Empty(t, s.rejectedRoutes) +} diff --git a/backend/internal/service/codex_gateway_borrow_diagnose.go b/backend/internal/service/codex_gateway_borrow_diagnose.go index 6eb5c5ac3a19..ee81bfe86f4b 100644 --- a/backend/internal/service/codex_gateway_borrow_diagnose.go +++ b/backend/internal/service/codex_gateway_borrow_diagnose.go @@ -184,7 +184,7 @@ func (s *CodexGatewayBorrowService) Diagnose(ctx context.Context, request CodexB result := s.diagnoseTurn(operation, account, request.Model, previous) result.Mode, result.Turn = mode, turn emit(CodexBorrowDiagnosticEvent{Type: "result", Mode: mode, Result: &result, Requests: requests.Load(), Limit: limit}) - if !result.Completed { + if !result.Completed || result.Error != "" { break } previous = result.ResponseID diff --git a/backend/internal/service/codex_gateway_borrow_diagnose_session.go b/backend/internal/service/codex_gateway_borrow_diagnose_session.go index a82850d1c3e2..775b218629c6 100644 --- a/backend/internal/service/codex_gateway_borrow_diagnose_session.go +++ b/backend/internal/service/codex_gateway_borrow_diagnose_session.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "net/http" + "sort" "strings" "github.com/google/uuid" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const codexBorrowEchoTool = "codex_borrow_echo" @@ -45,15 +47,46 @@ func (d *codexBorrowDiagnostic) prepareHTTPSessionTurn(headers http.Header, payl } func codexBorrowSessionTerminal(raw string) (gjson.Result, error) { + // Native Codex streams may omit output from the final envelope. Preserve + // completed output items (including encrypted reasoning) by their stream + // index; deltas alone never prove a complete item or a completed response. + items := make(map[int]json.RawMessage) for _, line := range strings.Split(raw, "\n") { if !strings.HasPrefix(line, "data:") { continue } event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) switch event.Get("type").String() { + case "response.output_item.done": + index := int(event.Get("output_index").Int()) + item := event.Get("item") + if index < 0 || index >= 128 || !item.IsObject() { + return gjson.Result{}, errors.New("invalid diagnostic output item") + } + items[index] = json.RawMessage(item.Raw) case "response.completed", "response.done": response := event.Get("response") if response.Get("status").String() == "completed" && !response.Get("error").IsObject() { + if len(response.Get("output").Array()) == 0 && len(items) > 0 { + indexes := make([]int, 0, len(items)) + for index := range items { + indexes = append(indexes, index) + } + sort.Ints(indexes) + output := make([]json.RawMessage, 0, len(items)) + for _, index := range indexes { + output = append(output, items[index]) + } + encoded, err := json.Marshal(output) + if err != nil { + return gjson.Result{}, err + } + materialized, err := sjson.SetRaw(response.Raw, "output", string(encoded)) + if err != nil { + return gjson.Result{}, err + } + response = gjson.Parse(materialized) + } return response, nil } return response, errors.New("upstream terminal was not completed") @@ -66,6 +99,7 @@ func codexBorrowSessionTerminal(raw string) (gjson.Result, error) { func (d *codexBorrowDiagnostic) completeHTTPSessionTurn(result *CodexBorrowDiagnosticResult, sendErr error, status int) { response, err := codexBorrowSessionTerminal(result.RawResponse) + result.Completed = err == nil && status >= 200 && status < 300 if sendErr != nil { result.Error = sendErr.Error() return @@ -104,9 +138,8 @@ func (d *codexBorrowDiagnostic) completeHTTPSessionTurn(result *CodexBorrowDiagn } } result.Answer = strings.TrimSpace(answer.String()) - result.Completed = result.Answer == codexBorrowEchoValue - result.ToolRoundTrip = result.Completed - if !result.Completed { + result.ToolRoundTrip = result.Answer == codexBorrowEchoValue + if !result.ToolRoundTrip { result.Error = "continuation did not return the fixed tool result" } } diff --git a/backend/internal/service/codex_gateway_borrow_diagnose_session_test.go b/backend/internal/service/codex_gateway_borrow_diagnose_session_test.go new file mode 100644 index 000000000000..821257e16e4b --- /dev/null +++ b/backend/internal/service/codex_gateway_borrow_diagnose_session_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCodexBorrowSessionRebuildsCompletedStreamItems(t *testing.T) { + // Captures the production shape without retaining any real response data: + // complete items are emitted in SSE, while response.completed has output:[]. + first := "data: " + `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","name":"codex_borrow_echo","call_id":"synthetic-call","arguments":"{\"value\":\"borrow-continuation-check\"}"}}` + "\n\n" + + "data: " + `{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","encrypted_content":"synthetic-encrypted-item"}}` + "\n\n" + + "data: " + `{"type":"response.completed","response":{"id":"synthetic-1","model":"gpt-6.1-sol","status":"completed","output":[]}}` + "\n\n" + diagnostic := &codexBorrowDiagnostic{history: []any{map[string]string{"role": "user"}}} + result := CodexBorrowDiagnosticResult{RawResponse: first} + diagnostic.completeHTTPSessionTurn(&result, nil, 200) + require.True(t, result.Completed, result.Error) + require.Len(t, diagnostic.history, 4, "preserve reasoning, tool call and its linked output") + reasoning, ok := diagnostic.history[1].(json.RawMessage) + require.True(t, ok) + require.Contains(t, string(reasoning), "synthetic-encrypted-item") + second := "data: " + `{"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"borrow-continuation-check"}]}}` + "\n\n" + + "data: " + `{"type":"response.completed","response":{"id":"synthetic-2","model":"gpt-6.1-sol","status":"completed","output":[]}}` + "\n\n" + result = CodexBorrowDiagnosticResult{RawResponse: second} + diagnostic.completeHTTPSessionTurn(&result, nil, 200) + require.True(t, result.Completed, result.Error) + require.True(t, result.ToolRoundTrip) + require.Equal(t, codexBorrowEchoValue, result.Answer) +} + +func TestCodexBorrowSessionItemsStillRequireSuccessfulTerminal(t *testing.T) { + item := "data: " + `{"type":"response.output_item.done","output_index":0,"item":{"type":"message","content":[{"type":"output_text","text":"borrow-continuation-check"}]}}` + "\n\n" + for _, ending := range []string{"", "data: " + `{"type":"response.failed","response":{"status":"failed"}}` + "\n\n"} { + _, err := codexBorrowSessionTerminal(item + ending) + require.Error(t, err) + } +} + +func TestCodexBorrowSessionSeparatesCompletionFromTaskResult(t *testing.T) { + diagnostic := &codexBorrowDiagnostic{history: []any{"user", "tool-output"}} + result := CodexBorrowDiagnosticResult{RawResponse: "data: " + `{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"different answer"}]}]}}` + "\n\n"} + diagnostic.completeHTTPSessionTurn(&result, nil, 200) + require.True(t, result.Completed, "upstream completion is independent of the fixed-answer check") + require.False(t, result.ToolRoundTrip) + require.NotEmpty(t, result.Error) +} diff --git a/frontend/src/components/admin/codex/CodexBorrowActivity.vue b/frontend/src/components/admin/codex/CodexBorrowActivity.vue index 15db98aefb0d..e67a238adbf0 100644 --- a/frontend/src/components/admin/codex/CodexBorrowActivity.vue +++ b/frontend/src/components/admin/codex/CodexBorrowActivity.vue @@ -33,10 +33,11 @@

{{ phase === 'ordinary' ? text('正在检查普通路径…', 'Checking ordinary path…') : text('正在准备并检查借用路径…', 'Preparing and checking borrowed path…') }} {{ text('已发送', 'Requests sent') }} {{ requests }} / 8

-

{{ result.mode === 'ordinary' ? text('普通路径', 'Ordinary path') : text('借用路径', 'Borrowed path') }} · {{ text('第', 'Turn') }} {{ result.turn }} {{ result.completed ? text('轮:已完成', ': completed') : text('轮:未完成', ': not completed') }}

+

{{ result.mode === 'ordinary' ? text('普通路径', 'Ordinary path') : text('借用路径', 'Borrowed path') }} · {{ text('第', 'Turn') }} {{ result.turn }} {{ result.completed ? text('轮:上游已完成', ': upstream completed') : text('轮:未确认完成', ': not completed') }}

{{ result.dispatched === false ? text('准备阶段结束,未发出对照请求', 'Stopped before dispatching this comparison') : result.applied ? text('实际已使用借用', 'Borrow was applied') : text('未使用借用', 'Borrow was not applied') }} · {{ result.reported_model || '—' }} · {{ result.duration_ms }} ms

{{ result.answer || '—' }}

{{ result.tool_round_trip ? text('工具结果与续接已核对', 'Tool result and continuation verified') : text('Codex 工具调用与完整历史续接检查', 'Checking Codex tool call and full-history continuation') }}

+

{{ text('上游已完成,但本轮检查未通过。', 'The upstream completed, but this diagnostic check did not pass.') }}

{{ result.failure_stage }} · {{ result.failure_reason }}

{{ text('本次借用验证依据', 'Borrow verification for this request') }}
{{ JSON.stringify(result.verification, null, 2) }}
{{ result.error }}
diff --git a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts index f5ad596b630a..14115d8fcc68 100644 --- a/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts +++ b/frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts @@ -153,6 +153,8 @@ export default { not_configured: 'This account and model are absent from the saved configuration', disabled: 'Borrowing is currently disabled', route_expired: 'The borrow route has expired; prepare or validate it again', + source_passed_target_failed: 'The source returned a candidate but target validation failed. Replacement is bounded and demand-driven, with an acquisition cooldown.', + source_returned_rejected_route: 'The source returned the same rejected route. Its lease was not extended; acquisition can resume after cooldown.', source_qualified: 'The source account can provide a borrow route', source_probe_failed: 'Source route collection failed; see technical details', source_response_not_qualified: 'The source response did not meet the borrowing conditions', diff --git a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts b/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts index 2a3d254bc690..2b62348d6521 100644 --- a/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts +++ b/frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts @@ -153,6 +153,8 @@ export default { not_configured: '此账号与模型未包含在已保存配置中', disabled: '借用功能当前关闭', route_expired: '借用线路已过期,请重新准备或验证', + source_passed_target_failed: '来源返回了候选,但目标验证失败;按需限次重新获取,冷却期间不重复采集。', + source_returned_rejected_route: '来源再次返回同一条失败线路,未延长租期;冷却后再按需尝试。', source_qualified: '来源账号可以提供借用线路', source_probe_failed: '采集来源线路失败,请查看技术详情', source_response_not_qualified: '来源响应未满足借用条件',