Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .downstream/gateway-borrow-notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ commit `5ca3cca21eeaf4ca8a694a7f2f8f0ecd9575c549`, under LGPL-3.0. The source
files are `codex_gateway_pin.go`, `astra_routing_upstream.go`,
`astra_target_validation.go`, `openai_codex_state_probe.go`,
`openai_codex_ws_anchor.go`, `pelicanHtml.ts` and the pelican preview components.
The non-rotating source/target protocol was subsequently realigned with v2.10.3,
commit `fd1b5ee4eeb20961fbb783fa6f136a1704271e90`; retained host adaptations
are listed in [the scoped review](upstream-review-ranxi-v2.10.3.md).
The repository [LICENSE](../LICENSE) retains the LGPL-3.0 terms.

HTML fence selection also references `web/src/lib/render.ts` from
Expand Down
79 changes: 32 additions & 47 deletions .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,49 +93,34 @@ OAuth-like accounts can be selected; the source and target lists are disjoint.
The extension never rewrites account model mappings, proxies, WS switches,
groups, quota or scheduling state. Normal credential refresh remains available.

Source acquisition shares one process-local `__oailb` candidate. Its maximum
lease is 230 seconds, capped by an earlier upstream expiry; receiving the same
value during a live lease does not extend it. Source preparation is sequential
and uses the ordinary OAuth account-test payload (default instructions, fixed
short user prompt, medium effort), with its own configured normal connection
policy. It does not inherit target-probe session/window headers, routing hints,
encrypted-output options or connection-close behavior.
Source acquisition and the target's two STATE shots preserve the pinned
plaintext-JSON wire protocol. They do not enter the business zstd encoder after
clearing old body headers. This leaves the saved compression setting and normal
generation unchanged. Candidate status records `source_request_encoding`, and
verification records the actual encoding of each shot.
Target probes use the pinned byte template with the actual selected model and
effective tier. Continuation keeps the target's `__cflb` before the fixed borrowed
`__oailb`, as the pinned replacement helper does. Ordinary business bodies and
cookie ordering are unchanged.
It has a 90-second budget. Each target/model is validated with two completed
HTTP 200 streams, at most 45 seconds each: the first must return STATE, and
the second may omit STATE or return the same value. Qualification includes
the target identity, actual model, exit, client headers and TLS profile.
The probe clears the business STATE and validates its own two-shot STATE, so
echoing the next business STATE does not invalidate an otherwise identical live
proof. The sender keeps that exact business STATE and its existing continuation
isolation. Native session/window identity, model, tier, exit and the original
candidate expiry still constrain reuse; no credential gains a longer lease.
Target probes preserve the template's native session/thread/window headers and
generate only a fresh legacy `session_id` for each shot, matching ranxi's full
probe contract. The effective service tier in the gateway-owned routing hint is
also included in the probe body. Proofs include native identity and routing
headers; per-request tracing IDs do not invalidate them. Manual templates use a
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.
The protocol reference is ranxi v2.10.3 (`fd1b5ee4`), non-rotating branch.
Candidates are stored per source with an active-source preference. Their maximum
lease is 230 seconds, capped by earlier upstream expiry; seeing a value again
while its lease is live does not extend it. Source acquisition uses the normal
OAuth account-test payload and OpenAI transport. Target probes use the fixed
plaintext JSON template and the host's OpenAIHarvest policy, with no copied
transport configuration and no business service tier inserted into their body.
The actual selected model is preserved: Astra follows the original; Sol is a
separate downstream model extension with independent qualification.

Source acquisition has a 90-second bound. The two target probes have 45 seconds
each and require completed HTTP 200 streams. The first must return STATE; the
second may omit it or return the same value. Probes clone the business headers,
clear business STATE and old body headers, preserve native session/thread/window
headers, and generate a new legacy `session_id` per shot. The second shot carries
only the first probe's STATE and target `__cflb` with the same borrowed `__oailb`.
Nothing is written back to the business session. The business request keeps its
own model, tier, cookies, STATE and history; borrowing replaces only `__oailb`.

The original cache identity includes route cookie, proxy, Authorization,
ChatGPT-Account-ID, UA, Originator, Version, business STATE and TLS profile.
The host adds actual model and fingerprint policy revision. Trace IDs, native
session headers and business tier are not additional cache rules. STATE changes
therefore require fresh validation. A failed target cools that qualification for
15 seconds; it does not discard a source candidate or force a replacement attempt.
After cooldown the next request may validate again; missing or expired candidates
are acquired on demand. There is no renewal timer, exit rotation, ordinary fallback
or business replay. Business WS keeps its existing continuation anchors.

Status/config/history reads do not call models. Saving an enabled config starts
one finite preparation; business misses prepare on demand. There is no renewal
Expand All @@ -152,10 +137,10 @@ partial readiness after rechecking
expiry. Policy, credential/model-map and exit changes invalidate displayed evidence;
actual dispatch still verifies the finalized request fingerprint.

The effective service tier travels from the finalized HTTP body or WS frame in
local request context into both qualification and its cache key. Explicit
`default`, `auto` and `scale` remain distinct even though their routing hints are
model-only. No internal tier header is sent upstream.
The finalized business service tier stays in request context for diagnostics.
Probe and business inputs are reported separately. `acquisition` reports the
source trigger, stage, start/end time, failure and cooldown; each target also
reports its latest validation interval. A source candidate is not target proof.

Status adds `setup`, `observed_since` and `recent_usage`. The last actual dispatch
and in-process counters are retained per configured target/model/protocol/purpose;
Expand Down
34 changes: 34 additions & 0 deletions .downstream/upstream-review-ranxi-v2.10.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ranxi v2.10.3 scoped review

Reference: `fd1b5ee4eeb20961fbb783fa6f136a1704271e90`, released tag v2.10.3.
Official Wei-Shaw v0.2.15 is already integrated; do not merge it again.

| Upstream change | Disposition |
| --- | --- |
| Borrow fingerprint, candidate, STATE probe and automatic preparation core | No change from v2.10.2; correct downstream deviations against the non-rotation branch. |
| Official v0.2.15 protocol compatibility, provider catalog and model directory | Reuse integrated code. `model_protocol_catalog.go` and the single-manifest preservation in `openai_codex_models_pinned.go` are already present. |
| Pelican showcase notice (#356) | Apply its factual interpretation to the downstream workbench: visual works are observations, not model identity or intelligence scores. |
| Excel/BPS OAuth, dynamic tools and model discovery | Excluded. |
| Worker/TOTP, 2FA, tickets, notifications, automatic group takeover, exit rotation | Excluded. |
| Unreleased production branch | Excluded; not the update baseline. |

## Host adaptations retained

- Parameterize target model for Sol, with qualification isolated from Astra.
- Use the host credential/identity builders and currently bound account proxy.
- Keep request-body model context so strict borrowing also applies to large business requests.
- Shared flights, per-waiter cancellation and revision cancellation replace the original global try-lock.
- Serialize complete two-shot probes by account within the existing execution capacity.
- Bounded save preparation continues after individual target failure and checks actual expiry at completion.
- Observation context prevents health, scheduling, quota and billing writes, while allowing normal credential refresh.
- Retain strict dispatch proofs, continuation isolation, diagnostic budget and raw administrator evidence.

## Removed deviations

- Ignoring business STATE in the v12 cache identity.
- Extra cache keys and business-tier injection into the fixed target probe.
- Target rejection forcing a fresh source candidate and retry in the non-rotation path.
- Separate source/target HTTP manager configuration copies and custom TLS policy.

Source alignment and offline regression do not prove successful live borrowing.
Production and live acceptance are recorded separately in the deployment evidence.
2 changes: 1 addition & 1 deletion backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

173 changes: 6 additions & 167 deletions backend/internal/repository/codex_gateway_borrow_upstream.go
Original file line number Diff line number Diff line change
@@ -1,171 +1,10 @@
package repository

import (
"crypto/sha256"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
import "github.com/Wei-Shaw/sub2api/internal/service"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/service"
)

const (
upstreamProtocolModeCodexBorrowTargetH1 = "codex_borrow_target_h1_no_reuse"
codexBorrowProbeHeaderTimeout = 45 * time.Second
)

// codexGatewayBorrowProbeUpstream owns two independent client managers. In
// particular, probes cannot consume the business client's eviction budget or
// change its HTTP/2 proxy fallback state, even for the same account and proxy.
type codexGatewayBorrowProbeUpstream struct {
source *httpUpstreamService
target *httpUpstreamService
}

func NewCodexGatewayBorrowProbeUpstream(cfg *config.Config) service.CodexGatewayBorrowProbeUpstream {
newManager := func(purpose service.HTTPUpstreamProfile) *httpUpstreamService {
return &httpUpstreamService{
cfg: cloneCodexBorrowProbeHTTPConfig(cfg),
clients: make(map[string]*upstreamClientEntry),
codexBorrowPurpose: purpose,
}
}
return &codexGatewayBorrowProbeUpstream{
source: newManager(service.HTTPUpstreamProfileCodexBorrowSource),
target: newManager(service.HTTPUpstreamProfileCodexBorrowTarget),
}
}

func (s *codexGatewayBorrowProbeUpstream) Do(req *http.Request, proxyURL string, accountID int64, _ int) (*http.Response, error) {
manager, req, err := s.requestManager(req)
if err != nil {
return nil, err
}
// Probe capacity follows the copied global transport settings, independently
// of the account's gateway concurrency slots and business pool dimensions.
return manager.Do(req, proxyURL, accountID, 0)
}

func (s *codexGatewayBorrowProbeUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
manager, req, err := s.requestManager(req)
if err != nil {
return nil, err
}
return manager.DoWithTLS(req, proxyURL, accountID, 0, profile)
}

func (s *codexGatewayBorrowProbeUpstream) requestManager(req *http.Request) (*httpUpstreamService, *http.Request, error) {
if req == nil || req.URL == nil {
return nil, nil, errors.New("borrow probe request URL is nil")
}
switch service.HTTPUpstreamProfileFromContext(req.Context()) {
case service.HTTPUpstreamProfileCodexBorrowSource:
// Source sends the normal OpenAI transport policy through its own pool.
ctx := service.WithHTTPUpstreamProfile(req.Context(), service.HTTPUpstreamProfileOpenAI)
return s.source, req.Clone(ctx), nil
case service.HTTPUpstreamProfileCodexBorrowTarget:
clone := req.Clone(req.Context())
clone.Close = true
return s.target, clone, nil
default:
return nil, nil, errors.New("borrow probe request purpose is missing")
}
}

// Copy only the configuration read by the upstream transport. The managers
// neither mutate nor retain slices belonging to the business configuration.
func cloneCodexBorrowProbeHTTPConfig(cfg *config.Config) *config.Config {
if cfg == nil {
return nil
}
allowlist := cfg.Security.URLAllowlist
allowlist.UpstreamHosts = slices.Clone(allowlist.UpstreamHosts)
allowlist.PricingHosts = slices.Clone(allowlist.PricingHosts)
allowlist.CRSHosts = slices.Clone(allowlist.CRSHosts)
return &config.Config{
Gateway: config.GatewayConfig{
ConnectionPoolIsolation: cfg.Gateway.ConnectionPoolIsolation,
MaxIdleConns: cfg.Gateway.MaxIdleConns,
MaxIdleConnsPerHost: cfg.Gateway.MaxIdleConnsPerHost,
MaxConnsPerHost: cfg.Gateway.MaxConnsPerHost,
IdleConnTimeoutSeconds: cfg.Gateway.IdleConnTimeoutSeconds,
ResponseHeaderTimeout: cfg.Gateway.ResponseHeaderTimeout,
OpenAIResponseHeaderTimeout: cfg.Gateway.OpenAIResponseHeaderTimeout,
OpenAIHTTP2: cfg.Gateway.OpenAIHTTP2,
MaxUpstreamClients: cfg.Gateway.MaxUpstreamClients,
ClientIdleTTLSeconds: cfg.Gateway.ClientIdleTTLSeconds,
},
Security: config.SecurityConfig{URLAllowlist: allowlist},
}
}

func (s *httpUpstreamService) codexBorrowClientCacheKey(key string) string {
if s.codexBorrowPurpose == service.HTTPUpstreamProfileDefault {
return key
}
return string(s.codexBorrowPurpose) + "|" + key
}

// Fingerprinted probe clients must be keyed by the full resolved profile, not
// merely its display name. A bound profile can change without changing its name.
func codexBorrowTLSProfileKey(profile *tlsfingerprint.Profile) string {
encoded, _ := json.Marshal(profile) // Profile contains only JSON-safe values.
return fmt.Sprintf("%x", sha256.Sum256(encoded))
}

func cloneCodexBorrowTLSProfile(profile *tlsfingerprint.Profile) *tlsfingerprint.Profile {
if profile == nil {
return nil
}
clone := *profile
clone.CipherSuites = slices.Clone(profile.CipherSuites)
clone.Curves = slices.Clone(profile.Curves)
clone.PointFormats = slices.Clone(profile.PointFormats)
clone.SignatureAlgorithms = slices.Clone(profile.SignatureAlgorithms)
clone.ALPNProtocols = slices.Clone(profile.ALPNProtocols)
clone.SupportedVersions = slices.Clone(profile.SupportedVersions)
clone.KeyShareGroups = slices.Clone(profile.KeyShareGroups)
clone.PSKModes = slices.Clone(profile.PSKModes)
clone.Extensions = slices.Clone(profile.Extensions)
return &clone
}

func (s *httpUpstreamService) buildCodexBorrowTLSFingerprintTransport(settings poolSettings, proxyURL *url.URL, profile *tlsfingerprint.Profile) (*http.Transport, error) {
if s.codexBorrowPurpose != service.HTTPUpstreamProfileCodexBorrowTarget {
return buildUpstreamTransportWithTLSFingerprint(settings, proxyURL, profile)
}
// Keep the selected cipher suites, extensions and other fingerprint fields,
// while excluding H2 from the actual ClientHello used by every target dialer.
profile = cloneCodexBorrowTLSProfile(profile)
if profile != nil {
profile.ALPNProtocols = []string{"http/1.1"}
}
transport, err := buildUpstreamTransportWithTLSFingerprint(settings, proxyURL, profile)
if err != nil {
return nil, err
}
configureCodexBorrowTargetTransport(transport)
return transport, nil
}

func configureCodexBorrowTargetTransport(transport *http.Transport) {
transport.ForceAttemptHTTP2 = false
transport.DisableKeepAlives = true
transport.DisableCompression = true
transport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper)
transport.Protocols = &http.Protocols{}
transport.Protocols.SetHTTP1(true)
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
} else {
transport.TLSClientConfig = transport.TLSClientConfig.Clone()
}
transport.TLSClientConfig.NextProtos = []string{"http/1.1"}
// Borrow probes use the host transport and its provider profiles, exactly as
// ranxi fd1b5ee4's non-rotating path. Observation isolation belongs to the
// request context; copying transport configuration here would drift again.
func NewCodexGatewayBorrowProbeUpstream(upstream service.HTTPUpstream) service.CodexGatewayBorrowProbeUpstream {
return upstream
}
Loading
Loading